vults-hook and the app exchange one JSON object per line over a local socket.
Transport#
| Platform | Endpoint | Access control |
|---|---|---|
| Linux | $XDG_RUNTIME_DIR/vults.sock (fallback /tmp/vults-<uid>/, folder 0700) | socket 0600, SO_PEERCRED |
| Windows | \\.\pipe\vults-<SID> | both ends check the other process’s SID |
| macOS | planned | getpeereid |
Messages#
Hook to app, version 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(our plugin),qwen,copilot, orotherfor any other tool (see Other agents). Antigravity, Cursor, Factory Droid and Kimi Code are built in but sent asother, withagent_nameantigravity,cursor,droidandkimi.agent_name: only withother, the tool’s name: 1 to 24 ofa-z,0-9and-. Refused: the names the wire has a kind for (claude,codex,copilot,opencode,qwenandother) andgemini, which has no kind since version 7 but stays taken because Gemini CLI’s old hooks may still be installed. Any other name, those four built-ins’ included, is a namedother, and anothernever gets a card. Absent otherwise.id: unique per message, opaque.zeca:trueonly on an event from the chat’s ownclaudeorcodex(the hook seesVULTS_ZECA=1in its environment), absent otherwise. Such an event is Zeca’s work: the app never makes it a session, a card or a bird, and it never haswants_reply, since the chat asks its own cards. Setting the variable elsewhere only hides that agent from the island; its own terminal still asks. Every process the chat’s CLI starts inherits it: aclaudethe chat launches through a command is hidden too.terminal: every field is optional.envonly lists the variables that were set among those identifying the terminal, plusVULTS_TASKin a session one of your tasks started (its bird takes off from Zeca).cwdis the payload’scwd, else the first entry ofworkspacePaths(Antigravity) orworkspace_roots(Cursor), else the hook’s own working folder.payload: the agent’s hook JSON withouttool_response,transcript_path(and Antigravity’stranscriptPathandartifactDirectoryPath); strings are capped at 2000 bytes. Two things survive intool_response: a tool’serror, and on aPostToolUseClaude Code’sstructuredPatch, cut to its first 400 lines (with"cut": truewhen lines were left out). On aPostToolUseof Codex’sapply_patch,tool_input.command(the patch) keeps up to 64 KiB. The fields are optional and the envelope did not change, so this needed no new version. An event that would still pass the 1 MiB message size has its strings cut shorter, then keeps only the fields that say what happened (hook_event_name,session_id,cwd,tool_name…): the app drops a longer line unread. Such an event never waits for an answer (wants_replyis false): its card could not show the whole of what Allow would authorize, so the agent’s own terminal asks.
An event waits for a reply (wants_reply) when it is a PermissionRequest from Claude Code,
Codex, OpenCode, Qwen Code or Copilot CLI, or a PreToolUse for AskUserQuestion from Claude Code or OpenCode sent by an entry installed
with --ask (vults-hook --agent claude --ask PreToolUse, with a 120-second timeout; OpenCode’s
plugin runs the relay the same way for its question tool, in Claude Code’s words). An entry without the
flag has the short timeout of every other event, so it never waits.
App to hook, only when wants_reply is 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 comes first, once the card is on screen: a human is being asked, and the decision may
take a while. A hook that hears neither waiting nor the reply within 2 s gives up, so an app
that is stopped or frozen (whose socket the kernel still accepts) holds an agent for 2 s, not for
the whole decision budget. A decision that beats the card (an Always rule) comes without it.
answers has one entry per question, in the order of tool_input.questions: a string (a choice’s
label, or the user’s own words) or, for a multi-select, a list of them. It goes by position because
the app only saw the questions with their strings capped; the hook keys the answers with the
questions it read.
An event from other never has wants_reply: nothing in the app answers another tool’s permission.
StatusLine is Claude Code’s statusLine input, sent by vults-hook --agent claude --statusline.
Its payload is only rate_limits (the plan’s 5-hour and weekly windows), cost.total_cost_usd
(what the session’s tokens would cost at API prices, for its card) and session_id; the session’s
paths, its other cost figures and its model never leave the hook. Before the session’s first reply
there are no rate_limits, and with no cost either nothing is sent. The hook prints only what the user’s own status line prints
(saved on install, see settings), or nothing when there is none, so Claude Code’s
status line looks as it did before.
Version 2 added other and agent_name, then gemini. Version 3 added answer. Version 4 added
opencode, which an older hook sent as other. Version 5 added waiting, then qwen, which an older hook sends as
other (the app reads it as any other tool’s, without a card). Version 6 added zeca. Version 7 removed gemini: hooks left from Gemini CLI send nothing. Version 8 added copilot. The app installs its own hook when it starts, so the two
always speak the same version; an event from another version gets unsupported.
A connection that gets no reply, a reply for another id, or unsupported makes the hook print
nothing.
Limits#
| Limit | Value |
|---|---|
| Message size | 1 MiB |
| Hook: connect | 300 ms |
| Hook: event with no reply | 2 s in total |
Hook: hearing waiting | 2 s |
| Hook: waiting for a decision | 110 s |
| App: UI acknowledgement of a card | 800 ms |
| App: decision | 108 s |
| App: a card’s answer accepted | 107 s from when the hook’s line was read (1 s left for the answer to travel back) |
| App: reading a message | 5 s |
| App: simultaneous connections | 32 |
Agent output#
The hook turns a decision into the format each agent expects. Claude Code and Codex read the same
PermissionRequest output:
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied from Vults"}}}Copilot CLI reads the bare decision:
{"behavior":"allow"}
{"behavior":"deny","message":"Denied from Vults"}OpenCode’s plugin reads the reply it hands to OpenCode’s permission endpoint, once or reject
(never OpenCode’s always: an Always allow is the app’s own rule):
{"reply":"once"}
{"reply":"reject"}and, for answers, one list of labels (or the user’s own words) per question, in order, which it hands to OpenCode’s question endpoint:
{"answers":[["Blue"],["S","M"]]}An answer becomes Claude Code’s PreToolUse output: the tool runs with the answers added to its
own input, keyed by each question’s text (checked against Claude Code 2.1.286):
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow",
"updatedInput":{"questions":[…],"answers":{"Which color?":"Blue","Which sizes?":["S","M"]}}}}When the number of answers does not match the questions, the hook prints nothing and Claude Code asks in its terminal.