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 event | Tempa event | What Tempa does | Output |
|---|---|---|---|
| SessionStart | session.start | Sync (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) |
| UserPromptSubmit | prompt.submit | Local 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 |
| PreToolUse | tool.pre | See below | {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny|ask","permissionDecisionReason":"...","additionalContext":"<guidance>"}}; never allow (TEMPA-213) |
| PostToolUse | tool.post | POST /v1/decide for guidance (1 s); failures are ignored, the tool already ran; skipped while the session backs off from a 429 | guidance: {"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"..."}}; deny: {"decision":"block","reason":"..."}; else nothing |
| Stop | turn.stop | Ship the spooled Tempa events; POST /v1/heartbeat, at most every 10 minutes | nothing |
| SessionEnd | session.end | Local 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_violation | SessionStart | Tool calls this session |
|---|---|---|
inform | the guidance as systemMessage, and for the model at the top of additionalContext | as usual |
ask | same | the first PreToolUse returns ask with the guidance, later ones as usual |
block | same | every 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
- Account policy: when the session's account check said
ask(first call only) orblock, that is the answer (Account check). - Fast path: tools of kind
readorsearchand session-only tools (tool vocabulary; for Claude CodeRead,Glob,Grep,LS,NotebookReadandTodoWrite) 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$VARIABLETempa 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, orTEMPA_HOME: login tokens and settings) is denied, policy idtempa/own-files. - MCP: on the structured MCP tool (
tool.mcp), anmcp__<server>__…tool of another server is denied only when a valid cached profile setsallow_external_mcptofalse; 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 profilemcp__tempa__<endpoint>__<tool>is denied unless the endpoint is in the profile'smcplist and the tool is on itstoolsallowlist (null = all). The server applies the same rule. - Local decision (TEMPA-123b): a call of kind
shellis 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=0or the profile settinglocal_evaluation: false), or the command isuv 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 backgroundtempa policy servewith 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. - Remote decision:
POST /v1/decidewith the event (tool.name,tool.kind,tool.mcpandtool.inputalways,promptand tool responses only when the profile's telemetry turns them on), within 1 s (TEMPA_DECIDE_TIMEOUT_MSordecide_timeout_msin~/.tempa/config.json, including any token refresh).Decision Output denydeny, reasonTempa (<policy ids>): <reason>, guidance asadditionalContextaskask, same reason formatguidanceno permissionDecision, the guidance asadditionalContextallowno output deferno permissionDecision(Claude Code's own permissions decide); guidance asadditionalContextif any, else no outputTempa never answers
allowfor Claude Code (TEMPA-213, as for Codex). A hook'sallowskips 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.allowthe developer chose, the permission mode) and its prompt decide. Tempa still denies and asks. For the same reason the profile'spermissions.allowis not rendered intosettings.local.json.
- Unreachable, timed out or rate limited (429), valid cached profile: the profile's offline rules. Tools in
offline.allow_toolsare allowed, everything else getsoffline.default_decision. The session is recorded asoffline_cached. After a 429 the reason readsTempa rate limit reached; offline rules of cached profile <version>and the session skips/v1/decideuntilRetry-Afterhas 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 astool.kindbefore TEMPA-125) givesTempa control plane rejected the decision request (it may be older than this CLI); …, and a 5xxTempa 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. - 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 reasonTempa 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 adenyinstead (policy idtempa/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'soffline.default_decisionin~/.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_profileonce.
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 on | What 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:
| Shape | When | Fields | Tool names |
|---|---|---|---|
| camelCase | The 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 --event | Copilot's (bash, view, create, edit, ...), COPILOT_TOOLS |
| VS Code-compatible | PascalCase event names, and VS Code | hook_event_name, session_id, cwd, tool_name, tool_input, tool_result | Claude 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 event | Tempa event | Output |
|---|---|---|
sessionStart | session.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 |
userPromptSubmitted | prompt.submit | Nothing: 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) |
preToolUse | tool.pre | {"permissionDecision":"allow|deny|ask","permissionDecisionReason":"Tempa (<policy ids>): <reason>\n\n<guidance>"}; defer gives no output |
postToolUse | tool.post | {"additionalContext":"<reason of a deny>\n\n<guidance>"}, else nothing |
agentStop | turn.stop | Nothing; spool flush and the heartbeat (harness: copilot) |
sessionEnd | session.end | Nothing; 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: 10onpreToolUseand the adapter gives/v1/decide2 s (COPILOT_DECIDE_TIMEOUT_MS;TEMPA_DECIDE_TIMEOUT_MSanddecide_timeout_msstill win). On a timeout, 429 or 403 it uses the cached profile's offline rules, as in Claude Code.offline.allow_toolsnames Claude Code's tools, so Copilot'sbashis allowed whereBashis (claudeCodeNamesin the tool table). Shell calls are decided locally from the policy module (TEMPA-123b, PreToolUse step 3), so the usualbashcall never waits for the network. - Account check (accounts.md, TEMPA-132). A Copilot session's account is classified as
unknownwith the signalother_harness, and the heartbeat reports it that way. The tenant'saccount_policythen applies as it does in Claude Code:informadds the notice to the session startadditionalContext,askasks once on the first tool call, andblockdenies every tool call, read-only ones too. Each answer is in Copilot's format, with the policy idtempa/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
askto 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, whosepreToolUserunstempa 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 runstempa 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-codehands such a payload to the Copilot adapter: Copilot's output, Copilot's layout,harness: copilotin 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 atimestampis never taken for a repeat. - Payload detection rather than leaving hooks out: Tempa does not write
.claude/settings*.jsonhooks, but a team may, and Copilot gives such hooks no way to tell their source. Tests:client/test/double-firing.test.tswith both payload shapes.
- Copilot's payload is answered as Copilot. Copilot's PascalCase payload has a
- Not used:
modifiedArgs(no Tempa decision rewrites arguments) andagentStop'sdecision: 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 hassessionId,timestampandcwd,toolArgsis an object,toolResultis{resultType, textResultForLlm},agentStophastranscriptPath(~/.copilot/session-state/<session>/events.jsonl),stopReasonandstop_hook_active, andsessionStarthassource: "new"andinitialPrompt. Plugin hooks run in the plugin's folder withPLUGIN_ROOTset; 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 legacyhooks.json). - MCP tools (TEMPA-164). A camelCase
toolNameof<server>-<tool>(recorded:tempa-second-brain__list_collections,tempa-work_item),<server>/<tool>ormcp__<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__deleteis denied whendeleteis 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'smcp__<server>__<tool>(unverified). - The harness version is what
copilot --versionsaid, checked once a day at session start (TEMPA-165), since Copilot does not tell its hooks;unknownbefore the first check or without a Copilot CLI (VS Code alone).copilot --versionprintsGitHub Copilot CLI 1.0.105.and a line about updates, in about 0.7 s (TEMPA-167); hooks also getCOPILOT_CLI_BINARY_VERSIONin 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 event | Tempa event | Output |
|---|---|---|
| SessionStart | session.start | As 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 |
| UserPromptSubmit | prompt.submit | On a secret {"decision":"block","reason":"..."}, without Claude Code's suppressOriginalPrompt (Codex rejects keys it does not know) |
| PreToolUse | tool.pre | deny: {"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 |
| PostToolUse | tool.post | As Claude Code: {"decision":"block","reason":"..."} for a flagged result (Codex replaces the tool's output with the reason), guidance as additionalContext |
| Stop | turn.stop | Nothing; spool flush and the heartbeat (harness: codex) |
| SessionEnd | session.end | Nothing; local clean-up (Codex gives it 1 s by default, at most 3) |
- No allow. Codex takes
permissionDecision: "allow"only together withupdatedInput(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
askas 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
askshell rules are rendered aspromptrules in.codex/rules/tempa-permissions.rules(cli.md). An ask (from/v1/decideor 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_modeisdefault(in other modes Codex may not prompt); - the rules file is in the repo;
~/.codex/config.tomlmarks 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.rulesis read in a trusted project,codex execpolicy checkagrees withcoveringRuleon the rendered rules, and apromptrule asks for a command that would run inside the sandbox too.codex execalways runs with approvalnever(permission_mode: "bypassPermissions"in every payload, whatever-c approval_policysays), 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. - a rendered prompt rule covers the whole shell command; compound commands (
- 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/decidegets 2 s (CODEX_DECIDE_TIMEOUT_MS;TEMPA_DECIDE_TIMEOUT_MSanddecide_timeout_msstill 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 readsCommand 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 onlyadditionalContextreaches 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>.claimkeyed 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 --versionsaid, once a day at session start;unknownbefore that. It printscodex-cli 0.157.1, read as0.157.1. - MCP names. Codex names a tool of the
tempaservermcp__tempa__<endpoint>__<tool>with every character but[A-Za-z0-9_]turned into_, sosecond-brainarrives assecond_brain. The hook maps the endpoint and the tool back to the profile's names before the MCP allowlist and/v1/decidesee 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 hastranscript_path(a string) andpermission_mode, SessionEnd has neithermodelnorpermission_mode, andtool_use_idisexec-<uuid>for a shell or patch andcall_<id>for other tools. The tool names andtool_responseshapes 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 event | Tempa event | Output |
|---|---|---|
| SessionStart | session.start | As 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 |
| BeforeAgent | prompt.submit | On a secret {"decision":"deny","reason":"..."}, which blocks the turn and discards the prompt |
| BeforeTool | tool.pre | deny: {"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 |
| AfterTool | tool.post | A flagged result: {"decision":"deny","reason":"..."}, which replaces the tool's output with the reason; guidance as hookSpecificOutput.additionalContext, added to the tool's output |
| AfterAgent | turn.stop | Nothing; spool flush and the heartbeat (harness: gemini-cli) |
| SessionEnd | session.end | Nothing; local clean-up |
- Allow is no objection. A hook
allowdoes not skip Gemini's own approval: its Policy Engine still decides whether to ask, as it does without Tempa (scheduler.tschecks 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 asadditionalContextin 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
asksince v0.36.0 (scheduler/hook-utils.ts): Gemini then shows its own confirmation with the hook'ssystemMessage, 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 whatgemini --versionsaid (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'saskshell rules are rendered asask_userrules 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 turnsask_userinto a deny there without Tempa's instructions. The payload does not say whether the run is interactive, so the hook reads what Gemini's ownisHeadlessMode(utils/headless.ts) looks at and the hook can see:CIorGITHUB_ACTIONSset totrue(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 -pstarted 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. SetCI=truefor 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/decide2 s (GEMINI_DECIDE_TIMEOUT_MS;TEMPA_DECIDE_TIMEOUT_MSanddecide_timeout_msstill 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 usualrun_shell_commandnever waits for the network. Locked tenants add the hooks to Gemini's system settings as well (TEMPA-157), which a developer can still bypass withGEMINI_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
timestampand the tool call; the claim file of TEMPA-145) answers it and the other gives no output. A payload without atimestampis 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 --versionsaid, once a day at session start;unknownbefore 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: thetool_responsetext ofrun_shell_command, themcp_contextof an extension's server, and thegemini --versionformat.
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 event | Tempa event | Output (always JSON, exit 0) |
|---|---|---|
| SessionStart | session.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 lastUserInput | On 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 |
| PreToolUse | tool.pre | deny: {"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 |
| PostToolUse | tool.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, Stop | turn.stop | {}; spool flush and the heartbeat (harness: antigravity) |
- Allow is no objection.
allowhands the call on to the next hook or Antigravity's own policy; Tempa never answersauto_approve. Inagy -pan 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 matchingpermissions.allowentry the call runs. - Ask is
force_ask(TEMPA-179). Antigravity'saskprompts but respects a developer's earlier "always allow";force_askalways 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'sallowchanges nothing. - No ask in a headless session. In
agy -pnothing can prompt. Recorded on 1.5.2 (TEMPA-185): aforce_askis denied as "user denied permission", without Tempa's reason, and the whole run ends there; anasklets the call run when apermissions.allowentry grants it, and is denied like anallowotherwise. The payload does not say whether the run is interactive, so the hook reads the same signals as for the Gemini CLI:CIorGITHUB_ACTIONSset totrue, 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 -pstarted from a terminal, or with its input piped, and, on Windows, anything but the CI variables; there aforce_askis answered by Antigravity (TEMPA-185). SetCI=truefor such scripted runs. - A second line in the CLI settings. The profile's shell
askanddenyrules are alsocommand(<prefix>)entries inpermissions.askandpermissions.denyof~/.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)inpermissions.denystoppedecho blocked --forceand letecho blockedxrun (recorded, TEMPA-185), and a hookdenyblocks a call thatpermissions.allowgrants. - Fails closed by itself. Antigravity's docs say a non-zero exit is logged and ignored (fail open).
agy -p1.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 onlyJSON 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 hookprints the adapter'sfailanswer: a PreToolUse deny with the reason, a PreInvocationterminate, a SessionStart warning,{}after the fact. When Tempa cannot start at all,bin/tempa-launch.mjsanswers the same way./v1/decidegets 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,invocationNumorexecutionNum, and the tool call) answers; the others answer{"decision":"allow"}or{}, which changes nothing. The turn is a digest ofinitialNumStepsandlastUserInput, becauseinvocationNumandexecutionNumstart 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 adenyends 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
--conversationgets no new SessionStart, and anagy -prun 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.argsholdsServerName(tempa_tempa),ToolName(second-brain__list_collections, not sanitised) andArguments, which Tempa reads as the server, the tool and the tool's input. The plugin's servertempa_tempais the proxytempa. Tempa still readsmcp__<server>__<tool>,<server>/<tool>andmcp_<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/decidesee 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;unknownbefore that (the App's version is not read). - Environment. The CLI runs plugin hooks in the plugin folder, user-level hooks in
~/.gemini/configand workspace hooks (.agents/hooks.json) in the workspace's.agentsfolder, with its ownPATHandANTIGRAVITY_CONVERSATION_IDset; there is noPLUGIN_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_askin 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
| Path | What |
|---|---|
~/.tempa/sessions/<session id>.json | Per-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.json | The kind of Claude account of the last session and what it was based on ({kind, signal, at}), for the heartbeat |
~/.tempa/attestation.json | Mode 0600: tempa attest --no-training ({no_training, attested_at, cli_version}) |
~/.tempa/spool/<yyyy-mm-dd>.jsonl | Tempa events waiting to be shipped, one JSON object per line: {"at","event","session_id","repo","attributes"} |
~/.tempa/cache/policies/<sha256>.wasm | Mode 0600: the policy modules of the cached profiles, for local decisions (TEMPA-123b) |
~/.tempa/sessions/<session id>.repos.json | Mode 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>.sock | The resident policy evaluator's socket (TEMPA-123c) |
~/.tempa/state.json | Last heartbeat time and the Claude Code version (from claude --version, once a day) |
~/.tempa/antigravity-workspace.json | Mode 0600: the workspace of the Antigravity conversation the hook saw last, for the MCP proxy (TEMPA-181) |
~/.tempa/antigravity-mcp-workspace.json | Mode 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.