Skip to content
Agentic Control Plane

Antigravity Hooks Reference — force_ask, Fail-Closed, and What --dangerously-skip-permissions Actually Removes

David Crowe David Crowe · · Updated · 21 min read
antigravity hooks permissions reference
Share X HN LinkedIn

Just want the answer? An Antigravity hook is a command registered in hooks.json that answers PreToolUse calls with a JSON decision: allow, deny, ask, force_ask, or deny_unless_prior_grant. A crashed hook blocks the call — fail-closed by default. One entry in ~/.gemini/config/hooks.json:

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "*",
        "hooks": [ { "type": "command", "command": "node ~/.acp/hooks/antigravity/hook.mjs" } ] }
    ]
  }
}

Install the ACP hook in one command →  ·  Antigravity's full control model →  ·  free up to 5 agents

Last updated: September 16, 2026 — added the stdin payload field by field, a minimal hook you can write yourself, a troubleshooting section, and the four-harness comparison. Everything here is read from Google’s hooks documentation or verified against the shipped agy 1.1.21 binary; the one thing we have not seen live — how the interactive TUI renders a force_ask reason — is marked as such rather than described.

Antigravity is Google’s agent-first development ecosystem — the IDE, the agy CLI that replaced Gemini CLI in May 2026, and extensions for VS Code, JetBrains, Zed, and Xcode, all reading one shared hook registration. Its hook system is younger than Claude Code’s and narrower than Cursor’s, but it makes two choices neither of those harnesses makes: a hook’s ask is a first-class decision the human actually sees, and a hook that fails blocks the call rather than letting it through. This is the reference for the five events, the payload, the decision vocabulary, and the one flag that erases all of it.

Where hooks live

hooks.json reads from two locations, shared across every Antigravity surface:

Scope Location Notes
Workspace .agents/hooks.json Behind folder trust — trust the folder and its hooks are live
User (global) ~/.gemini/config/hooks.json Shared by the CLI, the IDE, and the app — where the ACP installer writes, merged under its own key

The file’s shape, with the one asymmetry to know about:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "run_command",
        "hooks": [
          { "type": "command", "command": "node ~/hooks/check-shell.mjs", "timeout": 30 }
        ]
      }
    ],
    "Stop": [
      { "type": "command", "command": "node ~/hooks/receipt.mjs", "timeout": 10 }
    ]
  }
}

Tool events (PreToolUse, PostToolUse) are a list of { matcher, hooks[] } groups. Stop is a flat list of hook commands with no matcher. type: "command" is the only handler kind; timeout is in seconds and defaults to 30.

The five events

PreToolUse, PostToolUse, PreInvocation, PostInvocation, Stop. Tool-scoped events take a regex matcher against the tool name — run_command|view_file, browser_.*, or * to catch everything. Handlers are shell commands, camelCase JSON on stdin, a JSON decision on stdout.

PreInvocation and PostInvocation can inject steps into the agent loop, and PostInvocation can force-continue or terminate it. Stop hooks can re-enter the loop with decision: "continue" — capped at a built-in limit of consecutive continuations, so an always-blocking stop hook can’t hold the agent hostage indefinitely.

One contract quirk worth knowing before you write a handler: the payload doesn’t name its own event. Unlike every Claude-lineage hook, there’s no hookEventName field on the stdin JSON — a handler serving more than one event has to be registered with the event passed as an argument, not read from the payload.

What arrives on stdin

The PreToolUse payload, field by field:

Field Type What it carries
toolCall.name string Antigravity’s native tool name — run_command, view_file, write_to_file, replace_file_content, multi_replace_file_content, grep_search, find_by_name, list_dir, search_web, read_url_content, invoke_subagent, or an MCP tool’s own name
toolCall.args object The tool’s arguments as Antigravity holds them. For run_command the shell string is args.CommandLine — verified live on 1.1.21; not command
conversationId string The session — stable across every call in one conversation, and the natural key for anything you count per session
workspacePaths string[] The open workspace roots; the first is the closest thing to a cwd
modelName string The model serving the turn
stepIdx number The step’s index within the turn

PostToolUse carries the same shape plus an error string when the call failed — and not the tool’s output. It also fires on non-tool steps with toolCall set to null, which any handler on that event has to filter before it logs anything. The payload names a transcript file that holds the full exchange, if a handler needs to go further than the call itself.

Two absences shape every handler you’ll write. There is no event name (above), and there is no permission-mode field — nothing in the payload says whether a person is watching. A hook that wants to behave differently unattended has to learn that from somewhere else, and since the hook environment is sanitized, “somewhere else” means a file it reads.

The decision vocabulary, and the first native ask

A PreToolUse hook answers:

{ "decision": "deny", "reason": "…" }

where decision is one of allow, deny, ask, force_ask, deny_unless_prior_grant, with an optional permissionOverrides array that can grant action(target) resources — the same syntax as Antigravity’s permission rules — for the rest of the turn. ask prompts the human but respects a cached “Always Allow” grant from earlier in the session; force_ask prompts unconditionally, ignoring that cache. An unknown decision value is an error (“unknown pre-tool hook decision”); an empty decision is tolerated.

That distinction is the headline. No other mainstream harness lets a hook hand a call to the human as a first-class outcome the way force_ask does — Claude Code’s hooks can return ask, but a locally cached allow can still shortcut it in some flows; most other harnesses’ hook gates are deny-only.

PostToolUse: audit, not content scanning

PostToolUse fires after the tool call completes, with toolCall and an error string in the payload — not the tool’s actual output. That’s a hard limit worth knowing up front: a PostToolUse hook can log that a call happened and whether it failed, but it cannot inspect what a Read returned or scan a shell command’s stdout for secrets from the hook payload alone. The full exchange lives in the transcript file the payload points at, if you need to go further.

Two sharp edges beyond the vocabulary

The hook environment is sanitized. Only whitelisted variables reach the subshell a hook runs in, so env-var-based configuration of a hook mostly doesn’t work — configuration has to ride in a file the hook reads itself, not the process environment.

A Git repo’s .git directory mounts read-only inside the sandbox, even for an otherwise-writable agent, when the terminal sandbox is on.

Fail-closed: the core

Every event above shares one behavior, and it’s the opposite of Claude Code’s and Cursor’s default: a failing PreToolUse hook blocks the call. Crash, timeout, non-zero exit — Antigravity reports “pre-tool hook failed” and the tool call does not run. There’s no exit-code deny channel either; a denial has to travel as JSON with exit 0.

That cuts both ways. No silent lapse of a broken policy hook — good, and the opposite of a fail-open core where an unreachable backend quietly turns enforcement off. But a hook whose own upstream dependency goes down can brick every tool call in the session unless the hook itself carries a fallback posture. The ACP hook for Antigravity answers this directly: it always exits 0 with a JSON decision, including its own crash handling, and applies its own two-tier posture on top of Antigravity’s fail-closed core — attended sessions fail open with a loud [ACP] UNGOVERNED warning so a gateway outage never bricks an interactive session; unattended tiers fail closed, because nobody is there to notice a silent gap.

A minimal hook you can write yourself

The smallest useful PreToolUse hook is a deny-list on shell commands. It’s written to the contract above and nothing else: reads the camelCase payload, looks at toolCall.args.CommandLine, and — the part that matters on this harness — always exits 0 and always prints a decision, including when the payload is malformed, because on Antigravity a hook that throws blocks the call.

#!/usr/bin/env node
// ~/hooks/deny-list.mjs — minimal Antigravity PreToolUse hook
let raw = "";
process.stdin.on("data", (d) => (raw += d));
process.stdin.on("end", () => {
  let payload = {};
  try { payload = JSON.parse(raw); } catch { /* still answer */ }
  const call = payload.toolCall ?? {};
  const cmd = call.name === "run_command" ? String(call.args?.CommandLine ?? "") : "";
  if (/\brm -rf\b|--force\b|\bDROP TABLE\b|\bgh repo delete\b/i.test(cmd)) {
    console.log(JSON.stringify({
      decision: "deny",
      reason: "Blocked by policy: destructive command",
    }));
    return;
  }
  console.log(JSON.stringify({ decision: "allow" }));
});
// Never exit non-zero: Antigravity treats a failing hook as an error and
// blocks the call. Every outcome travels as JSON on stdout.

Register it under PreToolUse with "matcher": "run_command" and it sees every shell call. Swap deny for force_ask and the same script becomes a hold that a person has to answer, with no cached grant able to pre-empt it.

Two things worth being honest about, because they’re the ceiling of every regex hook. String-matching a shell command is not a shell parser; destructive behavior hides in compound commands, scripts, and aliases that no pattern list anticipates. And this hook has no idea whether anyone is watching — it will force_ask on a CI runner exactly as it would in the IDE, where Antigravity soft-denies it. The ACP version keeps the same contract but resolves the decision against workspace policy, resolves the tier from a config file because the environment is sanitized, and records every decision — including every deny — in an audit log you can read later.

The escape hatch removes the surface itself

--dangerously-skip-permissions auto-approves every tool permission request in Antigravity — and verified live on agy 1.1.21, with an A/B pair of otherwise-identical headless turns, it does something no other mainstream harness’s bypass flag does: PreToolUse hooks are not invoked at all under the flag. No hook execution, no decision, no record.

That’s the opposite of Claude Code, where deny rules and hooks still fire under --dangerously-skip-permissions and a hook deny still blocks the call. Treat the flag’s appearance as an alertable event in any fleet, and use scoped permissions.allow rules instead of the flag to unblock headless runs.

What the ACP hook does with the same contract

The installer detects Antigravity (the agy CLI, or an existing ~/.gemini/antigravity-cli profile), places the hook at ~/.acp/hooks/antigravity/hook.mjs, and merges its registration into ~/.gemini/config/hooks.json under its own acp key — the file is shared across the CLI, IDE, and app, so the merge never overwrites an existing hook already registered there. Before any tool runs, the hook is consulted via /govern/tool-use; after it runs, a completion record goes to /govern/tool-output; on session end, a receipt with a console review link lands in the scrollback.

Two mapping details worth knowing: Antigravity’s native tool names (run_command, view_file, replace_file_content, and so on) are normalized to a canonical shape before the policy check runs, with the native name preserved in the audit record as client_tool_name — verified live, run_command’s argument key is CommandLine, and the production hardline floor correctly denied a recursive root delete issued under that native name. And an ACP ask decision maps to force_ask, so a policy hold renders as a native Antigravity approval card rather than a generic block.

Troubleshooting

Hook isn’t firing at all. /hooks inside Antigravity lists what’s loaded. User-global hooks come from ~/.gemini/config/hooks.json; workspace hooks from .agents/hooks.json only load once the folder is trusted. Check the matcher, too — it’s a regex against the native tool name, so Bash matches nothing here; the shell tool is run_command.

Every call is blocked with pre-tool hook failed. Your hook crashed, timed out, or exited non-zero, and Antigravity blocked the call rather than skipping the hook. Run the hook by hand with a sample payload on stdin and confirm it prints JSON and exits 0 on every path — including malformed input and an unreachable upstream.

The hook fires but never sees the shell command. You read args.command. On Antigravity the field is args.CommandLine.

The hook can’t find its configuration. The environment is sanitized; the variable you set in your shell didn’t reach the subshell. Read a file instead.

Nothing fires under --dangerously-skip-permissions. Expected. The flag removes the hook layer.

A PostToolUse handler logs empty calls. It fires on non-tool steps with toolCall: null. Filter those before recording.

A Stop hook keeps the agent going. It returned decision: "continue". Anything else allows the stop; a receipt or a logger should return anything but continue.

Known limitations

  • PostToolUse carries no tool output. Content scanning of what a call actually returned isn’t possible from the hook payload; only the transcript file has it.
  • --dangerously-skip-permissions is total. No hook — this one included — is consulted under the flag. There’s no deny that survives it.
  • --local isn’t wired for Antigravity today. ACP’s on-device decision engine (~/.acp/policy.json, decisions logged to ~/.acp/audit.jsonl, no account) ships for Claude Code and Cursor; Antigravity’s hook currently reaches the hosted gateway for every decision. A workspace connection is required.
  • The hook environment is sanitized, so configuration has to live in a file (~/.acp/config.json, snake_case keys) rather than environment variables — unattended fleets should pin "agent_tier": "background" there explicitly.
  • Hooks see tool calls, not tokens — and on Antigravity, nothing else can either. Antigravity’s Gemini endpoint is fixed, with no base-URL override, so its model traffic can’t be routed through the ACP proxy; the cost X-ray lists it as tool-call control only.

Comparing to Claude Code, Codex, and Cursor hooks

Dimension Antigravity Claude Code Codex CLI Cursor
Config path ~/.gemini/config/hooks.json; workspace .agents/hooks.json behind folder trust ~/.claude/settings.json ~/.codex/hooks.json; repo .codex/hooks.json ~/.cursor/hooks.json
Events PreToolUse, PostToolUse, PreInvocation, PostInvocation, Stop PreToolUse, PostToolUse, and more PreToolUse, PostToolUse, PermissionRequest, SessionEnd A wide agent set: preToolUse, beforeShellExecution, beforeMCPExecution, …
Pre-call decisions allow, deny, ask, force_ask, deny_unless_prior_grant, permissionOverrides allow / deny / ask, input rewrite deny (JSON or exit 2), allow + updatedInput permission: deny; ask accepted but not enforced on preToolUse
Hook failure default Fail-closed — the call is blocked Fail-open Fail-open Fail-open unless failClosed: true
Deny channel JSON only — no exit-code deny JSON, or exit 2 JSON, or exit 2 JSON permission: "deny"
Under the bypass flag / mode Hooks not invoked (--dangerously-skip-permissions) Hooks still fire (--dangerously-skip-permissions) Hooks still fire (sandbox modes) Hooks still fire (Run Everything)

Three different harnesses, three different answers to what a hook is allowed to fail into — the Claude Code hooks reference, the Codex CLI hooks reference, and the Cursor hooks reference have the other contracts in full.

Practical takeaways

  1. Exit 0, always. On Antigravity a hook that dies takes the call with it. Put every outcome — including your own error handling — in the JSON.
  2. Register once per event, with the event as an argument. The payload won’t tell you which event you’re serving.
  3. Configure by file. The environment is sanitized; the config you exported may not arrive.
  4. Use force_ask for holds that must reach a person. It’s the one decision a cached grant can’t answer for you, and headless Antigravity soft-denies it correctly on its own.
  5. Alert on the flag. --dangerously-skip-permissions removes every hook, yours included. Scoped permissions.allow rules are the unblock; the flag is the event to watch for.

Frequently asked questions

What hook events does Google Antigravity support?

Five: PreToolUse, PostToolUse, PreInvocation, PostInvocation, and Stop. They register in hooks.json — workspace-local at .agents/hooks.json (behind folder trust) or user-global at ~/.gemini/config/hooks.json, shared by the CLI, the IDE, and the app. Tool events take a regex matcher on the tool name; a matcher of ‘*’ catches every tool call.

What can an Antigravity hook decide?

A PreToolUse hook answers with decision: allow, deny, ask, force_ask, or deny_unless_prior_grant, plus an optional permissionOverrides array. ask respects a cached Always-Allow grant; force_ask ignores it and prompts unconditionally. No other mainstream harness’s hook vocabulary includes a native ask as a first-class outcome the way force_ask does.

What does an Antigravity PreToolUse hook receive on stdin?

camelCase JSON with toolCall (name and args), conversationId, workspacePaths, modelName, and stepIdx. There is no field naming the event, so a handler serving more than one event is registered once per event with the event passed as a command-line argument. For run_command, the shell string is args.CommandLine — verified live on agy 1.1.21. PostToolUse adds an error string but not the tool’s output.

What happens when an Antigravity hook crashes or times out?

The tool call is blocked. Antigravity treats a failing PreToolUse hook — crash, timeout, non-zero exit — as an error and fails closed: the opposite of Claude Code’s and Cursor’s fail-open defaults. A hook you install must always exit 0 with a JSON decision and carry its own posture for what happens when its own upstream is unreachable.

Does --dangerously-skip-permissions disable Antigravity hooks?

Yes, completely — verified live on agy 1.1.21. Under the flag, PreToolUse hooks are not invoked at all; there’s no hook execution, no decision, and no record. That’s different from Claude Code, where hooks still fire and a deny still blocks under its bypass flag. On Antigravity the flag removes the interception surface itself, not just the prompts.

Why isn't my Antigravity hook firing?

Check /hooks inside Antigravity for what’s registered, then the file: user-global hooks live in ~/.gemini/config/hooks.json and workspace hooks in .agents/hooks.json, which only load once the folder is trusted. If the session was started with –dangerously-skip-permissions, no hook fires at all. And if the hook runs but every call is blocked with ‘pre-tool hook failed’, the hook is crashing, timing out, or exiting non-zero — on Antigravity that blocks the call instead of skipping the hook.

Where to read more

Share X HN LinkedIn
Get the next data drop
What agents actually cost, new tool-surface captures, and the occasional incident post-mortem — sent when we publish something worth your inbox, not on a schedule. Unsubscribe anytime.
Share: Twitter LinkedIn
Related posts

← back to blog