Skip to content
Docs menu

tempa hook for Claude Code, GitHub Copilot, Codex, Gemini CLI and Antigravity

The plugin calls tempa hook --harness claude-code for every hook event. The CLI reads the hook JSON from stdin, maps it to the Tempa event model (schema/event.json, adapter in client/src/harness/claude-code.ts) and prints Claude Code's output format. Shapes and failure semantics come from the TEMPA-004 spike.

--harness is looked up in the harness registry. Claude Code, GitHub Copilot, Codex, Gemini CLI and Antigravity are implemented (the others are described below); a harness that is known but planned would fail closed (exit 2). The logic is shared: handleHook in client/src/hook/handler.ts answers every harness, and a HookDialect holds what differs (tool names, output formats, the decide budget, login hints). The adapter contract, the capability matrix per harness and the degrade rules (for example ask as a deny with instructions) are in harnesses.md.

Output contract

  • An answer is one complete JSON object on stdout and exit 0, or nothing at all (no opinion).
  • Any unexpected error prints nothing on stdout, tempa: hook failed (<reason>); blocking (fail closed) on stderr, and exits 2, which blocks PreToolUse and UserPromptSubmit. Tempa never exits 1 on purpose (Claude Code treats 1 as a non-blocking error and lets the call through). The exception is a harness whose docs say it ignores a failing hook's exit code and that shows the model no reason when it does block (Antigravity): it always gets JSON, and on an error its adapter's deny with exit 0 (Antigravity).
  • The plugin also sets onFailure: "block" and a timeout on PreToolUse and UserPromptSubmit, which covers what the CLI cannot catch (Node missing, a hang, a kill).

Per event

Claude Code eventTempa eventWhat Tempa doesOutput
SessionStartsession.startSync (cache only while younger than sync_interval, else a 3 s fetch), render into the repo, classify the Claude account and apply account_policy (below), fetch the profile's policy module when it is not cached (2 s, PreToolUse), write the session file{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"<summary>"},"systemMessage":"<warning>"}; systemMessage only when offline, not logged in, Node too old, files were skipped, the account check has something to say, or the profile changed since the developer was last told (below)
UserPromptSubmitprompt.submitLocal check for secrets and personal data (private keys, AWS, GitHub, Anthropic and Slack tokens, password=-style assignments, IBANs with a valid checksum, BSNs next to the word BSN that pass the elfproef)On a match {"decision":"block","reason":"Tempa blocked this prompt: it looks like it contains <kind>. ...","suppressOriginalPrompt":true}, else nothing
PreToolUsetool.preSee below{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny|ask","permissionDecisionReason":"...","additionalContext":"<guidance>"}}; never allow (TEMPA-213)
PostToolUsetool.postPOST /v1/decide for guidance (1 s); failures are ignored, the tool already ran; skipped while the session backs off from a 429guidance: {"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"..."}}; deny: {"decision":"block","reason":"..."}; else nothing
Stopturn.stopShip the spooled Tempa events; POST /v1/heartbeat, at most every 10 minutesnothing
SessionEndsession.endLocal clean-up only (1.5 s budget, no network)nothing

The SessionStart summary puts the guardrails first, then the standards, skills, commands, agents and MCP endpoints with one line each and pointers to the rendered files. It aims under 6,000 characters and is cut at 10,000 (Claude Code's hard cap). Outside a git repo nothing is rendered, so the standards' text is added inline while it fits. When the tenant turned on its plan notice (feature flag plan.session_notice) and is over a limit of its plan, the profile's tenant.notice adds one neutral line under the first (its first line only, at most 300 characters; plans.md). A plan never changes what a hook decides.

Account check

At SessionStart, in parallel with the sync, the CLI classifies the session's Claude account (TEMPA-132, accounts.md): from the cloud provider variables, claude auth status (authMethod only, 1.5 s at most; TEMPA_SKIP_CLAUDE_AUTH_STATUS turns it off), whether the API key and token variables are set (never their value) and whether Claude Code's managed settings file pins an organisation. It remembers the kind for the heartbeat (~/.tempa/account.json) and applies the profile's account_policy (config.md):

on_violationSessionStartTool calls this session
informthe guidance as systemMessage, and for the model at the top of additionalContextas usual
asksamethe first PreToolUse returns ask with the guidance, later ones as usual
blocksameevery PreToolUse, read-only tools too, returns deny with Tempa (account policy): ...

A violation is an account kind outside allowed_kinds, or a missing tempa attest --no-training when require_training_attestation is on and the kind may be personal (personal, unknown). Both are counted as policy.decision events with the policy id tempa/account-policy. The verdict is kept in the session file, so a new session (after /login or tempa attest) is checked again. With the default policy nothing is shown.

What changed since the last sync

SessionStart (and tempa sync) compares the profile with the one the developer was last told about for the repo and puts the differences in the systemMessage (TEMPA-213), so a change to what the agent may do never reaches the laptop unseen:

Tempa profile changes for github.com/devleaps/bridge since the last sync: permissions: +deny Bash(rm:*),
-ask Bash(git push:*); policies: default ask → allow, +network; skills, commands and agents: ~tdd-workflow,
+deploy; MCP: ~second-brain (now https://mcp.example.com/brain), +github (https://mcp.example.com/github).

+ is added, - removed, ~ changed (a skill's content, an endpoint's URL or tool list). The areas are the permission rules, the policy bundles and default decision (what the hook applies), skills, commands and agents, and MCP endpoints; standards and settings are left out. The first profile for a repo gets one line with counts (Tempa: first profile for <repo>: 2 permission rules, 1 policy bundle, …). What was told is kept as announced.json next to the repo's cached profile (~/.tempa/cache/<sha256(repo key)>/), so it is said once per change, by whichever harness or command synced first.

PreToolUse

  1. Account policy: when the session's account check said ask (first call only) or block, that is the answer (Account check).
  2. Fast path: tools of kind read or search and session-only tools (tool vocabulary; for Claude Code Read, Glob, Grep, LS, NotebookRead and TodoWrite) are allowed at once (only the session file is read, no network; well under 30 ms in-process), but only when every path they name is inside the session's directory or its repo (TEMPA-223). Symlinks are followed, ~ is the home directory, and a glob counts by its part before the first wildcard. A read or search elsewhere (~/.ssh, ~/.aws, another project, ../, a brace pattern or a $VARIABLE Tempa cannot reason about) gets no decision, so the harness's own prompt applies. Any file tool (read, search, write, edit) that names Tempa's own folder (~/.tempa, or TEMPA_HOME: login tokens and settings) is denied, policy id tempa/own-files.
  3. MCP: on the structured MCP tool (tool.mcp), an mcp__<server>__… tool of another server is denied only when a valid cached profile sets allow_external_mcp to false; without a valid profile (none cached, or expired) the remote decision applies the tenant's setting, so a stale cache never blocks what the tenant allows. With a cached profile mcp__tempa__<endpoint>__<tool> is denied unless the endpoint is in the profile's mcp list and the tool is on its tools allowlist (null = all). The server applies the same rule.
  4. Local decision (TEMPA-123b): a call of kind shell is decided on the laptop from the cached profile's policy module (policies.wasm, downloaded at SessionStart), with the same parser rules, bundles, default decision and policy ids as /v1/decide (policies.md); no network, and the answer has the same format as a remote one (below). Step 4 only runs when the module cannot answer: the profile names none, it is not downloaded yet, its ABI is newer than the CLI, it does not load, the evaluation fails, local evaluation is off (TEMPA_LOCAL_EVALUATION=0 or the profile setting local_evaluation: false), or the command is uv add/pip install, which the server checks on PyPI first. In that last case the local answer is used when step 4 gets no answer, before the offline rules. The hook asks the resident evaluator first (TEMPA-123c, policies.md): a background tempa policy serve with the module and the grammar loaded, on a Unix socket, 250 ms at most. Without one it decides in its own process and starts one for the next call.
  5. Remote decision: POST /v1/decide with the event (tool.name, tool.kind, tool.mcp and tool.input always, prompt and tool responses only when the profile's telemetry turns them on), within 1 s (TEMPA_DECIDE_TIMEOUT_MS or decide_timeout_ms in ~/.tempa/config.json, including any token refresh).
    DecisionOutput
    denydeny, reason Tempa (<policy ids>): <reason>, guidance as additionalContext
    askask, same reason format
    guidanceno permissionDecision, the guidance as additionalContext
    allowno output
    deferno permissionDecision (Claude Code's own permissions decide); guidance as additionalContext if any, else no output

    Tempa never answers allow for Claude Code (TEMPA-213, as for Codex). A hook's allow skips Claude Code's own permission prompt, so a profile that allows a command (from a tenant the developer joined by mistake, or a careless config change) would run it unseen. An allow from a policy, the offline rules, the read-only fast path or the no-profile path is therefore no decision: Claude Code's own settings (permissions.allow the developer chose, the permission mode) and its prompt decide. Tempa still denies and asks. For the same reason the profile's permissions.allow is not rendered into settings.local.json.

  1. Unreachable, timed out or rate limited (429), valid cached profile: the profile's offline rules. Tools in offline.allow_tools are allowed, everything else gets offline.default_decision. The session is recorded as offline_cached. After a 429 the reason reads Tempa rate limit reached; offline rules of cached profile <version> and the session skips /v1/decide until Retry-After has passed (see Rate limits). A control plane that refuses the request (400 or 422, usually one older than the CLI that does not know a field it sends, such as tool.kind before TEMPA-125) gives Tempa control plane rejected the decision request (it may be older than this CLI); …, and a 5xx Tempa control plane failed on the decision request; … (TEMPA-195), so neither reads as unreachable. The CLI still sends every field: trimming the request to what an older server knows would need a second round trip inside the hook's time budget.
  2. Unreachable (or not logged in) and no cached profile: guardrails do not fail open (TEMPA-224). Every call that is not a read gets ask, on every call, with the reason Tempa control plane unreachable, no profile cached (or the rejection or failure above, …, no profile cached), what happens and the fix (tempa sync, tempa doctor). It is a deny instead (policy id tempa/no-profile) for:
    • a shell call on a login that never had a profile (logged in, but no sync ever succeeded);
    • any call when the tenant's offline default is deny. Each verified sync remembers the login's offline.default_decision in ~/.tempa/fail-modes.json (0600), apart from the profile cache, so this holds when the cache is deleted or the profile expired.

    Without a login at all, calls ask. On a harness without ask (Codex) every ask is a deny with the way on. The session is recorded as offline_no_profile once.

Rate limits

tempa-api limits decide and profile per tenant and per device (operations.md, TEMPA-056) and answers a request over a limit with 429 Too Many Requests and Retry-After. The hook treats that like an unreachable control plane, so a rate limit never blocks a session and never fails closed:

Over the limit onWhat the developer gets
POST /v1/decide (PreToolUse)The cached profile's offline rules, with the reason Tempa rate limit reached; offline rules of cached profile <version>; without a cached profile the no-profile path (ask on every call, deny shell on a login that never had a profile). The session then skips /v1/decide until Retry-After has passed (5 s without one, at most 60 s), so a throttled session does not keep knocking
POST /v1/decide (PostToolUse)No guidance for that tool call (the tool already ran); skipped during the same backoff
GET /v1/profile (SessionStart, tempa sync)The cached profile while it is valid, with the usual offline warning; the cache is kept
POST /v1/heartbeat (Stop)Nothing: the next heartbeat goes out after the usual 10 minutes
POST /v1/otlp/... (spool flush)The refused batch is dropped (telemetry.md)

The offline rules are the profile's own: with offline.default_decision: deny a rate-limited session denies what the profile would deny offline, by the admin's choice. Tests: client/test/hook.test.ts (rate limit cases) and client/test/sync.test.ts (a 429 keeps the cache).

Suspended organisation

When the operator suspends a tenant (TEMPA-055, auth.md), tempa-api answers its requests with 403 "This Tempa organisation is suspended. …". The hook treats that like an unreachable control plane, so a suspension never blocks a session: PreToolUse takes the cached profile's offline rules with the reason Tempa organisation suspended; offline rules of cached profile <version>, PostToolUse gives no guidance, SessionStart keeps and serves the cached profile while it is valid (with the offline warning that quotes the server's message), and the refresh token is kept (the token endpoint answers 403, not 401), so the CLI works again as soon as the tenant is reactivated. tempa status shows "logged in, but your organisation is suspended" with the server's message (exit 0: nothing to fix on the device), and tempa doctor warns with the same. Tests: client/test/hook.test.ts and client/test/login.test.ts.

GitHub Copilot

The Copilot plugin calls tempa hook --harness copilot --event <event> (TEMPA-160; the plugin is described in harnesses.md). The adapter is client/src/harness/copilot.ts. It brings each payload into Claude Code's shape and runs the same handler, so the same policy gives the same decision in both harnesses. A conformance test sends the same shell call in all three forms and checks this.

Two payload shapes, both documented in GitHub's hooks configuration:

ShapeWhenFieldsTool names
camelCaseThe Copilot CLI with camelCase event names (the plugin's hooks)sessionId, timestamp (epoch ms), cwd, toolName, toolArgs (an object; a JSON string is read too), toolResult, prompt, reason, traceparent (the W3C trace of Copilot's OTel spans; not on sessionStart); no event name, hence --eventCopilot's (bash, view, create, edit, ...), COPILOT_TOOLS
VS Code-compatiblePascalCase event names, and VS Codehook_event_name, session_id, cwd, tool_name, tool_input, tool_resultClaude Code's (Bash), plus Copilot's in case VS Code sends them

A payload whose hook_event_name is set is read as the VS Code shape, whatever --event says. Without --event the camelCase event is told from its fields (toolResult, toolName, prompt, ...). A payload the adapter cannot read fails closed with exit 2, which Copilot treats as a deny on preToolUse: no JSON, no cwd, toolArgs that is not a JSON object, an event it cannot tell. Without a sessionId, one session per working directory is used.

Copilot eventTempa eventOutput
sessionStartsession.start{"additionalContext":"<warnings>\n\n<summary>"}. Copilot reads nothing else at session start, so warnings go in front. It renders Copilot's own layout (.github/instructions, .github/skills, .github/agents; TEMPA-162, cli.md) and the summary points at it
userPromptSubmittedprompt.submitNothing: Copilot drops a command hook's output, so a prompt with a secret cannot be blocked. The secret check only keeps it out of work tracking (the VS Code shape gets Claude Code's block in case it is read)
preToolUsetool.pre{"permissionDecision":"allow|deny|ask","permissionDecisionReason":"Tempa (<policy ids>): <reason>\n\n<guidance>"}; defer gives no output
postToolUsetool.post{"additionalContext":"<reason of a deny>\n\n<guidance>"}, else nothing
agentStopturn.stopNothing; spool flush and the heartbeat (harness: copilot)
sessionEndsession.endNothing; local clean-up

The VS Code shape gets the same fields at the top level and again in hookSpecificOutput (with hookEventName), so either reader finds them.

  • Inside the timeout. Copilot allows a tool call whose hook times out, so the plugin sets timeoutSec: 10 on preToolUse and the adapter gives /v1/decide 2 s (COPILOT_DECIDE_TIMEOUT_MS; TEMPA_DECIDE_TIMEOUT_MS and decide_timeout_ms still win). On a timeout, 429 or 403 it uses the cached profile's offline rules, as in Claude Code. offline.allow_tools names Claude Code's tools, so Copilot's bash is allowed where Bash is (claudeCodeNames in the tool table). Shell calls are decided locally from the policy module (TEMPA-123b, PreToolUse step 3), so the usual bash call never waits for the network.
  • Account check (accounts.md, TEMPA-132). A Copilot session's account is classified as unknown with the signal other_harness, and the heartbeat reports it that way. The tenant's account_policy then applies as it does in Claude Code: inform adds the notice to the session start additionalContext, ask asks once on the first tool call, and block denies every tool call, read-only ones too. Each answer is in Copilot's format, with the policy id tempa/account-policy. The notice speaks of GitHub Copilot (TEMPA-162): Tempa cannot identify a Copilot account, so it names the allowed kinds, says Tempa can only check them in Claude Code, and points the training attestation at GitHub's Copilot settings instead of claude.ai.
  • Ask. Copilot shows ask to the developer. In prompt mode (copilot -p), autopilot and the cloud agent Copilot turns it into a deny ("Denied by preToolUse hook (unable to ask user for confirmation): <reason>", recorded with the CLI 1.0.105), and the no-profile asks then become denies.
  • The cloud agent (TEMPA-163) runs only hooks committed in the repo. An opted-in repo has .github/hooks/tempa.json, whose preToolUse runs tempa hook --harness copilot --bundle .github/tempa --bundle-key <key>: decisions from the committed bundle (signed by the control plane, checked against the pinned key, TEMPA-226) and module only, no login, no network, no telemetry (harnesses.md).
  • No double firing (TEMPA-161). Copilot runs every hook it finds, one after the other: its plugin's, and the repo's .claude/settings*.json (which it reads as Claude Code's, with the VS Code-compatible payload). A Tempa hook in such a file runs tempa hook --harness claude-code, so on a machine with both harnesses Tempa could answer one Copilot event twice, and render Claude Code's layout into a Copilot session. Two rules prevent it:
    • Copilot's payload is answered as Copilot. Copilot's PascalCase payload has a timestamp, which Claude Code does not send, and no Claude Code transcript (<config dir>/projects/<project>/<session>.jsonl), which Claude Code always sends. --harness claude-code hands such a payload to the Copilot adapter: Copilot's output, Copilot's layout, harness: copilot in events.
    • One answer per event. The Copilot adapter keeps the last event it answered per session (~/.tempa/sessions/<id>.last-event.json): its time (timestamp, epoch milliseconds or ISO), the hook event, the tool by Claude Code's name (or the MCP tool) and the input. A second run of the same event within 10 seconds gets the same answer, in its own format, without deciding, rendering or reporting again. A tool call the agent makes twice has two timestamps and is decided twice; a payload without a timestamp is never taken for a repeat.
    • Payload detection rather than leaving hooks out: Tempa does not write .claude/settings*.json hooks, but a team may, and Copilot gives such hooks no way to tell their source. Tests: client/test/double-firing.test.ts with both payload shapes.
  • Not used: modifiedArgs (no Tempa decision rewrites arguments) and agentStop's decision: block.
  • Recorded from a real Copilot CLI (TEMPA-167). The camelCase fixtures in client/test/fixtures/copilot/ are payloads of the Copilot CLI 1.0.105 on macOS, with fictional ids and paths: every event has sessionId, timestamp and cwd, toolArgs is an object, toolResult is {resultType, textResultForLlm}, agentStop has transcriptPath (~/.copilot/session-state/<session>/events.jsonl), stopReason and stop_hook_active, and sessionStart has source: "new" and initialPrompt. Plugin hooks run in the plugin's folder with PLUGIN_ROOT set; repo hooks (.github/hooks/*.json) in the repo. A tool call Tempa denies is not passed to the hooks of plugins after Tempa's. Still unverified: which shape VS Code sends, and the Copilot CLI 0.0.400, which runs none of the plugin's hooks (it reads only legacy hooks.json).
  • MCP tools (TEMPA-164). A camelCase toolName of <server>-<tool> (recorded: tempa-second-brain__list_collections, tempa-work_item), <server>/<tool> or mcp__<server>__<tool> is an MCP tool (never one of Copilot's own tools), so the local MCP allowlist applies as in Claude Code: tempa-second-brain__delete is denied when delete is not on the endpoint's allowlist, and another server's tool is denied when the profile blocks external MCP servers. The VS Code shape uses Claude Code's mcp__<server>__<tool> (unverified).
  • The harness version is what copilot --version said, checked once a day at session start (TEMPA-165), since Copilot does not tell its hooks; unknown before the first check or without a Copilot CLI (VS Code alone). copilot --version prints GitHub Copilot CLI 1.0.105. and a line about updates, in about 0.7 s (TEMPA-167); hooks also get COPILOT_CLI_BINARY_VERSION in their environment, which Tempa does not read yet. Copilot's own telemetry is in telemetry.md. Step rules that match Claude Code's tool names (Bash, Edit) do not match Copilot's camelCase names yet.

Codex

The Codex plugin calls tempa hook --harness codex (TEMPA-142; the plugin is TEMPA-141). The adapter is client/src/harness/codex.ts. Codex sends Claude Code's payload shape (hook_event_name, session_id, cwd, tool_name, tool_input, tool_response, plus turn_id, model and permission_mode), so the same handler answers it with Codex's tool table (Bash, apply_patch, mcp__<server>__<tool>, update_plan, spawn_agent; tool vocabulary). Events Tempa does not handle (PermissionRequest, SubagentStart, SubagentStop, PreCompact, PostCompact, Interrupt) get no output.

What Codex accepts is stricter than Claude Code. Read from its hooks docs and its source (codex-rs/hooks/src/engine/output_parser.rs and the output schemas in codex-rs/hooks/schema/generated, 2026-11-02):

Codex eventTempa eventOutput
SessionStartsession.startAs Claude Code: hookSpecificOutput.additionalContext and systemMessage. It renders Codex's layout (AGENTS.md, .agents/skills, .codex/agents; TEMPA-144, cli.md) and the summary points at it
UserPromptSubmitprompt.submitOn a secret {"decision":"block","reason":"..."}, without Claude Code's suppressOriginalPrompt (Codex rejects keys it does not know)
PreToolUsetool.predeny: {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Tempa (<policy ids>): <reason>","additionalContext":"<guidance>"}}. Everything else is no decision, with the guidance as additionalContext when there is any
PostToolUsetool.postAs Claude Code: {"decision":"block","reason":"..."} for a flagged result (Codex replaces the tool's output with the reason), guidance as additionalContext
Stopturn.stopNothing; spool flush and the heartbeat (harness: codex)
SessionEndsession.endNothing; local clean-up (Codex gives it 1 s by default, at most 3)
  • No allow. Codex takes permissionDecision: "allow" only together with updatedInput (a rewrite), and marks a plain allow as a failed hook. Tempa never rewrites a tool call, so its allow, guidance and defer are no decision: Codex's own approval policy then decides, as it does without Tempa. A read-only tool's fast path gives no output either.
  • No ask in the hook. Codex marks ask as a failed hook and runs the tool. So an ask becomes a deny that keeps the policy's reason and says how to go on (ask-as-deny, harnesses.md). That covers asks from a policy, from the offline rules (offline.default_decision: ask) and from the account policy. Without a profile the first tool call is denied with the reason and the way on, and the rest of the session is allowed.
  • Except where Codex asks itself (TEMPA-143). Codex's execpolicy can ask: the profile's ask shell rules are rendered as prompt rules in .codex/rules/tempa-permissions.rules (cli.md). An ask (from /v1/decide or the offline rules) then gets no decision from the hook, only the guidance as context, so Codex's own prompt asks the developer. This happens only when all of these hold:
    • a rendered prompt rule covers the whole shell command; compound commands (;, &&, |, $(...)) never count;
    • the payload's permission_mode is default (in other modes Codex may not prompt);
    • the rules file is in the repo;
    • ~/.codex/config.toml marks the project, or the nearest folder above it that it names, trust_level = "trusted" (Codex loads project rules only then; a repo inside a trusted folder counts, and Codex CLI 0.157.1 writes that repo's own [projects."<repo root>"] entry on its first session there).

    In every other case the deny stays. Seen on Codex CLI 0.157.1 (TEMPA-149): a git-excluded .codex/rules/tempa-permissions.rules is read in a trusted project, codex execpolicy check agrees with coveringRule on the rendered rules, and a prompt rule asks for a command that would run inside the sandbox too. codex exec always runs with approval never (permission_mode: "bypassPermissions" in every payload, whatever -c approval_policy says), so there a prompt rule rejects the command ("approval required by policy, but AskForApproval is set to Never") and Tempa keeps its own deny. Which interactive modes prompt is not checked.

  • Fails open. Codex blocks a tool call only on an explicit deny or on exit 2 with a reason on stderr. Any other exit code, a crash without exit 2, a timeout, or an answer Codex cannot parse (unknown keys, a deny without a reason) marks the hook failed and the tool runs. Tempa's own errors exit 2 with the reason on stderr (and the launcher turns a crashed Node into exit 2), so they block; a hang does not. The plugin gives PreToolUse 10 s and /v1/decide gets 2 s (CODEX_DECIDE_TIMEOUT_MS; TEMPA_DECIDE_TIMEOUT_MS and decide_timeout_ms still win), after which the cached profile's offline rules answer. Seen on Codex CLI 0.157.1 (TEMPA-149): a deny blocks the call and the model reads Command blocked by PreToolUse hook: <reason>. Command: <command> (Tool call blocked by … for an MCP tool); exit 2 from the launcher blocks with its stderr as the reason; a SessionStart answer with only additionalContext reaches the model as context and is not reported as a failed hook. Codex's docs say it plainly: treat tool hooks as a guardrail, not a complete enforcement boundary. Locked tenants deliver the hooks as managed hooks (requirements.toml, TEMPA-148), which a developer cannot turn off.
  • Hosted tools (web search) do not reach hooks, so the fetch allowlist cannot see them.
  • One answer per event (TEMPA-145). For the IDE extension, which loads no plugins, the CLI writes user-level hooks (~/.codex/hooks.json, running ~/.tempa/codex-hook.sh). In the CLI those run next to the plugin's, and Codex runs matching hooks in parallel (seen on 0.157.1: both start for every event, about 50 ms apart, and overlap). The first Tempa process to claim an event answers it; the claim is an exclusive file ~/.tempa/sessions/<hash>.claim keyed by the tool call id, the turn id, or the session start's source, and it holds for 10 seconds. Every other process gives no output. That changes nothing, because Codex blocks when any hook denies and adds the context of each. A process that dies after claiming leaves the event unanswered, which Codex treats as no opinion.
  • Account check. A Codex session is unknown (other_harness), as in Copilot; the notice speaks of Codex.
  • Version. Codex does not tell its hooks its version, so it is what codex --version said, once a day at session start; unknown before that. It prints codex-cli 0.157.1, read as 0.157.1.
  • MCP names. Codex names a tool of the tempa server mcp__tempa__<endpoint>__<tool> with every character but [A-Za-z0-9_] turned into _, so second-brain arrives as second_brain. The hook maps the endpoint and the tool back to the profile's names before the MCP allowlist and /v1/decide see them (before TEMPA-149 every call to a hyphenated endpoint was denied with "not in your profile").
  • Recorded (TEMPA-149): the fixtures in client/test/fixtures/codex/ are payloads from Codex CLI 0.157.1 (codex exec), with fictional ids and paths; the output is checked against Codex's output schemas (client/test/fixtures/codex/hook-output.schemas.json). Every payload has transcript_path (a string) and permission_mode, SessionEnd has neither model nor permission_mode, and tool_use_id is exec-<uuid> for a shell or patch and call_<id> for other tools. The tool names and tool_response shapes are in harnesses.md.

Gemini CLI

The Gemini extension calls tempa hook --harness gemini-cli (TEMPA-152; the extension is TEMPA-151). The adapter is client/src/harness/gemini.ts. Gemini sends Claude Code's payload fields (session_id, cwd, tool_name, tool_input, tool_response, plus timestamp, transcript_path and, for MCP tools, mcp_context) under its own event names, which the adapter renames, so the same handler answers it with Gemini's tool table (run_shell_command, read_file, replace, mcp_<server>_<tool>, ...; tool vocabulary). Events Tempa does not handle (BeforeModel, AfterModel, BeforeToolSelection, Notification, PreCompress) get no output.

Read from the hooks reference and the source (packages/core/src/hooks/types.ts, hookRunner.ts, hookAggregator.ts, scheduler/hook-utils.ts, 2026-11-04; the TEMPA-150 spike):

Gemini eventTempa eventOutput
SessionStartsession.startAs Claude Code: hookSpecificOutput.additionalContext (added to the history) and systemMessage. It renders Gemini's layout (GEMINI.md, .gemini/skills, .gemini/commands, .gemini/agents; TEMPA-154, cli.md) and the summary points at it
BeforeAgentprompt.submitOn a secret {"decision":"deny","reason":"..."}, which blocks the turn and discards the prompt
BeforeTooltool.predeny: {"decision":"deny","reason":"Tempa (<policy ids>): <reason>\n\n<guidance>"}; the reason is the tool error the model sees. ask (Gemini CLI 0.36.0 and newer): {"decision":"ask","reason":"...","systemMessage":"..."}, the same text, which Gemini's confirmation shows. allow and guidance: {"decision":"allow"}, guidance as hookSpecificOutput.additionalContext. defer: no output
AfterTooltool.postA flagged result: {"decision":"deny","reason":"..."}, which replaces the tool's output with the reason; guidance as hookSpecificOutput.additionalContext, added to the tool's output
AfterAgentturn.stopNothing; spool flush and the heartbeat (harness: gemini-cli)
SessionEndsession.endNothing; local clean-up
  • Allow is no objection. A hook allow does not skip Gemini's own approval: its Policy Engine still decides whether to ask, as it does without Tempa (scheduler.ts checks the hook first, then the policy).
  • Guidance before the call does not reach the model. BeforeTool reads no additionalContext (only SessionStart, BeforeAgent and AfterTool do), so a deny carries the guidance after its reason, and an allow's guidance only goes as additionalContext in case Gemini starts reading it. The model gets guidance after the call from the AfterTool answer.
  • Ask, depending on the version. Gemini's BeforeTool hooks can answer ask since v0.36.0 (scheduler/hook-utils.ts): Gemini then shows its own confirmation with the hook's systemMessage, even where its policy would allow the call or the session runs in yolo mode. So asks from a policy, from the offline rules, from the account policy and the no-profile path are real asks there. The version is what gemini --version said (once a day, at session start); for an older Gemini CLI, or while the version is still unknown (the very first session), an ask becomes a deny that keeps the policy's reason and says how to go on (ask-as-deny, harnesses.md), and without a profile the first tool call is denied with the way on, as in Codex. Except where Gemini's Policy Engine asks (TEMPA-153): the profile's ask shell rules are rendered as ask_user rules in ~/.gemini/policies/tempa-permissions.toml (cli.md), and Gemini checks the hook before its policies. So on those versions an ask gets no decision from the hook when a rendered rule covers the whole shell command (compound commands never count) and the policy file is this profile's (its origin line names the node and version); then Gemini's own confirmation asks. Gemini reads policies when it starts, so a file the session's own start rendered may only count from the next session (TEMPA-158).
  • No ask in a headless session. A forced ask from a hook may wait for an answer no one gives in gemini -p (unverified, TEMPA-158), and Gemini's Policy Engine turns ask_user into a deny there without Tempa's instructions. The payload does not say whether the run is interactive, so the hook reads what Gemini's own isHeadlessMode (utils/headless.ts) looks at and the hook can see: CI or GITHUB_ACTIONS set to true (Gemini passes both through its environment sanitisation, except in its strict mode for GitHub, where only an allowlist passes), and whether the process has a controlling terminal (/dev/tty; none in CI, cron or a pipeline). Either means headless, and an ask is then a deny with the way on, on every version. Not detected: gemini -p started from a terminal, or with its input piped, has a terminal and no CI variable, so a hook ask there is still a real ask; on Windows only the CI variables count. Set CI=true for such scripted runs, or check in TEMPA-158 whether Gemini answers a headless ask itself.
  • Fails open, mostly. Gemini treats a hook's exit 2, or any other non-zero exit, with text on stdout or stderr as a deny with that text (convertPlainTextToHookOutput; stderr is read when stdout is empty), so Tempa's own errors (exit 2, the reason on stderr) and the launcher's (Node or the CLI missing) block. These let the tool run: a timeout (60 s by default; the extension sets 10 s on BeforeTool), a hook that cannot be started, exit 1 (a crashed Node prints its stack and exits 1, which Gemini reads as an allow with a warning, unless the launcher turns it into 2), exit 0 with text that is not JSON, and no output at all. So the adapter gives /v1/decide 2 s (GEMINI_DECIDE_TIMEOUT_MS; TEMPA_DECIDE_TIMEOUT_MS and decide_timeout_ms still win), after which the cached profile's offline rules answer, and shell calls are decided locally from the policy module (TEMPA-123b, PreToolUse step 3), so the usual run_shell_command never waits for the network. Locked tenants add the hooks to Gemini's system settings as well (TEMPA-157), which a developer can still bypass with GEMINI_CLI_SYSTEM_SETTINGS_PATH (lock-down-advisory).
  • One answer per event. Gemini runs the hooks of every source (the extension's, the user's, the system settings') for an event, in parallel, and blocks when any of them denies. With a lock-down pack two Tempa hooks run per event; Gemini hands both the same input, so the first Tempa process to claim the event (keyed by the session, the event, its timestamp and the tool call; the claim file of TEMPA-145) answers it and the other gives no output. A payload without a timestamp is always answered.
  • Account check. A Gemini session is unknown (other_harness), as in Codex; the notice speaks of the Gemini CLI.
  • Version. Gemini does not tell its hooks its version, so it is what gemini --version said, once a day at session start; unknown before that. It decides whether an ask is a real ask (above).
  • Unverified until a real Gemini CLI runs it (TEMPA-158): the fixtures in client/test/fixtures/gemini-cli/ are written from Gemini's hook input types; still to confirm: the tool_response text of run_shell_command, the mcp_context of an extension's server, and the gemini --version format.

Antigravity

The Antigravity plugin calls tempa hook --harness antigravity --event <Event> (TEMPA-178; the plugin is TEMPA-177), through node ./bin/tempa-launch.mjs from the plugin folder. The adapter is client/src/harness/antigravity.ts. The agy CLI, the App and the IDE share one hooks contract: camelCase JSON (conversationId, workspacePaths, transcriptPath, modelName, lastUserInput, and toolCall with name and args on tool events, stepIdx, invocationNum, executionNum) without an event name, so the hook line passes it. The adapter brings the payload into Claude Code's shape (conversationId as the session, workspacePaths[0] as the working directory, the home directory when there is none, toolCall as the tool) and the same handler answers it with Antigravity's tool table (run_command with CommandLine, read_file, write_file, edit_file, call_mcp_tool, the docs' view_file and replace_file_content, ...; tool vocabulary).

Read from the hooks reference (antigravity.google/docs/hooks, 2026-11-05) and the TEMPA-176 spike, and checked against a real agy -p 1.5.2 on macOS (TEMPA-185, the recorded fixtures in client/test/fixtures/antigravity/):

Antigravity eventTempa eventOutput (always JSON, exit 0)
SessionStartsession.start{"injectSteps":[{"systemMessage":{"systemMessage":"<warnings>\n\n<summary>"}}]}. Session start renders the profile into .agents/ first (TEMPA-180, cli.md), and the summary points at the rules, skills and agents; outside git it carries the standards' text
PreInvocation (first model call of a turn, invocationNum 0)prompt.submit on lastUserInputOn a secret {"injectSteps":[{"errorMessage":{"userMessage":"..."}}],"terminationBehavior":"terminate"}: the turn stops before the model sees the prompt, and the reason is shown to the developer only. Otherwise {}, or what Tempa had to say after the last tool (below)
PreInvocation (a later model call)none{}, or what Tempa had to say after the last tool
PreToolUsetool.predeny: {"decision":"deny","reason":"Tempa (<policy ids>): <reason>\n\n<guidance>"}; the reason reaches the model. ask: {"decision":"force_ask","reason":"..."} with the same text (headless: a deny with the way on). allow and guidance: {"decision":"allow"}, guidance as the reason. No opinion (defer, a second Tempa hook): {"decision":"allow"}, since Antigravity wants a decision
PostToolUsetool.post{} (the only answer it takes). A flagged result or guidance is kept in the session and given to the model as a system message at the next PreInvocation
PostInvocation, Stopturn.stop{}; spool flush and the heartbeat (harness: antigravity)
  • Allow is no objection. allow hands the call on to the next hook or Antigravity's own policy; Tempa never answers auto_approve. In agy -p an allow does not grant a command the settings do not allow: the CLI soft-denies it and ends the run (recorded, TEMPA-185). With a matching permissions.allow entry the call runs.
  • Ask is force_ask (TEMPA-179). Antigravity's ask prompts but respects a developer's earlier "always allow"; force_ask always prompts, so a company ask is not silenced by a cached grant. The reason (and the guidance after it) goes with it. That covers asks from a policy, from the offline rules (offline.default_decision: ask), from the account policy and the no-profile path. Of several hooks the most restrictive answer wins (deny > force_ask > allow), so a second Tempa hook's allow changes nothing.
  • No ask in a headless session. In agy -p nothing can prompt. Recorded on 1.5.2 (TEMPA-185): a force_ask is denied as "user denied permission", without Tempa's reason, and the whole run ends there; an ask lets the call run when a permissions.allow entry grants it, and is denied like an allow otherwise. The payload does not say whether the run is interactive, so the hook reads the same signals as for the Gemini CLI: CI or GITHUB_ACTIONS set to true, or no controlling terminal (/dev/tty). Either means headless, and an ask is then a deny that keeps the policy's reason and says how to go on (ask-as-deny). Not detected: agy -p started from a terminal, or with its input piped, and, on Windows, anything but the CI variables; there a force_ask is answered by Antigravity (TEMPA-185). Set CI=true for such scripted runs.
  • A second line in the CLI settings. The profile's shell ask and deny rules are also command(<prefix>) entries in permissions.ask and permissions.deny of ~/.gemini/antigravity-cli/settings.json (cli.md), so a deny still holds where the hook does not run. The hook does not rely on them: it answers every ask itself. The prefix is matched word by word: command(echo blocked) in permissions.deny stopped echo blocked --force and let echo blockedx run (recorded, TEMPA-185), and a hook deny blocks a call that permissions.allow grants.
  • Fails closed by itself. Antigravity's docs say a non-zero exit is logged and ignored (fail open). agy -p 1.5.2 does the opposite (recorded, TEMPA-185): a PreToolUse hook that exits 1 or 2, is killed at its timeout or prints invalid JSON blocks the call, and the model reads only JSON hook "jsonhook__<group>_PreToolUse_0_0" failed: ...; empty output is no opinion. Tempa relies on neither: every answer is JSON with exit 0. On its own errors (input that is not a payload, a hook line without --event, a crash) tempa hook prints the adapter's fail answer: a PreToolUse deny with the reason, a PreInvocation terminate, a SessionStart warning, {} after the fact. When Tempa cannot start at all, bin/tempa-launch.mjs answers the same way. /v1/decide gets 2 s (ANTIGRAVITY_DECIDE_TIMEOUT_MS) of the plugin's 10 s, and shell calls are decided locally from the policy module (TEMPA-123b).
  • One answer per event. Several Tempa hooks can run for one event (the plugin's and a user-level group, TEMPA-183 and TEMPA-184); the most restrictive answer wins. The first Tempa process to claim the event (keyed by the conversation, the event, the turn, its stepIdx, invocationNum or executionNum, and the tool call) answers; the others answer {"decision":"allow"} or {}, which changes nothing. The turn is a digest of initialNumSteps and lastUserInput, because invocationNum and executionNum start at 0 again in every turn (recorded, TEMPA-185): without it a quick second prompt within the 10 s claim window went unchecked. A payload without an index (other than at session start) is always answered. Recorded with both groups installed: both run, one system message reaches the model at SessionStart, and doctor sees both fire. Groups run one after another in an order that changed between runs, and a deny ends the event for the groups after it.
  • No session end. Antigravity has no session end hook; a turn ends on Stop (and PostInvocation), and session files are pruned after a week. A conversation resumed with --conversation gets no new SessionStart, and an agy -p run that ends on a soft-denied permission sends no PostInvocation or Stop (recorded, TEMPA-185).
  • MCP tools are all called through Antigravity's own call_mcp_tool (recorded, TEMPA-185): toolCall.args holds ServerName (tempa_tempa), ToolName (second-brain__list_collections, not sanitised) and Arguments, which Tempa reads as the server, the tool and the tool's input. The plugin's server tempa_tempa is the proxy tempa. Tempa still reads mcp__<server>__<tool>, <server>/<tool> and mcp_<server>_<tool> as a tool name, and never takes one of Antigravity's own tools for MCP. Should a name arrive sanitised as in Codex (second_brain), the endpoint and tool are mapped back to the profile's before the MCP allowlist and /v1/decide see them (profileMcp); a name that matches no endpoint of the profile stays as it is and is denied (TEMPA-181).
  • Workspace for the MCP proxy. At SessionStart and at every prompt the hook records the conversation's workspace (~/.tempa/antigravity-workspace.json), which the MCP proxy uses when Antigravity sends no MCP roots (mcp-proxy.md).
  • Account check. An Antigravity session is unknown (other_harness); the notice speaks of Antigravity.
  • Version. Antigravity does not tell its hooks its version: agy --version (it prints the bare version, 1.5.2), once a day at session start; unknown before that (the App's version is not read).
  • Environment. The CLI runs plugin hooks in the plugin folder, user-level hooks in ~/.gemini/config and workspace hooks (.agents/hooks.json) in the workspace's .agents folder, with its own PATH and ANTIGRAVITY_CONVERSATION_ID set; there is no PLUGIN_ROOT (recorded, TEMPA-185).
  • Still to check on a real Antigravity (TEMPA-208): the App, the IDE and the ACP server (do plugin hooks run there, the fail mode, force_ask in front of a person), a system message injected at PreInvocation (Tempa's after-the-tool guidance; none came up in the recorded runs), and Windows.

Local files

PathWhat
~/.tempa/sessions/<session id>.jsonPer-session flags (the no-profile question was asked, offline modes recorded, decide_retry_at after a 429, the account policy's ask/block). Removed on SessionEnd, or after a week
~/.tempa/account.jsonThe kind of Claude account of the last session and what it was based on ({kind, signal, at}), for the heartbeat
~/.tempa/attestation.jsonMode 0600: tempa attest --no-training ({no_training, attested_at, cli_version})
~/.tempa/spool/<yyyy-mm-dd>.jsonlTempa events waiting to be shipped, one JSON object per line: {"at","event","session_id","repo","attributes"}
~/.tempa/cache/policies/<sha256>.wasmMode 0600: the policy modules of the cached profiles, for local decisions (TEMPA-123b)
~/.tempa/sessions/<session id>.repos.jsonMode 0600: the repo (git root, remote) around each directory the session's hooks ran in, so a tool call needs no git (TEMPA-123c)
~/.tempa/run/evaluator-<version>.sockThe resident policy evaluator's socket (TEMPA-123c)
~/.tempa/state.jsonLast heartbeat time and the Claude Code version (from claude --version, once a day)
~/.tempa/antigravity-workspace.jsonMode 0600: the workspace of the Antigravity conversation the hook saw last, for the MCP proxy (TEMPA-181)
~/.tempa/antigravity-mcp-workspace.jsonMode 0600: which workspace the Antigravity MCP proxy served last and how it found it, for tempa doctor

The spool is shipped as OTLP logs on Stop, at the end of SessionStart and after tempa sync (see cli.md); PreToolUse and SessionEnd only append to it.