Guide
Hooks in the Claude Agent SDK
A hook in the Claude Agent SDK is an async function you pass in options.hooks,
keyed by event name, that the SDK awaits when a lifecycle event fires. It receives a typed
object describing the event; what it returns can allow, deny or rewrite a tool call, inject context
into the model's next turn, or stop the run. There are 31 events in
HOOK_EVENTS. In practice a normal single-prompt run fires seven of
them.
This is the part of the SDK that is documented as a list and almost never as a behaviour. So we
installed it, registered a callback on every one of the 31 events, and ran it against a toy
repository until we had the thing nobody publishes: the measured order events actually
arrive in, including what happens inside a subagent, what the permission flow looks like
from the hook's side, and which events never showed up at all. Everything below was run against
@anthropic-ai/claude-agent-sdk 0.3.231 (npm latest and
next) on Node 22.22.2, on August 11, 2026. Where we could not trigger
something, it says so.
If you have not written an SDK agent before, start with
building an agent on the Claude Agent SDK —
it covers query(), the message loop and the permission chain. This post assumes all of
that and only talks about hooks.
First: two different things are called "hooks"
This is the confusion worth resolving before anything else, because the two mechanisms share event names, share an output schema, and are otherwise nothing alike.
settings.json hook | SDK hook | |
|---|---|---|
| Where it lives | ~/.claude/settings.json, .claude/settings.json, a plugin | options.hooks in your program |
| What it is | A shell command (or http, mcp_tool, prompt, agent) | An async function in your process |
| How it receives the event | JSON on stdin | A typed object as the first argument |
| How it blocks | Exit code 2, or JSON on stdout with exit 0 | The returned object. There is no exit code |
| Who installs it | Whoever owns the repo or the machine | You, the author of the harness |
| Survives a restart | Yes — it is config on disk | No — it lives as long as your process |
The trap is that they are not alternatives. They both run, in the same session, for the same
event. We put a PreToolUse command hook in the toy repo's
.claude/settings.json that appended a line to a log file, registered SDK
PreToolUse callbacks for the same run, and got three SDK callback firings and three
lines in the log — one pair per tool call. The docs are explicit about why: settings files are
loaded "for default query() options."
The switch is settingSources. Re-running the identical program with
settingSources: [] produced the same SDK callbacks and an empty log file. So:
By default your SDK agent inherits the hooks of whatever repository it is pointed at.
A cloned repo's .claude/settings.json is executable configuration, and
query() loads it unless you say otherwise. If your harness runs untrusted
repositories, settingSources: [] is the line you are looking for. Note the cost:
it also stops CLAUDE.md loading, which needs 'project'.
Everything you know about the shell flavour — the 30-plus events, the matcher grammar, exit 2 versus exit 1 — still applies on that side, and we took it apart in Claude Code hooks explained, with working configs in five copy-paste examples. From here on, "hook" means the SDK kind.
The callback signature, exactly
Three arguments, and a promise of an object. This is the whole contract:
type HookCallback = (
input: HookInput,
toolUseID: string | undefined,
options: { signal: AbortSignal },
) => Promise<HookJSONOutput> You register them per event, in an array of matchers, each holding an array of callbacks:
import { query } from '@anthropic-ai/claude-agent-sdk'
const q = query({
prompt: 'Read src/a.js and summarise it.',
options: {
hooks: {
PreToolUse: [
{
matcher: 'Write|Edit',
timeout: 30,
hooks: [
async (input, toolUseID, { signal }) => {
console.log(input.hook_event_name, input.tool_name, toolUseID)
return {}
},
],
},
],
},
},
}) The three arguments, in order of how often you will care:
-
input— a discriminated union over 31 member types, narrowed byhook_event_name. Every member extendsBaseHookInput:session_id,transcript_path,cwd, and the optionalprompt_id,permission_mode,agent_id,agent_typeandeffort.PreToolUseHookInputaddstool_name,tool_inputandtool_use_id;PostToolUseHookInputaddstool_responseandduration_ms. -
toolUseID— the correlation key betweenPreToolUseandPostToolUsefor one call. For tool events it is the realtoolu_01…id; for non-tool events we observed a session-scoped UUID (UserPromptSubmit,Stop) and, forPostToolBatch, an id of the formhook-6052628…. Do not parse it, only compare it. -
{ signal }— anAbortSignalthat fires when the hook times out. Pass it to yourfetch. If you do not, a hung HTTP call is a hung agent for the duration of the timeout.
One field detail worth having before you write a path check. The tool_input a hook sees
is the resolved input, not what the model emitted:
model emitted: {"file_path": "src/a.js"}
hook received: {"file_path": "/private/tmp/…/toyrepo/src/a.js"}
That is convenient — you can compare against an absolute allowlist without resolving anything
yourself — and it is also the reason a naive startsWith('src/') guard silently matches
nothing.
All 31 events
HOOK_EVENTS in sdk.d.ts is the authoritative list. The Python SDK exposes a
subset; the TypeScript SDK exposes all of them.
| Event | Matcher matches on | Python too? | Fires when |
|---|---|---|---|
SessionStart | source: startup, resume, clear, compact, fork | No | Session initialisation |
Setup | trigger: init, maintenance | No | Session setup / maintenance |
UserPromptSubmit | — | Yes | A user prompt is submitted |
UserPromptExpansion | see reference | No | A typed command or MCP prompt expands |
PreToolUse | tool name | Yes | Before a tool call; can block or rewrite |
PermissionRequest | tool name | Yes | A tool call needs a permission decision |
PermissionDenied | tool name | No | Auto mode denies a call |
PostToolUse | tool name | Yes | A tool returned a result |
PostToolUseFailure | tool name | Yes | A tool failed instead of PostToolUse |
PostToolBatch | — | No | Once per batch, before the next model call |
MessageDisplay | — | No | An assistant text message completes |
Notification | notification type | Yes | Agent status message |
SubagentStart | agent type | Yes | A subagent starts |
SubagentStop | agent type | Yes | A subagent finishes |
Stop | — | Yes | The turn ends normally |
StopFailure | error type | No | The turn ends with an API error |
PreCompact | manual | auto | Yes | Before compaction |
PostCompact | manual | auto | No | After compaction |
TaskCreated | — | No | A task is created via TaskCreate |
TaskCompleted | — | No | A background task completes |
TeammateIdle | — | No | A teammate goes idle |
Elicitation | see reference | No | An MCP server asks for input |
ElicitationResult | see reference | No | A user answers an elicitation |
ConfigChange | config source | No | A settings file changes |
InstructionsLoaded | load reason | No | A CLAUDE.md / rules file loads |
CwdChanged | — | No | The working directory changes |
FileChanged | exact-match set only | No | A watched file changes |
DirectoryAdded | — | No | A working directory is added mid-session |
WorktreeCreate | — | No | A git worktree is created |
WorktreeRemove | — | No | A git worktree is removed |
SessionEnd | exit reason | No | Session termination |
The measured firing order
Here is the run. One prompt, a two-file toy repository,
permissionMode: 'dontAsk', Bash in disallowedTools, and one
callback registered on all 31 events that logs the elapsed milliseconds. Nothing is elided; the
only lines added are the tool_use blocks from the message stream and the terminal
result, for orientation.
4903ms UserPromptSubmit
4907ms --- system init model=claude-opus-5
7047ms MessageDisplay
7674ms >>> tool_use Read
7678ms PreToolUse [Read]
7696ms PostToolUseFailure [Read]
8236ms >>> tool_use Read
8236ms PreToolUse [Read]
8240ms PostToolUseFailure [Read]
8417ms PostToolBatch
10335ms MessageDisplay
11797ms >>> tool_use Read
11799ms PreToolUse [Read]
11812ms PostToolUse [Read]
12269ms >>> tool_use Read
12270ms PreToolUse [Read]
12277ms PostToolUse [Read]
12282ms PostToolBatch
15064ms >>> tool_use Write
15065ms PreToolUse [Write]
15092ms PostToolUse [Write]
15093ms PostToolBatch
18558ms MessageDisplay
18576ms Stop
18580ms --- result success turns=6 usd=0.3516 Seven distinct events fired. Read the shape off it:
-
UserPromptSubmitis first — before thesystem initmessage. Your hook runs before your own message loop has seen anything at all. Any per-run state aUserPromptSubmithook needs must exist before you callquery(). -
PostToolUseFailurefires instead ofPostToolUse, not alongside it. The first twoReadcalls failed (wrong paths) and produced noPostToolUseat all. An audit log built onPostToolUsealone silently omits every failed call. If you want one line per tool call, subscribe to both, or usePostToolBatch. -
PostToolBatchfires once per batch, after the last tool in it resolves. Three batches here: two failed reads, two successful reads, one write. It is the only event with a one-to-one relationship to model turns, which makes it the right hook for "inject a reminder once per turn" rather than once per tool. -
MessageDisplayis the assistant-text heartbeat, once per completed text message — three of them, at 7,047 ms, 10,335 ms and 18,558 ms, bracketing the tool batches. -
Stoplands 4 ms before theresultmessage. If your UI reacts to both, expect them effectively simultaneously.
What a subagent looks like from outside
Second run, same setup, but the prompt asks for one general-purpose subagent. This is
the trace people guess at:
6946ms >>> tool_use Agent
6950ms PreToolUse [Agent]
6956ms SubagentStart [general-purpose]
11923ms PreToolUse [Read] <-- inside the subagent
11938ms PostToolUse [Read]
11943ms PostToolBatch <-- inside the subagent
16503ms SubagentStop [general-purpose]
16510ms PostToolUse [Agent]
16533ms PostToolBatch
22543ms Stop
Two things fall out of that. First, the nesting is strict and complete:
PreToolUse [Agent] wraps SubagentStart, which wraps the subagent's own
tool events, which are followed by SubagentStop and only then
PostToolUse [Agent]. Second, and more usefully:
your PreToolUse hook fires for tools called inside subagents too. A
guard registered once on the main query covers the whole tree. The way to tell them apart is
input.agent_id, which is populated only inside a subagent — not
agent_type, which is also set on the main thread of an --agent session.
PostToolBatch fires at both levels. If you use it for per-turn bookkeeping in a run
with subagents, count on more of them than you have main-thread turns.
What did not fire, and why
Across five runs with all 31 events registered, 24 never fired. That is not a bug list; most of them describe situations a short headless run does not contain. But two of them are genuinely surprising, and worth more than a shrug.
SessionStart and SessionEnd callbacks never fired in any
of our runs — while SessionStart and SessionEnd command hooks in the same session's .claude/settings.json did, writing
shell SessionStart / shell Stop / shell SessionEnd to disk
in that order. So the events fire; the SDK callback does not see them. The plausible reading is a
registration race at both ends: callbacks are wired up over the control channel after the session
is already up, and the transport is torn down before the end-of-session event is delivered.
The practical consequence is small but sharp: do not put session setup or resource cleanup in
a hook. Setup goes before query(), teardown goes after the
for await loop, in a finally. If you need on-disk session hooks to fire,
that is the settings.json path — which, remember, is loaded by default.
The rest, honestly categorised:
- Needs an interactive surface.
Notification— even underpermissionMode: 'default'with a tool call being denied, noNotificationfired. In headless SDK use there is nobody to notify, so the event that most people reach for first in the CLI is the one you are least likely to see here. - Needs a longer or unluckier session.
PreCompact/PostCompact(our runs never came close to the context window),StopFailure(no API error occurred),TeammateIdle. - Needs a feature we did not exercise.
Setup,UserPromptExpansion(needs a typed slash command or MCP prompt),Elicitation/ElicitationResult(needs an MCP server that asks for input — see building one),TaskCreated/TaskCompleted(needs theTaskCreatetool),WorktreeCreate/WorktreeRemove,CwdChanged,DirectoryAdded,FileChanged,ConfigChange,InstructionsLoaded(which did not fire even withsettingSourcesat its default and a project directory present). - Did not fire when we expected it to.
PermissionDenied. We denied aWritefromcanUseTooland gotPermissionRequestbut noPermissionDenied; the documentation scopes that event to auto mode classifier denials, which we did not run. Do not use it as a general "a tool was blocked" signal.
The permission flow, from the hook's seat
Third run: permissionMode: 'default', a canUseTool callback that denies
everything, and a prompt that wants to write a file. The order is not the one the field names
suggest:
4137ms >>> tool_use Write
4140ms PreToolUse [Write]
4151ms canUseTool(Write) -> DENY
4152ms PermissionRequest [Write]
4199ms PostToolBatch PreToolUse runs first, and only if it has not already decided does the call fall
through to the permission flow. PermissionRequest is that fall-through — it is not
fired for tools that PreToolUse, an allow rule or the permission mode already settled,
which is the same short-circuit that makes canUseTool go quiet under
bypassPermissions. And after a denial there is no PostToolUse, no
PostToolUseFailure and no PermissionDenied: the batch simply closes.
Which leaves a real gap worth naming. There is no single event that means "this tool call did
not happen." If you are building an audit trail, the honest reconstruction is
PreToolUse minus PostToolUse minus PostToolUseFailure, or the
tool_calls[] array on PostToolBatch, which carries an optional
tool_response per call.
What a hook can return
Return {} to observe and change nothing. Everything else lives in two places: a
handful of top-level fields accepted on every event, and one hookSpecificOutput object
whose shape depends on the event.
| Return | Effect | Where |
|---|---|---|
{} | Proceed unchanged | Any event |
continue: false + stopReason | Ends the run. Outranks every event-specific decision | Any event |
systemMessage | Shows the user a message | Any event |
suppressOutput | Hides the hook's output from the transcript | Any event |
{ async: true, asyncTimeout } | The agent proceeds without waiting. Cannot block, modify or inject | Any event |
permissionDecision: 'allow' | 'deny' | 'ask' | 'defer' | Decides the tool call. defer ends the query for later resumption | PreToolUse |
updatedInput | Replaces the entire tool input object | PreToolUse |
updatedToolOutput | Replaces the tool result before the model sees it | PostToolUse |
additionalContext | Appends text the model will read | UserPromptSubmit, PostToolUse, PostToolBatch, Stop, Notification, others |
initialUserMessage, sessionTitle, reloadSkills, watchPaths | Session shaping | SessionStart — which, see above, we could not get to fire |
decision: { behavior: 'allow' | 'deny' } | The permission verdict | PermissionRequest |
retry: true | Tells the model it may retry | PermissionDenied |
The three that we verified do what they say, and the one that does not:
deny reaches the model, in its own words
A PreToolUse hook on Write returning:
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason:
'Writing to disk is not permitted in this run. Print the content in your reply instead.',
},
}
…produced this, from the model, unprompted: "I read src/a.js and found one TODO, but
I couldn't write the file — this run doesn't permit writing to disk, so here's the content
instead." The reason is not decoration; it is the model's only explanation of what happened, and
a bad one ("denied by policy") produces an agent that retries the same call three times. When
several hooks disagree the precedence is deny > defer > ask > allow: one
deny is enough.
additionalContext genuinely steers the run
A UserPromptSubmit hook returning
additionalContext: 'HOUSE RULE: the file you write must be named NOTES.md, never TODO.md.'
against a prompt that explicitly asked for TODO.md produced a
Write to NOTES.md. This is the cheapest injection point in
the SDK — no system-prompt surgery, no per-call plumbing — and it is a serviceable place to put
house conventions that would otherwise live in a CLAUDE.md your harness cannot rely on
being loaded.
continue: false stops the loop, and does almost nothing else
This one is a trap. A PreToolUse hook returned
{ continue: false, stopReason: "harness pulled the plug" } for a specific file:
4790ms >>> tool_use Read {"file_path":"src/b.js"}
4791ms PreToolUse [Read b.js] -> continue:false
4795ms PostToolUse [Read]
4848ms --- result success turns=3 is_error=false result=""
The tool still ran — PostToolUse fired for that same call. The Stop hook
did not fire. And the terminal message came back
subtype: 'success', is_error: false, with an
empty result string. A caller checking subtype sees a
healthy run that produced nothing. If you use continue: false, record the reason
yourself, and treat an empty result on a successful run as the signal it is.
Matchers, and what happens when several match
SDK matchers follow the settings-file grammar exactly: "*", "" or omitted
matches everything; a string of letters, digits, _, -, spaces,
, and | is exact-match or a |/, separated list;
anything else is an unanchored JavaScript regex. MCP tools appear as
mcp__<server>__<tool>, so mcp__memory alone matches nothing and
you want mcp__memory__.*.
We registered four PreToolUse matcher entries at once — 'Read',
'Rea.*', no matcher, and 'Write' — with the first one sleeping 300 ms:
2925ms A: matcher "Read" #1 (sleeps 300ms)
2926ms B: matcher "Read" #2
2926ms C: matcher "Rea.*"
2926ms D: no matcher
3228ms A: matcher "Read" #1 wakes
3239ms PostToolUse [Read]
Three things are measured there at once. Exact, regex and omitted matchers all fire
for the same call — matching is not first-match-wins, there is no specificity ordering, and a
catch-all logger will double up with your targeted rule. All four entered within 1 ms of each other,
so they run in parallel, and A's sleep did not delay B, C or D. And the tool itself
waited for the slowest of them: PostToolUse landed 11 ms after A woke.
The documentation is blunt about the consequence and it is worth repeating: completion order is
non-deterministic, so never write one hook that depends on another having run. The
same rule that bites in settings.json — two hooks both returning
updatedInput and the last writer winning — bites identically here.
Async behaviour: what blocks the loop
By default, everything. We made a PreToolUse hook sleep 3,000 ms and watched the agent
sit there:
3544ms >>> tool_use Read
3545ms PreToolUse [Read] -> sleeping 3000ms
6551ms PreToolUse [Read] -> awake, returning allow
6571ms PostToolUse [Read]
Three full seconds of agent wall-clock, paid on every matching tool call. That is the price of a
hook that does network I/O, and it is why the third argument carries an
AbortSignal: hand it to your fetch so a slow endpoint is cancelled at the
matcher's timeout rather than at TCP's leisure.
The escape hatch is { async: true, asyncTimeout: 30000 }. The agent proceeds
immediately and your promise finishes in the background. The trade is total: an async hook
cannot block, cannot rewrite input and cannot inject context, because the decision
point is already behind it. Logging, metrics, Slack pings — yes. Policy — never. A hook that
returns async: true and a permissionDecision is a policy you
think you have.
timeout is set per matcher entry, in seconds, and applies to every
callback in that entry. Omitted, your callback inherits the event's command-hook
default — 600 s for most events, but 30 s for UserPromptSubmit and 10 s for
MessageDisplay. Note the unit mismatch that is easy to fly past:
timeout is seconds, asyncTimeout is milliseconds.
Error handling: what happens when your hook throws
We threw a plain Error from a PreToolUse hook matched on
Write, in the middle of a run that was about to write a file:
10219ms D: no matcher
10219ms E: matcher "Write" -- THROWS
10234ms PostToolUse [Write]
13326ms --- result success turns=4 is_error=false
The exception was swallowed. The Write went ahead,
PostToolUse fired 15 ms later, and the run finished clean. Nothing in the message
stream said a hook had failed.
Do not build on that. The documentation says the opposite — "an unhandled exception can interrupt
the agent" — and the two statements are reconcilable (it can, on some paths, in some versions)
without either being safe to rely on. What you can rely on is the failure mode you actually care
about: a policy hook that throws is a policy that is not enforced, and by default nobody
tells you. The discipline is one try/catch per hook, with an explicit decision in the catch:
const guard: HookCallback = async (input) => {
try {
if (await isDangerous(input)) {
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: 'Blocked by policy: writes outside the workspace.',
},
}
}
return {}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return {}
metrics.increment('hook.guard.error')
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: 'Policy check failed to run; denying by default.',
},
}
}
}
Fail closed if the hook is a guardrail, fail open if it is a logger — but decide, in code, rather
than inheriting whatever the current version happens to do with an exception.
The short version
- SDK hooks are functions returning objects.
settings.json hooks are processes returning exit codes. Both fire, in the same run, unless you set settingSources: []. - 31 events exist; 7 fire in an ordinary run:
UserPromptSubmit, MessageDisplay, PreToolUse, PostToolUse / PostToolUseFailure, PostToolBatch, Stop. PostToolUseFailure replaces PostToolUse. Subscribe to both or your audit log lies. SessionStart and SessionEnd callbacks did not fire in our testing. Do setup and teardown around query(). - Your
PreToolUse hook covers subagents too. input.agent_id tells you which. - All matching hooks run in parallel, and the tool waits for the slowest. Nothing is ordered.
deny + a good permissionDecisionReason is the highest-leverage thing a hook can return; the model reads it. continue: false ends the loop and still reports success with an empty result. - A throwing hook was swallowed in our run. Catch and decide explicitly anyway.
Frequently asked questions
What is a hook in the Claude Agent SDK?
An async function you pass in options.hooks, keyed by event name, that the SDK calls when a lifecycle event fires. Its signature is (input, toolUseID, { signal }) => Promise<HookJSONOutput>. It receives a typed object describing the event, and what it returns can allow, deny or rewrite a tool call, inject context, or stop the run. There is no stdin, no exit code and no subprocess — that is the settings.json flavour of hooks, which is a different mechanism with the same name.
How are SDK hooks different from settings.json hooks?
Same event names, same JSON output schema, different delivery. A settings.json hook is a shell command Claude Code spawns with the payload on stdin, and it signals a block with exit code 2. An SDK hook is a function in your process that returns an object; there is no exit code. They also coexist: in our run both fired for the same tool call, because query() loads user, project and local settings by default. Pass settingSources: [] to run SDK hooks alone.
Do SessionStart and SessionEnd hooks fire in the Agent SDK?
Not as callbacks, in our testing. We registered a callback for all 31 events across five runs on SDK 0.3.231 and SessionStart and SessionEnd never fired, while shell hooks for the same two events in .claude/settings.json did fire in the same run. The events are real; the SDK callback for them is registered too late to see the start and the transport is gone by the end. Do session setup before query() and teardown after the loop.
Does an SDK hook block the agent loop?
Yes, unless you opt out. We made a PreToolUse hook sleep 3,000 ms; the hook returned 3,006 ms after it was entered and PostToolUse followed 20 ms after that. The agent waits for every matching hook to resolve before proceeding. Return { async: true } for pure side effects such as logging or webhooks, and the agent continues immediately — at the cost that an async hook cannot block, modify or inject anything.
What happens if my hook throws?
In our run, nothing dramatic: a PreToolUse hook that threw a plain Error was swallowed, the Write it was attached to still executed, PostToolUse still fired and the run finished with subtype success and is_error false. The documentation warns that an unhandled exception can interrupt the agent, so do not rely on the swallow. Catch inside the hook and decide explicitly, because a hook that throws is a policy that silently is not enforced.
How do I stop a run from inside a hook?
Return { continue: false, stopReason: "..." }. Be aware of what it does not do: in our run the tool call that the hook was inspecting still executed and still fired PostToolUse, and the final result message came back with subtype success, is_error false and an empty result string. It ends the loop, it does not veto the tool and it does not look like an error. To veto a tool call, return permissionDecision: 'deny' instead.
Sources
All measurements are ours, run on @anthropic-ai/claude-agent-sdk 0.3.231 (npm latest and next) on Node 22.22.2, on
August 11, 2026, against a two-file toy repository — five runs, six turns or fewer
each, on Claude Opus 5. They illustrate the mechanism; timings depend on the model, the repository
and the network, and event coverage depends on which features a run exercises. Type definitions
(HOOK_EVENTS, HookCallback, HookCallbackMatcher,
BaseHookInput, SyncHookJSONOutput, AsyncHookJSONOutput and
every per-event input and output type) are quoted from the published package's
sdk.d.ts. Event descriptions, the Python/TypeScript availability split, matcher rules,
the parallel-execution and precedence notes and the exception warning:
Agent SDK — hooks.
settingSources semantics and includeHookEvents:
Agent SDK TypeScript reference.
Shell-hook exit codes, per-event timeouts and the matcher grammar:
Claude Code hooks reference.
One negative result worth flagging: includeHookEvents: true produced no
hook_started / hook_progress / hook_response system messages
for SDK callback hooks in our run — those lifecycle messages appear to describe command hooks,
which have stdout and stderr to report.
Where Backgrind fits
Everything above is the machinery for a decision that has no human attached to it. The other half of
the problem is the run where there is one — where deny is the wrong answer and
"ask the person who owns this repo" is the right one, except they are in another window.
Backgrind is the overlay for that case: it PTY-wraps the Claude Code CLI you already
run and turns a held PreToolUse decision into an ambient toast over whatever is on
screen, answerable with one keystroke. Different layer, same event. If you are building the harness,
build the reachable human in too — see the demo for what that looks like.