Há algumas semanas construí um plugin do Claude para uma Product Manager do meu time. O briefing parecia simples: ela estava se afogando em sete superfícies — Slack em vários canais, tickets do Jira, páginas do Confluence, uma agenda lotada de reuniões, Gmail, code reviews, um ou outro doc compartilhado — e nenhuma dessas ferramentas conversa entre si. Uma thread do Slack, um ticket do Jira, um arquivo no Figma e um e-mail para o jurídico podem ser todos sobre a mesma coisa sem que nenhum deles saiba.
O entregável acabou sendo três coisas ao mesmo tempo:
- Um briefing matinal que sintetiza todas as fontes em uma única visão
- Um dashboard ao vivo onde ela de fato trabalha — responde mensagens, cria tickets, marca itens como feitos
- Um cofre de conhecimento crescente para que "o que decidimos sobre X há seis semanas?" tenha uma resposta
O que tornou isso interessante não foram as features. Foi a restrição que guiou cada decisão de design: eu estava construindo para a máquina de outra pessoa. Não a minha. Não de um desenvolvedor. De uma PM que precisa que a coisa simplesmente funcione.
Este é um relato dos padrões que eu usaria de novo, dos que não usaria, e de alguns bugs de instalação que me custaram horas reais.
A arquitetura: três camadas, um trabalho cada
┌──────────────────────────────────────────────────────┐
│ Skill (o cérebro, roda sob demanda) │
│ Fases: pull → reconcile → correlate → write → render│
│ ↓ ↓ │
│ ┌──────────────────┐ ┌────────────────────────┐ │
│ │ Vault (histórico)│ │ Artifact (UI ao vivo) │ │
│ │ Markdown + Obs │ │ HTML + JS + conectores │ │
│ └──────────────────┘ └────────────────────────┘ │
└──────────────────────────────────────────────────────┘
O Skill é o orquestrador. Puxa dos conectores, reconcilia estado entre execuções, correlaciona itens em assuntos, escreve no vault e renderiza o dashboard. Cinco fases, cada uma com um propósito.
O vault é markdown puro. Uma pasta, organizada em daily/, subjects/, tickets/, people/, archive/. Referências cruzadas usam a sintaxe de wikilink do Obsidian ([[AUTO-892]], [[billing-migration]]) e tags (#status/in-progress, #area/billing). Abra a pasta no Obsidian e você ganha a graph view, backlinks e busca full-text de graça. Sem banco de dados. Sem integração para manter.
O artifact é um dashboard HTML ao vivo que puxa dados frescos ao abrir. Ele consulta os conectores novamente via uma API callMcpTool fornecida pelo host e consegue disparar prompts de chat de volta via sendPrompt. Uma ponte de mão dupla.
A decisão não óbvia foi o substrato. Continue lendo.
Padrão #1: markdown puro bate banco de dados
Considerei um banco de dados de verdade — SQLite, JSON store, um Postgres pequeno — e rejeitei todos. Markdown é fácil de buscar com grep, versionável com git, abre em qualquer editor e sobrevive a toda refatoração de qualquer ferramenta que eu venha a adicionar no futuro. A "query language do banco" é glob de filename mais regex.
O vault cresceu para dezenas de arquivos ao longo de semanas. Um SQLite teria me dado queries indexadas mas custaria todo o resto. Para essa escala de dados e essa usuária, não valia a pena.
Dica prática: Se você está construindo um assistente pessoal para alguém que não é desenvolvedor, parta de texto puro como padrão. A pessoa consegue abrir em qualquer editor, e você consegue debugar o sistema lendo arquivos. As duas coisas importam mais que velocidade de query nessa escala.
Padrão #2: correlação entre fontes em duas passadas
A parte difícil de um assistente de PM não é puxar dados das fontes. É reconhecer que uma thread do Slack, um ticket do Jira, um arquivo do Figma e um e-mail são todos sobre a mesma coisa.
Eu faço isso em duas passadas:
Passada 1 — pareamento por entidade. Varrer cada item por identificadores estáveis: chaves de ticket (regex [A-Z]+-\d+), números de PR perto de "PR" ou "pull", nomes de canal, nomes de repo, nomes de pessoas da lista do time, títulos de documentos. Agrupar itens que compartilham uma entidade. Isso captura aproximadamente 70–80% das correlações de graça, deterministicamente.
Passada 2 — similaridade de tópico. Para itens que não foram agrupados na Passada 1, pedir ao modelo de linguagem para agrupar por tópico. Uma thread do Slack "plano de migração de billing?" e uma página do Confluence "Rollout do Billing v2" são obviamente o mesmo assunto mesmo que nenhuma referencie a outra.
A saída é uma lista de subjects, cada um com um slug (kebab-case, usado como filename), um título legível e uma lista de itens vindos de várias fontes. Um subject é o tópico mais amplo; um ticket é um item específico do Jira. A página de um subject usa wikilinks para seus tickets; páginas de ticket linkam de volta. O painel de backlinks do Obsidian torna isso navegável nas duas direções.
Dica prática: Seja conservadora na Passada 2. Mesclar demais é pior que mesclar de menos porque suja o vault. Tunei o prompt para errar para o lado de "são assuntos diferentes" a menos que a evidência seja forte.
Padrão #3: dedup antes de criar (e o truque do rodapé com URL de origem)
Quem já teve uma backlog cheia já abriu um ticket duplicado. A solução é um Skill que roda uma busca por similaridade antes da criação, não depois.
O fluxo:
- Extrair de 3 a 6 termos de busca distintivos do título e descrição propostos (substantivos próprios, termos de domínio, identificadores — não verbos genéricos).
- Rodar três buscas JQL em ordem: match exato de summary, todos os termos top com AND, varredura ampla por qualquer termo nos últimos 90 dias.
- Pontuar cada candidato de 0 a 1 quanto à similaridade usando julgamento do modelo, com calibração explícita nos docs de referência do Skill (≥0,9 = claramente o mesmo; 0,7–0,9 = provavelmente relacionado; <0,5 = match coincidente de palavra-chave).
- Se algo pontuar ≥0,7, mostrar os top 3 com status, responsável e última atualização — e perguntar antes de criar.
- Só criar após confirmação explícita.
O Skill também é o único caminho que o assistente usa para criar ticket. Chamadas diretas a createJiraIssue são explicitamente desencorajadas nas instruções do Skill. Todo ticket passa por dedup, o que significa que duplicatas praticamente deixam de acontecer.
O truque que tornou isso ainda mais útil: todo ticket criado inclui Originado de: <URL de origem> no rodapé da descrição (o permalink do Slack ou a thread de e-mail que disparou a criação). Esse rodapé é o que viabiliza a detecção de resolução server-side depois. Mais sobre isso a seguir.
Dica prática: Um Skill de pré-criação vale mais que um alerta pós-criação. Depois que você abriu a duplicata, tem que limpar. Depois que você se bloqueou de abrir, o problema sumiu.
Padrão #4: persistência são duas camadas, não uma
A primeira versão do dashboard tinha um bug sutil. Todo briefing era uma escrita nova — itens puxados de manhã substituíam os de ontem. Qualquer coisa que a PM não tivesse agido desaparecia silenciosamente.
A solução é um modelo de duas camadas:
Camada 1 — arquivo de estado server-side (.pm-state.json no vault). Rastreia cada item pendente por ID estável entre execuções:
{
"items": {
"<id-estavel>": {
"source": "slack" | "gmail" | "jira" | "github",
"first_seen": "ISO-timestamp",
"last_seen": "ISO-timestamp",
"status": "pending" | "resolved" | "stale" | "dismissed",
"resolved_reason": null | "user_replied" | "ticket_created" | "merged",
"snapshot": { ... }
}
}
}
IDs estáveis são o permalink do Slack, o thread_id do Gmail, a chave do ticket do Jira, ou owner/repo#number. Nunca um hash de conteúdo; sempre o identificador da própria fonte.
Camada 2 — localStorage client-side. Quando a PM clica em Enviar/Pular/Marcar-feito no dashboard, essa decisão persiste entre reloads de página via um mapa em localStorage chaveado pelo ID do item com expiração de 14 dias.
As duas camadas são necessárias. O arquivo de estado persiste entre briefings mesmo se o artifact for fechado. A camada de localStorage faz as ações da UI parecerem instantâneas sem esperar a próxima execução do briefing.
A parte não óbvia é a detecção de resolução server-side — para cada item pendente, o briefing checa ativamente se ele foi resolvido do lado da fonte, antes de decidir mostrá-lo de novo:
| Tipo de item pendente | Checagem de detecção |
|---|---|
| DM/menção no Slack | slack_read_thread; procurar a própria mensagem da PM depois de last_seen |
| Thread do Gmail | get_thread; procurar mensagem enviada pela PM depois de last_seen |
| Ticket sugerido | JQL: description ~ "<source_url>" OR comment ~ "<source_url>" |
| Bloqueador | getJiraIssue; se o status não for mais "Bloqueado", resolvido |
| PR esperando review | se merged_at não for nulo, resolvido |
A detecção de ticket criado só funciona por causa do rodapé com URL de origem do Padrão #3. Todo ticket criado via Skill de dedup inclui sua URL de origem. Então o próximo briefing encontra qualquer ticket criado a partir de qualquer fonte — via dashboard, via chat ou por uma pessoa do time independentemente — com uma busca JQL.
Dica prática: Qualquer uma das camadas de persistência sozinha gera uma UX ruim. Só o arquivo de estado, e os cliques não ficam registrados até o próximo briefing. Só o localStorage, e itens reaparecem depois de cada refresh. Elas são baratas de adicionar juntas se você projetar pensando nisso desde o começo.
Padrão #5: sub-agentes precisam ser emitidos em uma única mensagem
O briefing puxa de até sete fontes. Feito em série, são 60–90 segundos. Em paralelo, são 15–25 segundos — limitado pela fonte mais lenta.
O truque é um que eu já tinha aprendido do jeito difícil: spawnar um sub-agente por fonte e emitir todas as chamadas de ferramenta de agente em uma única mensagem de resposta. Tool calls dentro de uma mensagem do assistente rodam concorrentemente. Se você emite em várias mensagens, elas executam em sequência e a paralelização vai embora.
Cada fetcher retorna um contrato JSON fixo:
{
"source": "slack" | "jira" | ...,
"status": "ok" | "partial" | "failed",
"error": null | "<mensagem curta>",
"data": { ... formato específico da fonte ... }
}
Isolamento de falha é o benefício subestimado. Um conector instável de uma fonte não quebra o resto do briefing — o orquestrador anota essa fonte como falhada na seção de saúde das fontes do dashboard e segue em frente.
Dica prática: Se você escreve Skills que orquestram múltiplos sub-agentes, faça de "emitir todas as Agent tool calls em uma única mensagem" a primeira regra no doc de referência de paralelismo do Skill. É o erro mais comum quando um modelo escreve lógica de orquestração, e dá um ganho de velocidade de 5–8x.
Onde as coisas dão errado
Eu não estaria fazendo um favor a você se só falasse das vitórias. Três coisas doeram o suficiente para lembrar.
A validação na instalação me deu mensagens de erro opacas. O plugin foi publicado sem problemas na v0.1, depois falhou completamente ao instalar na v0.2 com uma única mensagem inútil: "Plugin validation failed." O validador de CLI do host (claude plugin validate) só checa o plugin.json e reportou o manifest como ok. A validação real de instalação era mais rígida e não revelava nenhum erro específico. Tive que bisecionar.
Construí um plugin de teste mínimo, byte a byte idêntico à última versão boa conhecida, só com a versão incrementada. Instalou. Daí adicionei componentes de volta pela metade até achar o que quebrou a instalação. Seis rodadas de bisseção no total. As falhas reais, em ordem:
- Dotfiles em caminhos não-padrão. Um arquivo
vault-seed/.team-roster-default.json. O instalador aceita apenas um caminho específico de dotfile. Renomeado. - Campo
version:no frontmatter do Skill. O doc de schema lista como opcional; o instalador rejeita qualquer campo de frontmatter além denameedescription. Removido de todos osSKILL.md. - Sinais de menor/maior em descrições de Skill. O brutal. A descrição de um Skill continha
"qual é o último sobre <subject> antes da reunião". O literal<subject>parecia uma tag HTML não fechada para o parser do instalador. Mesmo erro genérico "validation failed" — seis rodadas de bisseção até eu estreitar para um único Skill, depois uma única linha, depois uma única substring.
Dica prática: Teste instalação no dia um com um shell mínimo de plugin, depois adicione cada componente incrementalmente. Quanto mais próximo seu loop de dev estiver de "instalar de verdade no sistema alvo", mais rápido tudo o resto se move. Se eu tivesse feito isso desde o começo, teria pego as três pegadinhas imediatamente em vez de empilhar sete Skills novos em uma versão e gastar meio dia bisecionando.
O bug de encoding. O dashboard renderizava — (em-dash) como â€". Clássico de bytes UTF-8 decodificados como Latin-1. Dois caracteres de HTML resolveram:
<meta charset="utf-8">
Mais uma regra de defesa em profundidade no Skill: ao serializar o blob de dados que o Skill injeta no artifact, escapar não-ASCII para \uXXXX (Python: json.dumps(d, ensure_ascii=True)). Assim não importa como o host serve o HTML — o JSON é ASCII puro e decodifica corretamente.
Se seu dashboard alguma vez mostrar â€" ou ·, esse é seu bug.
Drafts que não soavam como a PM. A primeira versão da feature de respostas sugeridas parecia output genérico de LLM. A solução foi um Skill separado que lê as últimas 50 mensagens enviadas no Slack e 20 e-mails da PM, extrai um perfil de tom (tamanho, formalidade, despedidas, manias, o que ela não faz) e escreve em style-guide.md no vault. Os sub-agentes de draft leem esse arquivo uma vez no começo do batch. Com ele, os drafts soam como ela. Sem ele, são ruído.
O que eu configuraria no dia um
Se eu estivesse começando um plugin parecido do zero amanhã, eis o que eu configuraria primeiro:
Um loop mínimo de instalação. Empacotar e instalar um shell quase vazio de plugin no dia um. Adicionar componentes incrementalmente e re-instalar depois de cada um. Pegar falhas do validador imediatamente, não na semana três.
IDs estáveis desde o primeiro commit. Permalink do Slack, thread_id do Gmail, chave do ticket do Jira, owner/repo#number. Todo padrão de persistência downstream depende deles. Se você começar com hashes de conteúdo ou match difuso, vai se arrepender.
Modelo de persistência em duas camadas desde o começo. Arquivo de estado no vault, localStorage no artifact, comunicando via IDs estáveis. Encaixar isso depois significa desfazer lógica de escrita nova em vários pontos.
Regras de segurança permanentes por escrito. Respostas no Gmail vão sempre para Rascunhos, nunca envio direto. Envios no Slack exigem um clique explícito. Se um envio no Slack falhar, expor o texto para colar manualmente — não tentar de novo em outro canal. Essas regras vivem tanto na descrição do Skill quanto no JS do artifact, porque qualquer uma sozinha é um único ponto de falha.
O que eu daria para a próxima pessoa fazendo isso
Um pequeno kit de padrões, mais ou menos na ordem em que pagaram:
| Padrão | Por que importa |
|---|---|
| Vault em markdown puro com wikilinks e tags | Histórico de graça, sem integração para manter |
| IDs estáveis em todo lugar | Base para persistência entre execuções |
| Correlação em duas passadas (entidade → tópico) | 80% do agrupamento é grátis; só o residual precisa de julgamento do LLM |
| Dedup antes de criar + rodapé com URL de origem | Um padrão elimina tickets duplicados e viabiliza detecção de criação em outro lugar |
| Sub-agentes emitidos em uma mensagem | Speedup de 5–8x, isolamento de falha, formato limpo |
sendPrompt do artifact para invocar Skills | Liga botões da UI a fluxos de Skill com dedup |
| Modelo em duas camadas de arquivo de estado + localStorage | A única forma de fazer "já tratei isto?" parecer certo |
<meta charset="utf-8"> + JSON ensure_ascii | Solução grátis para um bug que só aparece depois do ship |
E um único anti-padrão, aprendido devagar: não coloque placeholders com sinais de menor/maior em valores de descrição no frontmatter YAML quando seu instalador parseia como markup. A mensagem de erro vai ser só "validation failed".
Se você está construindo uma ferramenta parecida, a arquitetura é mais geral que os conectores específicos. Troque Jira por Linear, Slack por Discord, Confluence por Notion — os padrões se sustentam. O formato que importa é: Skill orquestra, vault lembra, artifact executa as ações. O resto é detalhe.
O que mais me surpreendeu não foi nenhuma decisão técnica isolada. Foi quanto a restrição "não é para mim" mudou o trabalho. Quando você constrói para si, consegue absorver cem pequenos atritos porque conhece suas próprias ferramentas. Quando constrói para outra pessoa, cada um desses atritos vira um bug. Essa pressão deixou o design melhor.