Control and interaction matrix
Which sources answer, which only observe, and where each vendor's ceiling sits: the control loop, the form loop, and the steering boundary, regenerated by docs/gen-agents.js.
Control in AEP is declarative and opt-in: a target says what it accepts in
its hello control.accepts list, consumers send only that, and every
command is acknowledged and answered by cause-linked outcome Events
(AEP-0004). A source that
declares nothing is not broken; observe-only is a per-vendor honesty
decision, recorded with its reason in the tables below.
The control loop
sequenceDiagram
participant V as Vendor agent
participant A as Adapter
participant R as Relay
participant C as Consumer
V->>A: permission moment<br/>(hook held open)
A->>R: attention.requested<br/>(kind permission)
R->>C: attention.requested
C->>R: control.attention.respond<br/>(cause = request id)
R->>A: command, delivered<br/>to one target
A->>R: ack (control.accepted)
A->>V: decision (allow or deny)
A->>R: attention.answered,<br/>attention.resolved
Command support
| Source | hello control.accepts |
Answerable kinds | Ack window | Observed-only attention | Vendor-side cancel | tool.denied facts |
|---|---|---|---|---|---|---|
| Claude Code | control.attention.respond + opt-in: x.claude-code.control.instruct (steering, AEP_INSTRUCT=1; see the design record) |
permission (typed text follows the AEP-0008 receiver rule: an option word counts as that option, other free text is a deny WITH the text as the hook message, the vendor's own custom-reply path), form |
10000 ms | n/a | n/a | PermissionDenied hook → tool.denied |
| Codex | control.attention.respond + opt-in: x.codex.control.instruct (steering, AEP_INSTRUCT=1; see the design record) |
permission (typed text follows the AEP-0008 receiver rule: an option word counts as that option, other free text is a deny WITH the text as the decision message, the guidance path) |
10000 ms | n/a | n/a | on the OTel channel (tool_decision deny); see the OTel-inbound row |
| Gemini CLI | n/a (no control block) |
n/a | n/a | n/a | n/a | n/a |
| Qwen Code | control.attention.respond + opt-in: x.qwen-code.control.instruct (steering, AEP_INSTRUCT=1; see the design record) |
permission (typed text follows the AEP-0008 receiver rule: an option word counts as that option, other free text is a deny WITH the text as the decision message, the vendor's guidance path) |
10000 ms | n/a | n/a | n/a |
| VS Code agent (Copilot) | [] (declared read-only) + opt-in: control.attention.respond (the held PreToolUse permission gate: AEP_VSCODE_CONTROL=1, scoped by AEP_VSCODE_GATE_TOOLS; the vendor's own allow/deny/ask decision surface; see the adapter README), x.vscode.control.instruct (steering: AEP_INSTRUCT=1; queued text rides hookSpecificOutput.additionalContext on the next eligible hook reply, five source-settled moments; see the steering design record) |
permission (control opt-in: the held PreToolUse gate; an ask answer escalates to VS Code's own user-approval flow; typed text follows the AEP-0008 receiver rule: an option word incl. ask counts as that option, other free text denies with the text as the rendered reason) |
n/a | n/a | n/a | a commanded operator deny lands tool.denied by: policy cause-linked to the answer (hook denies are the vendor's hooks-policy plane; no further hook fires for the blocked call) |
| Kimi Code CLI | [] (declared read-only) + opt-in: control.attention.respond (the held PreToolUse permission gate: AEP_KIMI_CONTROL=1, scoped by AEP_KIMI_GATE_TOOLS; the vendor decision surface is BINARY allow/deny: an allow is "no hook objection", the vendor's own permission flow still runs downstream; see the adapter README), x.kimi-code.control.instruct (steering: AEP_INSTRUCT=1; queued text rides {message} on the next UserPromptSubmit reply (the vendor's one context-accepting reply surface) and SessionEnd drops leftovers; see the steering design record) |
permission (control opt-in: the held PreToolUse gate, binary allow/deny; typed text follows the AEP-0008 receiver rule: an option word counts as that option, other free text denies with the text as the rendered reason) |
n/a | permission (always on; the vendor's own TUI approval prompt observed as an out-of-band pair: PermissionRequestopens the request with no options claimed,PermissionResultrecords the human's answer, a rejection landstool.denied by: runtime) |
the dedicated Interrupt hook IS the vendor's own Esc moment (fired only for reason "cancelled"): run.cancelled by: user with no drain heuristic on the happy path; a run left open drains at the next prompt or at SessionEnd |
a commanded operator deny lands tool.denied by: policy cause-linked to the answer (the vendor renders the structured deny reason in the TUI); a vendor-side human rejection lands tool.denied by: runtime cause-linked to the oob answer |
| OpenCode | control.attention.respond |
permission (typed text follows the AEP-0008 receiver rule: an option word counts as that option; unmatched free text applies the refusing status; the hook output carries status only, so guidance stays stream-visible) |
10000 ms | n/a | n/a | n/a |
| Kilo Code | control.attention.respond |
permission (dual surface; through 7.4.5: the held hook, typed text follows the AEP-0008 receiver rule, an option word counts as that option, unmatched free text applies the refusing status with guidance stream-visible only; from 7.4.6: options mirror the vendor reply enum once/always/reject verbatim and a reject's free text REACHES the vendor as the reply message), form (questions, from 7.4.6) |
10000 ms | n/a | n/a | n/a |
| Cline | [] (declared read-only) + opt-in: control.attention.respond (hub approvals: AEP_CLINE_HUB_CONTROL=1; see the design record), control.cancel (scope run → hub run.abort, same gate), x.cline.control.send_input (user-plane input, same gate) |
permission (hub opt-in) |
n/a | permission (tap-only mode, AEP_CLINE_HUB=1 without the control gate) |
agent_abort → run.cancelled; the opt-in control.cancel drives hub run.abort, and the same hook fact returns cause-linked to the command |
n/a |
| Hermes | [] (declared read-only) + opt-in: control.attention.respond (the held operator-deny gate on pre_tool_call: AEP_HERMES_CONTROL=1, scoped by AEP_HERMES_GATE_TOOLS; see the design record), x.hermes.control.instruct (steering, AEP_INSTRUCT=1; see the design record) |
permission (control opt-in: the held tool gate; typed text follows the AEP-0008 receiver rule: an option word counts as that option, other free text is a deny with the text as the reason; the vendor's own approval gate stays observed read-only in every posture) |
n/a | permission (the vendor's own gate, every posture; the card carries the vendor's four-way decision menu as optionsdata and declaresrespond_via: [oob], AEP-0008), form (the clarifyoverlay: the ask AND the vendor-delivered answer both on the wire;respond_via: [oob], AEP-0006) |
on_session_end interrupted → run.cancelled; no commanded cancel: no vendor cancel write exists on this surface (control.cancel not declared even under the gate) |
policy denies → tool.denied by: policy; a commanded operator deny rides the SAME pinned mapping (the vendor classes the block plugin_block) cause-linked to the command |
| Antigravity | n/a (no control block) |
n/a | n/a | n/a | n/a | n/a |
| pi | [] (declared read-only) + opt-in: control.attention.respond (the held operator gate on the blockable tool_call: AEP_PI_CONTROL=1, scoped by AEP_PI_GATE_TOOLS; registration itself is the opt-in; see the design record) |
permission (control opt-in: the held tool gate; a deny rides the vendor's own block result and the observed skip stays the pinned tool.failed, never recast) |
n/a | n/a | stop_reason: aborted → run.cancelled; no commanded cancel: aborts belong to the client-owned process modes (control.cancel not declared even under the gate) |
none in any posture: a commanded operator deny lands as the pinned tool.failed cause-linked to the command (the bus does not distinguish blocked-from-failed; no recast) |
| AG-UI | [] (declared read-only) |
n/a | n/a | n/a | n/a | n/a |
| ACP | [] (declared read-only) |
n/a | n/a | permission |
stopReason: cancelled → run.cancelled |
n/a |
| OpenHands | [] (declared read-only) + opt-in: control.attention.respond (answerable confirmation: AEP_OPENHANDS_CONTROL=1; see the design record), control.pause (→ the vendor's own lifecycle pause, honored only from running, same gate), control.resume (→ the vendor's run call, honored only from paused; refused where it would start work or bypass a pending confirmation, same gate), x.openhands.control.send_input (user-plane input appended as a user message, the vendor run flag pinned false: never a run trigger, never an implicit approval; no status guard, same gate), x.openhands.control.interrupt (the escalated pause: instant, resumable; honored only from running, elsewhere the vendor falls back to a pause no-op and the command is refused, same gate) |
permission (control opt-in) |
n/a | permission (default posture) |
status transition deleting → run.cancelled; no commanded cancel: no vendor write terminates a run (control.cancel deliberately not declared even under the gate) |
hook-sourced UserRejectObservation → tool.denied (by: policy); a user-sourced rejection stays vendor-scoped (a human decision, not a policy block) |
| OTel inbound | n/a (no control block) |
n/a | n/a | n/a | n/a | tool_decision deny records → tool.denied |
| OpenCode SSE | [] (declared read-only) + opt-in: control.attention.respond (answerable permission in the vendor's own once/always/reject vocabulary: AEP_OPENCODE_SSE_CONTROL=1, primary mode only; see the design record), control.cancel (scope run → the server's own session.abort; the vendor's idle boundary closes the synthesized run as run.cancelled cause-linked, same gate) |
permission (control opt-in, primary mode) |
n/a | permission (default posture, primary mode) |
the opt-in control.cancel drives the server's session.abort (a true activation terminal on this channel); without a command in flight the idle boundary stays a plain run.finished |
n/a |
| Kilo Code SSE | [] (declared read-only) + opt-in: control.attention.respond (answerable permission in the vendor's own once/always/reject vocabulary: AEP_KILOCODE_SSE_CONTROL=1, primary mode only; the REST routes are byte-identical to OpenCode's at the re-verified pins), control.cancel (scope run → the server's own session.abort; the vendor's idle boundary closes the synthesized run as run.cancelled cause-linked, same gate) |
permission (control opt-in, primary mode) |
n/a | permission (default posture, primary mode) |
the opt-in control.cancel drives the server's session.abort (a true activation terminal on this channel); without a command in flight the idle boundary stays a plain run.finished |
n/a |
| Kimi web (kap-server) | [] (declared read-only) + opt-in: control.attention.respond (answerable approval in the vendor's own approved/rejected decision vocabulary, free text as feedback: AEP_KIMI_WEB_CONTROL=1, primary mode only; POST /api/v1/sessions/{sid}/approvals/{approval_id}, outcome riding the vendor's own event.approval.resolved broadcast), control.cancel (scope run → the vendor session abort, a non-destructive active-work terminal; the turn.ended reason "cancelled" boundary closes the run cause-linked, same gate) |
permission (control opt-in, primary mode: approvals only; question answering is deferred, reason recorded) |
n/a | permission (default posture, primary mode: the wire approval loop observed read-only), form (default posture, primary mode: the question loop presented read-only on the field_spec shape) |
the opt-in control.cancel drives the vendor session abort; the vendor's own turn.ended reason "cancelled" is the boundary that closes the run; without a command in flight the same boundary maps to a plain run.cancelled (the channel does not know who cancelled) |
n/a |
Two targets accept control.cancel today, both opt-in: Cline through its
hub control channel, where the vendor's abort verb returns as its own hook
fact cause-linked to the command, and the OpenCode SSE bridge in primary
mode, where the server's session.abort ends the in-flight work and the
vendor's own idle boundary closes the synthesized run as run.cancelled.
Every other cancellation fact in the table is a vendor-side moment
each mapping reports honestly. The demo synthetic agent remains the
reference implementation of a cancel-capable target, and consumers surface
a nack from any non-accepting target as the correct, sovereign answer.
control.pause/control.resume have their first real target: the
OpenHands bridge, opt-in, the one source whose vendor documents lifecycle
pause/run calls. The paused state returns as the vendor's own status
fact riding state.delta, cause-linked to the command, and the refusals
are load-bearing: a resume is refused where it would start new work or
silently accept a pending confirmation.
The form loop
sequenceDiagram
participant V as Vendor agent
participant A as Adapter
participant C as Consumer
V->>A: structured question<br/>(multi-field)
A->>C: attention.requested<br/>(kind form, field_spec)
C->>A: control.attention.respond<br/>(values)
A->>V: validated values
A->>C: attention.answered,<br/>attention.resolved
Structured input requests (AEP-0006)
carry a typed field_spec so a consumer can render real forms and answer
with validated values; invalid values earn control.rejected with the
request kept pending.
| Source | Form loop today |
|---|---|
| Claude Code | AskUserQuestion and MCP Elicitation/ElicitationResult → attention.requested kind form (AEP-0006), answered end to end |
| Codex | n/a |
| Gemini CLI | n/a |
| Qwen Code | n/a |
| VS Code agent (Copilot) | n/a |
| Kimi Code CLI | n/a |
| OpenCode | n/a |
| Kilo Code | question.asked (from 7.4.6) → attention.requested kind form on the AskUserQuestion shape (index-id choices, vendor custom → other), answered end to end over POST /question/{requestID}/reply |
| Cline | n/a |
| Hermes | SHIPPED observed: the clarify ask-the-user tool → attention.requested kind form (AEP-0006) beside the pinned tool trio, with respond_via: [oob]; no result-consuming hook exists on this surface, so the bus honestly cannot answer; the card mirrors the vendor's own entry-point choice normalization, and the vendor-delivered answer rides attention.answered via oob (a picked choice as its structural 1-based id; Other/open-ended as redacted-gated text); the vendor's timeout sentinels land attention.timeout, cancel/undeliverable/blocked/error resolve dismissed (design record) |
| Antigravity | n/a |
| pi | n/a |
| AG-UI | n/a |
| ACP | n/a |
| OpenHands | n/a |
| OTel inbound | n/a |
| OpenCode SSE | n/a |
| Kilo Code SSE | n/a |
| Kimi web (kap-server) | n/a |
Today Claude Code, Kilo Code, Hermes are the form-loop sources; the loop itself is generic: any source whose vendor exposes a structured question surface adopts it without new protocol work.
A form loop is ANSWERABLE exactly when the vendor consumes an injected
answer (eligibility is a consumption property); where the ask and its
answer are only observable, the card declares respond_via: ["oob"] and
the vendor-delivered answer still rides attention.answered via oob;
the diagram above shows the answerable shape, and the observed shape
replaces the two middle arrows with the vendor asking its own user.
Where the steering boundary sits
Most vendors expose surfaces that could drive the agent: context injection, input rewriting, mid-run messages. The mappings deliberately leave them unused: an observer that silently changes what it observes has broken the capture-honesty contract. They are catalogued here because the boundary is part of the protocol's claim to honesty; where one IS exercised (the SHIPPED rows below), it arrives as an explicit, declared, commanded, opt-in capability (the vendor-scoped verb path of AEP-0004 §5 and the steering design record), never as a side effect.
| Source | Vendor steering surface and its use | Ceiling and headroom |
|---|---|---|
| Claude Code | SHIPPED opt-in: x.claude-code.control.instruct queues text delivered as additionalContext on the next eligible hook reply, audited on the stream (design record); updatedInput/updatedToolOutput rewrites stay unused |
blockable task/teammate/config moments are captured as facts, never driven; PermissionDenied.retry deliberately unused |
| Codex | SHIPPED opt-in: x.codex.control.instruct queues text delivered as additionalContext on the five documented moments (SessionStart, SubagentStart, PreToolUse, PostToolUse, UserPromptSubmit), audited on the stream (design record); no SessionEnd hook exists, so the session-end drop outcome has no Codex moment (recorded deviation); PostToolUse result replacement stays unused |
native OTel log export is the second channel, adopted through the OTel-inbound mapping |
| Gemini CLI | BeforeAgent/AfterTool context injection; BeforeModel prompt/model mutation (automation-side hooks, out of the mapped scope) |
no human-in-the-loop hook exists (Notification can neither grant nor deny): a vendor ceiling, so observe-only is the honest posture |
| Qwen Code | SHIPPED opt-in: x.qwen-code.control.instruct queues text delivered as additionalContext on the seven documented moments (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, PreCompact), audited on the stream with the session-end drop (design record); the async command-hook systemMessage surface stays deliberately unused |
a PermissionRequest interrupt output field and a two-phase todo-hook structure are documented vendor-side and unmapped |
| VS Code agent (Copilot) | SHIPPED opt-in: x.vscode.control.instruct queues text delivered as additionalContext on the five source-settled moments (SessionStart, UserPromptSubmit, SubagentStart, PreToolUse, PostToolUse (the last docs-silent but typed AND consumed, types over prose); PreCompact excluded, no output surface), audited on the stream (design record); no SessionEnd exists on this target, so there is no session-end drop moment (the Codex deviation); a held PreToolUse reply carries the permission decision, never steering |
the opt-in OTel channel's LogRecord family is ADOPTED (the OTel-inbound sidecar's --vendor vscode profile, fixture vscode-otel.json: tool-failure visibility the hook surface cannot carry); the richer invoke_agent/execute_tool span channel is recorded headroom (a new receiver message shape); updatedInput rewrite stays deliberately unused; PostToolUse is success-only and the VSCode hook target has no SessionEnd; end visibility waits on the vendor (agent hooks are Preview) |
| Kimi Code CLI | SHIPPED opt-in: x.kimi-code.control.instruct queues text delivered as {message} on the next UserPromptSubmit reply; the vendor wraps hook stdout as a hook_result block appended to context, and that is its ONE context-accepting reply surface (every other event's stdout is display-only); SessionEnd EXISTS here, so leftovers drop with reason "session-ended" (the Claude Code rule), audited on the stream |
live-validated (the five-beat guide run, incl. the permission pair and both opt-in controls). The kap-server control plane is BUILT as the Kimi web channel (bridge-kimi-code-web; see its own card). Two further machine surfaces stay recorded, deliberately unbuilt: native ACP (kimi acp, protocol v1; observable TODAY through the ACP bridge with zero code) and the append-live wire.jsonl session records. The vendor ships weekly: the 16-event enum, the strict config schema, and the kap wire vocabulary are standing re-verify surfaces; a vendor-side config rewrite (the web UI settings save serializes config.toml) STRIPS the aep-setup fence comments; the writer then degrades to a presence check (no duplicate hooks, but fence-refresh stops working until the fence is re-landed). |
| OpenCode | chat.params/chat.headers/shell.env mutation hooks exist and are deliberately unregistered; the server REST API can answer permissions out of band |
tool.execute.before can block by throwing (unused); a typed v2 question/permission event family exists in the published SDK but is not yet plugin-exposed |
| Kilo Code | the same bus surface as OpenCode, observation hooks only by the design record | the vendor's event-model migration lands at 7.4.6 (verified against the shipped 7.4.11 binary; dual permission surface + the question form loop shipped 2026-07-20); kilo serve HTTP + SSE and a native OTLP export are documented second channels |
| Cline | typed SubprocessHookControl fields (overrideInput, appendMessages, systemPrompt) exist and are never returned; hub session.send_input ships as the opt-in x.cline.control.send_input: user-plane input (delivery queue/steer), deliberately NOT instruct |
the hub approval loop, run.abort, and session.send_input are adopted opt-in (the design record); session.fork, schedule.*, and the hub's capability brokerage stay out of scope, stated |
| Hermes | SHIPPED opt-in: x.hermes.control.instruct queues text delivered through the vendor's own context injection: the pre_llm_call hook reply's {"context": …}, the ONE site the runtime consumes (joined into the user message at turn start), audited on the stream; delivery at latest on the next turn start, drop rides the real session end (on_session_finalize, never the per-turn on_session_end) (design record) |
the TUI-gateway JSON-RPC stream with approval.request is a documented second channel (bridge-shaped, deferred); the vendor's own approval gate stays hook-unanswerable by design (pre-answering it is the one thing the vendor forbids; verified at the pin and on a live install: the approval hook dispatch discards results) |
| Antigravity | PreInvocation/PostInvocation accept injectSteps; Stop can force continuation; never sent |
PreToolUse decisions (allow/deny/ask/force_ask) exist on the wire, but approval outcomes are not hook-visible: the gap that keeps this mapping observe-only |
| pi | --mode rpc carries steer/follow_up/abort/fork and an extension-UI request/response sub-protocol: a client-owned process mode, not the adapter's channel |
mutable tool_result stays unregistered; the blockable tool_call is now the opt-in gate's one surface (the default posture registers neither); the fork's typed approval pair has not reached upstream: the named trigger, mapping to the attention pair plus whatever the bus emits for the skipped call |
| AG-UI | n/a | the protocol is read-only by design: there is no control loop to claim |
| ACP | n/a | byte-transparency outranks everything: the tee never injects; the ACP v2 RFD track (permission-request shape) is the standing watch |
| OpenHands | the events socket is read-write and the REST API carries respond_to_confirmation, lifecycle pause/run, the message append, and /interrupt; never-send is the DEFAULT posture (the auth frame is the only socket write, ever); AEP_OPENHANDS_CONTROL=1 inverts it for exactly five REST writes, and the socket stays auth-frame-only either way. User-plane input rides x.openhands.control.send_input (an appended user message, run flag pinned false): the Cline-class divergence, deliberately outside the convergent instruct corpus per the steering record |
the approval loop, pause/resume, user-plane send-input, and the interrupt escalation all ship on the opt-in control channel; remaining: the webhook second channel |
| OTel inbound | n/a | companion/primary dedup keeps the hook adapter authoritative when both channels observe the same session |
| OpenCode SSE | the SSE stream is read-only by construction and stays so; AEP_OPENCODE_SSE_CONTROL=1 (primary mode) pairs it with exactly two REST writes: the permission reply and session.abort; the server's prompt/command routes (user-plane driving) stay out of scope, stated |
the REST control channel ships opt-in (primary mode; the record's own natural actuator); remaining: the typed v2 question/permission surface (a named watch, not plugin- or stream-exposed at 1.18.3); the Kilo SSE twin ships as its own channel, the bus union verified byte-identical at @kilocode/sdk 7.4.11 vs 1.18.3 |
| Kilo Code SSE | the SSE stream is read-only by construction and stays so; AEP_KILOCODE_SSE_CONTROL=1 (primary mode) pairs it with exactly two REST writes: the permission reply and session.abort; the server's prompt/command routes (user-plane driving) stay out of scope, stated |
the fork twin of the OpenCode SSE channel, shipped with the same two-mode discipline and the same opt-in control pair; remaining: the typed v2 question/permission surface (the shared named watch) and live validation alongside the plugin channel's |
| Kimi web (kap-server) | the WS stream is read-only by construction and stays so; AEP_KIMI_WEB_CONTROL=1 (primary mode) pairs it with exactly two REST writes: the approval decision and the session abort; the prompt submit/steer routes (user-plane driving) stay out of scope, stated |
reaches kap-hosted (kimi web) sessions ONLY: the terminal TUI runs in-process with no server surface (unreachable by vendor design, recorded unsupported); remaining: question-answer actuation (the per-item id round-trip), instance-registry discovery of multiple servers, and live validation against a real kimi web session |
Implementation homes: impl/adapter-claude-code · impl/adapter-codex · impl/adapter-gemini-cli · impl/adapter-qwen-code · impl/adapter-vscode · impl/adapter-kimi-code · impl/adapter-opencode · impl/adapter-kilocode · impl/adapter-cline · impl/adapter-hermes · impl/adapter-antigravity · impl/adapter-pi · impl/bridge-agui · impl/bridge-acp · impl/bridge-openhands · impl/bridge-otlp-in · impl/bridge-opencode-sse · impl/bridge-kilocode-sse · impl/bridge-kimi-code-web.