↓ Skip to main content

Hook protocol

Documented version: v0.1.8

vults-hook and the app exchange one JSON object per line over a local socket.

Transport
#

PlatformEndpointAccess 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
macOSplannedgetpeereid

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, or other for any other tool (see Other agents). Antigravity, Cursor, Factory Droid and Kimi Code are built in but sent as other, with agent_name antigravity, cursor, droid and kimi.
  • agent_name: only with other, the tool’s name: 1 to 24 of a-z, 0-9 and -. Refused: the names the wire has a kind for (claude, codex, copilot, opencode, qwen and other) and gemini, 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 named other, and an other never gets a card. Absent otherwise.
  • id: unique per message, opaque.
  • zeca: true only on an event from the chat’s own claude or codex (the hook sees VULTS_ZECA=1 in 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 has wants_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: a claude the chat launches through a command is hidden too.
  • terminal: every field is optional. env only lists the variables that were set among those identifying the terminal, plus VULTS_TASK in a session one of your tasks started (its bird takes off from Zeca). cwd is the payload’s cwd, else the first entry of workspacePaths (Antigravity) or workspace_roots (Cursor), else the hook’s own working folder.
  • payload: the agent’s hook JSON without tool_response, transcript_path (and Antigravity’s transcriptPath and artifactDirectoryPath); strings are capped at 2000 bytes. Two things survive in tool_response: a tool’s error, and on a PostToolUse Claude Code’s structuredPatch, cut to its first 400 lines (with "cut": true when lines were left out). On a PostToolUse of Codex’s apply_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_reply is 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
#

LimitValue
Message size1 MiB
Hook: connect300 ms
Hook: event with no reply2 s in total
Hook: hearing waiting2 s
Hook: waiting for a decision110 s
App: UI acknowledgement of a card800 ms
App: decision108 s
App: a card’s answer accepted107 s from when the hook’s line was read (1 s left for the answer to travel back)
App: reading a message5 s
App: simultaneous connections32

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.