Steering: cross-adapter design record
Design record for the opt-in steering capability: consumer-sent instructions delivered through a vendor's own context-injection surface, as declared vendor-scoped control verbs.
Status: SHIPPED for Claude Code, Codex, Qwen Code, the VS Code agent,
and Hermes (each in impl/adapter-*/, AEP_INSTRUCT=1; per-adapter
steering smokes in the reference CI).
Most captured runtimes expose a surface that could drive the agent; the control matrix catalogues them as deliberately unused, because an observer that silently changes what it observes breaks capture honesty. Steering is the one sanctioned path from "unused" to "used": explicit, declared, commanded, audited, built entirely from what AEP-0004 §5 already provides. Nothing normative moves.
The loop
sequenceDiagram
participant C as Consumer
participant R as Relay
participant A as Adapter (opted in)
participant V as Vendor agent
C->>R: x.claude-code.control.instruct<br/>(text)
R->>A: command, delivered<br/>to one target
A->>R: ack (control.accepted,<br/>cause = command id)
Note over A: instruction queued<br/>for the session
V->>A: next eligible<br/>hook fires
A->>V: reply carries<br/>additionalContext
A->>R: x.claude-code.instruction.delivered<br/>(cause = command id)
Note over V: the model reads it<br/>on its next request
Design
- Vendor-scoped verbs, one shared shape.
x.{vendor}.control.instruct, gated byhellocontrol.acceptslike a core command (AEP-0004 §5's extension rule); payload{ "text": string [redacted] REQ }, target scopesession, identical across adapters so the corpus stays convergent. Rejected: a corecontrol.instructnow (a normative change the evidence does not yet earn);steer/injectnaming ("instruct" names what the surface does: instructions read by the model). - Opt-in, never default. Only under
AEP_INSTRUCT=1. Off, the verb is absent fromhello, and a compliant relay refuses the command sender-side (the AEP-0004 §4.2 relay-behalferrorframe, codeunsupported); the daemon's ownunsupportednack is defense-in-depth for relays that do not gate oncontrol.accepts. Rejected: default-on; observe-only is the shipped posture and the product's honesty claim. - Delivery rides the vendor's documented surface, honestly queued.
For Claude Code:
hookSpecificOutput.additionalContext, accepted on eleven hook moments, capped at 10,000 characters, read by the model on its next request; steering is advisory and asynchronous by vendor design. Accepted instructions queue per session (FIFO, cap 20; a full queue nackscontrol.rejected { code: "invalid" }with a capture-gated detail) and attach, joined in arrival order, to the next eligible hook reply. Eligible = a context-accepting moment whose reply would otherwise be empty AND whose context the runtime actually consumes: a parser that accepts a field nothing reads is not a delivery surface. Held loops (permission, elicitation) never mix with steering; their replies carry decisions. Rejected: replying on held loops (two semantics in one reply); synthesizing a delivery moment (no such vendor surface exists); delivering on one hook type only (starves sessions that never fire it). - Every step is on the stream. Receipt earns the standard ack
(
control.accepted, cause = command id; AEP-0004 §2.3/§3). Delivery emitsx.{vendor}.instruction.delivered { queued_ms }, cause-linked to the command; a session ending with instructions still queued emitsx.{vendor}.instruction.dropped { reason: "session-ended" }per instruction. The instructiontexton the stream is capture-gated[redacted]-class; the hook reply into the vendor is local control flow, never capture-gated. Rejected: silent drops (unaudited steering is the failure mode this design prevents); a delivered flag with no cause link. - Authorization is the relay's existing rule. Commands flow only on authenticated bindings (AEP-0004 §4.1); tokenless consumers are watch-only; finer grain means separate relays with separate tokens.
- Consumer behavior. Render the steering affordance only where
hellodeclares the verb; send via the owning relay; settle on the stream (ack + delivery event), never on optimistic local state: the same discipline as cancel and respond.
What this is not
Not a chat channel (one-way instructions, no reply semantics), not
input rewriting (updatedInput and updatedToolOutput stay unused;
mutating what the agent did is a different, undesigned capability),
and not a core protocol change (no schema, registry, or spec text
moves; the verbs live in the vendor extension namespace the protocol
already defines).
Adoption map
| Vendor | Surface | Status |
|---|---|---|
| Claude Code | hookSpecificOutput.additionalContext: eleven moments, 10,000-char cap |
x.claude-code.control.instruct; drop rides SessionEnd |
| Codex | additionalContext on SessionStart, SubagentStart, PreToolUse, PostToolUse, UserPromptSubmit; no documented cap |
x.codex.control.instruct; no SessionEnd hook, so no session-end drop moment: the queue lives for the daemon lifetime, and a live session delivers at latest on its next UserPromptSubmit |
| Qwen Code | additionalContext on SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, PreCompact; no documented cap; async systemMessage unused |
x.qwen-code.control.instruct; drop rides SessionEnd |
| VS Code agent | additionalContext on SessionStart, UserPromptSubmit, SubagentStart, PreToolUse, PostToolUse, typed and consumed in the pinned source; no documented cap; PreCompact excluded, its hook has no output surface on this vendor |
x.vscode.control.instruct; no SessionEnd on this hook target (same queue posture as Codex); a held PreToolUse reply (the opt-in permission gate) carries the decision, never steering |
| Hermes | the pre_llm_call reply's {"context": ...}, the one context return the runtime consumes (joined into the user message at turn start, never the system prompt); other events parse context that nothing reads, so they are ineligible; no documented cap |
x.hermes.control.instruct; delivery at latest on the next turn start; drop rides on_session_finalize, the real session end (on_session_end fires per turn and closes the run); a gated pre_tool_call reply carries the deny decision, never steering |
| Cline | hub session.send_input, user-plane prompt submission (the vendor's own steer is a pending-prompt queue-jump, not context injection) |
divergent by design: x.cline.control.send_input instead of instruct; see its design record |
| OpenHands | POST .../events send_message, user-plane prompt submission (always appended; a live run consumes it on a later step; the run flag stays false) |
divergent by design: x.openhands.control.send_input under its bridge's control gate; see its design record |
| Others | see the steering boundary | each needs its own design record |
Verified against code.claude.com/docs/en/hooks,
learn.chatgpt.com/docs/hooks, and the
qwenlm.github.io/qwen-code-docs hooks reference (all fetched
2026-07-15), and against vendor source at microsoft/vscode tag 1.129.0
(125df4672) and NousResearch/hermes-agent tag v2026.7.7.2
(9de9c25f; consumption confirmed on a live install).