Adapters: vendor hooks to AEP events
Overview of the twelve reference adapters translating vendor hook and plugin systems to AEP events.
The code this page cites lives in the reference repository,
agenteventprotocol/reference; file paths below are
relative to that repository's source tree.
Twelve adapters ship in the reference stack. Each one translates a vendor's native hook or plugin callbacks into AEP events, per the AEP-0002 Annex A source-vocabulary mappings:
| Adapter | Source |
|---|---|
| Claude Code | impl/adapter-claude-code/adapter.js |
| Codex | impl/adapter-codex/adapter.js |
| Gemini CLI | impl/adapter-gemini-cli/adapter.js |
| Qwen Code | impl/adapter-qwen-code/adapter.js |
| VS Code agent | impl/adapter-vscode/adapter.js |
| Kimi Code | impl/adapter-kimi-code/adapter.js |
| OpenCode | impl/adapter-opencode/adapter.js |
| Kilo Code | impl/adapter-kilocode/adapter.js |
| Cline | impl/adapter-cline/adapter.js |
| Hermes | impl/adapter-hermes/adapter.js |
| Antigravity | impl/adapter-antigravity/adapter.js |
| pi | impl/adapter-pi/adapter.js |
Their control posture splits three ways:
- The control loop. Claude Code, Codex, Qwen Code, OpenCode, and Kilo Code implement the same experimental control loop for permission prompts (AEP-0004).
- Observe-only by DEFAULT, with opt-in control legs. Cline, Hermes, pi,
the VS Code agent, and Kimi Code: the hub tap, the held operator-deny gate,
the held
tool_calloperator gate, and the two held PreToolUse permission gates. See their design records and the adapter READMEs. - Observe-only, with no control leg to opt into. The Gemini and Antigravity adapters: their vendor surfaces expose no answerable human-in-the-loop hook.
pi draws the line most cleanly. Its per-event registration means the default posture never subscribes an interception surface, so neutrality is structural rather than an answered value, and registration itself is where the opt-in is drawn (see the design record).
The set is vendor-neutral on purpose: if a mapping decision only makes sense for one vendor, it probably isn't a core event.
The daemon shape
Each adapter is a small HTTP daemon (http.createServer) that the vendor's
own hook configuration calls synchronously, once per hook firing
(impl/adapter-claude-code/adapter.js:589-616,
impl/adapter-codex/adapter.js:263-290). It exposes one POST endpoint plus
GET /healthz, which reports { ok, relay }, with relay: true once the WS
binding to the relay is live. Codex hooks are command-only, so codex-hook.js
is a stdin/stdout shim in front of the same daemon protocol; see
impl/adapter-codex/*.example.* for the wiring.
Every hook call runs through handleHook(input, respond), which switches on
hook_event_name and calls the shared emit(session, type, data, extra)
helper (impl/adapter-claude-code/adapter.js:99,
impl/adapter-codex/adapter.js:101).
emit() always does two things with every event
(impl/adapter-claude-code/adapter.js:56-118):
- it appends the envelope as one JSON line to a per-session file under
~/.aep/logs/<agent>/<session>.jsonl, the durable record; - it sends the same JSON over the adapter's WebSocket connection to the
relay's
/socketendpoint if that connection is up, or queues it inoutboxuntil thehellohandshake completes.
The JSONL log is what aep timeline --file and aep validate read once the
relay's buffer has already rotated the session out. See
cli.md.
Session/run identity: Claude Code's session_id maps directly to AEP
session, and a UserPromptSubmit hook starts an AEP run
(impl/adapter-claude-code/adapter.js:341, 348-355). Codex's thread is the
AEP session and its turn_id is the AEP run
(impl/adapter-codex/adapter.js:151-158); the comment at that line spells out
the mapping explicitly because the vocabulary doesn't match CC's one-to-one.
Hook to event mapping
Both files carry the same fallback rule for hooks with no core mapping:
x.{vendor}.session.{snake_case(hook)}, the mechanical vendor-extension
fallback the taxonomy defines for exactly this case
(impl/adapter-claude-code/adapter.js:552-585,
impl/adapter-codex/map-hooks.js:95-103). The Codex mapping is a pure module
the daemon delegates to, pinned by the Annex A fixture.
The diagram below is the mapping as implemented today. The normative table it must agree with is AEP-0002 Annex A.
graph LR
SessionStart --> session_started["session.started"]
SessionEnd --> session_ended["session.ended"]
UserPromptSubmit --> run_started["run.started"]
Stop --> run_finished["run.finished"]
StopFailure --> run_failed["run.failed (CC only)"]
PreToolUse --> tool_requested["tool.requested"]
PostToolUse --> tool_completed["tool.completed"]
PostToolUseFailure --> tool_failed["tool.failed (CC only)"]
PermissionDenied --> tool_denied["tool.denied (CC only)"]
PermissionRequest --> attention_requested["attention.requested"]
Elicitation --> attention_requested
ElicitationResult --> attention_settled["attention.answered + attention.resolved"]
SubagentStart --> delegation_started["delegation.subagent.started"]
SubagentStop --> delegation_stopped["delegation.subagent.stopped"]
PostCompact --> session_compacted["session.compacted"]
TaskCreated --> progress_planned["progress.task.planned (CC only)"]
TaskCompleted --> progress_completed["progress.task.completed (CC only)"]
Notification --> progress_status["progress.status (CC only)"]
TeammateIdle --> agent_idle["agent.idle (CC only)"]
unmapped["unmapped hook"] --> vendor_ext["x.vendor.session.hook_name"]
Three documented deviations from a strict mapping, worth knowing before you read the code:
PreCompactemits nothing;session.compactedfires once, atPostCompact. The Claude Code and Codex adapters carry a matching comment marking this a deviation (impl/adapter-claude-code/adapter.js:548,impl/adapter-codex/map-hooks.js:93-94). Whether the spec should pair pre/post compaction markers remains an open design question for a future revision.- Codex has no separate tool-failure hook: a failed tool still produces
tool.completed, nevertool.failed(impl/adapter-codex/map-hooks.js:52-54).tool.failedon Codex is reserved for runtime-reported failures, which the hook surface doesn't report today. - CC's
AskUserQuestionmaps to oneattention.requested (kind=form)per tool call (AEP-0006), not the Annex-Akind=permission, because the tool is the agent asking the human, not a permission gate. See the question loop below.
The PermissionRequest control loop
This is the founding scenario for AEP's control profile: a tool call needs a yes/no (or a redirect) from a human, and the agent process must not proceed until it gets one.
Both adapters implement the identical state machine
(impl/adapter-claude-code/adapter.js:422-496,
impl/adapter-codex/adapter.js:202-253), and the CC adapter additionally
routes AskUserQuestion to a dedicated question loop (next section). The rest
of this section cites the Claude Code file; the Codex file is line-for-line
the same shape.
The hook fires and the adapter emits attention.requested with two named
options (allow/deny) and respond_via: ['control']
(impl/adapter-claude-code/adapter.js:440-445). It then does not call
respond() yet: the HTTP response to Claude Code's hook stays open.
A timer for AEP_ATTENTION_TIMEOUT_MS (default 55s) is armed alongside a
pendingAttention resolver keyed by the request's event id
(impl/adapter-claude-code/adapter.js:447-455).
The pending request resolves one of two ways:
- Timeout (
impl/adapter-claude-code/adapter.js:447-453): the timer fires, the adapter emitsattention.timeout, and callsdone({}), an empty decision body. Returning no decision tells Claude Code to fall back to its own interactive permission dialog; the adapter never blocks the agent indefinitely. - A
control.attention.respondcommand arrives over the WebSocket (onRespondCommand,impl/adapter-claude-code/adapter.js:129-155) and is acked withcontrol.accepted. The pending resolver inimpl/adapter-claude-code/adapter.js:455-495then runs. The ack iscontrol.rejectedinstead if the request id is unknown (answered twice, say), or if the waiter'svalidatehook rejects the answer, per the AEP-0006 form-values rule; the rejection'sdetailfield is capture-gated atredacted, matching its schema annotation.
The resolver is where the three answer shapes branch. (The reference adapter's source carries a comment block at exactly this branch; the rationale is spelled out below.)
{option: 'allow'}→option = 'allow'→ hook decision{behavior: 'allow'}.{option: 'deny'}→option = 'deny'→ hook decision{behavior: 'deny', message: 'Denied via AEP attention loop'}.{text: '...'}(nooption) → the AEP-0008 receiver rule applies first. Text equal to an option word (allow/deny, any case, trimmed) IS that option: the word is the decision, not guidance. Any OTHER text lands as{behavior: 'deny', message: <text>}, Claude Code's "tell Claude what to do differently" path: the model reads the message text as guidance, adjusts its approach, and can re-request the tool. That is the same mechanism as typing a custom reply into CC's own permission dialog, just arriving over the control channel instead of the terminal.- Empty/unrecognized answers (
optionabsent and no usabletext) fall through to a plain deny, same as the{option:'deny'}case.
attention.answered always records the APPLIED option (AEP-0008; the stream
is self-describing about what was decided). answer.text is present only when
guidance text survives the mapping, and it is redacted through the same
gate()/red() pipeline as prompts: the schema marks it
x-aep-capture: redacted, so it is never emitted above that ceiling
regardless of the adapter's general capture setting
(impl/adapter-claude-code/adapter.js:474).
attention.resolved follows immediately, cause-chained to the
attention.answered event (impl/adapter-claude-code/adapter.js:475-479).
sequenceDiagram
participant CC as Claude Code (hook)
participant A as Adapter
participant R as Relay
participant Op as Operator (control sender)
CC->>A: PermissionRequest hook<br/>(HTTP, held open)
A->>R: attention.requested<br/>(kind=permission,<br/>options=[allow,deny])
A->>A: arm ATTENTION_TIMEOUT_MS timer
alt operator responds in time
Op->>R: control.attention.respond<br/>{answer:{option}}<br/>or {answer:{text}}
R->>A: control.attention.respond<br/>(routed to owning<br/>connection)
A->>R: control.accepted<br/>(cause = command id)
alt answer.option === 'allow'
A->>CC: hook decision<br/>{behavior: allow}
else answer.option === 'deny'
A->>CC: hook decision<br/>{behavior: deny,<br/>message: canned}
else answer.text present (no option)
A->>CC: hook decision<br/>{behavior: deny,<br/>message: <text guidance>}
end
A->>R: attention.answered<br/>(answer.text<br/>capture:redacted)<br/>+ attention.resolved
else timeout window elapses
A->>R: attention.timeout
A->>CC: hook decision {}<br/>(no decision)
Note over CC: Claude Code shows its<br/>own permission dialog
end
Why the free-text branch is load-bearing
The three-branch mapping above (specifically {text} → deny-with-guidance) is
the easiest part of the loop to get wrong. An adapter that maps
answer.option only turns a consumer's free-text answer into a bare deny,
discarding the guidance text the schemas define in answer.text.
The Claude Code and Codex adapters map all three answer shapes. A regression
smoke test exercises all three and is wired into ci.sh.
AskUserQuestion: the question loop
Claude Code's AskUserQuestion tool (its structured clarifying-question UI)
also arrives through the PermissionRequest hook, but it is not a permission
gate; the tool is the agent asking the human. Mapping it through the generic
loop renders a multiple-choice question as an Allow/Deny permission card
whose "Allow" only unblocks CC's own local dialog.
The CC adapter therefore routes it to a dedicated handler,
handleAskUserQuestion (in the reference adapter; the Annex-A deviation
rationale is the comment block above it). The emission is AEP-0006's
structured-form shape:
- One
attention.requested (kind=form)per tool call. The whole 1-4 question batch is a single event carrying onefield_specper question (AEP-0002 §5.2):type: "select",required: true,other: truealways (the tool always offers a custom "Other" answer),multimirroringmultiSelect, and choice ids as 1-based indexes. The batch is a wire fact any consumer can render, not an adapter-local answer barrier. The pure mapping isimpl/adapter-claude-code/map-ask-user-question.js, pinned by the Annex-A mapping fixture (conformance/fixtures/mappings/claude-code.json). - Answers arrive as
answer.values. That is a flat map keyed by field id: achoices[].id, an array of ids formulti, or free text (the "Other" path). The waiter'svalidatehook (impl/adapter-claude-code/adapter.js:141-151, viaimpl/shared/aep.jsformValuesError) enforces AEP-0002 §5.3's MUST-reject rule: a missing required field, or a non-choice string for aselectwithoutother:true, is nackedcontrol.rejected{reason:"invalid"}and the request stays pending for a retry. A valid answer maps back to Claude Code labels, and the held hook returns{behavior: "allow", updatedInput: {questions, answers}}, the samePermissionResultshape the Agent SDK documents forcanUseTool. (A{text}/{option}answer from a consumer that predateskind:"form"is accepted for single-question calls: AEP-0006's degradation path.) - Fail-open on timeout. A form's fields either all arrive in one
valuesmap or the whole form gets oneattention.timeoutterminal; there is no partial-answer split, by design (AEP-0006 Rationale). The hook is freed with no decision and CC re-asks the full set in its own dialog. - Non-gating surfaces dismiss. As with permissions, a
PostToolUsearriving while the form is pending settles it as oneresolved{dismissed}and frees the hook.
Capture semantics differ from the permission card in one important way:
Allow/Deny labels are structural, but question text and choice labels are
model-generated content. field_spec.label and choices[].label carry
the same [metadata*] exception as prompt (AEP-0006): at
capture: metadata the mapping module degrades labels to structural
placeholders. Both ceilings are pinned in the mappings fixture.
Answered values are gated type-dependently (impl/shared/aep.js
gateFormValues, conformance corpus conformance/fixtures/capture-gating/):
choice ids and number/boolean values stay structural at any ceiling, while
string-field and "Other" free text follow answer.text's redacted-minimum
rule.
Smoke cases 5-8 in impl/adapter-claude-code/smoke-attention.js cover four
cases: the single-question round-trip, the one-event-per-batch wire fact with
the required-field nack and the free-text "Other" answer, whole-form timeout
fail-open, and single-event dismissal.
Mission Control
(agenteventprotocol/mission-control)
renders the fields array as a real form: text/number/checkbox/select
controls plus an "other..." input per field_spec.type, one submit sending
answer.values. The Codex adapter is untouched, because Codex exposes no
equivalent hook and this mapping is ready if one appears. updatedInput
suppressing CC's own question dialog was verified against a live session.
Elicitation: the same loop for MCP servers
Claude Code's Elicitation hook surfaces an MCP server's elicitation/create
request (the protocol AEP-0006's field vocabulary was designed congruent
with), and the CC adapter runs it through the same held-hook form loop as
AskUserQuestion (handleElicitation,
impl/adapter-claude-code/adapter.js:277-337).
The pure schema→field_spec mapping is
impl/adapter-claude-code/map-elicitation.js, pinned by the Annex-A mappings
fixture at both capture postures. Field ids are the requested schema's
property keys, so accepted answer.values map back to the elicitation content
verbatim. MCP's four primitive shapes land on the four field types, and enum
fields are closed selects, no other:true escape, unlike
AskUserQuestion.
A validated answer returns {action: "accept", content: values} to Claude
Code. A timeout frees the hook with no decision, and CC's own dialog takes
over. An ElicitationResult arriving while the request is pending, the
parallel-surface case, settles it as answered via: "oob"; an orphan
ElicitationResult emits nothing (one terminal per request).
Gemini CLI: the observe-only third adapter
impl/adapter-gemini-cli/ follows the Codex deployment shape exactly: a
fail-open stdin/stdout command shim (gemini-hook.js) in front of the same
one-endpoint daemon protocol, registered via the vendor's settings.json.
settings.example.json ships the block, and the per-chunk AfterModel hook
is deliberately left unregistered by default.
The pure mapping (impl/adapter-gemini-cli/map-hooks.js) is pinned
machine-usably by conformance/fixtures/mappings/gemini-cli.json, and the
differences are the point:
- No control loop. Gemini's
Notificationhook is observability-only: it can neither grant nor deny aToolPermissionalert, so the adapter maps it toprogress.statusand never raisesattention.requested. A respond affordance the runtime cannot honor would be a lie. - Synthesized runs. No turn id is exposed; the adapter opens one AEP
runperBeforeAgentand closes it atAfterAgent, cause-linked. - An honest failure split.
tool_response.errormaps totool.failedwith the error type at metadata, the split Codex's surface cannot make. - The model plane stays vendor-namespaced.
BeforeModel/AfterModel/BeforeToolSelectionland inx.gemini-cli.session.*(model id at metadata;AfterModelat severitydebugsince it fires per streamed chunk). - Pre-moment compaction.
PreCompressis the vendor's only exposed compaction moment, sosession.compactedfires there, a documented deviation from the siblings' post-moment emission; same fleet fact.
Verification status per Annex A: documented + fixture-proven (the CI smoke drives all eleven hooks through the shim and daemon against a tokened relay); live-session validation pending.
Qwen Code: the Claude-Code-shaped sibling
impl/adapter-qwen-code/ is the fourth adapter and the closest sibling to the
Claude Code one: sixteen registered hooks with the same common stdin fields,
an exact tool_use_id pairing (tighter than Gemini's (session, tool)
keying), and a PermissionRequest that runs the same hold-open control
round-trip the Claude Code and Codex daemons implement.
Qwen's native type: "http" hooks POST straight to the daemon, so the shipped
settings.example.json needs no shim, while qwen-hook.js wraps the same
endpoint fail-open for command-hook deployments.
Its honest calls:
- runs synthesized per prompt (no turn id is documented)
- the
permission_promptnotification suppressed against the first-class hook (the Claude Code dedupe convention) - compaction once at the post moment (the vendor's sixteen-hook set includes
PostCompact) - todos on
progress.task.*emitted ONCE per persisted write (the vendor fires every registered todo hook in a validation AND a postWrite phase; the validation firing is the silent pre-moment, the same rule; verified from the published 0.19.10 tarball, 2026-07-15) permission_modeat metadata, a fleet-relevant security posture.
The vendor's seventeenth hook, the fire-and-forget streaming MessageDisplay
(0.19.10), stays deliberately unregistered: mid-turn streaming fails the
fleet-observer test, the Claude Code MessageDisplay position. Pin:
conformance/fixtures/mappings/qwen-code.json; verification status per Annex
A: documented + fixture-proven, live-session validation pending.
OpenCode: the plugin-shaped fifth adapter
impl/adapter-opencode/ is the first adapter whose vendor surface is not a
command-hook system. OpenCode loads JS plugins into its own Bun runtime, so
the shim half IS a plugin file: opencode-plugin.js, dropped into
.opencode/plugins/, fail-open by construction.
It forwards the typed bus events plus the tool.execute.before/after and
hold-open permission.ask interception moments to the same daemon shape every
other adapter uses.
Its honest calls:
- runs synthesized on user-message/idle boundaries (no turn id)
callIDtool pairing- todo snapshots diffed into
progress.task.*transitions - NO
tool.failed(the typed hooks expose no error surface; failures ridex.opencode.session.error) - bus
permission.updatedsuppressed against the first-class hook - a child session naming its parent (the delegation shape)
- post-moment compaction.
Built to the design record and verified
against the published @opencode-ai/plugin/@opencode-ai/sdk v1 types. Pin:
conformance/fixtures/mappings/opencode.json; verification status per Annex
A: documented + fixture-proven, live-session validation pending.
Kilo Code: the OpenCode fork, carried whole
impl/adapter-kilocode/ observes Kilo Code, whose CLI core is an OpenCode
fork. At the pinned versions the published bus union (@kilocode/sdk@7.4.5)
is byte-identical to OpenCode's, verified member by member in the design
record.
The shim is a Kilo Code plugin (kilocode-plugin.js, the fork's
{ server } module shape, registered in the config's plugin array,
fail-open by construction) forwarding to the same daemon shape (adapter.js,
default 127.0.0.1:8391).
It registers observation hooks ONLY: the output-mutating model/compaction
hooks stay unused, because an observer that changes what it observes has
broken capture honesty. That's a statement about hook types, not
answerability: like OpenCode, this adapter is control-capable, the hold-open
permission ask is answered over control.attention.respond, while never
registering a hook that could rewrite the agent's own output.
The honest calls are the OpenCode calls on Kilo's own identity:
- synthesized runs
callIDpairing- snapshot-diffed todos
- NO
tool.failed(failures ridex.kilocode.session.error) - suppressed bus
permission.updated - the delegation parent
- post-moment compaction
- the machinery/UI planes in
x.kilocode.*.
Pin: conformance/fixtures/mappings/kilocode.json; verification status per
Annex A: types-verified + fixture-proven, live-session validation pending.
Cline: subprocess hooks, observe-only, an honest tool.failed
impl/adapter-cline/ observes Cline's SUBPROCESS hook generation: ten
zod-typed events whose schemas ship in the published @cline/shared@0.0.59
package. The shim is a hook script (cline-hook.js) reading stdin JSON and
forwarding fail-open; it never returns the vendor's agent-mutating control
fields.
No typed hook moment is answerable (approvals ride the vendor's hub channel, a deferred second shape per the design record), so the adapter is observe-only.
The honest calls:
taskIdis the session with REAL run boundaries (agent_start/agent_resumeopen;agent_end,agent_abort→run.cancelled,agent_error→run.failedclose, the vendor's abort/error distinction kept)- the typed
ToolCallRecord.errorfield splitstool.completed/tool.failedtruthfully, the first plugin-generation vendor that can - typed parent ids open and close the
delegation.subagent.*pair prompt_submitstays vendor-namespaced (message.user.submittedis reserved)- compaction lands at the vendor's only exposed (pre) moment
- the per-turn
iterationcounter stamps the envelopestep.
Pin: conformance/fixtures/mappings/cline.json; verification status per
Annex A: types-verified + fixture-proven, live-session validation pending.
Hermes Agent: shell hooks, source-pinned, the three-way tool split
impl/adapter-hermes/ observes Hermes Agent's shell-hook generation. The
vendor publishes no typed schema artifact, so the mapping is pinned against
SOURCE at the tag named in the design
record, and every payload's own
telemetry_schema_version string is the drift sentinel.
The shim is a shell-hook command (stdin JSON, fail-open) that by DEFAULT never
blocks and never injects context; the block-capable hook output is unused and
no hook moment is answerable. The opt-in operator-deny gate
(AEP_HERMES_CONTROL=1; see the design record) is the one exception: a gated
pre_tool_call is held as an answerable Allow/Deny attention request.
Typed text follows the AEP-0008 receiver rule: an option word counts as that option, and any other free text is a deny with the text as the reason, never an allow. A deny rides the vendor's own block directive, and the observed block rides the pinned mapping unchanged, cause-linked to the command. The vendor's own approval gate stays observed read-only in every posture.
The honest calls:
session_idis the session and the vendor's REAL per-turnturn_idrides verbatim as the run id (the per-turn hook closes RUNS, never sessions, the source-pinned naming trap)- the three-state tool result splits
tool.completed/tool.failed/tool.denied (by: "policy"), the first live surface onto the denied type - the vendor's own approval gate is observed read-only with the card made
self-describing (AEP-0008):
attention.requestedcarries the vendor's four-way decision menu asoptionsdata (the return contract:once/session/always/deny; the menu's show-full-command entry dissolves into the prompt) and declaresrespond_via: ["oob"], the decision is made in the agent's own UI - answers ride via
oob, timeouts asattention.timeout - typed parent ids pair the delegation moments on the parent session
- no compaction moment is invented.
Pin: conformance/fixtures/mappings/hermes.json; verification status per
Annex A: source-verified + fixture-proven, early live operator sessions
exercised (the approval-identity join), full live validation pending.
Antigravity: hooks.json on both hosts, the stepIdx join
impl/adapter-antigravity/ observes the five-hook hooks.json command system
shared by Antigravity 2.0 and the Antigravity CLI. Docs provenance: the binary
is closed, so the vendor's public changelog pins the load-bearing behaviors,
and CLI 1.0.16 is the minimum, because that release makes the empty
decision string a safely-handled reply.
Two wire facts shape the whole design, per the design record:
- no payload names its own hook event (the
PreInvocation/PostInvocationpayloads are byte-indistinguishable), so eachhooks.jsonregistration passes its event name as the shim's argument PostToolUsecarries onlystepIdx+error, no tool name, while the registry requires one ontool.completed/tool.failed, so the daemon joins on (conversationId,stepIdx):PreToolUsesupplies the name and args, the post moment closes the pair honestly (empty error → completed, non-empty → failed).
Unjoined completions fall back to x.antigravity.step.completed, because a
name is never invented. A dangling request synthesizes nothing: denied,
rejected, and still-running are indistinguishable, and approval outcomes are
not hook-visible on this surface, so nothing maps to attention.* or
tool.denied.
The neutral replies are byte-pinned in the smoke: {"decision": ""} on
PreToolUse and Stop (never a named decision, never "continue"), and {}
elsewhere (never injectSteps, never terminationBehavior).
Identity: conversationId verbatim; session.started synthesized on first
sight (no session end exposed); runs synthesized, opened at the first
invocation boundary and closed by Stop's typed terminationReason, with
bounded terminations as completions. PreInvocation/PostInvocation land on
run.step.started/run.step.finished, with invocationNum setting the
envelope step.
Pin: conformance/fixtures/mappings/antigravity.json; verification status per
Annex A: docs-verified + fixture-proven, live-session validation pending.
VS Code agent (Copilot): the Claude Code hook contract, adopted by the editor
impl/adapter-vscode/ observes the VS Code agent (Copilot) through agent
hooks (Preview). The vendor adopted the Claude Code hook contract wholesale,
so the eight VSCode-target lifecycle events arrive with CC-identical
snake_case payloads over stdin/stdout command handlers, and the proven shim
- daemon template applies directly (
vscode-hook.jsforwards to the daemon on127.0.0.1:8381, fail-open). Registration is workspace-native: mergehooks.example.jsoninto the workspace hooks file under.github/hooks/.
VS Code also parses .claude/settings.json hooks natively (read-only), so a
workspace wired for the Claude Code adapter already fires those commands under
the VS Code agent. That is worth knowing, because those events land on the
Claude Code daemon under the wrong agent identity; the workspace-native file
is the attribution-correct path.
Its honest calls:
- runs synthesized per prompt (no turn id exists)
tool_use_idpairing- compaction emitted at the PRE moment, the only moment this surface exposes (no post-compaction hook exists)
- three deliberate absences stated rather than papered over: the VSCode hook
target has no session-end event, no run-failure event, and its post-tool
hook fires on success only, so
session.ended,run.failed, andtool.failednever appear from this adapter (the vendor's opt-in OTel telemetry channel, which spans tool executions and hook decisions, is the named failure-visibility follow-up on the OTel-inbound precedent).
The OTel failure-visibility channel is verified (source read at the pin and current main, byte-identical): the channel is real and exportable to a user-configured OTLP endpoint (opt-in setting, default off; both traces and logs go to the same base).
It carries what the hook surface cannot: the execute_tool span records
failures (ERROR status, error.type, the error-bearing result attribute) and
the invoke_agent span carries per-invocation terminal status, while a
thinner tool-call LogRecord (success flag plus optional error type) rides
the logs channel, wire-compatible with the shipped OTLP-inbound receiver
as-is.
Two honest ceilings, stated:
- SESSION end does not exist on this product surface at all (the vendor's
SessionEndhook vocabulary belongs to its separate cloud coding-agent target, not the editor target; the eight-event claim above is exact) - the hook span's decision attribute is coarse
(
pass/block/non_blocking_error); the finer allow/deny/ask tier is computed after the span closes and never reaches OTel.
The LogRecord channel is implemented (fixture-proven): the OTLP-inbound
sidecar's --vendor vscode profile consumes the copilot_chat.*
LogRecord family: tool.call outcomes onto
tool.completed/tool.failed with the vendor's own error.type token,
the session.started frame in primary mode, everything else
channel-visible under x.vscode.otel.*, with both ceilings pinned as
fixture invariants (never tool.denied, never a synthesized
session.ended). The richer trace-span channel (a new receiver message
shape) is deferred; live validation of the OTel channel is pending.
The OPT-IN permission gate (AEP_VSCODE_CONTROL=1, scoped by
AEP_VSCODE_GATE_TOOLS) holds matched pre-tool calls as answerable attention
requests on the vendor's own three-decision surface: allow, deny, and ask,
an adapter with a vendor ask tier. An ask answer escalates the call into VS
Code's own user-approval flow.
A free-text answer denies with the text as the rendered reason (VS Code shows
every deny reason in chat, where the model reads it), and a commanded deny's
outcome is stated as tool.denied with by: "policy", cause-linked to the
answer. The held window (default 25 s) deliberately fits inside the vendor's
30 s hook timeout, and an unanswered gate frees the hook with no decision,
fail-open, like every held loop in the stack.
Agent hooks are Preview ("configuration format and behavior might
change"), so the adapter pins the contract at a source ref, unknown events
degrade to the x.vscode.session.* fallback, and a moved contract re-pins
before any further build.
Pin: conformance/fixtures/mappings/vscode.json (both conformance runners),
adopted in AEP-0002 Annex
A
as the sixteenth source mapping; verification status: documented +
fixture-proven against the pinned vendor source, live-session validation
pending.
See also
- Relay internals:
routeCommandis what deliverscontrol.attention.respondto the adapter that owns the session. - Capture & redaction: the
ceiling/gate mechanism
gate()/red()implement here. - Write an adapter: building your own adapter against this same contract.
- Normative source: AEP-0002 Annex A, AEP-0004.