O vults-hook e o app trocam um objeto JSON por linha através de um socket local.
Transporte#
| Plataforma | Endpoint | Controle de acesso |
|---|---|---|
| Linux | $XDG_RUNTIME_DIR/vults.sock (alternativa /tmp/vults-<uid>/, pasta 0700) | socket 0600, SO_PEERCRED |
| Windows | \\.\pipe\vults-<SID> | as duas pontas verificam o SID do outro processo |
| macOS | planejado | getpeereid |
Mensagens#
Do hook para o app, versão 8:
{ "kind": "event", "v": 8, "id": "18f…-1a2b", "agent": "claude", "event": "PermissionRequest",
"wants_reply": true,
"terminal": { "cwd": "/home/me/project", "pid": 4242, "env": { "TERM_PROGRAM": "kitty" } },
"payload": { "tool_name": "Bash", "tool_input": { "command": "cargo test" } } }agent:claude,codex,opencode(o nosso plugin),qwen,copilot, ouotherpara qualquer outra ferramenta (veja Outros agentes). Antigravity, Cursor, Factory Droid e Kimi Code são nativos, mas vão comoother, comagent_nameantigravity,cursor,droidekimi.agent_name: só comother, o nome da ferramenta: de 1 a 24 caracteres entrea-z,0-9e-. São recusados os nomes para os quais o fio tem um tipo (claude,codex,copilot,opencode,qweneother) egemini, que não tem tipo desde a versão 7 mas segue reservado porque os hooks antigos do Gemini CLI podem continuar instalados. Qualquer outro nome, inclusive os desses quatro nativos, é umothercom nome, e umothernunca ganha card. Ausente nos outros casos.id: único por mensagem, opaco.zeca:truesó num evento do próprioclaudeoucodexdo chat (o hook vêVULTS_ZECA=1no ambiente dele), ausente nos outros. Um evento assim é trabalho do Zeca: o app nunca o transforma em sessão, card ou pássaro, e ele nunca temwants_reply, já que o chat pergunta os próprios cards. Definir a variável em outro lugar só esconde aquele agente da ilha; o terminal dele continua perguntando. Todo processo que a CLI do chat inicia a herda: umclaudeque o chat abre por um comando também fica escondido.terminal: todos os campos são opcionais.envlista só as variáveis definidas entre as que identificam o terminal, maisVULTS_TASKnuma sessão que uma tarefa sua começou (a ave dela decola do Zeca).cwdé ocwddo payload, senão a primeira entrada deworkspacePaths(Antigravity) ouworkspace_roots(Cursor), senão a pasta de trabalho do próprio hook.payload: o JSON de hook do agente semtool_response,transcript_path(e ostranscriptPatheartifactDirectoryPathdo Antigravity); as strings são limitadas a 2000 bytes. Duas coisas sobrevivem emtool_response: oerrorde uma ferramenta e, numPostToolUse, ostructuredPatchdo Claude Code, cortado nas primeiras 400 linhas (com"cut": truequando linhas ficaram de fora). NumPostToolUsedoapply_patchdo Codex,tool_input.command(o patch) guarda até 64 KiB. Os campos são opcionais e o envelope não mudou, então isso não precisou de uma versão nova. Um evento que ainda passaria do tamanho de mensagem de 1 MiB tem as strings cortadas mais curtas e depois fica só com os campos que dizem o que aconteceu (hook_event_name,session_id,cwd,tool_name…): o app descarta uma linha maior sem ler. Um evento assim nunca espera resposta (wants_replyé falso): o card dele não conseguiria mostrar tudo o que o Allow autorizaria, então o próprio terminal do agente pergunta.
Um evento espera uma resposta (wants_reply) quando é um PermissionRequest do Claude Code, do
Codex, do OpenCode, do Qwen Code ou do Copilot CLI, ou um PreToolUse para AskUserQuestion do Claude Code ou do OpenCode enviado por uma entrada
instalada com --ask (vults-hook --agent claude --ask PreToolUse, com um timeout de 120 segundos;
o plugin do OpenCode roda o relay do mesmo jeito para a ferramenta question dele, nas palavras do
Claude Code). Uma entrada sem a
flag tem o timeout curto de todos os outros eventos, então ela nunca espera.
Do app para o hook, só quando wants_reply é true:
{ "kind": "waiting", "v": 8, "id": "18f…-1a2b" }
{ "kind": "decision", "v": 8, "id": "18f…-1a2b", "decision": "allow" }
{ "kind": "answer", "v": 8, "id": "18f…-1a2b", "answers": ["Blue", ["S", "M"]] }
{ "kind": "unsupported", "v": 8, "id": "18f…-1a2b" }waiting vem primeiro, assim que o card está na tela: um humano está sendo consultado, e a decisão
pode demorar. Um hook que não ouve nem waiting nem a resposta em 2 s desiste, então um app parado
ou congelado (cujo socket o kernel ainda aceita) segura um agente por 2 s, não pelo prazo inteiro
de decisão. Uma decisão que chega antes do card (uma regra Always) vem sem ele.
answers tem uma entrada por pergunta, na ordem de tool_input.questions: uma string (o rótulo de uma
opção, ou as palavras do próprio usuário) ou, numa seleção múltipla, uma lista delas. Vai por posição porque
o app só viu as perguntas com as strings limitadas; o hook associa as respostas às
perguntas que ele leu.
Um evento de other nunca tem wants_reply: nada no app responde à permissão de outra ferramenta.
StatusLine é a entrada de statusLine do Claude Code, enviada por vults-hook --agent claude --statusline.
O payload dele é só rate_limits (as janelas de 5 horas e semanal do plano), cost.total_cost_usd
(o que os tokens da sessão custariam em preço de API, para o card dela) e session_id; os caminhos, os
outros números de custo e o modelo da sessão nunca saem do hook. Antes da primeira resposta da sessão
não há rate_limits, e sem custo também nada é enviado. O hook imprime só o que o status line do próprio usuário imprime
(salvo na instalação, veja configurações), ou nada quando não há nenhum, então o status line
do Claude Code fica como era antes.
A versão 2 adicionou other e agent_name, depois gemini. A versão 3 adicionou answer. A versão 4
adicionou opencode, que um hook mais antigo mandava como other. A versão 5 adicionou waiting, depois qwen, que um
hook mais antigo manda como other (o app o lê como o de qualquer outra ferramenta, sem card). A versão 6 adicionou zeca. A versão 7 removeu gemini: os hooks que sobraram do Gemini CLI não mandam nada. A versão 8 adicionou copilot. O app instala o próprio hook quando inicia, então os dois
sempre falam a mesma versão; um evento de outra versão recebe unsupported.
Uma conexão que não recebe resposta, uma resposta para outro id, ou unsupported fazem o hook não
imprimir nada.
Limites#
| Limite | Valor |
|---|---|
| Tamanho da mensagem | 1 MiB |
| Hook: conectar | 300 ms |
| Hook: evento sem resposta | 2 s no total |
Hook: ouvindo waiting | 2 s |
| Hook: esperando uma decisão | 110 s |
| App: confirmação de um card pela UI | 800 ms |
| App: decisão | 108 s |
| App: resposta de um card aceita | 107 s a partir de quando a linha do hook foi lida (1 s para a resposta voltar) |
| App: leitura de uma mensagem | 5 s |
| App: conexões simultâneas | 32 |
Saída para o agente#
O hook transforma uma decisão no formato que cada agente espera. O Claude Code e o Codex leem a mesma
saída de PermissionRequest:
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied from Vults"}}}O Copilot CLI lê a decisão sozinha:
{"behavior":"allow"}
{"behavior":"deny","message":"Denied from Vults"}O plugin do OpenCode lê a resposta que ele entrega ao endpoint de permissão do OpenCode, once ou
reject (nunca o always do OpenCode: um Always allow é uma regra do próprio app):
{"reply":"once"}
{"reply":"reject"}e, para respostas, uma lista de rótulos (ou as palavras do próprio usuário) por pergunta, em ordem, que ele entrega ao endpoint de perguntas do OpenCode:
{"answers":[["Blue"],["S","M"]]}Uma resposta vira a saída de PreToolUse do Claude Code: a ferramenta roda com as respostas adicionadas à
própria entrada, associadas ao texto de cada pergunta (verificado com o Claude Code 2.1.286):
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow",
"updatedInput":{"questions":[…],"answers":{"Which color?":"Blue","Which sizes?":["S","M"]}}}}Quando o número de respostas não bate com o de perguntas, o hook não imprime nada e o Claude Code pergunta no terminal dele.