Specification

AgJSON v1

AgJSON is the typed event format that agent-framework normalizers emit and clients fold. This is its normative specification, for anyone writing a normalizer, a client or a conformant fold.

Release candidatev1.0.0-rc.3MITFrameworksGlossaryView as Markdown

Status: Release candidate (2026-10-16) · Spec version: 1.0.0-rc.3 (the value AgInputEnvelope.version carries; negotiation rules in §12) · License: MIT (see LICENSE; applies to this specification text and the reference SDK). The open, typed transport for normalized agent-framework I/O, unopinionated about storage and rendering — input, output, and UI-interaction anchors. Home: silverprotocol.io/AgJSON. Normalizers (framework I/O ⇄ AgJSON) ship as @silverprotocol/<framework>.

Each revision’s dated note, oldest first, and the errata policy live in §15 Revision history (informative).

This revision streamlines AgJSON to a focused transport for normalized framework I/O: agent-UI output is carried via MCP Apps + A2UI. These two are the respected external UI/tool specs; AgJSON defines no component schema and no render layer of its own. A2UI is the first-class agent-UI-OUTPUT path: when an agent draws components by streaming, it emits A2UI, carried Layer-A-opaquely in AgJSON output (resource blocks for MCP Apps HTML surfaces + ui.* surface RPC + the ui.surface.* / ui.data-model surface-stream events for A2UI). Two general-purpose constructs (state.snapshot/state.delta and hitl.ask.metadata/AgPausedAsk.metadata) are anchored on LangGraph (values/updates stream modes; interrupt(value: Any)). This revision also un-merges the surface-interaction layer: the old single merged AgUiAction is replaced by a shared AgSurfaceEnvelope + five per-spec-faithful constructs (the AgSurfaceInteraction union), so each external UI spec’s client→server message round-trips with its own field names instead of a lossy merge. It is a release candidate — only errata and fixes change it before 1.0.0, each as a further release candidate (§15).

Provenance: designed from a deep cross-framework survey (Anthropic Messages API + Agent SDK, OpenAI Responses + Agents SDK, Google Gemini/ADK/A2A v0.3.0/A2UI v1.0, Vercel AI SDK v5/v6, LangChain/LangGraph v1, Pydantic AI v2, MCP 2026-07-28 + MCP Apps 2026-01-26) and adversarially gap-checked against primary sources. Rationale + per-framework mapping derive from an internal design record; a published rationale document accompanies the first public release.

0. Notation, naming system & invariants

AgJSON is a neutral translation target. It RECORDS what frameworks do; it never IMPOSES a framework’s runtime model. Everything below follows from that.

The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, NOT RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in BCP 14 [RFC 2119] [RFC 8174] when, and only when, they appear in all capitals, as shown here. The dated revision notes (§15 Revision history), any passage marked non-normative or informative, and a parenthetical that says what the reference SDK derives, counts or does are informative: they state no requirement, and a count, a helper name or an example in them is never a conformance assertion. A §10 item or an §8.0 host obligation that names the reference SDK binds it.

0.1 The opaque value

0.2 Layer

0.3 The EVENT-vs-BLOCK dot rule (load-bearing)

Every wire type discriminant is self-identifying by one rule:

A wire type containing a DOT is a streaming EVENT — shaped namespace(.sub)*.lifecycle (one or more namespace segments followed by a lifecycle final), where the final dot-segment is an action/lifecycle word drawn from the closed set: start | delta | end | done | call | result | ask | error | abort | blocked | required | snapshot | metadata | block | capabilities | action-result | update | remove | write (the core lifecycle words) plus a small fixed tail of payload-naming finals — assembled (the reassembled tool-args), opaque (the verifiable reasoning blob), context (host hint), display-mode (surface display request), and data-model (the A2UI surface data-model push). remove (message deletion), write (memory side-channel) — added 2026-06-23 pre-freeze.

A wire type with NO dot (a bare lowercase-kebab noun) is a content BLOCK.

So type alone tells you whether you are looking at a stream event or a persisted content block — no context required.

The shape admits multi-segment events: tool.args.delta and tool.args.assembled have three dot-segments (namespace=tool, intermediate args, final delta/assembled). The mechanical check is always against the FINAL segment; the intermediate args sub-segment is permitted by the namespace(.sub)*.lifecycle form above. The A2UI surface events ui.surface.start / ui.surface.update / ui.surface.end are likewise three-segment (namespace=ui, intermediate surface, final start/update/end); the OpenAI widget reply ui.widget.result is three-segment (namespace=ui, intermediate widget, final result — result is in the closed set); ui.data-model is two-segment with the payload-naming final data-model.

This resolves the historical tool.result collision cleanly:

content.block is the canonical “emit one whole, non-streamed block” event: its final segment block is a noun, yet it is a dotted EVENT (it transports a BLOCK). It is the only event whose final segment names a content unit, and block is fixed in the closed set above precisely so the rule stays mechanical.

message.start / message.end are dotted lifecycle events (the message-partition openers/sealer, §4). They open and seal an AgMessage boundary in the stream; they do not themselves carry a content block.

Three bare-noun EVENTs are an enumerated carve-out, NOT blocks: error (the non-terminal advisory — deliberately bare to distinguish it from terminal turn.error), source (a grounding source), and handoff (an agent-to-agent edge). These three single-word event names are fixed by this spec; no other dotless type is an event. The mechanical rule for a reader is therefore: dotted ⇒ event; dotless ⇒ block UNLESS it is one of error / source / handoff.

ext.<vendor>.<key> is the SOLE exemption from the closed-set final-segment rule. A namespaced vendor-extension event (§12) is identified by its ext. prefix, and its FINAL dot-segment is vendor-defined — NOT drawn from the closed lifecycle/payload set above. The ext. prefix alone marks it as an event; the closed-set check does not apply to its final segment. No other dotted type may carry an open final segment.

0.4 Casing rules

0.5 Identity naming (see §1)

0.6 One word per concept

turn (the exchange) · step (one loop iteration) · block (a content unit) · tool-call / tool-result · reasoning (never “thinking”) · hitl (the human-in-the-loop family) · ask/answer (the HITL request/response — ask materializes as the hitl.ask event, answer materializes as the AgHitlAnswer resume payload; there is no hitl.answer wire type) · opaque (provider-bound verifiable blob) · paused (a within-run wait — the word interrupt is purged everywhere, including async-tool scheduling, which uses preempt) · surface (an A2UI / MCP-Apps / OpenAI-Apps live render target) · surface-interaction (a client→server surface message — AgSurfaceInteraction, the un-merged successor of the old AgUiAction, a DIFFERENT axis from the hitl pause) · notice (a persisted, non-conversational transcript row — never “system message”, which names the instruction role) · grant mode (a declared accept variant on a HITL ask — never “permission” or “scope” alone, which name host policy, not wire vocabulary) · phase (a block’s position in the turn — "interim" = narration between steps; the vendor spellings commentary/final_answer/narration stay verbatim in the vendor bag, and “progress” stays reserved for the ephemeral signals of §3.2). Type names are Ag<Noun> PascalCase.

0.7 Three artifacts

The triad reads: AgInput in / AgEvent stream / AgMessage object.

1. Identity & hierarchy — the backbone

AgJSON state is a strict containment tree. Every unit is both placeable in the tree and directly addressable for persistence and reconnect.

1.1 The containment tree

thread (conversation)
 └─ turn (exchange)
     └─ step (one loop iteration)
         └─ message
             ├─ block        (text · image · reasoning · …)
             └─ tool-call

A subagent is a NESTED turn — it is a full turn with its own turnId and a parentTurnId pointing at the enclosing turn. There is no separate “subagent entity”: nesting reuses the turn machinery. A subagent’s events interleave with the parent’s in ascending seq within their invoke (INV-SEQ, §5.0); every event self-identifies its owning turn via its turnId (§4 base), so a reducer routes each block to the correct turn’s open message regardless of interleaving (§5).

1.2 The triple-id rule

Every entity is located by three ids:

  1. Its own id — so it is directly addressable.
  2. Its immediate parent’s id — so it is placeable in the tree.
  3. The root threadId — so it can be persisted / reconnected without walking the chain.

The root threadId on every entity is what makes the format addressable for a key-value persistence layer (you can write any unit knowing only its own id, its parent, and the partition root). This is exactly what reduce() (§5) reconstructs, and it maps 1:1 to a reference host’s DynamoDB thread-message table (partition key = threadId). The value is the host’s, not the Normalizer’s (§8.0 Partition root, host obligation 6): a Normalizer given no thread stamps a placeholder that is not a partition root, and the reference corpus is captured with none.

1.3 Per-entity id-field table

The “owner (cross-ref)” column is the id an entity carries to name its owner: the owning turn’s turnId for step, message, ask, artifact and source; the containing message’s messageId for block, tool-call and tool-result; and, for turn itself, its upward link parentTurnId. The column is the field a producer sets; the §1.1 tree shows placement, and the two need not coincide (a message names its turn, not its step).

Entity Own id Owner (cross-ref) Root Notes
thread threadId — (self) The conversation root.
turn turnId parentTurnId (parent) threadId parentTurnId present ⟺ this turn is a subagent (its UPWARD link).
step id turnId threadId One loop iteration inside a turn.
message id turnId threadId The role + content record (AgMessage). Opened by message.start.
block id messageId threadId A content unit inside a message; binds to the most-recently-opened message of its turn and candidateIndex (absent ⇒ 0) (§5). The id is the stream’s: a built text or reasoning block carries no id (§2), and a data block keeps its own for reconcile-by-id (§5).
tool-call toolCallId messageId threadId Cross-referenced by tool-result.toolCallId; binds to the most-recent open message of its turn and candidateIndex (absent ⇒ 0).
tool-result toolCallId messageId threadId Same toolCallId binds result to its call (not its own id); tool.done.messageId is the result message’s own id; tool.done.candidateIndex absent ⇒ 0.
ask (HITL) askId turnId threadId May reference a toolCallId.
artifact artifactId turnId threadId A side-channel landing zone (NOT an AgBlock; AgArtifact, §2/§5).
source sourceId turnId threadId Grounding source.

The root threadId is carried on turn.start, message.start and artifact.start (§8.0 Partition root) and inherited by the entities under them.

A surface (A2UI / MCP-Apps / OpenAI-Apps render target) is deliberately NOT in this table. A surfaceId names a LIVE render-side address, not a persisted containment-tree entity; surfaces are ephemeral and never folded into AgReduceResult (see §4 / §5 / §6 Pattern 1b). There is no surface row precisely because surfaces are live-only. The surface-interaction envelope (AgSurfaceEnvelope, §3) nonetheless keeps surfaceId / toolCallId / turnId / threadId at the AgJSON layer so a client→server surface message stays addressable (correlatable to its producing tool-call and owning turn) even though no surface row is ever persisted.

referenceTurnIds (input-only, optional) carries cross-turn references (e.g. “regenerate from these prior turns”) and is not a parent link.

2. Content blocks — the shared spine

AgBlock is the full object-form union (dotless-kebab type discriminants, per §0.3). The same union appears in both directions — input messages, output content, and tool-result.content — so input == output minus the streaming envelope (§3.2).

type AgSource =
  | { type: "base64"; mediaType: string; data: string }   // mediaType REQUIRED; must match bytes
  | { type: "url";    url: string; mediaType?: string }
  | { type: "file";   fileId: string; mediaType?: string };
// MCP image/audio are base64-only {data,mimeType}; url/file are the Anthropic/Gemini superset.
// A wrong merge silently drops url/file — conformance-tested both ways.

// Grounding-source payload union (§4 `source` event) — reused verbatim by
// AgTurnRecord.sources[] so the two never drift.
type AgSourcePayload =
  | AgSource
  | { url: string; title?: string }
  | { type: "document"; mediaType?: string; title?: string; filename?: string };

// Host-only side metadata (MCP _meta): keys are FLAT-or-namespaced.
// Namespacing (dotted-prefix + slash, e.g. "acme.tracing/spanId") is a RECOMMENDED convention
// for HOST-OWNED annotations, NOT a wire-type constraint. Bare keys are permitted: the MCP key
// grammar (optional dotted prefix + slash OR a bare name), the reserved OpenTelemetry keys
// traceparent/tracestate/baggage, and flat framework metadata (A2A free-form, Vercel createdAt/
// totalTokens, Apps SDK component _meta) all round-trip unchanged.
type AgMeta = Record<string, JsonValue>;
// RECOMMENDED validator (SHOULD, not a type-level MUST):
//   /^([a-z0-9.-]+\/)?[A-Za-z0-9_.-]+$/  matches the MCP grammar (optional dotted prefix + slash,
//   or a bare name); traceparent | tracestate | baggage are additionally reserved-bare.
//
// RESERVED CONVENTION — transcription marker:
//   _meta["agjson/transcription"] = { role: "input" | "output", kind: "transcription" }
//   Marks a block or message as a transcription artifact (STT input / TTS output).
//   AgRole has no "input"/"output" values, so this annotation lives in `_meta` rather than
//   the block's `role`. It is a CONVENTION only — no schema arm change is needed.

// Replay-load-bearing provider metadata (OpenAI itemId etc.).
// Branded nominally distinct from AgMeta: a value that round-trips to the provider
// must NOT be confused with host-only annotations. The provider-replay channel imposes NO key
// namespacing — keys (flat or otherwise) echo VERBATIM (Pydantic provider_details, OpenAI itemId).
type AgProviderMeta = AgMeta & { readonly __brand: "AgProviderMeta" };

// Opaque provider-bound reasoning blob (signatures / encrypted / redacted reasoning),
// echoed back byte-identical or multi-turn reasoning breaks (hard 400 on Anthropic/Gemini).
// "ciphertext" carries an encrypted CoT / encrypted_content; "encrypted" is an accepted alias.
type AgOpaque = { kind: "signature" | "ciphertext" | "encrypted" | "redacted"; value: string; provider?: string };

// MCP content-block annotations (MCP-frozen verbatim — see §0.4). Routing/display hint:
// audience distinguishes model("assistant") vs end-user("user") blocks; priority orders display.
// Carried through reduce() UNCHANGED.
type AgAnnotations = { audience?: ("user" | "assistant")[]; priority?: number; lastModified?: string };

type AgBlock =
  | { type: "text"; text: string; citations?: AgCitation[]; phase?: string; providerMetadata?: AgProviderMeta; annotations?: AgAnnotations; _meta?: AgMeta }
  | { type: "image"; source: AgSource; providerMetadata?: AgProviderMeta; annotations?: AgAnnotations; _meta?: AgMeta }
  | { type: "audio"; source: AgSource; providerMetadata?: AgProviderMeta; annotations?: AgAnnotations; _meta?: AgMeta }
  | { type: "file";  source: AgSource; filename?: string; providerMetadata?: AgProviderMeta; annotations?: AgAnnotations; _meta?: AgMeta }   // video = mediaType video/*
  | { type: "document"; source: AgSource; title?: string; providerMetadata?: AgProviderMeta; annotations?: AgAnnotations; _meta?: AgMeta }
  | { type: "resource"; resource: AgEmbeddedResource; annotations?: AgAnnotations; _meta?: AgMeta }       // ui:// surfaces ride here
  | { type: "resource-link"; uri: string; name?: string; title?: string; description?: string; mimeType?: string; size?: number; annotations?: AgAnnotations; _meta?: AgMeta }  // name/title/description/size: the MCP ResourceLink members of the same names, by reference; size is an integer byte count of the linked resource, never of content delivered to the model
  | { type: "code"; language: string; code: string; annotations?: AgAnnotations; _meta?: AgMeta }            // was executable_code
  | { type: "code-result"; outcome: "ok"|"failed"|"deadline_exceeded"; output: string; annotations?: AgAnnotations; _meta?: AgMeta }  // was code_execution_result
  // ONE reasoning block. Visible text + an optional opaque provider-bound part.
  // UNIFIES the old thinking / redacted_thinking / reasoning_state into one block.
  // opaque/providerMetadata/itemId are REPLAY-LOAD-BEARING — like tool-call.signature, they MUST
  // round-trip byte-identical on re-input (Gemini thoughtSignature on thought / built-in-tool steps:
  // echo or 400; OpenAI rs_ reasoning item id; Anthropic signature/redacted blobs ride the reasoning block's `opaque` carrier — `kind:"signature"|"redacted"`). A reasoning block
  // in AgMessage.content is re-emitted to the provider VERBATIM including opaque/providerMetadata.
  // `phase` (text and reasoning blocks) is a normalized VIEW of the producer's own marker of where the block sits in the turn.
  // The one documented value is "interim": the producer marked the block as narration between steps, not the turn's answer.
  // Absent means unspecified — never "answer". reduce() carries any string value verbatim. `phase` is not a replay channel (§12):
  // the vendor's own marker stays the replay source — OpenAI `phase` in providerMetadata, the Anthropic signature in `opaque`.
  | { type: "reasoning"; text?: string; opaque?: AgOpaque; provider?: string;
      phase?: string;                             // normalized interim-narration view (§2 note above); NEVER replayed
      providerMetadata?: AgProviderMeta;       // authoritative lossless vendor bag (OpenAI itemId, …; NOT the Anthropic reasoning signature — that rides the block's `opaque` carrier)
      providerDetails?: unknown;                  // CONVENIENCE ALIAS — flat provider-keyed replay dict, echoed verbatim (Pydantic provider_details); NOT the only home (any part's provider_details maps to that block's providerMetadata, see §2 note below)
      itemId?: string;                            // OpenAI Responses reasoning item id (rs_…), echoed for stateless re-input
      annotations?: AgAnnotations; _meta?: AgMeta }
  // A provider-bound conversation summary (Pydantic CompactionPart). Visible-text + opaque-blob,
  // mirrors reasoning. Persisted-and-replayed: MUST be re-sent to the same provider on later turns.
  | { type: "compaction"; text?: string; opaque?: AgOpaque; provider?: string; annotations?: AgAnnotations; _meta?: AgMeta }
  // A grounding/search result carrying an opaque per-result replay blob (Anthropic web_search_result
  // encrypted_content — "must be passed back in multi-turn conversations"). opaque.kind:"ciphertext".
  | { type: "search-result"; url?: string; title?: string; opaque?: AgOpaque; pageAge?: string; annotations?: AgAnnotations; _meta?: AgMeta }
  | { type: "tool-call"; toolCallId: string; name: string; input: unknown;        // was tool_use
      serverName?: string; providerExecuted?: boolean;
      signature?: string; provider?: string;          // Gemini thoughtSignature rides the tool-call — echo or 400
      title?: string;                                  // provider/model-supplied tool title (Vercel)
      toolMetadata?: AgMeta;                        // per-tool metadata bag (Vercel)
      itemId?: string;                                 // OpenAI Responses fc_ item id (echoes the input item array); DISTINCT from toolCallId (=call_id)
      providerCallIndex?: number;                      // Gemini null-id parallel-call positional index — replay-load-bearing (§8)
      uiVisibility?: ("model"|"app")[];                // MCP Apps access-control scope (from the tool's _meta.ui.visibility); model=model-callable, app=app-only
      providerMetadata?: AgProviderMeta; annotations?: AgAnnotations; _meta?: AgMeta }
  | { type: "tool-result"; toolCallId: string; content: AgBlock[];             // was tool_result
      outcome?: "ok"|"error"|"denied"|"input_required";  // denied is its own outcome (NOT a derived isError alias); input_required = MCP MRTR pause disposition
      structuredContent?: unknown;   // MCP — MODEL-FACING structured result (delivery host-determined; §2.1)
      uiData?: unknown;              // surface/view, MODEL-HIDDEN (MCP Apps structuredContent / OpenAI Apps component data; §2.1)
      sideData?: unknown;            // app-only side data (NEVER model, NEVER UI) — renamed from "artifact" (§2.1)
      errorText?: string;            // free-form error message (Vercel tool-output-error); set only when outcome==="error", may be absent on an error (§2.2)
      errorCode?: string;            // structured server-tool error code (Anthropic web_search_tool_result_error.error_code)
      providerMetadata?: AgProviderMeta;  // provider-side tool-result metadata (Vercel)
      toolMetadata?: AgMeta; dynamic?: boolean;     // Vercel tool-output toolMetadata + dynamic-vs-static distinction
      pendingInput?: { requestState?: string; inputKeys?: string[] };  // MCP MRTR carrier when outcome==="input_required"
      preliminary?: boolean;         // set when the result is partial/kept-open (tool.done.more:true); cleared by the final tool.done (§2.2, §5)
      isError?: boolean;             // MCP-frozen field, kept verbatim (derived: outcome==="error")
      annotations?: AgAnnotations; _meta?: AgMeta }
  | { type: "data"; name: string; id?: string; data: unknown; transient?: boolean; annotations?: AgAnnotations; _meta?: AgMeta }
  | { type: "provider-raw"; vendor: string; raw: unknown; annotations?: AgAnnotations; _meta?: AgMeta };  // lossless LAST-resort escape hatch

interface AgEmbeddedResource { uri: string; mimeType?: string; text?: string; blob?: string; _meta?: AgMeta }
// UI surface convention: uri="ui://…", mimeType "text/html;profile=mcp-app",
//   _meta.ui = { resourceUri, csp?, permissions?, domain?, prefersBorder? }  (MCP Apps 2026-01-26 base fields);
//   plus widgetState? — an OpenAI Apps SDK field on _meta.ui (setWidgetState), OpenAI-Apps ONLY, NOT an
//   MCP-Apps concept (§6 Pattern 5).
// NOTE: the resource block is the MCP Apps STATIC-HTML surface path ONLY. A2UI declarative-component
//   surfaces are NOT routed through this text/html resource block — they ride ui.surface.* / ui.data-model
//   (§4, §6 Pattern 1b), carried Layer-A-opaquely.
// NOTE: tool VISIBILITY is an ACCESS-CONTROL scope and lives on the TOOL, not the resource —
//   it moved to AgToolDef.uiVisibility / tool-call.uiVisibility (§3, §2). It is NOT a render convention.

// The A2A streamed-artifact ENTITY (the ONLY meaning of the word "artifact"). Side-channel,
// NOT an AgBlock. reduce() lands these in AgReduceResult.artifacts (§5).
interface AgArtifact {
  artifactId: string; turnId: string; threadId: string;
  name?: string; description?: string;
  parts: AgBlock[];
  extensions?: string[];     // foreign A2A active-extension URIs (§0.5)
  _meta?: AgMeta;
}

// A folded memory write (§5) — the landing record for memory.write events, parallel to AgArtifact.
// Side-channel; NOT an AgBlock. scope="thread" records are bound to the fold's thread (§13.11) and REPLACED by a
// messages.snapshot (§5). scope="agent"/"user"/"skill" are a SEPARATE cross-thread axis of the producer's own store
// (NOT bound by the §1.1 thread containment tree; whether this host keeps them is its declared projection, §13.11) and
// are untouched by a per-thread snapshot (a blanket REPLACE would let one thread's snapshot clobber
// cross-thread memory it has no authority over). The absent key targets the scope-default record (scope, "").
interface AgMemoryRecord {
  scope: "agent" | "user" | "skill" | "thread";
  key?: string;              // absent ⇒ scope-default record (scope, "")
  value: unknown;            // required on the LANDED record (the fold seeds it); JsonValue
  reason?: string;
  durable?: boolean;         // true = the producer's store keeps the record across threads (§13.11); not a claim about this host
  turnId?: string;
  threadId?: string;
  _meta?: AgMeta;            // host-only side metadata (§12); folded from `memory.write._meta`, never from `value`
}

// Per-turn folded record (paused asks, prompt.blocked safety, handoffs, sources, lifecycle state).
// Part of AgReduceResult; restorable on snapshot resync (§5/§4 messages.snapshot).
interface AgTurnRecord {
  turnId: string; parentTurnId?: string; threadId: string;
  outcome?: AgOutcome; finishReason?: AgFinishReason; finishReasonRaw?: string; usage?: AgUsage;  // finishReasonRaw (draft.4): verbatim native reason when finishReason is a fallback (§8.0)
  safety?: AgSafety[];
  handoffs?: Array<{ kind?: "transfer"|"escalate"; fromAgentId?: string; toAgentId?: string; toAgentName?: string }>;
  sourceIds?: string[];
  sources?: Array<{ sourceId: string; source: AgSourcePayload; chunkIndex?: number; providerMetadata?: AgProviderMeta }>;  // FULL grounding-source records; sourceIds[] is appended in event order; the binding index
  promptBlocked?: { reason: "safety"|"blocklist"|"prohibited"|"other"; reasonRaw?: string; safety?: AgSafety[] };  // the REQUIRED reason + blockedness itself, not just safety[]; reasonRaw (draft.8): the native block reason verbatim when reason is "other"
  asks?: AgPausedAsk[];
  taskState?: string;        // verbatim A2A TaskState (NEVER reducer-invented) for states that don't map to an outcome (submitted/rejected/auth-required-at-rest/unknown)
  displayRequired?: Array<{ provider: string; html: string }>;  // ToS-must-render records (§4 display.required)
  trigger?: AgTrigger;       // what triggered this turn (folded from turn.start.trigger)
  guardrails?: Array<{       // guardrail evaluations folded from guardrail.result events on this turn
    target: "input" | "output" | "tool";
    passed: boolean;
    action?: "block" | "retry" | "rewrite" | "override" | "terminate";
    reason?: string;
    guardrailName?: string;
    safety?: AgSafety[];
  }>;
  capabilities?: AgCapabilities;  // agent's capabilities folded from agent.capabilities event (first-turn negotiation; §5)
}

// reduce() landing container — the well-typed return of the §5 fold (replaces "object == AgMessage").
interface AgReduceResult {
  messages: AgMessage[];
  artifacts: AgArtifact[];
  memory: AgMemoryRecord[];  // memory side-channel landing records (parallel to artifacts; required)
  turns: AgTurnRecord[];
  state?: unknown;           // shared-state working copy
}

// Citation: typed location union; index FRAME + UNIT explicit (Gemini grounding = UTF-8 BYTE offsets).
type AgCitation = {
  citedText: string; source?: string; title?: string;
  confidence?: number | number[];       // single, or per-support array (Gemini groundingSupport confidenceScores)
  confidenceScores?: number[];          // explicit per-chunk array alias (Gemini)
  encryptedIndex?: string;              // Anthropic web_search_result_location encrypted_index — MUST round-trip multi-turn
  indexFrame?: "source" | "response";   // Anthropic char/page/block = source; OpenAI/Gemini url/offset = response
} & (
  | { kind: "char";   documentIndex: number; startCharIndex: number; endCharIndex: number; unit?: "char"|"byte"|"utf16"; bounds?: "[start,end)"|"[start,end]" }
  | { kind: "page";   documentIndex: number; startPage: number; endPage: number }
  | { kind: "block";  documentIndex: number; startBlockIndex: number; endBlockIndex: number }
  | { kind: "url";    url: string; startIndex?: number; endIndex?: number; unit?: "char"|"byte"|"utf16"; bounds?: "[start,end)"|"[start,end]" }
  | { kind: "offset"; startIndex: number; endIndex: number; sourceIds: string[]; partIndex?: number; unit: "char"|"byte"|"utf16"; bounds: "[start,end)"|"[start,end]" }
);

Code-language round-trip note. Gemini ExecutableCode.language is a closed enum (LANGUAGE_UNSPECIFIED | PYTHON); AgJSON’s code.language is a free string. Round-trip OUT is lossy-safe, but a normalizer emitting a Gemini executableCode part MUST map an unknown code.language defensively (don’t emit a provider-rejected language). No schema change.

Pydantic provider_details on ALL part kinds (clarification, no schema change). A Pydantic part’s provider_details is not exclusive to the thinking part: a part of ANY kind (text / tool-call / compaction / thinking) maps onto that block’s providerMetadata (AgProviderMeta) verbatim. The providerDetails? field on the reasoning block is a convenience alias for the flat provider-keyed replay dict, not the only home for provider_details. This closes the latent gap where non-reasoning parts carry provider_details.

2.1 Tool-result channels (4, one consumer each)

A tool-result routes its payload over four channels, each with exactly one consumer. The channel encodes the audience — there is no audience flag. For the model-facing channels the audience is the one the channel is defined for, not a record of delivery: whether a host forwards content or structuredContent to the model is the host’s contract and is not observable on the wire.

Channel Field Reaches Maps from
content content: AgBlock[] model every framework’s model-readable result.
structuredContent structuredContent?: unknown model-facing (delivery host-determined) base-MCP structuredContent (outputSchema), OpenAI Apps structuredContent.
uiData uiData?: unknown surface/view, model-hidden MCP Apps structuredContent (view-only / “not added to model context”), OpenAI Apps component-only _meta data.
sideData sideData?: unknown app-only (not model, not render) LangChain ToolMessage.artifact.

Routing note. content and structuredContent are both model-facing (the latter is the typed/structured form — base MCP + OpenAI Apps are model-facing, and structuredContent keeps its exact base-MCP typing). uiData is the model-HIDDEN surface/view channel: MCP Apps’ own structuredContent is view-only (“not added to model context”) and routes here, as does OpenAI Apps component-only _meta hydration data. sideData (renamed from the old artifact channel) is strictly app-only. The word artifact is now reserved exclusively for the A2A streamed entity (artifact.* events + AgArtifact, §2/§4); it is no longer a tool-result channel.

_meta is not a result channel. _meta (AgMeta) carries host/protocol annotations (timestamps, tracing) — NOT component data and NOT a result channel. OpenAI Apps component-only _meta (model-hidden hydration data) routes to uiData, not _meta. A producer’s host record (see Host records below) is side metadata, not a result channel.

View locator. A view’s locator is not result data. When a tool’s native result carries MCP Apps _meta.ui, a normalizer MUST carry that ui member unchanged (subject to §8.0 item 28) on the tool.done’s _meta. On a kept-open result, _meta is replaced only when a later tool.done carries it (§5), so the tool-result’s _meta.ui.resourceUri survives a later snapshot that omits it. A ui:// value inside uiData, structuredContent or content is result data and follows the §5 payload rule; a producer that keeps it current re-sends it in every snapshot. (Non-normative: a host that needs a view’s identity to outlive a later snapshot — to keep the view mounted, to scope its actions, or to persist it — reads it from the tool-result’s _meta.ui.resourceUri where the server stamps it (MCP Apps 2026-01-26 declares the locator on the tool descriptor and defers result-level links; a reference MCP Apps server stamps the per-render locator on the result). A uiData of null is result data meaning the result has no view data; it is not a retraction signal.)

Host records. A producer may deliver part of a tool’s native result to the model only in another form (for example a text rendering), or not at all when that part states facts about the call itself (for example identifiers, status, the model used, usage, statistics or where the run’s output is kept), while its framework exposes that part’s structured record only host-side. A native channel that the channel table above maps to uiData, sideData or structuredContent rides that channel, never a host record. Such a producer MAY carry the record on the tool.done’s _meta, under a key in a namespace the producer owns. The record is host-only side metadata (§12), not a result channel. It claims neither that the model read it nor that it is complete, and it carries no durability promise. On a kept-open result, a producer that keeps the record current re-sends it with every snapshot (§5). A consumer MUST NOT add a usage value found in any _meta or AgMessage.metadata to an AgUsage total.

Neutral structuredContent carrier (host convention). Frameworks without a native structured channel populate it by host convention — e.g. the OpenAI Agents SDK hands MCP structuredContent only to a host-supplied MCPServer.customDataExtractor, whose return lands as SDK-only RunToolCallOutputItem.customData; the facet reads customData.structuredContent (and, as a fallback, a { structuredContent: X } wrapper tool output). Under this convention the channel carries the structured result as the host surfaced it, which the model may not have received.

2.2 Tool-result flag reconciliation (input vs output)

A tool result has three representations — the tool-result AgBlock (§2), the tool.done event (§4), and AgInput.kind:"tool-result" results[] (§3) — and input == output minus the streaming envelope (§0.3). The flag sets line up as:

A client echoing a tool.done back as kind:"tool-result" carries the shared core verbatim, drops the output-only flags, and supplies input-only flags as needed.

Pydantic RetryPromptPart round-trip (mapping note, no schema change). A Pydantic RetryPromptPart maps to AgInput.kind:"tool-result" results[] with outcome:"error", toolCallId and errorText carrying the serialized retry content (the entry has no name member; a part with no tool name needs none); the structured ErrorDetails[] (loc/msg/type) is preserved verbatim under _meta (e.g. _meta['pydantic/retryErrors']) or providerMetadata. The tool_name-None case (an output-VALIDATION retry, not a tool-call retry) has no tool name to carry (the retry targets the model’s structured output, not a specific tool). This closes the latent RetryPromptPart gap.

3. Input — AgInput

AgInput is a discriminated union on kind over a shared envelope. Each variant carries only what its kind needs.

type AgRole = "user" | "assistant" | "tool" | "system" | "notice";
// Normalizer: Gemini "model"→"assistant"; A2A "agent"→"assistant"; OpenAI "developer"→"system".
// NO "reasoning" / "activity" role. (Every framework already normalizes reasoning to an assistant
//   message whose content is a single reasoning block; there is no dedicated reasoning/activity role.)
// "notice" (draft.2) is a PERSISTED, NON-CONVERSATIONAL transcript row — see the notice rules below.

The notice role (draft.2). A notice is a session-level annotation injected into the transcript by the host, the normalizer, or the framework — a banner, a halt explanation, an adapter bookkeeping row. It is a message role (not an event) because notices are persisted transcript rows and only messages have persistence semantics in the fold (§5); it is not role:"system" because the two sit on opposite sides of the one line that matters: system-role content is instructions to the model (replayed), a notice is an annotation about the session (never replayed).

// ── Reasoning request knob (neutral; covers OpenAI o-series effort + Anthropic/Gemini budgets) ──
interface AgReasoningConfig {
  mode: "enabled" | "disabled";
  effort?: string;   // an open string (§12 table: OPEN STRING); documented values "minimal" | "low" | "medium" | "high" — neutral mapping; a provider may accept more; absent means unset, and a JSON null is malformed (§0.2)
  budgetTokens?: number;                              // Anthropic/Gemini thinking budget
}

// ── HITL answer (the resume payload; see §3.1 and §7) ──
interface AgHitlAnswer {
  askId: string;
  status: "resolved" | "declined" | "cancelled";   // preserves MCP accept/decline/cancel as status
  reply?: unknown;     // kind-typed by the originating ask.kind (approve-with-edits rides here)
  grantModeId?: string;  // grant-mode echo (draft.2) — REQUIRED iff status:"resolved" AND the ask declared grantModes; MUST echo a declared id; NEVER on declined/cancelled (§7)
  reason?: string;     // human rationale; reaches the model
  ordinal?: number;    // resume-binding ordinal for LangGraph positional in-node interrupts (§7)
  token?: string;      // server anti-forgery (RECOMMENDED; a binding MAY require it — §11 item 4; threat model: §13.1)
  requestState?: string;  // MCP MRTR opaque server continuation blob; MUST be echoed BYTE-IDENTICAL from
                          //   the originating ask; clients MUST NOT inspect/mutate. DISTINCT from token.
}

// ── Surface interaction (a DIFFERENT axis from HITL — a client→server surface message, not a pause) ──
// REPLACES the old merged AgUiAction. Each external UI spec keeps its OWN faithful field names
// behind a shared correlation envelope, so nothing is lost to a lossy merge. EXPLICIT discriminant on
// `surface` (reverses the old positional identification); A2UI's three client→server legs carry a
// second inner discriminant `a2uiMessage` so they narrow without structural-presence guessing.

// Shared correlation envelope for ALL surface interactions (§1.3, §4 surface addressing).
// surfaceId/toolCallId/turnId/threadId/_meta stay at the AgJSON layer (NOT in the per-spec payload),
// so reduce()/replay stays addressable even though surfaces are live-only (§5).
interface AgSurfaceEnvelope {
  surface: "a2ui" | "mcp-app" | "openai-app";   // EXPLICIT discriminant (reverses the old positional identification)
  surfaceId: string;        // the LIVE render-side address (replaces the merged type's overloaded sourceId)
  toolCallId?: string;      // cross-ref to the producing tool-call
  turnId?: string;          // owning-turn cross-ref (§1.3)
  threadId?: string;        // root cross-ref (§1.3)
  _meta?: AgMeta;        // AgJSON-layer host annotations ONLY (the A2UI action.timestamp is NOT here anymore — it is a first-class field on the A2UI action arm)
}

// The surface-interaction union. Carried by AgInput.kind:"resume" uiActions[].
type AgSurfaceInteraction =
  | AgA2uiSurfaceAction
  | AgA2uiFunctionResponse
  | AgA2uiError
  | AgMcpAppViewMessage
  | AgOpenAiWidgetAction;

// ── A2UI v1.0 client→server `action` (A2UI-frozen field names kept VERBATIM, §0.4) ──
type AgA2uiSurfaceAction = AgSurfaceEnvelope & {
  surface: "a2ui"; a2uiMessage: "action";
  name: string;                       // A2UI action.name — REQUIRED
  sourceComponentId: string;          // A2UI action.sourceComponentId — REQUIRED
  timestamp: string;                  // A2UI action.timestamp — REQUIRED, ISO-8601 (first-class now; old _meta carrier bug fixed)
  context: Record<string, unknown>;   // A2UI action.context — REQUIRED resolved data-bindings (string-keyed map; values opaque per §0.1, map shape known — the sole sanctioned Record<string,unknown>, §0.1)
  wantResponse?: boolean;             // A2UI action.wantResponse (default false) → server actionResponse = ui.action-result
  actionId?: string;                  // A2UI action.actionId — REQUIRED-when-wantResponse===true
};

// ── A2UI v1.0 client→server `functionResponse` (inbound leg of the server callFunction RPC) ──
type AgA2uiFunctionResponse = AgSurfaceEnvelope & {
  surface: "a2ui"; a2uiMessage: "function-response";
  functionCallId: string;             // echoes callFunction.functionCallId
  call: string;                       // the function name called
  value: unknown;                     // the return value
};

// ── A2UI v1.0 client→server `error` (surface-side error report, upstream-faithful): carries
// EXACTLY ONE of surfaceId (surface-scoped) | functionCallId (function-call-failure), per A2UI
// v1.0's Generic Error oneOf ──
type AgA2uiError = Omit<AgSurfaceEnvelope, "surfaceId"> & {
  surface: "a2ui"; a2uiMessage: "error";
  surfaceId?: string;                 // overrides the envelope: XOR with functionCallId — surface-scoped error
  functionCallId?: string;            // XOR with surfaceId — function-call-failure error
  code: string; message: string;
  path?: string;                      // JSON-Pointer to the failed binding; REQUIRED when code === "VALIDATION_FAILED"
};

// ── MCP Apps 2026-01-26 view→host RPCs (nested union on the verbatim JSON-RPC method) ──
// MCP-frozen method names + content/structuredContent kept VERBATIM. Handshake/lifecycle
// (ui/initialize, ui/notifications/*) are host-internal and excluded.
type AgMcpAppViewMessage = AgSurfaceEnvelope & { surface: "mcp-app" } & (
  | { method: "ui/update-model-context";   // last-write-wins on surfaceId, deferred to next user turn
      params: { content?: AgBlock[]; structuredContent?: unknown } }
  | { method: "ui/message";                // role FIXED "user"; single text block; → injected-user-message path
      params: { role: "user"; content: { type: "text"; text: string } } }
  | { method: "ui/request-display-mode";   // NO modal (OpenAI-only); granted reply rides ui.display-mode
      params: { mode: "inline" | "pip" | "fullscreen" } }  // AgDisplayMode's order
  | { method: "ui/open-link"; params: { url: string } }
);

// ── OpenAI Apps SDK widget (window.openai) component→server RPCs (nested union on method) ──
// callTool is a SPEC-UNIQUE surface interaction, NOT the normalized tool-call.
// sendFollowUpMessage → injected user message (kind:"start"). OpenAI-only fields live ONLY here.
type AgOpenAiWidgetAction = AgSurfaceEnvelope & {
  surface: "openai-app";
  toolResponseMetadata?: unknown;        // window.openai.toolResponseMetadata global echo (OpenAI-only)
} & (
  | { method: "setWidgetState"; widgetState: unknown }
  | { method: "callTool"; name: string; args: unknown; callId: string }   // callId correlates ui.widget.result
  | { method: "sendFollowUpMessage"; prompt: string; scrollToBottom?: boolean }
  | { method: "requestDisplayMode"; mode: "inline" | "pip" | "fullscreen"; requestId: string }  // NO modal; granted reply rides ui.display-mode
);

interface AgClientCapabilities {
  frontendTools?: Array<{ name: string; description?: string; inputSchema: unknown }>;
  hitl?: { ask?: boolean; approveWithEdits?: boolean; form?: boolean; auth?: boolean; grantModes?: boolean };  // grantModes (draft.2): client renders declared accept variants + echoes grantModeId (§7)
  streaming?: { partialMessages?: boolean };
  uiResources?: { catalogs?: string[]; htmlResources?: boolean; viewMessageTurns?: boolean };  // viewMessageTurns (draft.4): a view's text message becomes the client's next user turn (§6)
  state?: { jsonPatch?: boolean };
}

// Agent→client capabilities (the in-band other half of negotiation; §6, §11 item 5).
// A2A AgentCard-compatible superset, advertised on the first turn (carrier: agent.capabilities event, §4).
interface AgCapabilities {
  streaming?: { partialMessages?: boolean };
  pushNotifications?: boolean;
  securitySchemes?: AgAuthConfig[];
  extensions?: string[];          // foreign A2A active-extension URIs
  uiCatalogs?: string[];
  profile?: "CORE" | "EXTENDED" | "ADVANCED";
  memoryScopes?: Array<"agent" | "user" | "skill">;  // the non-thread memory scopes this host persists (§13.11, host obligation 7); absent declares none
}

interface AgToolDef {
  name: string; description?: string; inputSchema: unknown; strict?: boolean;
  providerExecuted?: boolean;                         // server already ran it; client MUST NOT execute
  uiVisibility?: ("model"|"app")[];                   // MCP Apps access-control scope (from the tool's _meta.ui.visibility):
                                                      //   "model" = in tools/list + model-callable; "app" = app-only.
                                                      //   Host MUST exclude app-only tools from the agent tool list and reject cross-origin app calls.
  source?: { type: "mcp"; serverName: string } | { type: "function" } | { type: "frontend" };
  _meta?: AgMeta;
}

// Run configuration carried by the "start" kind.
interface AgRunConfig {
  model?: string; system?: string | AgBlock[];
  tools?: AgToolDef[];
  toolChoice?: "auto" | "none" | "required" | { type: "tool"; name: string };
  responseFormat?: { type: "json_schema"; name?: string; schema: unknown; strict?: boolean };
  reasoning?: AgReasoningConfig;
  maxTokens?: number; temperature?: number; topP?: number; stopSequences?: string[];
  context?: AgBlock[];                             // every framework's context reverts faithfully to AgBlock[]
  pushNotification?: { url: string; token?: string; auth?: { scheme: string; credentials?: string } };
}

// ── Shared envelope fields on EVERY AgInput variant ──
interface AgInputEnvelope {
  protocol: "agjson"; version: string;               // semver; minor = additive-only
  threadId: string; turnId: string; parentTurnId?: string;
  capabilities?: AgClientCapabilities;
  state?: unknown;                                   // shared-state echo (LangGraph input-direction state) — opaque (§11 item 1)
  metadata?: AgMeta;                              // may carry a namespaced runtime-replay handle (LangGraph langgraph/threadId, langgraph/checkpointId, …) + per-call extras
}

// ── The discriminated union ──
type AgInput =
  // New user message + run config.
  | (AgInputEnvelope & {
      kind: "start";
      messages: AgMessage[];
      run?: AgRunConfig;
    })
  // Resume: answer a paused turn's asks and/or carry surface interactions (§3.1); the resuming invoke's events open a new turn (§5.0 INV-XINV).
  | (AgInputEnvelope & {
      kind: "resume";
      answers?: AgHitlAnswer[];
      uiActions?: AgSurfaceInteraction[];
    })
  // Client-executed tool results flowing back in.
  | (AgInputEnvelope & {
      kind: "tool-result";
      results: Array<{
        toolCallId: string; content: AgBlock[];
        outcome?: "ok"|"error"|"denied"|"input_required";
        structuredContent?: unknown; uiData?: unknown; sideData?: unknown;
        errorText?: string; errorCode?: string;
        providerMetadata?: AgProviderMeta; toolMetadata?: AgMeta; dynamic?: boolean;
        pendingInput?: { requestState?: string; inputKeys?: string[] };
        isError?: boolean;
        willContinue?: boolean; scheduling?: "when_idle"|"preempt"|"silent"; annotations?: AgAnnotations; _meta?: AgMeta
      }>;
    });

AgInputEnvelope.turnId is the host’s identifier for the first top-level turn the invoke opens; a normalizer MAY stamp it on that turn. A host MUST NOT reuse a turnId an earlier invoke of the thread used, except to re-send a paused turn as §8.0 host obligation 5 directs. On a reused id, a repeated turn.start merges into the turn’s record, open or closed, and for a turn closed paused permits the paused refresh (§5.0 INV-MSG); a message.start naming a closed turn parks.

3.1 Resume semantics

The resume kind carries answers (HITL AgHitlAnswer[], §7) and uiActions (surface interactions AgSurfaceInteraction[], §6). HITL and surface-interaction are deliberately distinct axes: a HITL answer resolves a turn.done pause; an AgSurfaceInteraction is an ongoing surface interaction (a client→server A2UI / MCP-Apps / OpenAI-Apps message) that need not pause the turn.

Replay-load-bearing resume handles. Resume/answer back-normalization MUST echo the source runtime’s continuation pointers verbatim when present (analogous to providerMetadata):

3.2 AgMessage — the unified message object

There is one message type used in both directions (the old AgInputMessage is folded in). Input-only fields are optional.

interface AgMessage {
  id: string;
  role: AgRole;
  content: AgBlock[];
  turnId?: string;                  // owning turn (cross-ref) when persisted (§1.3)
  threadId?: string;                // root link when persisted (§1.3)
  candidateIndex?: number;          // n>1 candidate partition (absent ⇒ 0 — §5 partitioning); persisted so snapshot round-trips keep candidate identity
  referenceTurnIds?: string[];      // input-only: cross-turn references (NOT a parent link)
  messageMetadata?: unknown;        // opaque app bag — Vercel messageMetadata (usage accounting: totalTokens, model id); ALSO the home of the A2UI client data-model snapshot under the `a2uiClientDataModel` key (sendDataModel:true per-surface snapshot, keyed by surfaceId; re-homed here from the old AgUiAction.dataModel field — §6 Pattern 5, §11 item 1) and of the same snapshot when it rides A2A Message.metadata
  extensions?: string[];            // foreign A2A active-extension URIs (§0.5)
  metadata?: AgMeta;
  agentId?: string;                 // agent that produced this message (multi-agent attribution)
  agentName?: string;               // human-readable agent name (display)
  agentRole?: string;               // role of the agent in the pipeline (e.g. "researcher", "analyst")
  noticeSource?: "host" | "adapter" | "framework";  // role:"notice" only — the architecture layer that injected the row (§3, draft.2)
  model?: string;                   // model that produced this message (per-message model attribution)
  usage?: AgUsage;                  // per-message token usage landed from message.end.usage verbatim, cumulative flag preserved (INV-DELTA, §8 item 4)
}

AgMessage is the object reduce() reconstructs into AgReduceResult.messages (§5) and the unit persisted to ThreadMessage. OpenAI/Anthropic store:false/ZDR reasoning continuity rides the per-BLOCK reasoning/search-result opaque (not a message-level field); Vercel usage accounting rides messageMetadata; the A2UI sendDataModel:true client data-model snapshot rides messageMetadata.a2uiClientDataModel (§11 item 1).

4. Output — streaming events AgEvent

Flat, typed, id-correlated. Base on every event: { type, seq, id?, turnId?, messageId?, parentId?, _meta? } — the envelope carries no timestamp; a host requiring one re-stamps outside the wire contract (a wall-clock field on the base envelope would invite non-deterministic stamping, contradicting §5.0 INV-FOLD). seq is an ascending, gap-free ordinal scoped to one Normalizer instance — one invoke (it restarts at 0 each invoke; INV-SEQ, §5.0; there is no cross-invoke global ordinal at this layer), turnId names the owning turn of the event, and messageId (when present) names the open message the event attaches to. Two arms give messageId a specialized reading: on tool.done it is the ADOPTION id of the landed tool message (§5, §8 item 15); on turn.done it names the messageMetadata target. Tool-call args arrive as raw partial-JSON fragments; the server MUST emit tool.args.assembled {input} with the reassembled object so thin clients never parse partial JSON.

Per-turn routing. When more than one turn is open (an interleaved subagent + parent), an event attaches to the open message of the turn named by its turnId, never to a positional “current turn.” Single-turn top-level streams MAY omit turnId (it defaults to the sole open turn). A terminal of a nested turn (a turn.done, turn.error or turn.abort closing a turn opened by subagent.start) MUST carry that turn’s turnId; the sole-open-turn default does not apply to it.

Surface addressing. surfaceId, when present on a ui.* event, names the A2UI / MCP-Apps / OpenAI-Apps render surface the event targets — a LIVE render-side address, NOT a containment-tree entity; surfaces are ephemeral and never folded into AgReduceResult (§5). There is no surfaceId row in §1.3.

Per §0.3, every type below is dotted (it is an event), except the three enumerated bare-noun events error / source / handoff. The dotless content-block types live only in block positions — a member typed AgBlock or AgBlock[], such as content.block, artifact.delta.part, a tool result’s content, the content of a messages.snapshot’s messages and the parts of its artifacts, AgRunConfig.system/.context and an input message’s content — never as an event type.

type AgEvent =
  // ── LIFECYCLE ──
  | { type: "turn.start"; threadId: string; turnId: string; trigger?: AgTrigger }   // top-level turns only; nested turns open via subagent.start
  | { type: "turn.done"; turnId: string; outcome: AgOutcome; finishReason: AgFinishReason; finishReasonRaw?: string; usage?: AgUsage; safety?: AgSafety[]; messageId?: string; messageMetadata?: unknown; taskState?: string }  // finishReasonRaw (draft.4): verbatim native reason when finishReason is a fallback; folds to AgTurnRecord.finishReasonRaw (§8.0)
  | { type: "turn.error"; turnId?: string; message: string; code?: string; retriable?: boolean; usage?: AgUsage }   // terminal; usage = accrued billing on the errored turn (recorded verbatim, mirrors turn.done)
  | { type: "turn.abort"; turnId?: string; reason?: string }
  | { type: "error"; code?: string; message: string; retriable?: boolean }        // NON-terminal
  | { type: "message.start"; id: string; role: AgRole; turnId: string; threadId: string; stepId?: string; extensions?: string[]; candidateIndex?: number; agentId?: string; agentName?: string; agentRole?: string; noticeSource?: "host"|"adapter"|"framework"; model?: string }  // opens an AgMessage boundary; candidateIndex absent ⇒ candidate 0 (§5 partitioning); noticeSource only with role:"notice" (§3, draft.2)
  | { type: "message.end"; id: string; usage?: AgUsage }                         // seals the message boundary; usage = per-message token carrier
  | { type: "message.remove"; id: string | "*"; turnId?: string }                 // id="*" (REMOVE_ALL) requires turnId (§5); structural delete
  | { type: "step.start"; stepName?: string; id: string; turnId?: string }
  | { type: "step.done"; stepName?: string; id: string; usage?: AgUsage }
  | { type: "prompt.blocked"; reason: "safety"|"blocklist"|"prohibited"|"other"; reasonRaw?: string; safety?: AgSafety[] }  // reasonRaw (draft.8): the native block reason verbatim when reason is "other" (§8.0, §10 item 23, §12); folds to AgTurnRecord.promptBlocked.reasonRaw
  // ── CAPABILITY NEGOTIATION (agent→client, first turn) ──
  | { type: "agent.capabilities"; capabilities: AgCapabilities }
  // ── RECONNECT / RESYNC ──
  | { type: "messages.snapshot"; messages: AgMessage[]; turns?: AgTurnRecord[]; artifacts?: AgArtifact[]; memory?: AgMemoryRecord[] }  // full-state resync; memory REPLACES ONLY scope="thread" records
  | { type: "host.context"; theme?: unknown; capabilities?: unknown; container?: unknown }  // host display/runtime hint — `capabilities` here is the HOST surface's render hint, DISTINCT from the agent's `agent.capabilities` negotiation payload; live-only, never folded
  // ── TEXT ──
  | { type: "text.start"; id: string; phase?: string; role?: "assistant"; parentId?: string; index?: number; previousPartKind?: string; providerMetadata?: AgProviderMeta; candidateIndex?: number }  // candidateIndex absent ⇒ candidate 0 (§5 partitioning)
  | { type: "text.delta"; id: string; delta: string; providerMetadata?: AgProviderMeta }   // APPENDS
  | { type: "text.end"; id: string; phase?: string; providerMetadata?: AgProviderMeta; citations?: AgCitation[] }  // citations = the STREAMED-text citations carrier: attaches to the ended `text` block named by `id`; a normalizer MUST NOT re-emit the text as a duplicate supplement block
  // ── REASONING ──
  | { type: "reasoning.start"; id: string; phase?: string; mode?: "summarized" | "full"; partIndex?: number; previousPartKind?: string; providerMetadata?: AgProviderMeta; itemId?: string; candidateIndex?: number }  // partIndex opens a new summary part (OpenAI summary_index → partIndex), never folded (§5); candidateIndex absent ⇒ candidate 0
  | { type: "reasoning.delta"; id: string; delta: string; partIndex?: number; providerMetadata?: AgProviderMeta }  // APPENDS
  | { type: "reasoning.end"; id: string; phase?: string; provider?: string; providerMetadata?: AgProviderMeta }
  | { type: "reasoning.opaque"; id: string; kind: "signature"|"ciphertext"|"encrypted"|"redacted"; value: string; provider?: string; itemId?: string }  // REPLACE — sets opaque on the reasoning block (default; the Anthropic thinking signature / OpenAI rs_ encrypted_content / Gemini thoughtSignature-on-thought / Pydantic signature carrier)
  | { type: "reasoning.opaque.delta"; id: string; delta: string }   // APPENDS to a signature scratch buffer (Pydantic signature_delta); ended by the following reasoning.opaque
  // ── TOOL CALL (stateful reassembly) ──
  | { type: "tool.start"; toolCallId: string; name: string; parentId?: string; index?: number; dynamic?: boolean; serverName?: string; providerExecuted?: boolean; requiresApproval?: boolean; title?: string; toolMetadata?: AgMeta; uiVisibility?: ("model"|"app")[]; itemId?: string; providerMetadata?: AgProviderMeta; candidateIndex?: number; longRunning?: boolean }  // candidateIndex absent ⇒ candidate 0 (§5 partitioning)
  | { type: "tool.args.delta"; toolCallId: string; delta: string }     // RAW PARTIAL JSON STRING (APPENDS)
  | { type: "tool.args.assembled"; toolCallId: string; input: unknown; signature?: string; title?: string; toolMetadata?: AgMeta; providerMetadata?: AgProviderMeta }  // reassembled object (MANDATORY); Gemini's tool-call signature rides here / tool-call.signature
  | { type: "tool.done"; toolCallId: string; messageId?: string; content: AgBlock[]; outcome?: "ok"|"error"|"denied"|"input_required"; structuredContent?: unknown; uiData?: unknown; sideData?: unknown; errorText?: string; errorCode?: string; providerMetadata?: AgProviderMeta; toolMetadata?: AgMeta; dynamic?: boolean; pendingInput?: { requestState?: string; inputKeys?: string[] }; isError?: boolean; skipSummarization?: boolean; more?: boolean; preliminary?: boolean; candidateIndex?: number }  // carries the tool-result; candidateIndex absent ⇒ candidate 0 (§5 partitioning)
  // ── ATOMIC BLOCK DELIVERY ──
  | { type: "content.block"; block: AgBlock; transient?: boolean; candidateIndex?: number }  // emit one whole, non-streamed block (the noun-final EVENT exception, §0.3); candidateIndex absent ⇒ candidate 0
  | { type: "message.metadata"; messageId?: string; metadata: AgMeta }
  | { type: "source"; sourceId: string; source: AgSourcePayload; chunkIndex?: number; providerMetadata?: AgProviderMeta }
  // ── STREAMED ARTIFACTS (A2A) ──
  | { type: "artifact.start"; artifactId: string; turnId: string; threadId: string; name?: string; description?: string; parentId?: string; extensions?: string[] }
  | { type: "artifact.delta"; artifactId: string; part: AgBlock; append: boolean }
  | { type: "artifact.end"; artifactId: string; lastChunk: true }
  // ── MANDATORY DISPLAY ──
  | { type: "display.required"; provider: string; html: string }       // ToS-must-render grounding (was provider_display/mandatoryDisplay)
  // ── SUBAGENT / HANDOFF ──
  | { type: "subagent.start"; turnId: string; parentTurnId: string; agentId?: string; agentName?: string }   // the SOLE nested-turn opener
  | { type: "subagent.done"; turnId: string; parentTurnId: string }   // bracket close: follows the nested turn's terminal (§5.0 INV-TURN); no fold effect
  | { type: "handoff"; kind?: "transfer"|"escalate"; fromAgentId?: string; toAgentId?: string; toAgentName?: string } // `transfer`: control passes to the named agent, which continues the same thread; `escalate`: the emitting agent escalated to its enclosing agent (ADK `EventActions.escalate`) — no target, no nested turn
  // ── HITL (one family; see §7) ──
  | { type: "hitl.ask"; askId: string; kind: "approval"|"form"|"text"|"choice"|"auth"|"url"; message?: string; schema?: unknown; choices?: AgChoice[]; grantModes?: AgGrantMode[]; authConfig?: AgAuthConfig; url?: string; toolCallId?: string; continuation?: "resume"|"turn"; reason?: string; metadata?: AgMeta; requestState?: string; inputKey?: string; resumeBinding?: "id"|"positional"; ordinal?: number; token?: string; expiresAt?: string }
  // ── AGENT ↔ SURFACE RPC (A2UI v1.0 + OpenAI Apps SDK) ──
  | { type: "ui.call"; surfaceId: string; callId: string; method: string; args?: unknown; wantResponse?: boolean; callableFrom?: "clientOnly"|"remoteOnly"|"clientOrRemote" }  // callableFrom carries the called function's A2UI execution boundary, which the pinned A2UI snapshot declares in catalog metadata (§14); absent, it states no boundary; a surface's rejection of a remote call to a clientOnly function rides ui.result.error
  | { type: "ui.result"; surfaceId: string; callId: string; method?: string; value?: unknown; error?: { code: string; message: string; path?: string } }  // method echoes ui.call.method for the A2UI functionResponse.call leg; error.path = JSON-Pointer for an A2UI VALIDATION_FAILED reciprocal rejection
  | { type: "ui.action-result"; surfaceId: string; actionId: string; value?: unknown; error?: { code: string; message: string; path?: string } }  // server actionResponse to a client-initiated A2UI action; error.path = JSON-Pointer for an A2UI VALIDATION_FAILED
  | { type: "ui.widget.result"; surfaceId: string; callId: string; result: string }  // OpenAI Apps SDK window.openai.callTool reply leg — `result` (a string) is returned to the widget; `callId` correlates the originating AgOpenAiWidgetAction(method:"callTool"); live-only / non-folding (§5)
  | { type: "ui.display-mode"; mode: "inline"|"pip"|"fullscreen"; granted?: "inline"|"pip"|"fullscreen"; surfaceId?: string; toolCallId?: string }  // granted = authoritative reply leg; surfaceId scopes a per-surface display request. Display modes track the pinned upstream surfaces (§14 References): OpenAI Apps SDK DisplayMode = pip | inline | fullscreen (OpenAI's modal is the separate requestModal API, NOT a display mode and NOT carried here); MCP Apps ui/request-display-mode likewise has no modal.
  // ── A2UI SURFACE LIFECYCLE + DATA-MODEL PUSH (server→renderer; opaque, ADVANCED) ──
  | { type: "ui.surface.start"; surfaceId: string; catalogId: string; surfaceProperties?: unknown; sendDataModel?: boolean; components?: unknown; dataModel?: unknown; toolCallId?: string }  // A2UI createSurface; catalogId REQUIRED (carried BY REFERENCE — the catalog itself is never transmitted); components/dataModel/surfaceProperties OPAQUE unknown (zero component schema); toolCallId MAY link the surface to the producing tool-call
  | { type: "ui.surface.update"; surfaceId: string; components: unknown }   // A2UI updateComponents — the streamed adjacency-list passes through VERBATIM
  | { type: "ui.surface.end"; surfaceId: string }                          // A2UI deleteSurface
  | { type: "ui.data-model"; surfaceId: string; path?: string; value?: unknown }  // A2UI updateDataModel server→surface push; path is a JSON-Pointer defaulting to "/", absent value = delete-at-path, value OPAQUE
  // ── OPAQUE / ADVANCED STATE PASSTHROUGH ──
  | { type: "state.snapshot"; snapshot: unknown }                      // LangGraph "values" stream mode (full graph-state dict after each node)
  | { type: "state.delta"; patch: unknown }                            // an RFC-6902 JSON Patch (array) OR a key-replace patch (object: {key: wholeValue}) — both opaque on the same carrier; fold rule in §5
  // ── MEMORY SIDE-CHANNEL ──
  // value and patch are mutually exclusive (exactly one required — enforced at parse).
  // absent key ⇒ scope-default record. scope="agent"/"user"/"skill" record a write to the producer's own cross-thread store (§13.11).
  | { type: "memory.write"; scope: "agent"|"user"|"skill"|"thread"; key?: string; value?: unknown; patch?: unknown; reason?: string; durable?: boolean }
  // ── GUARDRAIL EVALUATION (folded onto AgTurnRecord.guardrails[]) ──
  | { type: "guardrail.result"; target: "input"|"output"|"tool"; passed: boolean; action?: "block"|"retry"|"rewrite"|"override"|"terminate"; reason?: string; guardrailName?: string; safety?: AgSafety[] }
  // ── NAMESPACED VENDOR EXTENSION (RFC-6648: no x- prefix) ──
  | { type: `ext.${string}.${string}`; [k: string]: unknown };

type AgChoice = { id: string; label: string; value?: unknown };
// A declared accept variant on a HITL ask (§7 grant modes, draft.2). `id` is ASKER-OPAQUE:
// the protocol carries the mode set, it never defines mode semantics (see §7).
type AgGrantMode = { id: string; label?: string; description?: string };
type AgAuthConfig = { scheme: string; scopes?: string[]; authorizationUrl?: string; tokenUrl?: string; clientId?: string; audience?: string };  // the optional fields carry ADK credential-exchange values (§8.0 item 12)
type AgSafety = { category: string; score?: number; probability?: string; blocked?: boolean };

// What triggered a turn (folded from turn.start.trigger onto AgTurnRecord.trigger).
type AgTrigger = {
  kind: "user" | "resume" | "schedule" | "webhook" | "email" | "agent" | "cron" | "unknown";
  ref?: string;  // opaque correlation id, cron expression, webhook path, etc.
};

// A single paused ask entry (shared between hitl.ask and AgOutcome.paused.asks[]).
type AgPausedAsk = {
  askId: string; kind: "approval"|"form"|"text"|"choice"|"auth"|"url"; message?: string;
  toolCallId?: string; schema?: unknown; choices?: AgChoice[]; grantModes?: AgGrantMode[]; authConfig?: AgAuthConfig; url?: string;
  reason?: string; metadata?: AgMeta;             // free-form ask rationale + arbitrary bag — the ONLY lossless carrier for LangGraph interrupt(value: Any)
  requestState?: string; inputKey?: string;          // MCP MRTR continuation
  resumeBinding?: "id"|"positional"; ordinal?: number;  // LangGraph positional binding
  token?: string; expiresAt?: string
};

type AgOutcome =
  | { type: "success"; result?: unknown }
  | { type: "error"; message: string; code?: string; retriable?: boolean }  // absent = unstated (§5 turn.error row), never coerced to false
  | { type: "rejected"; reason?: string }                       // A2A task-level "rejected" — DISTINCT from the tool-call "rejected" finishReason
  | { type: "aborted"; reason?: string }                        // turn.abort's dedicated marker, symmetric with "error"; taskState is verbatim-A2A only and is NEVER reducer-invented
  // within-run pause: the turn parks on one or more HITL asks (was "interrupt"/"await")
  | { type: "paused"; asks: AgPausedAsk[]; result?: unknown };  // result MAY accompany a pause (a partial value emitted before parking)

type AgFinishReason =
  | "stop"
  | "token_limit"             // was "length" — the output max-tokens cap
  | "context_window_exceeded" // Claude 4.5+ model_context_window_exceeded — DISTINCT from token_limit (compact/split vs raise max_tokens)
  | "tool_call"               // was "tool_use"
  | "paused"                  // HITL pause only (was "interrupt"/"await")
  | "pause_turn"              // Anthropic server-loop checkpoint — NON-HITL, no asks[], re-input is a plain replay
  | "refusal"
  | "safety_blocked"          // collapses content_filter/prohibited_content/spii/recitation — see §10 item 7
  | "malformed_tool_call" | "unexpected_tool_call" | "rejected"   // single-source, tagged (tool-call rejection)
  | "other" | "unknown";      // neutral targets for Vercel "other" / SDK-core "unknown"

interface AgUsage {
  inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number;
  reasoningTokens?: number; toolUseInputTokens?: number; totalTokens?: number; costUsd?: number;
  totalTokensRaw?: number;  // the provider's own reported total, verbatim; present only where totalTokens differs from it (§4 inclusion note)
  costScope?: string;     // the accounting scope of costUsd where it is a running total over a scope other than this object's own (e.g. "query"); absent = costUsd has the scope this object's cumulative flag declares (§4 costUsd note)
  cumulative?: boolean;   // true = this object's token counters are the producer's RUNNING TOTALS, preserved verbatim (an Anthropic message stream's usage; the Claude Agent SDK's per-model totals in byModel) — consumers derive per-step by subtracting adjacent snapshots; false/absent = per-step (OpenAI, ADK, Vercel, and a Claude turn terminal's counters). The flag binds the object it sits on; a nested byModel entry carries its own.
  byModel?: Record<string, AgUsage>;  // per-model breakdown (self-recursive)
  serverToolRequests?: number;        // server-executed MCP tool-request count for quota reporting
}

safety_blocked conformance. A normalizer emitting finishReason: "safety_blocked" (or prompt.blocked) SHOULD populate safety[].category so the collapsed reason (content_filter / prohibited_content / spii / recitation) remains recoverable. When the source emits only a bare content-filter signal with no category (e.g. Vercel’s UI stream), safety_blocked MAY be emitted with an empty safety[]. See §10.

A block is not an outcome. A safety block or a refusal is signalled by a prompt.blocked event or by the finishReason "safety_blocked" or "refusal" of the turn.done that closes the turn, whether that turn.done is emitted on the block’s event or when a deferred close is released; safety[], an entry whose blocked is true included, carries the block’s category detail and is not on its own a block signal. This note does not classify guardrail.result; a guardrail evaluation reports its own action. The outcome of a turn that closes with either finish reason is the close the normalizer records for its framework; this note does not constrain it (the reference normalizers close such turns as success, error or paused). A consumer MUST NOT decide that a turn was, or was not, blocked from outcome.type. A normalizer that closes a turn with turn.done for a native reason or refusal signal its mapping sends to "safety_blocked" or "refusal" MUST carry that finishReason whatever outcome it closes with, except that a close whose outcome is paused MAY carry "paused" instead unless §5.0 INV-FLUSH (2) directs the deferred finishReason (§8.0 item 26, §10 item 25). A block carried only by a native finish reason is therefore not signalled when no turn.done carrying that finish reason closes the turn: for example when the normalizer’s mapping does not send the reason to "safety_blocked" or "refusal", when its framework does not deliver it to the normalizer, when no close is built from its event, when the turn closes paused with finishReason "paused", or when the turn closes as turn.abort or turn.error by another path (a close §5.0 INV-FLUSH (2) releases as turn.abort, a host-fed error, an interruption under §8.0 item 17). On a turn.done that closes the turn and whose outcome is not paused, a native reason the mapping leaves unmapped rides finishReasonRaw (§10 item 23), and a consumer MAY recognize a block from a raw value it knows. A block whose own event closes the turn as turn.error (for example when a framework reports the block as an error that carries a message) carries no finish reason (turn.error has no finish-reason field); where the framework’s error exposes a native code, turn.error.code carries it, unnormalized, and a consumer MAY recognize a block from a native code it knows. See §10 item 58.

costUsd population and scope. costUsd carries a producer-reported cost figure verbatim (e.g. OpenRouter usage.cost, the Claude Agent SDK’s total_cost_usd — that framework’s client-side estimate, not an invoice figure); a normalizer never COMPUTES cost. Hosts that meter independently simply ignore it. Where costScope is absent, costUsd has the scope this object’s cumulative flag declares: per-step on a per-step object, a running total on a running-total object. Where costScope is present, costUsd is the producer’s running total over the named scope regardless of the flag ("query": the Claude Agent SDK’s query() call, which continues from restored state on a resumed or forked session and restarts on the framework’s reset commands), and a producer MUST set costScope on every object whose cost has a scope its token counters do not. A consumer reading a running-total cost takes the latest snapshot within one scope, MAY subtract adjacent snapshots of one scope for a per-turn delta (INV-DELTA, §5.0), MUST NOT sum snapshots of one scope, and SHOULD read a value below its predecessor as opening a new scope whose predecessor it banks. A consumer that does not recognise a costScope value reads the cost as a running total. The first snapshot of a resumed or forked scope MAY include spend the wire does not separate, and a running-total cost MAY cover calls (subagents, auxiliary models, a budget-crossing response on an error terminal) that the same object’s per-step token counters do not: cost and token counters on one object are not mutually reconcilable.

(Informative: a turn.done’s usage is the turn’s own accounting — field-wise, over the token counters both objects carry (inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, toolUseInputTokens and totalTokens, whatever either object’s cumulative flag), it is at least the last message.end.usage folded for each of the turn’s own messages; §10 item 67 states this for the reference corpus.) (Informative: a host totals the token counters of top-level terminals, reading, only for a top-level turn whose terminal carries none — a turn.abort, or a turn.error or turn.done whose usage is absent or carries no token counter — the last message.end.usage of each of that turn’s own messages; byModel follows its own cumulative flag, and costUsd has the scope the costScope rule above gives it, never a sum across snapshots of one scope.)

Vercel usage convention. When usage accounting is routed through messageMetadata (Vercel’s totalUsage.totalTokens + model id), it is conventionally carried on the open assistant message’s messageMetadata (or turn.done.messageMetadata), folded as a whole-value replace of messageMetadata (§5). Only AgMessage.messageMetadata, turn.done.messageMetadata, and the open assistant message are carriers; turn.start carries no messageMetadata field.

outputTokens inclusion (draft.3). outputTokens counts every token the provider generated for the turn or message and bills at its output rate — visible text AND reasoning/thinking. reasoningTokens, when present, is a BREAKDOWN of outputTokens, never an additional bucket: consumers MUST NOT add the two. A normalizer whose provider reports reasoning as a SIBLING counter (Gemini usageMetadata.thoughtsTokenCount beside candidatesTokenCount) MUST fold it in — outputTokens = candidatesTokenCount + thoughtsTokenCount — unless the provider’s own total shows the sibling is already included (promptTokenCount + candidatesTokenCount + toolUsePromptTokenCount == totalTokenCount with thoughts present), in which case candidatesTokenCount is carried as-is. The Gemini Live API reports its generated tokens on a different counter — responseTokenCount on the Gemini API wire and in @google/genai, which renames the Vertex Live wire’s candidatesTokenCount to it — and that counter excludes reasoning: a normalizer over the Live API MUST fold the sibling in unconditionally, outputTokens = responseTokenCount + thoughtsTokenCount, and the guard above does not apply to it. These folds, the input-side add below, the Live total below and the field-wise summation of one turn’s per-call reports into a per-step object (§8.0 item 4) are the only sanctioned arithmetic on usage: INV-DELTA’s verbatim rule (§5.0) governs cumulative streams and §8.0 item 19’s no-subtraction posture stands. totalTokens is copied from the provider or framework wire and MAY be absent (Anthropic reports none), with one exception: the Gemini Live API’s reported total can exclude reasoning (the Live API reference documents that total as prompt + response candidates, and every committed Live capture — two sessions, five usage reports — reported exactly that sum with thoughtsTokenCount beside it; @google/genai 2.24.0’s Live UsageMetadata instead reuses the generateContent docstring, which lists thoughts in the sum), so where Live usage carries a reported total a normalizer MUST emit totalTokens = inputTokens + outputTokens + (toolUseInputTokens ?? 0) and, where that differs from the reported total, MUST carry the reported total verbatim in totalTokensRaw; it emits totalTokensRaw nowhere else. A normalizer that sums several usage reports into one AgUsage sums their counters, the reported totals included, and applies these rules to the sums. Where totalTokens is present a conformant fold satisfies inputTokens + outputTokens + (toolUseInputTokens ?? 0) == totalTokens (§10 item 21). A Live totalTokens is the total Gemini’s generateContent surface reports for the same counts; it is not a billing figure, since the Live API prices each modality separately. outputTokens − reasoningTokens approximates visible-text output and is a consumer derivation — approximate on providers that estimate reasoning by re-tokenization (Anthropic thinking_tokens); reasoningTokens SHOULD be ≤ outputTokens when both come from the same provider snapshot. Input side (draft.5). inputTokens counts every input token the provider processed for the turn or message — uncached, read from a cache and written to a cache — and cacheReadTokens/cacheWriteTokens, when present, are BREAKDOWNS of it, never additional buckets: consumers MUST NOT add them to inputTokens, and an object carrying both satisfies inputTokens >= cacheReadTokens + cacheWriteTokens (§10 item 50). A normalizer whose provider reports an input counter that EXCLUDES cache tokens (Anthropic input_tokens) MUST add the provider’s cache counters in on every AgUsage it emits, byModel entries included — inputTokens = input_tokens + cache_read_input_tokens + cache_creation_input_tokens, the provider’s own definition of total input; on a running-total object the sum of three co-scoped running totals is itself the running total, so nothing is de-cumulated (INV-DELTA, §5.0). A provider whose input counter already includes them (OpenAI input_tokens, Gemini promptTokenCount, the Vercel AI SDK’s inputTokens) is carried verbatim. A normalizer never subtracts a cache counter out of inputTokens. inputTokens − cacheReadTokens − cacheWriteTokens is the uncached input and a consumer derivation: exact where the provider’s counters are additive by definition (Anthropic), approximate where the provider rounds its cache figure (OpenAI).

An AgUsage key this specification does not define is an annotation: consumers MUST pass it through (§0.2) and MUST NOT add it into any total or into the §10 item 21 identity. totalTokensRaw is likewise never added into any total or into the §10 item 21 identity: it records the provider’s own figure beside a totalTokens that differs from it. costScope is a scope label, never a counter: it is never added into any total and never affects the §10 item 21 identity.

5. reduce() — stream → object (normative, complete folding table)

5.0 Fold & stream invariants (normative)

The invariants below ARE the contract between a Normalizer (§8.0), the engine, and reduce(). Conformance (§10) tests them. They are stated as behavior; implementations and reviews MUST cite them by these names.

INV-FOLD (determinism & live/history identity). Folding a stream’s events through reduce() in ascending per-invoke order reproduces the persisted AgReduceResult; incremental (live) and batch (history) folds of the same events converge to structurally identical results — deep equality with arrays order-sensitive (blocks materialize in ascending seq of their creating events; a later same-id reconcile updates in place without changing position) and object keys order-insensitive; no number-formatting claim is made. Two conformant reduce() implementations produce structurally identical results for the same stream. (The batch surface returns { result, needsResync }; the equality claim binds result.) (Non-normative: a byte-comparison profile may canonicalize with RFC 8785 JCS first.)

INV-TURN (turn closure). Every top-level turn is opened by exactly one turn.start and every nested turn by exactly one subagent.start (§4); a normalizer MUST synthesize turn.start before emitting any event whose owner (INV-OWNER) is a top-level turn the stream has not opened, whether a content event or a prompt.blocked, guardrail.result, agent.capabilities, source, handoff or display.required record event (duplicate turn-opens are idempotent and merge fields: a later turn.start for a turnId the fold already holds a turn record for REPLACES the record’s threadId and, when present, its trigger; a later subagent.start for a turnId the fold already holds a turn record for records parentTurnId only when the record carries none; no other field moves). Every opened turn, top-level or nested, is closed by exactly one of turn.done | turn.error | turn.abort; a nested turn’s terminal carries its turnId and is immediately followed by that turn’s subagent.done, with no event between them, except that the subagent.done MAY be omitted after a nested turn closed at flush (INV-FLUSH). A normalizer whose stream ends with a turn still open MUST close it at flush with turn.abort (reason: stream-truncated), with turn.abort as §8.0 item 17 directs for a turn whose native stream signalled an interruption, with turn.error carrying an in-band error whose close the normalizer deferred to the end of the run (§8.0 item 35), or by releasing a close it deferred under §8.0 item 14 or item 26 as INV-FLUSH (2) directs — never a success turn.done. The one-terminal rule binds every emitter of the stream, a host’s own events (§8.0 host obligation 5) included; its one exception is the paused refresh of INV-MSG.

INV-MSG (message closure & seal). Every message.start is paired with exactly one message.end; flush() synthesizes message.end for each still-open message in insertion order. message.end seals the message: a block-creating event, a delta event, or a block-finalizing event (exactly text.end, reasoning.end, reasoning.opaque, tool.args.assembled) targeting a sealed message — or any message of a closed turn — is a reduce()-error → snapshot-resync, never a silent attach. A message.start naming a closed turn is likewise a reduce()-error → snapshot-resync; it never opens a message under that turn. A message.start naming an id the fold already holds as a message — open or sealed, carried by a folded messages.snapshot, or adopted by a tool.done — is likewise a reduce()-error → snapshot-resync, and the fold is unchanged; an id a folded message.remove removed is not held. No two events of one invoke open one message id, and a host never reuses a message id that its own earlier invokes of the thread named: a normalizer MUST NOT emit a message.start naming an id that an earlier message.start or tool.done.messageId of the invoke named, and a host MUST NOT deliver, among its own events (§8.0 host obligation 5), a message.start whose id is such an id or a message id that one of its own earlier invokes of the thread named, whether or not the fold has since released it. A turn is closed while the fold holds a turn record for it that carries an outcome; only a folded turn.done, turn.error or turn.abort sets a record’s outcome, so a terminal that folds onto no turn record (INV-OWNER) closes nothing, and taskState alone never closes a turn. A later turn.start or subagent.start naming a closed turn merges fields (INV-TURN) and does not reopen it. A turn.done, turn.error or turn.abort naming a closed turn is likewise a reduce()-error → snapshot-resync, and the fold is unchanged, whatever its outcome and whether or not it repeats the recorded one; one without turnId that no sole open turn resolves parks under INV-OWNER. The one exception is the paused refresh: a turn.done whose outcome.type is "paused", for a turn whose recorded outcome is paused and which a turn.start has named since the turn’s last terminal folded (a messages.snapshot folds no terminal, so the permission survives one), folds as the turn.done row of the §5 table states — its outcome and asks replace the recorded ones, a field it omits keeps its recorded value — and the turn stays closed. Closure is not transient reduce() state: unlike INV-BLOCK’s id scope it survives a 0-restart, and a messages.snapshot changes it only through the turn records the §5 per-container rule leaves in the fold, so a snapshot that carries turns closes exactly its carried turns that carry an outcome, and one that omits turns keeps every turn record and so every closure. This rule covers block events, message.start and terminals only: the message-level merges message.metadata and turn.done.messageMetadata fold onto a named message as their §5 rows state, except that a terminal this rule does not fold merges nothing. message.remove of a sealed message un-seals it per the pointer-revert rule (§5 table).

INV-BLOCK (block identity & order). Blocks append to message.content[] in ascending seq of their creating event; a same-id data block (via content.block) of the same message reconciles/REPLACEs in place. Streaming block ids and toolCallIds MUST be unique within an invoke (a fold that spans invokes resets this scope at each 0-restart, INV-SEQ): a block-creating *.start naming an id already present in the invoke, or a tool.done without more:true for a toolCallId whose result the invoke already landed without more:true, is a reduce()-error → snapshot-resync (never a duplicate append). A tool.done with more:true for a toolCallId whose final (more-less) result the invoke already landed is the same reduce()-error → snapshot-resync; a final result is never re-opened within its invoke. Normalizers MUST mint collision-free derived ids when the framework omits one (e.g. ${turnId}:${kind}:${ordinal}); identity MUST never derive solely from a per-event positional index.

INV-XINV (cross-invoke tool result). When an invoke delivers the result of a tool call that an earlier invoke opened (the call has no tool.start in this invoke), the normalizer MUST emit that result as exactly one tool.done without more:true, in a turn this invoke opened (INV-TURN) whose turnId no earlier invoke used, with a messageId that no other message of the thread uses, so the result lands in its own role:"tool" message (§8.0 “Ids across invokes” governs both ids; the reference derives the messageId as "<toolCallId>:result", and a normalizer whose framework can reuse a toolCallId across invokes derives it from this invoke’s turnId as well, for example "<turnId>:<toolCallId>:result"); it MUST NOT emit a tool.start for that toolCallId, and the result never targets the earlier invoke’s turn (INV-MSG). This rule binds only where the framework’s stream carries such a result; a normalizer never synthesizes one. Its outcome follows the normalizer’s mapping for that framework, and "denied" is emitted only where the stream carries a structural non-execution signal or where §8.0 item 25 (b) identifies a declined approval for a normalizer targeting ADK, and is otherwise never inferred from result text. Across turns, a result pairs with the most recent earlier call that has the same toolCallId and no result (binding per §1.3). When, before that final, this invoke lands one or more tool.done with more:true for a call an earlier invoke opened, each on the message the final names, each keeps the result open (§5 tool.done row) and the final is the one tool.done without more:true this invariant names. A final result for a call whose more:true result an earlier invoke landed targets a closed turn’s result and parks per the §5 tool.done row; this version defines no cross-invoke completion of a preliminary result.

INV-DELTA (true deltas & usage). Every *.delta carries only the increment — blind concatenation is safe. De-cumulation of cumulative content streams is a normalizer duty (the engine slices the per-id prior string). Cumulative usage is NOT de-cumulated anywhere: normalizers flag cumulative: true and preserve the provider’s running totals verbatim, less any own __proto__ member (§13.7); reduce() lands usage verbatim, less that member; per-step deltas are a consumer derivation (subtract adjacent snapshots). Complete-message sources are fragmented start→delta→end. Batch and incremental folds converge (INV-FOLD).

INV-SEQ (sequencing). seq is an ascending, gap-free ordinal scoped to one Normalizer instance (one invoke), starting at 0 each invoke and ascending across nested turns within that invoke. reduce() parks (snapshot-resync; only messages.snapshot/state.snapshot un-park) on a forward gap (ev.seq > lastSeq + 1); a backward jump to seq 0 — a new invoke’s 0-restart — updates lastSeq downward and folds normally. This backward tolerance is normative. A seq above 0 that does not exceed lastSeq (0 < ev.seq <= lastSeq) cannot open an invoke, so it is a re-delivery or an invoke whose first events were lost: reduce() MUST NOT fold it and parks exactly as on a forward gap, never skipping it silently. A reducer that has seen no event yet starts counting at the first event it receives, so it cannot detect the loss of an invoke’s first events. There is no cross-invoke global ordinal and no reconnect high-water resume at this layer; a host requiring a global ordinal re-stamps outside the normalizer. A conformant reducer MUST expose its resync condition to the caller. An event that arrives but is ignored under §0.2 occupies its seq slot: it advances lastSeq exactly as a folded event does, so only a seq that never arrives opens a forward gap. An input that is not a well-formed envelope (§0.2) occupies no slot. Events discarded while parked are unaffected: they still do not advance lastSeq.

INV-OWNER (total id / owner coverage). Every turn, message, block, and tool call carries an id, and every emitted event resolves to its owning turn (explicit turnId → owning message via messageId → last-opened turn — restored to the PARENT at that subagent turn’s subagent.done). All engine emit paths, including the generic emit(), apply the same owner backfill as the sugar primitives. tool.done.messageId, when present, names the landed tool-result message’s own id (adoption). A reducer encountering an unresolvable owner degrades loudly (reduce()-error → resync), never a silent no-op. A top-level turn.done, turn.error or turn.abort, or a record event, that carries no turnId and no messageId naming a folded message resolves to the sole open turn (§4 Per-turn routing), never to a closed turn; when no turn, or more than one turn, is open, its owner is unresolvable. A turn.done, turn.error or turn.abort whose turnId names a turn the reducer has not seen opened is not an unresolvable owner: it folds onto no turn record and does not set the resync condition. A turn is seen opened once a turn.start or subagent.start opens it, or once a folded messages.snapshot carries it in turns or as the turnId of one of its messages; a messages.snapshot that carries turns replaces what the reducer has seen opened with the turns it carries and its messages’ turnIds, and one that omits turns keeps what the reducer has seen opened and adds its messages’ turnIds. A terminal for a turn the reducer has seen opened but holds no turn record for (other than a nested turn whose record is held, below) folds onto a new record whose threadId is that of the first message, in the most recent messages.snapshot that carried the turn’s turnId, that carries both that turnId and a threadId; when no such message exists, the terminal folds onto no turn record and does not set the resync condition. A turn’s thread is known once a folded turn.start or message.start names the turn and carries a threadId, once a folded subagent.start has opened it and its parent’s thread or the fold’s root thread is known (its thread is the one the subagent.start row gives), or once a folded messages.snapshot carries the turn in turns or carries a message that names the turn and carries a threadId; a turn.start gives the thread (a duplicate merges per INV-TURN), and a subagent.start that opens a turn the fold holds no record for gives the thread the subagent.start row gives, and otherwise the first of these to hold gives it. A prompt.blocked, guardrail.result, agent.capabilities, source, handoff or display.required event (a record event) lands on the record of the turn it names, and a reducer that holds no record for that turn creates one carrying the turn’s thread. A record event naming a turn whose thread the reducer does not know is not an unresolvable owner: the reducer holds its landing, applies it to the turn’s record once the thread is known, and does not set the resync condition; result() omits a turn record whose thread is not known. A terminal for a nested turn whose record is held lands on that record; a messages.snapshot that carries turns discards such a record, keeping only its held display.required landings, which the next sentence places. A messages.snapshot that carries turns discards every held landing other than a display.required one, and a turn it carries in turns takes the record the snapshot carries; each held display.required landing for such a turn, in the order the landings folded, is then appended to that record’s displayRequired unless an entry of the carried record with the same provider and html, not matched by an earlier such landing, stands for it, and one for a turn the snapshot does not carry in turns stays held. A turn.start, subagent.start or message.start for a turn whose record the reducer holds sets that record’s thread when the record has none, and a subagent.start also sets its parentTurnId when the record carries none (INV-TURN). A reducer MUST NOT emit a turn record, or create a message for a turn, whose threadId is not that turn’s known thread. A turn is also seen opened once a folded message.start names it and carries a threadId; a terminal for such a turn that holds no turn record folds onto a new record carrying that threadId.

INV-FLUSH (end of stream). flush() is a normative term: at end of stream a normalizer (1) emits a synthetic message.end for each still-open message, in insertion order; that message.end MAY carry usage the normalizer received for the message’s round when the turn’s terminal cannot carry it; (2) closes each still-open turn per INV-TURN, innermost nested turn first — a still-open nested turn closes with turn.abort or turn.error, never with subagent.done alone. A close deferred under §8.0 item 14 is released as turn.done with outcome {type:"paused", asks} — naming each emitted hitl.ask whose tool call is still pending, and carrying the deferred finishReason and usage — when such an ask exists; otherwise verbatim when its outcome is not success; otherwise as turn.abort (reason: stream-truncated), with the deferred usage on that round’s message.end. The paused close §8.0 item 26 defers is released by the same mechanism, under one further condition: for a turn whose pause was signalled (item 26), that is still open at flush, and whose native stream signalled no interruption (§8.0 item 17), flush() emits turn.done with outcome {type:"paused", asks} naming each pending ask, finishReason:"paused", and the turn’s accumulated usage; a turn holding asks for which no pause signal was seen closes with the turn.error §8.0 item 35 directs when its invocation met an invocation error, and otherwise as turn.abort (reason: stream-truncated), or with turn.abort as §8.0 item 17 directs when its native stream signalled an interruption. Any other still-open turn whose invocation met an in-band invocation error closes with the turn.error §8.0 item 35 directs. A flush MUST NOT emit a success turn.done. (3) Beyond (1) and (2), a flush MAY emit only lifecycle closes that land no new content — text.end and reasoning.end carrying at most citations, phase and providerMetadata already received on the wire, step.done, and subagent.done after its nested turn’s terminal — and, before the turn closes of (2), the §8.0 graceful-degradation carries (ext.<vendor>.*) of native material the stream delivered, less any member §8.0 forbids a normalizer to emit. The one further set of events a flush MAY emit is the error turn of §8.0 item 26, and only after releasing under (2) the paused close of a turn whose invocation met an invocation error: that top-level turn’s turn.start, one message.start and one message.end for a message that carries no content and no usage, and its turn.error. A flush MUST NOT emit tool.args.assembled, tool.done, reasoning.opaque, content.block or any other event that creates or sets block content; dangling per-block scratch is dropped.

The persisted state MUST be reconstructible from the event stream by a reduce() such that reduce(events).result == AgReduceResult (the batch surface also returns needsResync — INV-SEQ’s exposed resync condition) under the INV-FOLD structural-equality relation (§5.0) — the live-SSE ↔ history-read invariant, where AgReduceResult = { messages, artifacts, memory, turns, state? } (§2) holds the message tree, the artifact side-channel, the memory side-channel, per-turn records, and the shared-state copy. A host’s storage projection MAY omit what §13 lets a host declare it does not keep: turns[].displayRequired where the grounding provider’s terms do not permit storing it (§13.3), and memory records of a non-thread scope the host has not declared it persists (§13.11); the identity then holds over every other field, and the reducer’s fold is unchanged. Events fold in ascending per-invoke seq; a forward gap (ev.seq > lastSeq + 1) triggers a snapshot-resync (request a fresh messages.snapshot/state.snapshot), never a partial guess; a backward jump to seq 0 — a new invoke’s 0-restart — updates the reducer’s high-water mark downward and folds normally, and a repeated or backward seq above 0 parks like a forward gap (normative; INV-SEQ, §5.0).

Trust boundary. A reducer ingesting events across a trust boundary (network, storage, another process) MUST validate each event against AgEvent (or an equivalent schema) BEFORE folding — an input that is not a well-formed envelope (§0.2) is a typed error to the caller and occupies no seq slot; a well-formed envelope of undefined type, or whose body fails validation, is not folded, occupies its seq slot, and MUST be reported to the caller — never a fold and never a silent skip. The reference SDK reports it in place as a non-folding ext.agjson.ignored event carrying { seq, ignoredType, raw }, which is live-only and MUST NOT be persisted; for an envelope past its depth bound (§0.2) it reports the same stub, its raw capped at the emit path’s depth limit so the stub itself stays within the bound; within one validated pipeline, re-validation inside the fold is NOT required (the fold’s precondition is already-parsed events).

Message partitioning (load-bearing). message.start opens an AgMessage keyed by id under the turn named by turnId. Subsequent block-creating events (text.start / reasoning.start / tool.start / content.block / tool.done) of that turn attach to the most-recently-opened message of that turn for its candidateIndex (absent ⇒ 0) and inherit its id as their messageId, until the next message.start in that turn or turn.done. The partition key is (turnId, candidateIndex) — absent ⇒ 0 keeps every existing single-candidate stream structurally identical (the back-compat anchor). message.end seals the message. When multiple turns are open, a block attaches to the open message of the turn named by the event’s turnId, never to a positional “current turn.”

Block insertion order. Blocks are appended to message.content[] in ascending seq of their creating event (text.start / reasoning.start / tool.start / content.block); a later same-id REPLACE updates in place without changing position. This makes two conformant reduce() implementations structurally identical for interleaved-block-kind turns (INV-FOLD, §5.0).

Turn-open idempotency. Opening a turn is idempotent on turnId; a second open for an existing turnId (a duplicate turn.start/subagent.start) merges fields (INV-TURN), never duplicates the turn.

Surface interactions are live-only. The AgSurfaceInteraction family (AgA2uiSurfaceAction / AgA2uiFunctionResponse / AgA2uiError / AgMcpAppViewMessage / AgOpenAiWidgetAction) is an INPUT construct (AgInput.kind:"resume" uiActions[]), not an event in this output stream, and it is never folded into AgReduceResult; the surface RPC EVENTS that mirror it (ui.*) are live-only / non-folding (below), exactly as before the un-merge. The a2uiClientDataModel snapshot is the one surface-interaction datum that DOES persist — it rides AgMessage.messageMetadata and folds with that message (§3.2, §11 item 1).

The table below is exhaustive: every event type states its folding semantics. Any event type not listed is live-only / non-folding (delivered to the live client, never persisted).

What a built record carries. A record or block the fold BUILDS from an event carries only members its record type declares (§2, §3.2); a reduce() implementation MUST NOT carry an event member the type does not declare onto the record or block that event creates or updates (INV-FOLD). A block or record the fold CARRIES whole — the block of a content.block, the part of an artifact.delta, every element of a messages.snapshot array — is kept as received (§0.2). Host metadata that must survive the fold rides a declared slot: a block’s _meta (text.start, reasoning.start, tool.done), AgMessage.metadata (message.metadata) and AgMemoryRecord._meta (memory.write); AgTurnRecord has no such slot, and artifact.start does not fold its _meta (draft.8); the event-level _meta of every other event, message.metadata’s included, folds nothing.

Event type Fold semantics
turn.start Open a turn (turnId, threadId) as an AgTurnRecord; top-level only; idempotent on turnId. Subsequent entities attach under it. Record trigger if present.
turn.done Close the turn (INV-MSG); record outcome/finishReason/finishReasonRaw/usage/taskState; safety, when present, REPLACES the turn’s safety[] as a whole value (the last prompt.blocked’s own ratings stay on promptBlocked.safety); if outcome.type==="paused", record the asks; messageMetadata replaces the messageMetadata of the message named by turn.done.messageId when present, else of the open message of the turn’s candidate-0 partition, whatever its role — that field only, as a whole value, not a per-key merge (the message.metadata event’s per-key merge lands on the message’s metadata, a different field). finishReason:"pause_turn" records a resumable checkpoint with no asks[] (re-input is a plain replay, NOT a resume kind). A turn.done for a closed turn folds only as INV-MSG’s paused refresh; any other is a reduce()-error → snapshot-resync (INV-MSG).
turn.error Terminal; mark the turn errored: fold outcome = {type:"error", message, code?, retriable?} on the turn record (INV-MSG). Non-folding into content (no block produced). Record usage verbatim when present (mirrors turn.done; the errored turn’s per-turn counters and whatever running-total cost the producer reports, §4). For a closed turn it is a reduce()-error → snapshot-resync (INV-MSG). Record code and retriable when present, as turn.error carried them: retriable is the producer’s judgement (a framework SDK’s, or the normalizer’s status inference), never this specification’s; an absent retriable is unstated, not false. The non-terminal error event’s retriable never folds.
turn.abort Terminal; fold outcome = {type:"aborted", reason?} on the turn record. taskState is NEVER reducer-invented (verbatim A2A only). Non-folding into content. For a closed turn it is a reduce()-error → snapshot-resync (INV-MSG).
error Live-only / non-folding (non-terminal advisory).
message.start Open an AgMessage keyed by id under the turn named by turnId (stepId, when present, rides the wire for live consumers only — steps do not fold); record threadId and, when present, extensions, candidateIndex, agentId, agentName, agentRole, noticeSource, model. Becomes the most-recently-opened message of the (turnId, candidateIndex) partition. A message.start naming a closed turn, or naming an id the fold already holds as a message, is a reduce()-error → resync, never an open and never a replacement (INV-MSG).
message.end Seal the message named by id (no further blocks attach to it). Record per-message usage if present (per-message token accounting, distinct from the turn-level turn.done.usage).
message.remove REMOVE the AgMessage keyed by id from AgReduceResult.messages and the blocks inside its content[]. Tool-result messages that adopted their own id via tool.done.messageId are removed ONLY by their own message.remove — no cascade. Removing a message also clears it as the most-recently-opened target: the open-message pointer for (turnId, candidateIndex) reverts to the highest-seq still-present un-sealed message of that (turn, candidate), or to none — in which case a subsequent block-creating event with no intervening message.start is a reduce()-error → snapshot-resync, never an implicit re-open. id === "*" (REMOVE_ALL) removes every message of exactly the turn named by turnId (zero matches = deterministic no-op, not error); id === "*" with no turnId is malformed → rejected at parse, never folded. Structural delete, non-folding into content; remaining messages keep ascending-seq order.
step.start Live-only structural marker — steps have NO AgReduceResult container and fold to nothing (the wire carries step boundaries for live consumers only).
step.done Live-only structural marker — step.done.usage has no fold target; turn.done.usage is the authoritative turn-level accounting. Folds to nothing.
prompt.blocked Record AgTurnRecord.promptBlocked = {reason, reasonRaw?, safety?} on the turn AND merge safety[] into the turn’s safety. Non-folding into content. Lands per INV-OWNER’s record-event rule (§5.0): held until the reducer knows the turn’s thread.
guardrail.result Append a guardrail evaluation record to AgTurnRecord.guardrails[] (target, passed, action?, reason?, guardrailName?, safety?). Non-folding into content. Lands per INV-OWNER’s record-event rule (§5.0): held until the reducer knows the turn’s thread.
agent.capabilities Record (REPLACE whole) the agent’s AgCapabilities on the turn (first-turn negotiation). Non-folding into content. Lands per INV-OWNER’s record-event rule (§5.0): held until the reducer knows the turn’s thread.
messages.snapshot REPLACE the working message set; turns and artifacts are replaced per-container conditionally — ONLY when the snapshot carries the corresponding turns?/artifacts? container; an OMITTED container is PRESERVED (a messages-only snapshot never wipes prior turns/artifacts). If memory? is present, REPLACE ONLY scope==="thread" records in AgReduceResult.memory; scope="agent"/"user"/"skill" (cross-thread in the producer’s store, §13.11) records are a SEPARATE axis untouched by a per-thread snapshot. Clear the transient reduce() state — per-toolCallId arg scratch buffers, reasoning signature scratch, open-message pointers, message seals, block positions and any open blocks — so no pre-snapshot delta is re-applied after the REPLACE. INV-BLOCK’s invoke-scoped id sets are not cleared (only a 0-restart resets them), and turn closure is not transient: it follows the turn records this row leaves in the fold (§5.0 INV-MSG), so a snapshot that carries turns closes exactly its carried turns that carry an outcome, and one that omits turns keeps every closure. A host that carries turns in a snapshot carries their outcome. A host that means to reopen a turn carries its record without an outcome. It also updates what the reducer has seen opened (§5.0 INV-OWNER): a snapshot that carries turns replaces it, and one that omits turns adds its messages’ turnIds to it.
host.context Live-only / non-folding (host hint).
text.start Create a text block keyed by id; merge providerMetadata (REPLACE-by-key); merge _meta into the block’s _meta (LangGraph node/tag attribution survives); set phase when present.
text.delta APPEND delta to the text block named by id; merge providerMetadata (REPLACE-by-key) if present.
text.end End the text block; merge providerMetadata (REPLACE-by-key, last-writer-wins); attach citations[] to the block when present (the STREAMED-text citation carrier); set phase when present (a present value REPLACES the block’s phase, an absent one leaves it unchanged).
reasoning.start Create a reasoning block keyed by id; set itemId; merge providerMetadata/_meta; set phase when present. partIndex is not folded: the block carries no partIndex and no part structure.
reasoning.delta APPEND delta to the text of the reasoning block named by id, in arrival order and whatever its partIndex, so a block’s summary parts form one text; merge providerMetadata.
reasoning.end End the reasoning block; merge providerMetadata (REPLACE-by-key); set provider and phase when present (a present value REPLACES, an absent one leaves it unchanged).
reasoning.opaque REPLACE opaque = { kind, value, provider } on the reasoning block named by id (the sole behavior); set itemId if present (replay-load-bearing). This is the Anthropic thinking-signature / OpenAI rs_ encrypted_content / Gemini thoughtSignature-on-thought-and-grounding / Pydantic signature carrier.
reasoning.opaque.delta APPEND delta to a per-id signature scratch buffer (Pydantic signature_delta); the assembled value is ended by the following reasoning.opaque REPLACE. A scratch for an id with no block position survives message.remove and any turn’s close, cannot land until a reasoning.start gives the id a position, and is cleared by a messages.snapshot or by its own landing.
tool.start Create a tool-call block keyed by toolCallId (set name, serverName, providerExecuted, title, toolMetadata, uiVisibility, itemId, providerMetadata; the event’s dynamic, requiresApproval and longRunning are not members of the tool-call block and are not carried onto it (§5, ‘What a built record carries’)).
tool.args.delta APPEND the raw partial-JSON delta to a scratch buffer for toolCallId (never the authoritative input).
tool.args.assembled AUTHORITATIVE for tool-call.input (set it from input; discard the scratch buffer). Set signature, title, toolMetadata if present; merge providerMetadata (REPLACE-by-key).
tool.done Land a tool-result block for toolCallId: content, outcome, structuredContent, uiData, sideData, errorText, errorCode, providerMetadata, toolMetadata, dynamic, pendingInput, isError, _meta. If messageId present, the landed tool-result message adopts it as its own id (stable ToolMessage identity). (more:true ⇒ sets the block’s typed preliminary flag and keeps the result open. While a result is kept open, every tool.done for that toolCallId MUST carry the full current result, including content and outcome — a snapshot, never a delta. A subsequent tool.done for the same toolCallId REPLACES the result payload as a unit: content, outcome, isError, structuredContent, uiData, sideData, errorText, errorCode and pendingInput take the later event’s values, and a payload field the later event omits is cleared. _meta, toolMetadata and dynamic are replaced only when the later event carries them; providerMetadata merges by key. The final, more-less result clears preliminary. A tool.done targeting a closed turn’s result is a reduce()-error → resync (never a silent mutation).) A tool.done that carries a messageId, finds no kept-open result for its toolCallId, and whose messageId names neither a message the fold holds nor an id a folded message.end sealed (a message.end seals the id it names even when the fold holds no message under it, until a messages.snapshot clears the seals or a message.remove removes a message held under it), for a turn that is open to adoption and not closed and that has a turn record result() returns, builds { id: messageId, role: "tool", content: [], turnId, threadId } under that turn (INV-OWNER): unsealed, with no candidateIndex, and not made its partition’s open message by the build (a message.remove pointer revert can reach it, per the message.remove row); for any other turn, or an id so sealed, it is a reduce()-error → snapshot-resync. A turn.start or subagent.start makes its turn open to adoption; a folded messages.snapshot that carries turns makes exactly the turns it carries open to adoption, and one that omits turns makes exactly the turns whose records result() returns just before it folds open to adoption. A tool.done that finds no kept-open result for its toolCallId and whose messageId names a message the fold holds attaches to it only while that message is unsealed and its turn is open to adoption and not closed, else it is a reduce()-error → snapshot-resync; a tool.done that finds a kept-open result folds onto that result whatever its messageId names, and is a reduce()-error → snapshot-resync when that result’s message is sealed or its turn is closed; message.end seals a built message.
content.block Insert block (appended in seq order); if the inserted block is a data block with an id, and the block the fold most recently created or inserted under that id (a text.start, reasoning.start or tool.start id, or a data block’s own id), whatever its type, lies in the same message, REPLACE that block in place (reconcile-by-id); otherwise append it (the blocks a messages.snapshot carried were created under no id, so they are appended to, never replaced). A transient:true on the event, or on a data block itself, ⇒ SKIP.
message.metadata Merge metadata into the message named by messageId (when messageId is absent, the open message of the turn’s candidate-0 partition, whatever its role — the event carries no candidateIndex; when that partition has no open message, as after its last-opened message’s message.end, the event changes no message and occupies its seq slot). Per key, into metadata; turn.done.messageMetadata replaces messageMetadata whole (the turn.done row).
source Land the FULL record on AgTurnRecord.sources[] ({sourceId, source, chunkIndex?, providerMetadata?}) — and append sourceId to AgTurnRecord.sourceIds[] in event order (the binding index citations resolve against post-fold). Synthesized source.sourceId order MUST preserve the original groundingChunks[] array order (or carry chunkIndex) so chunk-index citations bind correctly. Lands per INV-OWNER’s record-event rule (§5.0): held until the reducer knows the turn’s thread.
artifact.start Artifact side-channel landing — open an AgArtifact (keyed by artifactId, with turnId/threadId/name/description/extensions) in AgReduceResult.artifacts; NOT an AgBlock (§1.3). A second artifact.start for a held artifactId REPLACES the artifact whole, parts included.
artifact.delta append:false ⇒ start a new part; append:true ⇒ concatenate onto the last part when the last part and the delta’s part are both text blocks, else start a new part (into the side-channel, not content). A delta for an artifactId the fold does not hold folds to nothing and occupies its seq slot (INV-SEQ).
artifact.end End the artifact (side-channel); ending it sets no state the fold reads, so a later artifact.delta for the same artifactId still lands (§10 item 16).
subagent.start Open a nested turn (turnId, parentTurnId); idempotent on turnId; its threadId is the parent turn’s thread when that thread is known (INV-OWNER), else the fold’s root thread — the first threadId that a folded turn.start, message.start, artifact.start or messages.snapshot carried (for a messages.snapshot, the threadId it carried is that of the first turn it carries in turns, else that of the first of its messages that carries a threadId, and none when neither does); until one of the two is known the record is held and result() omits it, and the first that becomes known gives its thread (INV-OWNER); fold its events under that nested turn.
subagent.done Bracket close of the nested turn; no fold landing — the nested turn’s turn.done/turn.error/turn.abort closed it and recorded its outcome (INV-TURN). The owner reverts to the parent (INV-OWNER).
handoff Record the handoff edge on the turn. Non-folding into content. Lands per INV-OWNER’s record-event rule (§5.0): held until the reducer knows the turn’s thread.
display.required Record on the turn (AgTurnRecord.displayRequired) for the live render; non-folding into content but MUST NOT be dropped by a normalizer or reducer (ToS). (Host persistence and render duties: §13.3.) Lands per INV-OWNER’s record-event rule (§5.0): held until the reducer knows the turn’s thread.
state.snapshot REPLACE the shared-state working copy (resync) — the LangGraph values stream mode (full graph-state dict).
state.delta Apply the patch to the shared-state working copy according to its JSON type. An array is an RFC-6902 JSON Patch applied to the working copy; a non-empty JSON Patch against an absent working copy, or one that fails to apply, is a reduce()-error → snapshot-resync, never silently based on {} (as for memory.write). An operation whose path or from holds the reference token __proto__ fails to apply (§13.7), with that same outcome; a pointer token resolves against the working copy’s own members only, never an inherited one (RFC 6901 §4), so an operation whose target or from location must exist (replace, remove, move, copy, test) and names a member the working copy does not own fails to apply too, while add of such a name creates an own member (RFC 6902 §4.1). An object is a key-replace patch: each top-level member replaces the working-copy member of the same name with its whole value, with no merge below the top level, and a null value is stored as null, never treated as a deletion; an absent or non-object working copy is first replaced by {}. A key-replace patch never removes a top-level member (§13.8); a member leaves the working copy only through an RFC-6902 array patch or a state.snapshot. Any other patch (string, number, boolean or null) is a no-op and never a resync. An empty JSON Patch array ([]) against an absent working copy applies as a no-op, leaves the copy absent and sets no resync condition.
memory.write Land/update an AgMemoryRecord keyed by (scope, key) in AgReduceResult.memory; an absent key targets the scope-default record (scope, "") — exactly one keyless record per scope. value and patch are mutually exclusive per event (an event with both is rejected at parse): value sets the record; patch (RFC-6902) mutates it in place — a non-empty patch against a (scope, key) with no existing value is a reduce()-error → snapshot-resync (writer MUST seed a value first), never silently based on {}. An operation whose path or from holds the reference token __proto__ fails to apply (§13.7): a reduce()-error → snapshot-resync that leaves the record unchanged; a pointer token resolves against the record’s own members only, never an inherited one (RFC 6901 §4), so an operation whose target or from location must exist and names a member the record does not own fails to apply too, with that same outcome, while add of such a name creates an own member (RFC 6902 §4.1). The record carries the event’s _meta when present: value replaces the record whole, _meta included, so an event without _meta lands a record without one; patch replaces the record’s _meta only when the event carries one, as it does reason and durable. durable:true says the producer’s store keeps the record across threads (§13.11); side-channel, never into content. For a scope thread record, a value write lands it with the event’s turnId when the event carries one and with no threadId (§13.11). An empty JSON Patch array ([]) against an unseeded record applies no operation, seeds no record and sets no resync condition.
data (as a block via content.block) Reconcile-by-id: same id ⇒ REPLACE in place; the carrying event’s or the block’s own transient:true ⇒ SKIP.
hitl.ask Live-only (the within-run pause is recorded by turn.done.outcome="paused", above).
ui.call / ui.result / ui.action-result / ui.widget.result / ui.display-mode / ui.surface.* / ui.data-model Live-only / non-folding (surface RPC + ephemeral surface state). ui.widget.result (the OpenAI Apps callTool reply) is likewise live-only / non-folding.
ui.surface.start / ui.surface.update / ui.surface.end / ui.data-model Live-only / non-folding (ephemeral surface render state, like host.context).
ext.<vendor>.<key> Live-only / non-folding unless a vendor profile defines folding.

Ending a block (text.end, reasoning.end) sets no state the fold reads: while the block’s message is unsealed and its turn is open, a text.delta or reasoning.delta that arrives after its block’s end event appends and a reasoning.opaque that arrives after it lands, each as its row states (INV-MSG governs a sealed message and a closed turn). A text.delta, text.end, reasoning.delta, reasoning.end, reasoning.opaque or tool.args.assembled naming an id the fold holds no block position for (a messages.snapshot or message.remove clears the positions of the blocks it replaces or removes), or whose position holds a block of another type, leaves the fold unchanged and advances lastSeq (INV-SEQ); a reasoning.opaque.delta or tool.args.delta with no position APPENDS to its id’s scratch as its row states.

Citation attachment key. A citation attaches to the text block whose id it accompanies (carried inline on the block’s citations[]); offset-kind citations additionally reference grounding sources by AgCitation.sourceIds → source.sourceId, and partIndex anchors the byte range to the correct response part. For STREAMED text, citations arrive on text.end.citations and attach to the ended block — a normalizer MUST NOT re-emit the text as a supplement block (duplicate-fold hazard).

Replay vs append summary. Deltas (text.delta, reasoning.delta, tool.args.delta, reasoning.opaque.delta) APPEND. reasoning.opaque, a same-id data block (via content.block) of the same message, messages.snapshot, state.snapshot REPLACE. (messages.snapshot replaces turns/artifacts/memory per-container conditionally — omitted containers are preserved.) tool.args.assembled is authoritative. transient:true (on the event, or on a data block itself) is skipped. message.remove DELETES the named message (or all messages of the turn for id="*"); the open-message pointer for the (turnId, candidateIndex) partition reverts accordingly. Artifacts land in a side-channel (AgReduceResult.artifacts), never inside AgBlock[]. Memory writes land in a separate side-channel (AgReduceResult.memory); scope="agent"/"user"/"skill" records are cross-thread in the producer’s store, kept by a host only where it declares the scope (§13.11), and untouched by per-thread snapshots. providerMetadata MERGEs by key onto its block; toolMetadata REPLACEs its block’s value when the event carries it. Block annotations (MCP audience/priority/lastModified) carry through reduce() UNCHANGED. The surface-interaction family and the ui.* surface RPC events are live-only / non-folding (the un-merged AgSurfaceInteraction carries no new folding semantics); the lone persisted surface datum is the a2uiClientDataModel snapshot on AgMessage.messageMetadata.

phase timing. A producer MUST set phase on the block’s *.start when the marker is known before the first delta, and otherwise on the block’s *.end. A producer that learns the marker only after the block’s *.end MUST leave phase absent on that block: it MUST NOT emit a second *.end for the block or re-deliver the block by content.block. (Consumer note, non-normative: a host should restyle a phase:"interim" block — a status line or a muted row — rather than suppress it, since interim text can carry an approval preview; should exclude interim blocks when deriving a turn’s answer text; should treat an unrecognized phase value as absent; and renders a block with no phase exactly as before.)

6. UI-interaction model

AgJSON adds zero render layer; it provides the boundary-crossing anchors. There are two co-equal sanctioned agent-UI-OUTPUT surfaces (Pattern 1a + 1b), each respecting its own external spec.

Pattern 1a — agent emits an MCP Apps UI surface (static HTML). An MCP resource carried by content.block, uri:"ui://…", mimeType:"text/html;profile=mcp-app", _meta.ui={resourceUri, csp?, permissions?, domain?, prefersBorder?}. The producing tool carries the same _meta.ui.resourceUri. Tool VISIBILITY is an access-control scope on the TOOL (AgToolDef.uiVisibility / tool-call.uiVisibility), not a resource convention: model = in tools/list + model-callable; app = app-only (the host MUST exclude app-only tools from the agent tool list and reject cross-origin app calls). The four tool-result channels stay separate (§2.1): content (model) · structuredContent (model, structured) · uiData (surface/view, model-hidden) · sideData (app-only). host.context carries theme/capabilities/container dims.

Pattern 1b — agent emits an A2UI component surface (declarative streaming). When an agent draws components by STREAMING, it emits A2UI surface + component events (the §4 ui.surface.* / ui.data-model family); AgJSON carries the A2UI envelope VERBATIM and defines ZERO component schema (Layer-A: it respects A2UI, never reinvents it). The two surface-emission models are distinct: MCP Apps = an HTML resource block (ui:// + text/html;profile=mcp-app, Pattern 1a); A2UI = a declarative component-tree / data-model stream carried OPAQUELY on ui.surface.* / ui.data-model, with the client→server data-model snapshot riding AgMessage.messageMetadata.a2uiClientDataModel, keyed by surfaceId. A2UI is NOT routed through the text/html resource-block path; catalogId is carried BY REFERENCE (the catalog itself is never transmitted; the renderer resolves components against its own catalog registry). A ui.surface.start MAY carry toolCallId, linking the surface to the producing tool-call (mirrors the ui:// _meta.ui.resourceUri linkage). Render target. The Layer-B renderer (e.g. a SilverProtocol frontend client) resolves the A2UI declarative stream against its component catalog and paints it to static HTML — no client-side reactive framework runs in the surface; the agent drives every update via ui.data-model / ui.surface.update pushes. (AgJSON itself defines no HTML and no render step; “static HTML” is the client’s render target, not the wire shape.) Surfaces are live-only and never folded into AgReduceResult (§1.3, §5).

Pattern 2 — user acts on the surface. Component internals are out of scope. The crossing back normalizes to either (a) a client-originated tool-result (AgInput.kind:"tool-result") or an AgSurfaceInteraction (AgInput.kind:"resume", uiActions[]) correlated by toolCallId/surfaceId, or (b) an injected user message. For an A2UI surface, a user click normalizes to an AgA2uiSurfaceAction carrying the A2UI-frozen name + sourceComponentId (A2UI arbitrary action names) and, when wantResponse, an actionId. The A2UI action.context (the resolved dynamic data-bindings the user’s action carried) is the first-class AgA2uiSurfaceAction.context field; the A2UI sourceComponentId is the first-class sourceComponentId field; the A2UI action.timestamp is the first-class timestamp field (no longer the old _meta carrier). MCP Apps ui/message routes to the injected-user-message path. MCP Apps ui/update-model-context rides an AgMcpAppViewMessage (method:"ui/update-model-context") carrying params:{ content?: AgBlock[]; structuredContent?: unknown } with last-write-wins, deferred-to-the-next-user-turn semantics; it is NOT a message follow-up. The three core-MCP view→host messages every MCP view shares — tools/call, resources/read, notifications/message — are HOST-MEDIATED plumbing, NOT forwardable AgJSON surface interactions: the host serves them directly and none crosses the wire as an AgMcpAppViewMessage; a view-initiated tools/call enters AgJSON only as its resulting client-originated tool-result (AgInput.kind:"tool-result", correlated by toolCallId).

Pattern 3 — form. Output hitl.ask {kind:"form", message, schema?}; resume with an AgHitlAnswer {askId, status, reply} in AgInput.kind:"resume" answers[]. The status set (resolved/declined/cancelled) preserves the MCP 3-action distinction (decline = explicit no; cancel = dismissed).

Pattern 4 — approval / HITL. See §7. The run PAUSES via turn.done.outcome = {type:"paused", asks:[…]} (no turn.error or turn.abort: the paused turn.done closes the turn, and the resuming invoke’s events open a new turn, §5.0 INV-XINV). Resume via AgInput.kind:"resume", answers[]. Approve-with-edits rides AgHitlAnswer.reply; denied is a distinct recorded outcome (tool-result.outcome:"denied"), the human reason reaches the model, and auth/url are ask kinds. Side-effecting approvals bind to a server-issued token (§11 item 4 (threat model: §13.1)).

Pattern 5 — agent↔surface RPC + shared-state. A2UI v1.0 has two independent bidirectional RPC pairs: (a) server→surface callFunction → functionResponse maps to outbound ui.call {surfaceId, callId, method, args?, wantResponse?, callableFrom?} + the inbound client→server AgA2uiFunctionResponse (a2uiMessage:"function-response", carrying functionCallId + call + value) which the host echoes back to the renderer as ui.result {surfaceId, callId, method?, value?, error?} (the normalizer copies ui.call.method into ui.result.method so the A2UI functionResponse.call echo round-trips with both functionCallId and call; callableFrom carries the called function’s execution boundary, which the pinned A2UI snapshot declares in catalog metadata and this specification carries on the call (§14); a surface’s rejection of a remote call to a clientOnly function rides ui.result.error {code, message, path?} — path carries an A2UI VALIDATION_FAILED.path JSON-Pointer); (b) client→server action (an AgA2uiSurfaceAction with {name, sourceComponentId, surface:"a2ui", a2uiMessage:"action", wantResponse?, actionId?}) → actionResponse maps to that AgA2uiSurfaceAction (input) + the output event ui.action-result {surfaceId, actionId, value?, error?} (error.path carries a VALIDATION_FAILED JSON-Pointer). A client→server surface-side error crosses as an AgA2uiError (a2uiMessage:"error") carrying exactly one of surfaceId (surface-scoped error) or functionCallId (a function-call-failure error), per A2UI v1.0’s Generic Error oneOf — plus {code, message, path?}; path (JSON-Pointer) is REQUIRED when code:"VALIDATION_FAILED". On the function-response leg, the envelope’s surfaceId is AgJSON-synthesized correlation (upstream functionResponse has no surfaceId) and is STRIPPED on reversion to the A2UI wire.

The OpenAI Apps SDK component→server RPCs cross as an AgOpenAiWidgetAction (surface:"openai-app", nested on the verbatim method): setWidgetState carries the OpenAI-only widgetState; callTool {name, args, callId} is a spec-unique surface interaction (NOT the normalized tool-call) whose reply is the output event ui.widget.result {surfaceId, callId, result} correlated by callId; sendFollowUpMessage {prompt, scrollToBottom?} routes to an injected user message (AgInput.kind:"start"); requestDisplayMode {mode, requestId} is answered by ui.display-mode with the authoritative granted mode. The callId/requestId correlation ids on the OpenAI arm are AgJSON-synthesized correlation handles, not upstream OpenAI fields. ui.display-mode handles BOTH the OpenAI requestDisplayMode and the MCP Apps ui/request-display-mode; the granted mode (granted) is authoritative (mobile may coerce pip→fullscreen), and surfaceId scopes a per-surface display request. The OpenAI widgetState (on AgOpenAiWidgetAction(method:"setWidgetState") and _meta.ui.widgetState) is OpenAI Apps SDK only (setWidgetState); it is NOT an MCP-Apps concept and NOT a fallback A2UI carrier.

View-message delivery. A client sets uiResources.viewMessageTurns: true (§3 AgClientCapabilities) to declare that it delivers the text of an MCP Apps ui/message and of an OpenAI Apps sendFollowUpMessage as the user message of a later AgInput of kind:"start": at once when no turn of that thread is in progress, and after that turn ends when one is. Such a client never cancels a turn in progress to deliver a view’s message, and it answers the view with an error, never a success, for a message it does not deliver (for example, one the user declines); a client that cannot meet this leaves the flag absent. The flag holds for the input that carries it, and an absent flag declares nothing, so an agent host SHOULD NOT depend on view-message delivery for an input that does not carry it. An agent host MAY use the flag to end a turn without waiting in-turn for a view gesture, or to tell a server it calls that such gestures arrive as turns. The flag is AgJSON’s own client capability: it does not copy MCP Apps McpUiHostCapabilities.message, which a host sends to the view in ui/initialize and which this specification does not carry (§14). A delivered message is authored by the view, not typed by the user, even though it arrives as a role:"user" message.

A2UI data-model now carries BOTH directions: server→surface PUSH via the ui.data-model event (output), client→server snapshot via the a2uiClientDataModel key on AgMessage.messageMetadata (input, sendDataModel — keyed by surfaceId, the sole A2UI data-model input carrier; re-homed here from the old AgUiAction.dataModel field, §11 item 1). The same snapshot also rides A2A Message.metadata (→ AgMessage.messageMetadata/metadata/_meta). Any opaque shared-state echo stays on AgInput.state (LangGraph values/updates ride state.snapshot/state.delta, §11 item 1). MCP-Apps model-context updates ride AgMcpAppViewMessage(method:"ui/update-model-context"); MCP Apps never used state.* on the wire. (widgetState stays strictly OpenAI-Apps — it is NOT a fallback A2UI carrier.) Only live component-internal binding/reactivity is the named Layer-B loss.

Capability negotiation is bidirectional and in-band: AgClientCapabilities (client→agent, §3) + AgCapabilities (agent→client, §3), carried on the first turn by the agent.capabilities event (§4) — an A2A AgentCard-compatible superset (streaming, pushNotifications, securitySchemes, extensions, uiCatalogs, profile).

7. HITL — hitl.ask (event) / AgHitlAnswer (resume payload) (one family)

The single hitl.* family replaces both the old elicitation.request and the old interrupt. There is exactly one request shape (the hitl.ask event) and one response shape (the AgHitlAnswer resume payload). There is no hitl.answer wire type — the answer flows on the INPUT side as AgHitlAnswer in AgInput.kind:"resume" answers[].

Request (an AgEvent):

{ type: "hitl.ask";
  askId: string;
  kind: "approval" | "form" | "text" | "choice" | "auth" | "url";
  message?: string;
  schema?: unknown;                  // for kind:"form"
  choices?: AgChoice[];           // for kind:"choice"
  grantModes?: AgGrantMode[];     // declared accept variants (any kind; grant modes below) — order = display order; absent/empty ⇒ plain accept/decline
  authConfig?: AgAuthConfig;      // for kind:"auth"
  url?: string;                      // for kind:"url" (out-of-band consent — see below)
  toolCallId?: string;               // approval/auth tied to a specific tool-call
  continuation?: "resume" | "turn";  // ADVISORY only — see below
  reason?: string;                   // free-form ask rationale that reaches the model — the authoritative round-trip label (LangGraph interrupt() string, ADK/Anthropic/OpenAI/Pydantic approval rationale, A2A rejected reason)
  metadata?: AgMeta;              // free-form ask bag — the ONLY lossless carrier for LangGraph interrupt(value: Any) (arbitrary dict)
  requestState?: string;             // MCP MRTR opaque server continuation blob; echoed byte-identical (see below)
  inputKey?: string;                 // MCP MRTR map key, for multiple concurrent input requests
  resumeBinding?: "id" | "positional";  // LangGraph: parallel→"id", in-node repeats→"positional"
  ordinal?: number;                  // encounter index within the owning node/task (positional binding)
  token?: string;                    // server anti-forgery
  expiresAt?: string }

Response (an AgHitlAnswer, carried in AgInput.kind:"resume", answers[]):

{ askId: string;
  status: "resolved" | "declined" | "cancelled";   // preserves MCP accept/decline/cancel
  reply?: unknown;                                  // kind-typed (form fields, chosen id, text, approve-with-edits)
  grantModeId?: string;                             // grant-mode echo — REQUIRED iff status:"resolved" AND the ask declared grantModes; MUST echo a declared id; NEVER on declined/cancelled (grant modes below)
  reason?: string;
  ordinal?: number;                                 // for positional resume binding (LangGraph in-node)
  token?: string;
  requestState?: string }                           // MUST echo the originating ask's requestState byte-identical

Grant modes (draft.2) — scope-qualified consent, asker-declared. Real permission systems answer “yes” with a scope: the Claude Agent SDK’s permission flow declares permission_suggestions: PermissionUpdate[] whose destination enumerates persistence scopes ('session' | 'userSettings' | 'localSettings' | …) and echoes the chosen update back; the OpenAI Agents SDK’s approve(item, {alwaysApprove}) / reject(item, {alwaysReject}) is a fixed once/always pair; browser and mobile-OS permission prompts are the same shape (allow / allow-once / deny). grantModes carries that pattern neutrally — the asker declares the accept variants it offers, ids are asker-opaque, and AgJSON never defines a mode vocabulary of its own (the same posture as tool schemas).

The pause is the turn outcome. A within-run pause is recorded as turn.done.outcome = { type: "paused", asks: [...] } (the old "interrupt"/"await" outcomes are gone). The deleted AgElicitAction (accept|decline|cancel) and interrupt.reason are folded into kind/status/reason: the three MCP actions become the three status values; the old interrupt reasons become kind values; a free-form ask rationale (e.g. "upload_required", "policy_hold") is the authoritative reason round-trip label (kind stays a coarse classifier), and an arbitrary LangGraph interrupt(value: Any) dict rides metadata.

reason and metadata carriers (re-justification). reason is the human/agent-facing rationale that reaches the model on any ask: the LangGraph interrupt() string case; the ADK requested_auth_configs auth-pause rationale (alongside AgAuthConfig’s optional credential-exchange fields); the Anthropic Claude Agent SDK canUseTool / OpenAI Agents-SDK approval / Pydantic requires_approval approve-deny rationale; and the A2A task-level rejected reason. metadata is the only lossless outbound carrier for LangGraph interrupt(value: Any) — interrupt() surfaces an ARBITRARY JSON-serializable value (the pervasive HITL pattern is interrupt({question, tool_call, ...custom keys})); the string case maps to reason, a form schema to schema, a choice list to choices, but an arbitrary dict has no lossless home except this free-form bag. AgHitlAnswer.reply handles only the RETURN leg.

kind:"url" (consent-only). URL-mode elicitation ({kind:"url", url, message?}) directs the user out-of-band; data does NOT pass through the client. A status:"resolved" here means consent-to-open only, NOT that the interaction completed — completion is carried later by re-presenting requestState (ties to MRTR). auth stays the OAuth-specialized subtype; url covers generic out-of-band consent (API-key entry, payment).

MCP MRTR continuation. MCP revision 2026-07-28 (§14 [MCP]; SEP-2322) models server-initiated requests as Multi Round-Trip Requests: a paused tool call returns resultType:"input_required" with inputRequests (keyed map) and/or a requestState (at least one is present); a requestState, when present, the client MUST echo byte-identical and MUST NOT inspect. AgJSON carries requestState (and the inputKey map key) on hitl.ask and back on AgHitlAnswer; resume is a fresh AgInput carrying the echoed requestState. requestState is DISTINCT from token (anti-forgery).

inputKey (singular) vs inputKeys (plural) — deliberately distinct (§0.6). These name two different things and are NOT a one-word-per-concept violation: inputKey (singular, on hitl.ask / AgPausedAsk) is the selected map key — the single inputRequests entry this particular ask resolves, echoed in its answer. inputKeys (plural array, inside pendingInput on the tool-result block / tool-result input / tool.done) is the set of still-pending request keys advertised on a paused outcome:"input_required" result. One selected key per ask vs the full pending-key set on the result — distinct concepts, distinct words.

Resume binding (LangGraph). LangGraph has two resume-addressing modes. Parallel interrupts resume via an id-keyed map (resumeBinding:"id", the real interrupt id used as askId). Repeated interrupts within one node resume via a strict positional list in encounter order — and because the v1 in-node interrupt id is deterministic from the task namespace, all repeats share the same id, so AgJSON sets resumeBinding:"positional" + ordinal=0,1,2… and reconstructs LangGraph’s positional resume list by sorting answers[] on ordinal. The resume payload SHAPE (scalar vs id-keyed map) is selected by the binding (§3.1), not by answers.length.

A2A TaskState mapping. A2A has 9 TaskStates. completed/failed/canceled/input-required/auth-required map to outcomes/asks; task-level rejected (the agent refuses the task) maps to AgOutcome {type:"rejected"} — DISTINCT from the tool-call rejected finishReason. States with no clean outcome target (submitted, rejected-at-rest, auth-required-at-rest, unknown) ride the verbatim AgTurnRecord.taskState / turn.done.taskState.

continuation is advisory, never enforced. It RECORDS the source runtime’s model of how the answer flows back:

Per the CORE PRINCIPLE (§0), AgJSON is a neutral translation target: it records the source runtime’s continuation model but never imposes it. A client MAY satisfy any ask by either path.

AgSurfaceInteraction (§6) is a separate axis (a client→server surface interaction, not a pause) and is kept; it is not part of the HITL family.

8. Mandatory stateful normalization points

8.0 The Normalizer contract (normative)

A Normalizer is a stateful object with exactly two operations:

Lifetime. A Normalizer instance lives for exactly one invoke (one framework run/stream). All its state is within-invoke: de-cumulation buffers, open-entity maps, seq/id counters. Cross-turn state (session memory, prior messages) belongs to the reducer fold, never the Normalizer. seq is stamped by the engine per INV-SEQ (§5.0) — facets never mint seq.

Ids across invokes. A host may fold several invokes of one thread into one reduce() (INV-SEQ), so the turn ids and message ids a Normalizer mints MUST NOT repeat an id that an earlier invoke of the thread could have produced, and a Normalizer MUST NOT emit a message.start, or a block event, naming a turn after that turn’s terminal, or after a messages.snapshot it emitted carries that turn with an outcome (INV-MSG): framework content that arrives after a terminal defers the close, as §8.0 items 14 and 26 do, or opens a new turn; a result for a call of a closed turn instead rides an ext.<vendor>.* carry (§12), which folds nothing. A Normalizer satisfies the first rule with within-invoke state alone (Lifetime), by deriving each turn and message id from this invoke’s own native ids, from a host-supplied invoke id the host keeps unique per invoke, or from a nonce; a per-instance counter, a session, thread or resumed-invocation id never satisfies it, and an id derived from a toolCallId satisfies it only while the framework keeps toolCallIds unique across the thread.

Partition root. The threadId a Normalizer stamps on every turn.start, message.start and artifact.start of an invoke is the thread the host assigned that invoke to, supplied at construction (the reference facets take it as the threadId option). A Normalizer constructed with a thread MUST stamp that value on every such event of the invoke, nested turns included, and MUST NOT place it in any turnId or parentTurnId; it MUST NOT substitute a framework session id, a runtime checkpoint handle (§3, langgraph/threadId) or a fixed label for a thread the host supplied. A Normalizer constructed with none stamps a facet-local placeholder (for example a fixed label, or the framework’s own session id) that is not a partition root, and its documentation MUST say so.

Synthesis & owner backfill. For frameworks without native lifecycle events, the Normalizer synthesizes turn.start before the first event whose owner is an unopened turn, record events included (INV-TURN). A native signal that precedes any model response, such as a blocking input guardrail’s result or a capability handshake, is emitted inside a turn the Normalizer opens for it, and that turn closes with exactly one terminal that records the framework’s status (INV-TURN) and backfills every event’s owning turn per INV-OWNER (explicit turnId → owning message via messageId → last-opened turn, restored to the parent at the subagent turn’s subagent.done).

Graceful degradation (normative). A conformant Normalizer MUST NOT throw out of push() and MUST NOT silently drop a native event it cannot map: it emits best-effort canonical events plus either a typed ext.<vendor>.unparsed event carrying the raw payload, or the non-terminal error event. Unmappable ≠ ignorable — degradation is always visible on the wire. A native VALUE with no AgJSON target MUST NOT be dropped silently either: the normalizer emits the field’s fallback ("other" or "unknown" for AgFinishReason; a provider-raw block for an unmappable part) and carries the verbatim native value in the field’s companion — for a finish reason, turn.done.finishReasonRaw.

Native-selected prototypes. A normalizer is an emitter under §13.7: it MUST NOT let a native value select the prototype or class of any object it emits (every object it emits has the ordinary object or array prototype; a native that arrives with a null or foreign prototype is copied by its own members or rebuilt as a plain object, by the normalizer or by the engine helper it emits through), and it MUST NOT emit an own __proto__ member at any depth of any event member, nor an RFC-6902 operation whose path or from holds that reference token. It omits that unit and carries the members beside it unchanged; an array patch left with no operation is emitted as an empty patch, as item 28 directs; a string member is not searched. This omission takes precedence over the graceful-degradation rule and over every verbatim or byte-identical carry this section otherwise requires, and §13.10’s authorship exception does not apply to it.

Host-binding obligations. The host that owns the framework process has seven duties this spec depends on:

  1. Thrown terminals. A framework error that never reaches the stream (e.g. a max-turns exception thrown by the runtime) MUST be caught by the host and fed to the Normalizer as a host-terminal native event that the facet maps to turn.error (with the matching code, e.g. max_turns). The sentinel’s shape is facet-local (the reference OpenAI facet uses a __host_error__-typed native event); it is a host↔facet contract, never a wire-normative AgJSON type. When the event arrives while an ask whose pause was signalled is pending (§8.0 item 26) on a turn whose native stream signalled no interruption (§8.0 item 17), or while a round’s close is deferred (§8.0 item 14), the facet first releases that turn’s paused or deferred close and then closes the error with turn.error on a new top-level turn; when it arrives on an ADK turn whose invocation met an in-band invocation error and whose pause was not signalled (§8.0 item 35), the facet closes that turn with one turn.error carrying this event’s code and message. Usage a host-terminal event reports covers only work its stream did not report.
  2. Cross-invoke ordering. If a host needs a global ordinal across invokes (persistence, multiplexing), IT re-stamps or offsets seq outside the Normalizer (INV-SEQ). The Normalizer/reducer layer never sees a cross-invoke ordinal.
  3. One instance per invoke. Hosts construct a fresh Normalizer per invoke and MUST call flush() exactly once at stream end, delivering its events to the same consumers as push() output.
  4. Clean completion. Where a framework’s stream carries no in-band run terminal, a host SHOULD feed the Normalizer a host-completion native event after the framework run returns normally — never after a cancel, an abort signal or a thrown error — and before calling flush(). The facet then closes each still-open top-level turn from push(): turn.done with outcome:"paused" when an ask is pending, outcome:"success" when no ask and no long-running call is pending (a long-running call is pending for a turn while a functionCall part of a non-partial event of that turn, whose id that event lists in longRunningToolIds, has no functionResponse in that turn), and otherwise leaves the turn to flush(), except that a turn whose invocation met an ADK invocation error closes as §8.0 items 26 and 35 direct, never as success. The sentinel’s shape is facet-local, as in obligation 1; a host that does not feed it gets the INV-FLUSH close for each turn still open at flush().
  5. Host-appended events. A host MAY deliver AgJSON events of its own after the Normalizer’s flush() output in the same invoke (for example a paused hitl.ask turn). It MUST number them from the last seq the Normalizer emitted plus 1, gap-free, so they stay inside the invoke’s ordinal (INV-SEQ); the flush() output is then not the last event of the invoke. AgJSON defines no in-band end-of-invoke event: a clean transport close does not show that an invoke’s last events arrived, so detecting the end of an invoke belongs to the host transport, as reconnect and resume do (INV-SEQ). In an invoke that uses obligations 4 and 5 together, the order is: the framework’s native events, the completion event of obligation 4, flush(), then the host-appended events. A host that re-sends, in a later invoke of the thread, a paused turn it appended in an earlier one sends it as INV-MSG’s paused refresh: a turn.start, its hitl.ask events and a turn.done with outcome.type "paused", under the same turnId. A host MUST NOT send any other terminal for a turn that is already closed.
  6. Partition root. A host that persists, routes or folds across invokes by the wire threadId MUST supply, at Normalizer construction, the thread it has assigned the invoke to — the identity it persists under; for an invoke started by an AgInput, that envelope’s threadId (§3) as the host resolved it. A host that does none of these MAY construct without one, and then MUST NOT treat the stamped placeholder as a thread identity.
  7. Memory scopes. A host that persists a fold declares the non-thread memory scopes it persists (§13.11): in its documentation, to the projection it stores (the reference SDK’s toPersistable and toPersistableWithReport take them as memoryScopes; only the latter returns the omission report), and — where it emits agent.capabilities — as AgCapabilities.memoryScopes. It MUST persist every record of a declared scope and every scope thread record; its projection omits the rest and reports each omission to its caller. An absent declaration declares no non-thread scope.

A normalizer is not a pure per-event function. Implementations MUST handle (and conformance SHOULD fixture) all of:

Applicability scoping. Each point below binds an implementation when, and only when, it targets the framework the point names (a Claude-only normalizer is not non-conformant for skipping the Gemini points); Point 1 binds all implementations; point 5 binds every implementation whose native stream keys streamed content by a positional index rather than a stable id (the reference Anthropic normalizer is one, and re-keys such blocks to minted ids); point 10 binds every implementation whose framework may deliver a tool call’s name or arguments before its stable id (Pydantic AI is an example; the reference SDK ships no normalizer for it), and a normalizer whose framework keys every tool-call fragment by a stable id records it N/A. The same per-framework scoping governs the §10 fixture list.

  1. Partial-JSON accumulation per toolCallId → tool.args.assembled.

  2. Index→id synthesis (Anthropic deltas carry index, not id). Gemini functionCall.id may be null on the Developer API → synthesize a stable toolCallId AND record the original parallel-call positional index (on providerCallIndex / _meta) so re-input can restore name+position correlation when echoing functionResponse parts. (Informative: the reference ADK normalizer sets tool.start.index, on each tool.start it emits for a function-call part, to the zero-based position of that part in the native Content.parts, and adds providerMetadata.google.providerCallIndex with that value only when the call’s id is absent, null or empty; other normalizers may omit index.)

  3. ADK aggregate-event suppression (drop partial:false aggregate — the #1 double-render quirk).

  4. Usage flagging and per-turn summation (an Anthropic message stream’s message_delta usage and the Claude Agent SDK’s modelUsage are cumulative; its result usage is per-turn): flag cumulative: true on an AgUsage whose token counters are the provider’s running totals and preserve them verbatim; flag cumulative: false (or omit it) on a per-step object, a turn terminal whose counters cover only that turn included. A normalizer whose framework reports usage per model call MAY sum one turn’s non-partial reports field-wise into that turn’s per-step object (ADK usageMetadata). An event that item 35 names as repeating an in-band invocation error is counted once with the event it repeats; a partial report that item 35 takes for a repeated error counts toward that sum. Usage is NOT de-cumulated by the normalizer, the engine, or reduce() (INV-DELTA, §5.0); per-step deltas of a running total are a consumer derivation (subtract adjacent snapshots). A normalizer whose framework documents a counter as a placeholder for a value it reports elsewhere (the Claude Agent SDK’s assistant-message output_tokens without partial messages) MUST omit that counter rather than carry it as a running total. Cumulative content streams (per-id text/args) ARE de-cumulated — a normalizer duty the engine discharges (INV-DELTA, §5.0).

  5. Index-keyed → id re-key (LangChain/Pydantic delta streams).

  6. LangGraph positional intra-node pause matching (repeated interrupt() in one node is positional, not id-keyed → synthesize stable askIds with resumeBinding:"positional"+ordinal).

  7. (Binding only for implementations that EMIT AgMessage.content back to Gemini contents[] — re-input guidance; ingest-only normalizers are exempt.) Gemini parallel function-call serialization — when emitting AgMessage.content to Gemini contents[], group ALL tool-call blocks of a step (signature on the first in emission order) AHEAD of ALL tool-result blocks; never interleave call/result pairs (FC1+sig, FC2, FR1, FR2, not FC1, FR1, FC2, FR2, else 400).

  8. Gemini thoughtSignature on every signed part — preserve the signature on functionCall, thought, AND built-in-tool steps (google_search_call/result), not only the first functionCall; a thinking-only or grounded turn loses its signature otherwise and 400s on turn N+1.

  9. Signature assembly — assemble a reasoning signature that arrives in fragments (via reasoning.opaque.delta) or as a replacing value into the single REPLACE reasoning.opaque; the assembled signature MUST be byte-identical. (Pydantic AI’s ThinkingPartDelta.signature_delta replaces the part’s signature rather than accumulating; a normalizer for it emits the replacing value once.)

  10. Tool-call identity assembly — buffer index-keyed tool-call fragments whose name or arguments may arrive before a stable id (Pydantic AI’s ToolCallPartDelta carries tool_name_delta and args_delta as fragments and tool_call_id whole-or-absent) per index; emit tool.start only once a stable toolCallId is available (synthesize a temporary index-keyed id if args arrive first, then rewrite to the final toolCallId on assembly, rewriting any buffered tool.args.delta keys). Binds a normalizer whose framework delivers tool-call identity after its fragments; the reference SDK ships none.

  11. A2A initial-Task snapshot decomposition — fold the first Task’s history[] via messages.snapshot and seed artifacts[] via the artifact side-channel ONCE; subsequent TaskArtifactUpdateEvents with append:false REPLACE the same artifactId (no double-seed). Structurally identical to the ADK aggregate hazard. Binds a normalizer whose framework opens a stream with a Task or snapshot that also seeds artifacts (A2A); the reference SDK ships none, and the no-double-seed clause is exercised at reducer level by §10 item 16.

  12. ADK requested_auth_configs — a MAP (functionCallId → AuthConfig) normalizes to a SET of hitl.ask {kind:"auth", toolCallId, authConfig} (one per entry) folded into turn.done.outcome="paused".asks[] (the pausing agent’s step ends on that response — the invocation need not — and the AgJSON turn closes paused as item 26 directs). When a framework AuthConfig exceeds the flat AgAuthConfig, the surplus rides hitl.ask.metadata (sanctioned carrier — the ask stays lossless apart from the credential material item 28 omits).

  13. Anthropic absent-citations shape — the Anthropic wire OMITS citations on uncited text (undefined, never null); a normalizer MUST treat absent and null citations identically (no citations), and MUST NOT crash or emit a citations carrier for the absent case.

  14. OpenAI deferred round-close — the OpenAI Agents SDK wire delivers a round’s tool_output run-item AFTER response.completed; a normalizer MUST defer that round’s message.end + turn.done until the round’s pending tool results have landed, until the round’s handoff releases the close as below, or until stream end, releasing the stashed close at flush per INV-FLUSH (2), never as a success turn.done, so a tool result never targets a sealed message or closed turn (INV-MSG, §5.0). The same wire delivers a built-in call’s tool_called or tool_search_called run-item (item 20) after response.completed, so a round whose terminal response.output lists a built-in call is deferred under this item until that call’s run-item and, where the call has a paired result, that result have landed; a pending mcp_approval_request call (item 20) is a pending call of the round, so INV-FLUSH (2) releases the close as paused naming its ask. If the next model response of the invoke starts while the round still waits only on listed calls for which no run-item has streamed, the normalizer MUST release the close before that response’s turn.start, and those calls stop being pending results of the round. A run-item is streamed for a call when a run_item_stream_event delivers an item whose rawItem carries that call’s callId or, for a call that has none, that call’s item id, whatever the run-item’s name. When a handoff_occurred run-item lands the result of a call of a round whose close is deferred, each other call of that round that is still without a result, is a function_call whose tool.start this invoke emitted, and has had no run-item streamed for it stops being a pending result of that round, and the normalizer MUST NOT emit a tool.done for it later in the invoke. If no call of the round is then still pending, the normalizer MUST release the close in the same push(), after the tool.done of the call whose result handoff_occurred carries, as a message.end followed by a turn.done carrying the deferred outcome, finishReason and usage; otherwise the close stays deferred as above and is released when the round’s remaining results land, by a host-fed error, or at flush per INV-FLUSH (2). A pending call of any other kind, and a function_call that has had a run-item streamed for it, stays pending. A call that stops being pending this way keeps a tool-call block with no tool-result in the fold, as a call pending at flush does (INV-FLUSH (3)). In the default configuration @openai/agents-core 0.18.0 streams a tool_called run-item for each function tool call it accepts before any tool runs, runs a handoff only after every function tool of its step has returned and never while an approval is pending, and streams no run-item for a transfer call it ignores. A handoff input filter can remove handoff_occurred or a tool result from the stream, or move a tool result after handoff_occurred, and the release then follows what the stream shows. A host-fed error (host-binding obligation 1) that arrives while a round’s close is deferred releases that close per INV-FLUSH (2) before the normalizer closes the error.

  15. Claude post-seal tool results (adoption) — the Claude SDK closes the assistant message before its tool_result arrives; the normalizer emits tool.done.messageId so the result lands as a dedicated tool message via the §5 adoption row. The reference derives messageId = "<toolCallId>:result"; permission denials ride a dedicated carrier message ("<turnId>:denials") opened and sealed BEFORE the turn closes.

  16. Claude subagent result routing — inner tool results (parent_tool_use_id set) route to the SUBAGENT’s turn: the normalizer maps each spawning tool-call id to the subagent turn it opened. The subagent.start.parentTurnId it emits MAY be a spawning-tool-call cross-ref label ("turn_<toolCallId>") rather than an opened turn’s id; a reducer MUST tolerate an unopened parentTurnId (the subagent turn’s threadId falls back to the fold’s root threadId, §1.2) and MUST NOT fabricate a turn record for the label.

  17. Interruption terminals are never success — a normalizer whose native stream signals interruption (e.g. ADK interrupted) closes that turn with turn.abort exactly once and MUST NOT later emit a success turn.done for it; a normalizer targeting ADK MUST set that turn.abort’s reason to "interrupted", and, since turn.abort carries no usage, MAY carry the turn’s accumulated usage on the message.end that precedes that turn.abort, whether the turn closes from push() or from flush(); stream truncation without any terminal is closed at flush per INV-FLUSH (§5.0).

  18. ADK requested_tool_confirmations — the sibling MAP (functionCallId → confirmation) normalizes identically: one hitl.ask {kind:"approval", toolCallId, …} per entry, folded into the paused close’s asks[].

  19. Claude refusal-fallback retraction — the Claude Agent SDK’s model-refusal-fallback protocol (SDK ≥0.3.x: SDKAssistantMessage.supersedes + the system message SDKModelRefusalFallbackMessage.retracted_message_uuids) names previously-delivered messages by their wire-frame uuid — a DIFFERENT id space from the messageIds a normalizer actually emits (m.id for an assistant frame, "<toolCallId>:result" for an adopted tool-result frame per item 15). A normalizer MUST track uuid → the messageId(s) it produced and emit message.remove for each retracted uuid it can resolve, translated through that mapping (an unresolvable uuid is a no-op, never fabricated). Both retraction sources — supersedes (evict “on arrival”) and the end-of-turn notice (the authoritative per-turn audit record) — MAY be processed; message.remove on an already-removed or unknown id is idempotent. supersedes’s raw uuid list MAY additionally ride as host-only _meta (or message.metadata) on the superseding message for audit purposes. Usage caution: the turn terminal’s usage, its per-turn counters and the running totals it carries alike (item 4), is NOT adjusted for the retraction — the SDK’s own result.usage/modelUsage already reflects whatever billing occurred server-side; a normalizer MUST NOT invent usage subtraction for the refused leg.

  20. OpenAI built-in tool lifecycle (Shell / Apply-Patch / Computer-Use / Hosted-tool) — @openai/agents (observed at 0.12.0; introducing minor between 0.2.1 and 0.12.0 not pinned down) native Shell/Apply-Patch/Computer-Use/Hosted-tool calls reuse the EXISTING tool_called/tool_output run-item names with NEW rawItem discriminants (shell_call, apply_patch_call, computer_call, hosted_tool_call, …) that carry no name field (except hosted_tool_call, which has one) and whose output shape is NOT the function_call_result shape — a normalizer MUST NOT route them through the generic function_call tool-result mapping (shape-compatible field names silently produce empty content, the orphan-tool.done hazard). A normalizer MUST recognize these discriminants, synthesize a name where the wire omits one (e.g. "builtin:shell", "builtin:computer"), and drive a full tool.start/tool.args.*/tool.done lifecycle for each such call, whose events, within the invoke whose model round produced the call, name that round’s turn and never a turn opened for a later run-item of that invoke (the reference SDK drives the lifecycle from the run-item wrapper: hosted_tool_call is @openai/agents’ own umbrella discriminant, which no native Responses stream event carries, although that stream carries each wrapped item under its own type and the SDK’s response_done event carries the converted items); a result that a later invoke delivers follows INV-XINV (§5.0). A hosted_tool_call that the server has resolved has no paired tool_output: its tool.start and one tool.done fire from its tool_called run-item, the item’s native input and output members are read from the item or its providerData, an mcp_call’s arguments, parsed as JSON, land as tool.args.assembled, its server_label in tool.start.serverName and the MCP tool’s name (providerData.name) as its tool.start name, and the tool.done outcome is "error" when the native item’s status (for mcp_call, providerData.status) is failed or incomplete or the native item carries a non-null error (for mcp_call, providerData.error). An mcp_approval_request (a hosted_tool_call whose providerData.type is mcp_approval_request) is not resolved: a normalizer MUST emit its tool.start with toolCallId equal to the request’s id, name equal to the MCP tool’s name and serverName equal to its server_label, with the request’s arguments, parsed as JSON, as tool.args.assembled (a string that does not parse degrades as §8.0 ‘Graceful degradation’ directs), and MUST leave that call pending; for its tool_approval_requested run-item the normalizer MUST emit hitl.ask {kind:"approval", askId:"approval_<request id>", toolCallId:<request id>}. The call is closed by exactly one tool.done when the mcp_approval_response run-item (a hosted_tool_call whose name or providerData.type is mcp_approval_response) whose providerData.approval_request_id names it lands — outcome "ok" when its providerData.approve is true, which records the approval and not the result of the call it gates, and "denied" when it is not — in the same invoke, or in the resuming invoke per INV-XINV; that run-item creates no block of its own, and the mcp_call it gates is a separate call under its own id. computer_call carries BOTH action (single action) and actions (batch array); the SDK’s own runtime reads actions FIRST (if populated), falling back to action — a normalizer MUST mirror this precedence (actions ?? action ?? {}) to avoid silent loss of a batch-form call. computer_call_result‘s output is {type:"computer_screenshot", data} where data is a data:image/png;base64,… URL (the SDK mirrors the Responses API image_url slot; a refused call carries the SDK’s constant 1×1 placeholder, not a capture) — a normalizer MUST land it as an AgBlock file block in tool.done.content whose source is {type:"base64", mediaType:<the media type the data:header names>, data:<the payload after;base64,>} (AgSource’s base64 arm holds the base64 text alone), landing a value that carries no data: header verbatim as data with mediaType "image/png", rather than attempting the generic text-content path (same orphan-tool.done hazard). A computer_call_result whose output.providerData.approvalStatus is the string "rejected" (the @openai/agents rejection result, observed at 0.18.0 through 0.20.0 on both the declined-approval and the onSafetyCheck:false routes; it carries no executionStatus) MUST close tool.done {outcome:"denied"} with no errorText, no isError and no errorCode (§6 Pattern 4; it is the structural non-execution signal §5.0 INV-XINV names), its content a text block carrying output.providerData.message verbatim when that member is a non-empty string, followed by the file block above; every other computer_call_result closes "ok". The reference carries the run-item’s executionStatus, output.providerData.approvalStatus and output.providerData.message, and the item’s providerData.acknowledgedSafetyChecks, verbatim on that tool.done.providerMetadata. The SDK’s separately synthesized assistant message of the same text is its own native item and keeps its own carrier (the reference: ext.openai.late-message, live-only); the tool.done text block is the folded record. An absent executionStatus is not by itself a non-execution signal: the reference’s other approval rejections keep their own closes (a function-tool rejection → "ok"; a shell rejection’s exitCode:null → "error" and an apply_patch rejection’s status:"failed" → "error"). Tool-search (tool_search_call/tool_search_output, observed at 0.12.0) is a related but DISTINCT wire shape within this same family: UNLIKE the four discriminants above, it does NOT reuse tool_called/tool_output — it rides its OWN dedicated tool_search_called/tool_search_output_created run-item event names, streamed as a PAIRED call+output (mirroring shell/apply-patch/computer’s pairing, not hosted-tool’s single-shot collapse). A normalizer MUST still synthesize a name ("builtin:tool_search", the wire carries none) and drive the same full lifecycle. Its correlation id is WEAKER than every other builtin’s: call_id/callId are BOTH optional and nullable (vs. the others’ required callId), and the SDK’s own runtime resolves the pairing through a fallback chain (call_id/callId → a providerData.call_id/providerData.callId channel used by the SDK’s built-in client-executed tool-search loader → the item’s own id → a last-resort positional match) — a normalizer MUST mirror at least the id-resolvable legs of that chain and MUST NOT fabricate a correlation id for a call/output with none resolvable (degrade losslessly instead, e.g. a vendor-extension carry). The output’s tools array is a structured retrieval LISTING (tool references/definitions), never natural-language text — a normalizer MUST land it as a structured (non-text) tool.done.content block rather than inventing a text rendering, and MUST preserve a zero-match (empty-array) result rather than treating it as absent content. No error discriminant exists on this wire arm — outcome:"ok"-always.

  21. Claude informational notices (notice message, draft.2) — the Claude Agent SDK’s SDKInformationalMessage (type:"system", subtype:"informational") carries genuinely conversation/UX-relevant content/level/prevent_continuation?/tool_use_id? (transcript notices, an explanation for why a turn halted — e.g. a Stop-hook denial). Under draft.1 this rode the lossless-carry fallback ext.anthropic.informational{content, level, preventContinuation} (live-only) with a notice core construct deferred as a future spec-process decision; draft.2 RESOLVES that deferral (spec-process record: typescript-sdk#16 — a second independent producer, a reference host’s adapter system rows, arrived at the same gap). A normalizer MUST now emit a first-class notice message — message.start {role:"notice", noticeSource:"framework"} + one text block carrying content verbatim + message.end — with the wrapper siblings riding the first block’s host-only _meta (level, preventContinuation, toolUseId; wire names camelCased per the facet’s established carry convention; draft.2 through draft.4 placed them in providerMetadata, which §12 reserves for values that round-trip to the provider; wire names camelCased per the facet’s established carry convention; note tool_use_id was NOT carried by the draft.1 ext route — the promotion is strictly more lossless). The ext.anthropic.informational carry is RETIRED (superseded by the first-class home, not layered alongside it — one carrier per concept, §0.6). The frame passes the §3 notice admission test (persisted: it is transcript-relevant per its own doc; user-facing: it is a render banner; non-conversational: it is never model input); ephemeral status/progress frames continue to FAIL that test and stay in the ext.anthropic.frame bulk carry (item 22).

  22. Vendor-frame bulk carry (ext.<vendor>.frame) — when a wire surface exposes MANY distinct native frame kinds that each carry genuine consumer-facing content but share NO existing AgJSON vocabulary home (e.g. the Claude Agent SDK’s hook stdout/stderr, slash-command output, OAuth-flow instructions, toast notifications, file-persistence receipts, tool-use-summary prose, recalled-memory body text, a no-fallback refusal’s own diagnostic fields, suggested-prompt text, mirror-sync errors, and the Task-tool background-task-progress family), a normalizer MUST NOT mint one distinct ext.<vendor>.<key> per frame kind (ext-vocabulary sprawl) NOR silently drop them. It MUST carry each losslessly under ONE uniform key, ext.<vendor>.frame{kind, frame}: kind is the frame’s own discriminating subtype/type string (e.g. Claude’s msg.subtype or msg.type), and frame is the VERBATIM native message (no field-by-field reinterpretation — the whole frame rides opaquely, live-only per §12). A frame that DOES map onto existing AgJSON vocabulary (e.g. Claude’s standalone permission_denied notice enriching the already-handled permission_denials[] aggregate’s tool.done, §8 item 15) is wired to that existing home instead and is NOT swept into this bulk carry — this item covers only the residual arms with no existing home, studied and rejected as candidates for one (e.g. a Task-tool background-task frame’s content is NOT folded into subagent.start/subagent.done when doing so would duplicate what the nested subagent’s own tool-call stream already conveys — the double-fold hazard — while its status may still close a correlated run as item 29 requires; or when the correlating id is optional/absent on some instances of the frame). A frame that maps onto existing AgJSON vocabulary only in part is wired to that home for the mapped part; when its remaining consumer-facing fields have no other named carrier (for example providerMetadata, a provider-raw block, or a dedicated ext.<vendor>.<key>), the frame additionally rides this bulk carry whole and verbatim, after the home event(s) it produced. The home event is authoritative for every field it maps: the verbatim frame is an audit copy, and a consumer that reads a value from it MUST NOT prefer that value over the home or add it to the home’s usage or cost.

  23. ADK unmapped Part/Event fields (provider-raw carry) — Gemini Part fields with no dedicated AgJSON handling (mediaResolution, videoMetadata, toolCall, toolResponse, partMetadata — videoMetadata normally rides ALONGSIDE an already-handled inlineData/fileData part, per genai’s own doc, so the check MUST run unconditionally rather than only as an else-fallback) and the OPTIONAL ADK Event/LlmResponse fields candidateIndex/branch MUST NOT be silently dropped: a normalizer carries them via the existing provider-raw content block (mirroring the ADK facet’s own pre-existing actions/citationMetadata/customMetadata unmapped-field carry, less the omissions and reductions of items 28, 36 and 38 and §13.10) rather than discarding them when no dedicated AgJSON route exists (fixture-drift ratchet finding). This item does NOT extend to Event’s REQUIRED (non-optional) fields author/timestamp: both are present on every native event, so folding them into the same generic carry would emit a provider-raw block on every single event rather than an occasional one — a normalizer MAY leave them disposed silently-dropped pending a dedicated, non-noisy home (a future spec-process decision) instead.

  24. Reasoning-inclusive outputTokens (draft.3) — outputTokens includes reasoning tokens (§4). Where the provider’s output counter EXCLUDES reasoning and reports it as a sibling (Gemini thoughtsTokenCount), a normalizer MUST fold the sibling into outputTokens, guarded by the provider’s own total (prompt + candidates + toolUsePrompt == total with thoughts present ⇒ already inclusive, carry as-is). Where the provider’s output counter already includes reasoning (Anthropic output_tokens, OpenAI output_tokens, Vercel outputTokens), it is carried verbatim and the detail counter lands on reasoningTokens. A normalizer never subtracts reasoning out of outputTokens (item 19 posture) and never computes totalTokens, except over the Gemini Live API: there the output counter (responseTokenCount) excludes reasoning, the sibling always folds and the guard does not apply, and, where Live usage carries a reported total, a normalizer emits totalTokens = inputTokens + outputTokens + (toolUseInputTokens ?? 0), carrying a differing provider total verbatim in totalTokensRaw (§4). A normalizer likewise never subtracts a cache counter out of inputTokens; where its provider’s input counter excludes cache tokens it adds them in (§4 input side).

  25. ADK tool-failure envelope (draft.4). @google/adk (observed at 2.1.0) answers a failed tool call with a functionResponse whose response object carries an error member. Gemini’s FunctionResponse contract names this key for error details. ADK does this for an unresolvable tool name, for a thrown tool (including a thrown MCP call), and for several built-in tools.

    A normalizer MUST map a functionResponse.response that is a JSON object whose error member is present and not null, false, 0 or "" to tool.done with outcome:"error" and isError:true. It MUST keep response verbatim as the content data block. For a response this item maps to "error" that does not carry isError:true (the isError sentence below), it MUST set errorText to the error value when that value is a string and, when the response carries a string error_code or errorCode sibling, MUST set errorCode from the string error_code, else from the string errorCode.

    Two exceptions apply: (a) When the same event’s actions.requestedToolConfirmations or actions.requestedAuthConfigs names the call’s id, the response is ADK’s pause placeholder, not a result. The normalizer MUST NOT map it to "error" unless the response itself carries isError:true; such a response maps as the isError sentence below directs. The pause is carried by the hitl.ask of items 12 and 18. (Informative: the reference closes the placeholder’s call tool.done{outcome:"ok"} with the response verbatim in content and no isError, unless the response itself carries isError:true.) (b) A declined approval that does not carry isError:true and is not the pause placeholder of (a) MUST map to outcome:"denied" (§6 Pattern 4) with no errorText, no isError and no errorCode. A response is a declined approval when its error value is one of ADK’s rejection literals ("This tool call is rejected.", "Tool call rejected from confirmation flow.", or a string beginning "This tool call is rejected by policy engine."), or when its string error_code, else its string errorCode, is "CONFIRMATION_REJECTED". A normalizer MAY additionally identify a declined approval structurally, as a response for a call whose earlier approval ask was answered with confirmed:false.

    A response that carries isError:true, an MCP CallToolResult among them, MUST map to outcome:"error" with isError:true and no errorText or errorCode, whatever its other members. Failure signalled by other shapes, such as {status:"error", …} or a ReflectAndRetry plugin record, is out of scope and stays "ok". A tool that throws with an empty message reaches the wire as a success-shaped response and cannot be recovered. This item classifies no other framework’s result payload by shape.

  26. ADK pause family (draft.4) — an ADK pause is a functionCall part named adk_request_input, adk_request_credential or adk_request_confirmation (ADK’s own getUserInputRequests classification). A normalizer MUST emit one hitl.ask per pending request: adk_request_input → kind:"form" when the call’s response_schema argument is a non-null object, else kind:"text", with message from its message argument, schema from response_schema and its payload argument in metadata; adk_request_credential → kind:"auth"; adk_request_confirmation → kind:"approval". A request that items 12 or 18 also surface yields exactly one ask, and its toolCallId is the id of the adk_request_* call, the id ADK accepts as the answer. An ask is pending for a turn while the adk_request_* call that is its toolCallId has no functionResponse part in a non-partial event of the invocation; a functionResponse that answers that call within the invocation — ADK’s client-abort seal (item 35) answers every unanswered call, a pending ask’s own call included — ends the ask, and a turn that holds no pending ask does not close paused. While an ask is pending, a normalizer MUST close the invocation’s turn with turn.done {outcome:{type:"paused", asks}, finishReason:"paused"} as the last event of that turn in the invocation, after every event the invocation emits into that turn, unless the invocation met an invocation error (§8.0 item 35) and the turn’s pause was not signalled by the invocation’s end, or the turn’s native stream signalled an interruption (§8.0 item 17): from push() on the host-completion event of host obligation 4 when the host feeds it, or, for a turn whose pause was signalled, on a host-terminal event of host obligation 1 when the host feeds one; otherwise from flush() as INV-FLUSH (2) directs. A pause is signalled by the event with which ADK ends it (the confirmation request, the function response that carries requested_auth_configs, the content-less skip-summarization event that follows a request-input call, or a Workflow root’s resume record) or by a non-error final response of the pausing agent while an ask is pending; an event of the same invocation MAY follow that signal — a callback’s content, a later sub-agent’s output, a further model round or a usage-only report — and it belongs to the paused turn. A flush() that has seen no pause signal for a turn closes it as a truncation, or with the turn.error §8.0 item 35 directs when its invocation met an invocation error, or with turn.abort as §8.0 item 17 directs when its native stream signalled an interruption (INV-FLUSH (2)), never as paused. When the invocation met an invocation error (§8.0 item 35) and the turn’s pause was signalled, the normalizer closes the paused turn as above at the invocation’s end and then closes the error with turn.error on a new top-level turn of the same invocation, whose turn.start follows the paused turn.done and which carries one assistant message with no content; that turn.error carries the host-terminal event’s code and message when the host fed one, otherwise those of the invocation’s first in-band invocation error. The paused close carries the turn’s accumulated usage, and that turn.error carries only usage a host-terminal event reports. A turn whose pause was not signalled by the end of an invocation that met an invocation error closes with the turn.error of §8.0 item 35, never as paused. This governs the paused close of items 12 and 18. An event emitted from within a workflow node (one carrying ADK node info) MUST NOT close the invocation’s turn as success. The resume is the next invocation’s own turn.

  27. Interim-narration marker (phase, draft.4) — binds every normalizer whose source exposes an OpenAI Responses or Harmony assistant-message phase, or the Claude Agent SDK’s narration_block_indexes, whatever framework carries it. OpenAI phase:"commentary" → phase:"interim" on that message item’s text block(s); "final_answer", null, an absent value and any other vendor value → phase absent. A Claude thinking block listed in narration_block_indexes whose text is non-empty → phase:"interim" on that reasoning block; a listed block with empty text → phase absent. A normalizer MAY map a non-empty Anthropic thinking block to "interim" from the Messages API display:"updates" rule only when it itself requested display:"updates". A normalizer MUST NOT infer phase from block position, block text or turn end; MUST NOT derive it by decoding opaque or any signature; MUST NOT copy a vendor spelling into phase; and MUST keep a replay-bearing vendor marker (OpenAI phase) verbatim in that block’s providerMetadata; a null or empty phase is not a marker and is not carried.

  28. Credential material (draft.4) — binds every normalizer that targets a framework for which this item defines a framework credential object; today that is ADK alone. For ADK, a framework credential object is a JSON object whose authType (or auth_type) is one of ADK’s credential types (apiKey, http, oauth2, openIdConnect, serviceAccount) and that has a non-null apiKey, http, oauth2, serviceAccount or resourceRef member (in either spelling); the test is the object’s credential shape, not whether a member holds a secret. ADK’s credential-request call is adk_request_credential, matched by that name at any depth and never by its id.

    (a) Authentication requests. A normalizer MUST NOT emit credential material at any depth in any event it emits for an ADK authentication request — the hitl.ask and its metadata, the arguments and result of ADK’s credential-request call, and the paused asks[] that fold from them — and this takes precedence over any verbatim carry this section otherwise requires: for example client secrets; access, refresh and ID tokens; HTTP bearer or basic credentials and authorization headers; API keys; passwords; private keys; PKCE code verifiers; authorization codes and the response URIs that carry them; and state and nonce values carried as members of their own. The authorization request URI the user must open is not credential material, although its query carries the flow’s state, nonce and PKCE challenge, and a normalizer carries it as the framework issued it. In those events a normalizer carries only the members, at any depth, of a framework credential object that it knows to be non-secret and omits every other member, except in a state.delta patch or a state.snapshot, where it omits the whole unit that holds the object as (b) requires.

    (b) Shared state. A normalizer MUST NOT emit a framework credential object in a state.delta patch or a state.snapshot, whichever native event carries it and whether or not an authentication request preceded it. It omits the smallest unit that holds such an object: the key whose value is, or contains at any depth, the object (for ADK’s flat state map, the top-level entry; for an RFC-6902 patch, the operation that writes it together with every later operation whose path lies beneath that operation’s path); when a key-replace patch or a snapshot is itself such an object, the unit is that whole patch or snapshot, and its members are not separate units. It carries every other unit unchanged, except that it emits no own __proto__ key at any depth (§13.7), and it emits one such event for every native state change it would otherwise carry, with an empty patch when no unit remains; a key-replace patch or a snapshot left with no unit is the object {}. A normalizer targeting ADK also omits every entry whose key carries ADK’s invocation-scoped temp: prefix, whatever its value, as ADK’s own session service does before its runner yields the event. These omissions take precedence over the graceful-degradation rule and any verbatim carry this section otherwise requires, and this section licenses no other omission from shared state beyond §13.7’s.

    (c) provider-raw carries. Inside any value it carries in a provider-raw block, whichever native field carries that value and whether or not the invoke resumes an authentication request, a normalizer MUST NOT emit a member, at any depth, of a framework credential object, or of a response that names the framework’s credential-request call, that it does not know to be non-secret: it reduces each such object and each such response to the members it knows to be non-secret, and carries every other member of the enclosing value unchanged. When the same native event also carries a text part that is the framework’s rendering of a value this reduction changed, the normalizer omits that text part; for ADK, the rendering is a text part byte-equal to the JSON serialization of the event’s output before reduction. These provider-raw reductions and that text-part omission take precedence over the graceful-degradation rule, over item 23’s provider-raw carry and over any verbatim carry this section otherwise requires, and a normalizer MUST NOT throw from push() while applying them. They reach only what a normalizer can recognise within one native event: a credential reply that is neither a framework credential object nor carried inside a response that names the credential-request call (for example an untyped reply, object or string), a model’s restatement of a reply, and any copy a host reads or relays from native events outside the Normalizer are not covered (§13.9).

  29. Nested-run closure (draft.4) — a normalizer closes each nested turn with exactly one turn.done | turn.error | turn.abort carrying its turnId, immediately followed by its subagent.done (INV-TURN). A nested turn’s turnId MUST differ from every top-level turn’s turnId in the stream. The terminal records the framework’s own status for the sub-run (completed → success, failed → error, stopped or killed → aborted), never a status inferred from content; a normalizer MUST NOT emit a success turn.done for a nested turn unless the framework reported that sub-run’s completion, and a sub-run whose result the framework has not yet reported stays open — it MAY outlive its parent’s terminal — until the framework reports it or flush closes it (INV-FLUSH). When the framework reports no finish reason for the sub-run, the nested turn.done carries finishReason: "unknown". A terminal of a nested turn MUST NOT carry usage: the top-level turn is the usage accounting boundary, and a sub-run’s spend is reported only where the framework reports it — inside the enclosing top-level turn’s usage when the framework counts it there, or verbatim in the spawning tool.done’s providerMetadata, a host record on its _meta (§2.1), or an ext.<vendor>.* carry — so totalling usage over every terminal never counts a sub-run twice, where a running-total cost (costScope, §4) is totalled by taking its latest snapshot per scope, never by summing terminals. A handoff {kind:"transfer"} records that control passed to the named agent (an OpenAI Agents handoff; an ADK transfer_to_agent); the target’s subsequent output continues the same thread — as new top-level turns where the framework runs it as new responses, inside the same turn where it runs it inline. A normalizer that brackets the transfer’s execution as a nested turn closes that bracket before it emits handoff on the source turn; a normalizer whose framework runs the target inline emits handoff alone. A handoff {kind:"escalate"} records that the emitting agent escalated to its enclosing agent (ADK EventActions.escalate: a loop terminates); it names no target and opens no nested turn.

  30. ADK shared state (draft.4) — an ADK normalizer emits each actions.stateDelta it receives as one state.delta whose patch is a key-replace object (§5) holding the map’s top-level entries with their values unchanged, less every temp: entry and every unit item 28 omits (the object {} when that unit is the whole map); it adds no nesting and emits no JSON Patch. The resulting fold is the per-key replace of the deltas ADK yields; it is not ADK’s session state, which also holds host-seeded values and writes ADK applies without yielding them.

  31. MCP resource links in tool results (draft.5) — this item binds any normalizer that emits a native MCP resource_link content part of a tool result into tool.done.content. It MUST emit that part as one resource-link block, never as a provider-raw block. The block carries the native uri, name, title, description, mimeType, size, annotations and _meta verbatim where present, and omits each member the native part lacks. Item 28’s reductions apply as if the block were a provider-raw carry. They reduce framework credential objects and responses that name the framework’s credential-request call (item 28(c)); the block’s uri is neither, so a credential carried inside the uri value itself (for example a signed query) passes unchanged, as it did in provider-raw. Some native members do not fit the block: a member the resource-link block does not define (for example icons), a defined member whose native value lacks the type §2 gives it, or a defined member whose native value a schema for the type §2 gives it would accept only by altering it (for example an annotations object carrying a member AgAnnotations does not define); a member fits only when its value read through that schema equals its native value, item 28’s reductions applied first. Those members ride exactly one provider-raw block immediately after that resource-link, carrying only those members; when there are none, no such block is emitted. A part typed resource_link whose uri is absent or not a string is not such a part: it falls under this section’s graceful-degradation rule and rides one provider-raw block carrying the part whole, reduced per item 28 and keeping its native type, and is never dropped. A framework may deliver a link to the model only as a rendering (for example a text line) and expose its structured form only host-side. For such a framework, the normalizer keeps the rendering in content, MUST NOT add a resource-link block for that link to content, and MAY carry the structured form per §2.1’s host-record note.

  32. Claude deferred tool (draft.5) — a Claude Agent SDK result frame that carries deferred_tool_use with stop_reason:"tool_deferred" and is_error:false (a PreToolUse hook answered defer) ends the invoke with that tool call awaiting a host decision. A normalizer MUST emit, from push() on that frame, one hitl.ask {kind:"approval", toolCallId:<deferred_tool_use.id>} and close the turn with turn.done {outcome:{type:"paused", asks}, finishReason:"paused"} and no finishReasonRaw ("paused" is a mapping, not a fallback); the call’s tool-call block has no result in that turn. The pause MUST NOT be keyed on terminal_reason, and a result frame with stop_reason:"tool_deferred_unavailable" or is_error:true is not a pause. The ask carries no message (the result frame carries no hook reason for defer); the question a deferred tool asks, such as AskUserQuestion’s, lives in the call’s input, which a renderer reaches through toolCallId. The host answers through its own hook on a resumed session rather than AgInput.kind:"resume" (§7: either path); the resumed invoke’s result for the deferred call follows INV-XINV (§5.0), and that later tool.done for the same toolCallId is the in-band record that the ask was answered.

  33. Waiting fixes vs. reported fixes (draft.5) — when a framework WAITS for the user to fix something (authenticate, open a URL) before it continues, a normalizer MUST emit hitl.ask {kind:"auth"|"url"} and fold the pause as item 12 does (the ADK instance; item 26 closes the turn); it MUST NOT express that wait as remedy data on a terminal. A fix the framework reports without waiting (it ends the turn or the tool call) MUST NOT be silently dropped: it rides verbatim, less what §13.10 forbids, in a carrier §13.10 names that the §5 table folds — a block’s _meta on its start event, message.metadata when no block anchors it, turn.done.messageMetadata, or a notice row. A normalizer never lifts tool-authored content (structuredContent, a tool’s own _meta) into a harness remedy carrier.

  34. ADK prompt block (rc.1) — @google/adk (observed at 2.1.0) builds no event that carries promptFeedback: in StreamingMode.NONE, createLlmResponse (models/llm_response.js) turns a response whose prompt was blocked into an LlmResponse whose errorCode is the block reason, with errorMessage set to the block reason’s message when the platform sends one (@google/genai 2.24.0 documents that message as not supported on the Gemini API), usageMetadata when present, and no finishReason, no content and no safety ratings; a response-level block keeps finishReason beside errorCode. A normalizer targeting ADK MUST treat an event whose errorCode is a prompt-block value and which carries no finishReason, no content parts and no a2a:response entry in customMetadata as a prompt block, and MUST emit one prompt.blocked for each such event that arrives while its turn is open or after a success close of that turn (then into the new top-level turn it opens, §8.0 ‘Ids across invokes’), ordered before that turn’s terminal, with reason mapped SAFETY → "safety", BLOCKLIST → "blocklist", PROHIBITED_CONTENT → "prohibited" and any other value → "other" with reasonRaw verbatim (§10 item 23), and with no safety[], because the event carries no ratings; it MUST NOT emit one into a turn after that turn’s terminal. An event whose block repeats an earlier event’s, as a retried attempt’s event does, is a separate prompt-block event and yields its own prompt.blocked in the same way. An event that also carries promptFeedback.blockReason is recorded once, from its promptFeedback, in place of the record above, and a prompt.blocked that a normalizer targeting ADK emits from promptFeedback follows the same rule: one per event, never into a turn after that turn’s terminal. The prompt-block values are the values of @google/genai’s BlockedReason (observed at 2.24.0: BLOCKED_REASON_UNSPECIFIED, SAFETY, OTHER, BLOCKLIST, PROHIBITED_CONTENT, IMAGE_SAFETY, MODEL_ARMOR, JAILBREAK) and the Gemini API’s spelling of the unspecified value, BLOCK_REASON_UNSPECIFIED; a normalizer MAY treat a value that a later @google/genai adds to BlockedReason as a prompt-block value. A value it does not treat so is not a prompt block, and this point changes nothing for it. This point adds the record only; it changes no terminal a normalizer emits for the turn, and for a normalizer not configured for the host-completion event of host obligation 4 the sentence below states how item 35 reads a close on such an event. For a normalizer not configured for the host-completion event of host obligation 4, a prompt-block event that carries no errorMessage counts as a final response for item 35’s clause: a turn that such a normalizer closes on it with turn.done {outcome:{type:"success"}} — when the event carries no branch and no ADK node info, with no ask pending (item 26), before the invocation met its first invocation error — closes after the event’s prompt.blocked and keeps that close; one that carries errorMessage too is an in-band invocation error, and its turn closes as item 35 directs, never on that event. An event that carries finishReason beside errorCode (a response-level block), an errorCode that is not a prompt-block value (an HTTP or API error code, UNKNOWN_ERROR), or content parts is not a prompt block. The test applies to the event as observed: a session service that reloads an event without its raw form can drop its finishReason, so a response-level block can look like a prompt block, and such an event is read as a prompt block even though the block may have been a response-level one. An event that ADK’s RemoteA2AAgent relays carries an a2a:response entry in customMetadata, and at 2.1.0 the relay carries no finishReason, so a relayed prompt block and a relayed response-level block with the same code arrive alike; no relayed event is read as a prompt block, and in a relay that streams the relayed event carries the block’s code in errorCode when the remote agent sends one. A prompt.blocked records that a prompt of the turn was blocked, not that the turn produced no answer: a later agent of the same invocation may answer. For a model call the run makes itself, the shape arises only where createLlmResponse builds the response: ADK’s Live path builds its LlmResponses without it and sets no errorCode, and its Interactions path carries no BlockedReason value; at 2.1.0 the SSE streaming aggregator absorbs a prompt-block chunk and yields at most a content-less, usage-only event, so the shape reaches a normalizer only in StreamingMode.NONE at that version, and a prompt block in SSE mode records no prompt.blocked. (Informative: in that SSE case, a stream that carried a usage report yields one content-less, usage-only event; in an invocation whose only event it is, the reference ADK normalizer configured for the host-completion event of host obligation 4 closes the turn that event opens turn.done success with finishReason "stop" on the host-completion event, and otherwise closes it turn.abort at flush(); without a usage report the blocked model call yields no event, and the host-completion event opens no turn. A host cannot tell such a block from a content-less response; a host that needs to observe prompt blocks can run in StreamingMode.NONE on a path where createLlmResponse builds the response.)

  35. ADK invocation errors (rc.1) — an ADK invocation error is an event of the invocation that carries both errorCode and errorMessage (an in-band invocation error), or a host-terminal event of host obligation 1. A response-level block with no finish message carries no errorMessage and is outside this item. An event whose errorCode is "INVOCATION_ABORTED" is also outside this item: it is the seal @google/adk (observed at 2.3.0, on runAsync only; runLive yields none) appends when a client abort ends the invocation, recognized by ADK’s own isAbortEvent on that value alone, and it is the invocation’s native terminal — the generator returns after yielding it. A normalizer targeting ADK MUST close the invocation’s open turn from the push() that receives such an event with exactly one turn.abort whose reason is "cancelled", after that event’s own functionResponse parts have closed their calls as item 25 directs, MUST NOT emit an error event or a turn.error for it, and MAY carry the turn’s accumulated usage on the message.end that precedes that turn.abort, as item 17 allows; an event whose errorCode is any other value keeps this item’s route. ADK yields an in-band invocation error without ending the invocation — a SequentialAgent runs its next sub-agent, a Workflow node retries, a callback or a further model round follows — and its stream carries no in-band run terminal, so on the runAsync path a normalizer MUST NOT close a turn on an in-band invocation error: it MUST emit the non-terminal error event, with code from errorCode and message from errorMessage, from the push() that receives that event, and every later event of the invocation belongs to the same turn. In ADK’s SSE streaming mode a model call that ends on an in-band invocation error can repeat that error in item 3’s aggregate as its stream closes. Observed at ADK 2.1.0, that aggregate is an event that carries partial: false and repeats the errorCode and errorMessage of the previous event of the same author, branch and node path (nodeInfo.path; an absent branch or node path matches only an absent one), where that previous event does not carry partial: false; it reports the same in-band invocation error, not a further one: it yields no further error event, its content is suppressed as item 3 directs, and the model call’s usage counts once toward the turn’s usage, taken from the later of the two events that carries a usage report. Likewise, observed at @google/adk 2.1.0, RemoteA2AAgent puts an a2a:request entry in customMetadata on every event it relays that carries turnComplete: true; such an event that repeats the errorCode, errorMessage and usageMetadata of an earlier in-band invocation error of the same author, branch and node path in the invocation, and carries in customMetadata the same a2a:task_id entry as that earlier event, present on both (for the other values, an absent value matches only an absent one), reports that same error: it yields no further error event and adds no usage. Every other event that carries both errorCode and errorMessage is a further in-band invocation error. At 2.1.0 the relay’s closing event reports only the remote run’s first failure, and a failure that carries no content reaches the normalizer only through it, so a remote run’s later content-less failures do not reach the normalizer, and this recognition reads one such failure followed, in the same remote run, by a relayed failure with the same code, message and usage as one; a relayed failure that carries errorMessage without errorCode (a remote agent whose card declares no streaming, a failed request to the remote agent, or a remote peer that sends no error code) is not an in-band invocation error under this item; a relayed event that carries no a2a:task_id entry (the relay writes one only when the remote reply carries a task id) is not recognized, so it counts as a further failure; a remote model call that streams text in the SSE streaming mode and ends on a content-less block whose usage arrives on a later chunk relays an aggregate and a closing event with different usage, so that closing event is not recognized and counts as a further failure; and a host that places a2a:request and a2a:task_id entries in a direct event’s customMetadata, or a remote agent that supplies an a2a:task_id entry in its reply’s metadata, can make an event match that reports a further failure. At the invocation’s end — from push() on the host-completion event of host obligation 4 or on a host-terminal event of host obligation 1 when the host feeds one, otherwise from flush() as INV-FLUSH (2) directs — a normalizer MUST close the turn of an invocation that met an invocation error with exactly one turn.error, after every event the invocation emits into that turn and never as success, unless item 26 directs a paused close for it; a turn that a normalizer not configured for the host-completion event of host obligation 4 closed as success on a final response that carries no branch and no ADK node info (no ADK node info: item 26), before the invocation met its first invocation error, keeps that close; this item and item 26 then apply to the later top-level turns of the same invocation, and the error event of an in-band invocation error and the turn.error they direct each name one of those turns, never the closed one; that turn.error carries the host-terminal event’s code and message when the host fed one, otherwise those of the invocation’s first in-band invocation error, and the turn’s accumulated usage (item 4) plus any usage the host-terminal event reports. Each error event this item requires reports one in-band invocation error when it arrives; the turn.error reports the invocation’s outcome. ADK’s Live path (runLive) is outside this item: at 2.1.0 it yields no in-band invocation error, and a Live failure reaches the normalizer as the host-terminal event of host obligation 1. For this item, a final response is an event that is not partial, carries a completion marker (turnComplete: true, a finishReason or an errorCode), carries no functionCall part, is not an in-band invocation error (above), and is not the event with which ADK ends a pause (item 26). (Informative: @google/adk’s own isFinalResponse() differs: it returns final on skipSummarization, a non-empty requestedAuthConfigs or a non-empty longRunningToolIds whatever the event’s parts; otherwise it requires no functionCall part, no functionResponse part, partial not true and no trailing codeExecutionResult, and needs no completion marker. This item requires the marker, excludes only functionCall parts, and counts none of the three short-circuits.)

  36. ADK A2A relay bookkeeping (rc.1) — binds every normalizer that targets ADK. ADK’s RemoteA2AAgent (observed at @google/adk 2.1.0) records its exchange with a remote agent in the event’s customMetadata: a2a:request is the send request as the host issued it (the forwarded message with the local application, user and session ids, the send configuration, which can hold push-notification credentials of the shape §13.5 names, and the request metadata); a2a:response is the remote agent’s raw A2A event, which can nest the remote agent’s own copy of these entries; a2a:task_id and a2a:context_id are the remote task’s ids, for which AgJSON defines no field. A normalizer MUST NOT carry, in any event it emits, a top-level entry of a native event’s customMetadata (or custom_metadata, the snake_case spelling a raw native can carry) named a2a:request, a2a:response, a2a:task_id or a2a:context_id, whichever agent wrote it and whether or not the event completes a turn; it carries every other entry as this section otherwise requires, and when it removes at least one of them and no entry remains, it carries no such member, under either spelling (a member that held none of them is carried as this section otherwise requires, even when it is empty). Reading these entries, for example to recognise a relayed turn, is not carrying them. This omission takes precedence over the graceful-degradation rule, over item 23’s provider-raw carry, over item 28(c)’s carry of the enclosing value unchanged and over any verbatim carry this section otherwise requires; item 28(c)’s reductions still apply to the entries that remain, and a normalizer MUST NOT throw from push() while applying it. When a native event’s a2a:response entry (in customMetadata, or in custom_metadata, the snake_case spelling a raw native can carry) records a submitted status whose message is in the user role (kind "status-update" or "task", status.state "submitted" and status.message.role "user"; or, in the protobuf JSON spelling a relay on a2a-sdk 1.x records, no kind, status.state "TASK_STATE_SUBMITTED" and status.message.role "ROLE_USER"), a normalizer MUST NOT emit anything derived from the parts of the event’s content after its first k parts, where k is the number of parts the artifacts of the recorded a2a:response hold (0 when it records none), or from any long-running tool id taken from those parts (these are the parts the framework converted from that status.message: @google/adk’s relay converts a recorded task’s artifact parts before its status message, at most one part for each A2A part, and ADK-Python’s relay, which records the whole task for a status update too, converts a submitted status update from its message alone and a submitted task from artifact parts when it holds any, else, on its default path, from its status message): it maps the event as if those parts were absent, in text, reasoning, tool lifecycle, hitl.ask and pause state and in any value it carries; it maps every other part and member of the event as this section otherwise requires; and, when it omits at least one part, it emits one ext.google.relay-echo-omitted event for that native event, after the turn.start of the turn that native event maps into (a turn it opens for that event when none is open, even if nothing else of the event is emitted) and before that turn’s terminal event, whose only member beyond the base envelope (§4) is parts, the number of parts it omitted; any turnId it carries names that turn. This omission does not reach a forwarded message that arrives in any other shape (§13.12). It takes precedence over the graceful-degradation rule, over item 23’s provider-raw carry and over any verbatim carry this section otherwise requires, and a normalizer MUST NOT throw from push() while applying it.

  37. OpenAI Agents approval ask (rc.1) — a normalizer targeting OpenAI Agents MUST emit, for a tool_approval_requested run-item that is not a hosted-MCP approval (item 20) and whose item carries a callId, hitl.ask{kind:"approval", askId:"approval_<callId>", toolCallId:<callId>} where <callId> is that callId. (Informative: for a run-item with no callId, the reference uses the item’s id in its place and, when it carries neither, emits an askId unique within the invoke, derived from the invoke’s own stem, and no toolCallId.)

  38. ADK internal metadata (rc.2) — binds every normalizer that targets ADK. ADK (observed at @google/adk 2.2.1) reserves, for its own components, the entries of an event’s customMetadata whose name begins with __adk_internal_: callers cannot set them, and while stored events keep them, ADK removes them from the API responses, saved session files and A2A messages it produces; at that version they hold a tool-call integrity stamp and a restored-history marker, for which AgJSON defines no field. A normalizer MUST NOT carry, in any event it emits, a top-level entry of a native event’s customMetadata (or custom_metadata, the snake_case spelling a raw native can carry) whose name begins with __adk_internal_, whichever component wrote it and whether or not the event completes a turn; it carries every other entry as this section otherwise requires, and when it removes at least one of them and no entry remains, it carries no such member, under either spelling (a member that held none of them is carried as this section otherwise requires, even when it is empty). It reaches an entry by its own name only: a value nested inside another entry is not reached. This omission takes precedence over the graceful-degradation rule, over item 23’s provider-raw carry, over item 28(c)’s carry of the enclosing value unchanged and over any verbatim carry this section otherwise requires; item 28(c)’s reductions still apply to the entries that remain, and a normalizer MUST NOT throw from push() while applying it.

9. Profiles

The profile split resolves the CORE/text-path question explicitly: CORE has exactly one text path and a tool-result with content+outcome only.

ext.<vendor>.<key> is profile-agnostic (allowed in any profile, live-only by default). An agent advertises its profile via its capabilities (AgCapabilities.profile; AgClientCapabilities has no profile field); CORE clients ignore EXTENDED/ADVANCED events (the unknown-type-ignore rule, §0.2). A CORE client can therefore receive a turn.done/turn.error/turn.abort whose turnId it never saw opened: such a terminal closes a nested turn opened by the EXTENDED subagent.start, and never ends a top-level turn. §5.0 INV-OWNER says how a reducer folds it.

10. Conformance

A conformant normalizer ships fixtures asserting the items below. Items that name a reducer, a consuming ingest or a stored-record reader (1, 2, 16, 20, 22, 29, 37, 39, 48, 56, 64, 65) bind those implementations instead, as do the first sentences of items 27 and 43, item 35, the last sentence of item 45, the two reducer legs of item 47, the last sentence of item 50, leg (g) of item 53 and item 54, which bind reducers. A replay golden is every golden committed to the reference corpus, including a committed capture not yet enrolled in replay. The input leg of item 29 binds implementations that ship an input receiver, and ingest-only normalizers record it N/A.

(Items asserting emit→reduce→re-input round-trips — 4, 13, 15, 18 — bind only implementations that ship an emit/re-input surface (§8 item 7 scoping); ingest-only normalizers record them N/A. Framework-specific items follow §8’s applicability scoping. A framework-specific item whose framework has no normalizer in the reference SDK stays normative for a normalizer that targets that framework; it records N/A only under §8’s scoping or the item’s own binding sentence. (Informative: the reference manifest records each item’s disposition — RUNNABLE, COVERED-BY or N/A — and, separately, its evidence class — corpus, fixture-only or none; a COVERED-BY row names the test by its title.) A reducer leg whose events lie outside the profile an implementation advertises (§9) binds it only at a profile that includes those events; below that profile the implementation records the leg N/A.)

  1. reduce() invariant — stream → reduce == AgReduceResult for every block kind (the full §5 folding table), including block insertion order for interleaved-block-kind turns.
  2. Reconnect — stream-with-gap + messages.snapshot → reduce == AgReduceResult; a forward seq gap triggers snapshot-resync; a backward seq jump to 0 (a new invoke’s 0-restart) folds normally, and a repeated or backward seq above 0 parks (INV-SEQ). A gap spanning artifact.* / handoff / prompt.blocked / paused turn.done round-trips via the full-state snapshot. A gap mid-tool.args.delta and mid-text.delta asserts no double-apply after the snapshot (transient state cleared).
  3. Tool-result routing matrix — content→model; structuredContent→model-facing typed result (base MCP; delivery host-determined, not asserted); uiData→surface, NOT model (MCP Apps structuredContent); sideData→app-only. (model-readable? model-structured form? surface/view? app-only?.)
  4. Gemini signature loop — the thoughtSignature rides the correct entity and survives emit → reduce → re-input for (a) the first tool-call part (signature on tool-call.signature / tool.args.assembled.signature), (b) a thinking-only turn (signature on the reasoning block’s opaque, message/reasoning-block-targeted — NOT a tool-call target), and (c) a Google-Search-grounded turn (signature on the reasoning block’s opaque); also the OpenAI stateless reasoning loop (rs_ itemId + encrypted_content + interleaved fc_ items survive). Echo or turn N+1 is a hard 400.
  5. Source round-trips — MCP base64 and Anthropic url/file both survive (AgSource merge).
  6. Mandatory display — a display.required event is not dropped (ToS).
  7. safety_blocked category — any finishReason:"safety_blocked" (or prompt.blocked) SHOULD carry a populated safety[].category when the source provides it so the collapsed reason is recoverable; when the source emits only a bare content-filter signal with no category, safety_blocked MAY be emitted with empty safety[] (§4).
  8. Cumulative-usage verbatim fold — a cumulative Anthropic message-level usage stream folds with the provider’s running totals preserved verbatim and cumulative: true intact; a Claude turn terminal’s per-turn counters fold with cumulative false or absent and its costUsd, costScope and byModel verbatim, less any own __proto__ member at any depth of byModel (§13.7); nothing in the pipeline subtracts (INV-DELTA).
  9. ADK aggregate suppression — a partial:false aggregate reduces without double-render.
  10. Index→id re-key — a LangChain/Pydantic index-keyed delta stream reduces to id-keyed blocks. The content-block leg binds every normalizer whose framework keys streamed content by a positional index (the reference Anthropic normalizer re-keys such blocks to minted ids; its own suite exercises it); the tool-call leg binds a normalizer whose framework may deliver a tool call’s name or arguments before its stable id (Pydantic AI) — the reference SDK ships none for that leg, and a normalizer whose framework keys every delta by a stable id records this item N/A.
  11. LangGraph positional pause — two interrupt()s in one node yield two stable distinct askIds (via resumeBinding:"positional"/ordinal) that survive resume; the resume payload SHAPE (scalar vs map) matches the binding. Binds a normalizer whose framework resumes repeated in-node pauses positionally (LangGraph); the reference SDK ships none.
  12. Interleaved subagent + parent — an interleaved subagent+parent stream folds to the correct per-turn messages (each block routed by its event turnId).
  13. Replay-blob round-trips — Anthropic reasoning signature/redacted blobs survive byte-identical on the reasoning block’s opaque carrier (kind:"signature"|"redacted" — §5 reasoning.opaque row); Anthropic web-search encrypted_content (search-result.opaque) and encrypted_index round-trip; Pydantic CompactionPart round-trips; bare-key _meta (timestamp, traceparent) and a flat A2A/Vercel metadata key survive emit→reduce→re-input unchanged. The Pydantic CompactionPart and A2A metadata legs bind normalizers for those frameworks; the reference SDK ships none.
  14. A2UI RPC round-trips (per-arm) — assert on the A2UI ARM specifically: the server callFunction→ the client AgA2uiFunctionResponse (a2uiMessage:"function-response") round-trips BOTH functionCallId AND call (and the echoed ui.result.method); an AgA2uiSurfaceAction (a2uiMessage:"action") carries NO OpenAI widgetState (the un-merge keeps OpenAI-only fields off the A2UI arm); a wantResponse:true action (with its actionId) → ui.action-result; the sendDataModel per-surface data-model snapshot round-trips on AgMessage.messageMetadata.a2uiClientDataModel (NOT on a per-action field); action.context resolved bindings survive on AgA2uiSurfaceAction.context; an AgA2uiError round-trips code/message/path in BOTH shapes — surface-scoped (surfaceId, no functionCallId) and function-call-failure (functionCallId, no surfaceId) — and a both-fields or neither-fields shape is REJECTED (the VALIDATION_FAILED.path JSON-Pointer reaches ui.result.error.path / ui.action-result.error.path); and the OpenAI AgOpenAiWidgetAction(method:"callTool", callId) reply round-trips as ui.widget.result {callId, result}.
  15. Gemini parallel ordering — a 2-call parallel turn asserts the grouped FC1,FC2,FR1,FR2 ordering survives emit→reduce→re-input.
  16. A2A initial-Task — an initial Task carrying an artifact + a later artifact-update for the same artifactId yields exactly ONE artifact; an artifact.delta after that artifact’s artifact.end lands on it.
  17. Signature reassembly — a 2-fragment reasoning.opaque.delta signature reassembles byte-identically; an id-fragmented tool call assembles to a single stable toolCallId. The id-fragmented leg binds a normalizer whose framework may deliver a tool call’s name and arguments before its id (Pydantic AI); the reference SDK ships none.
  18. MCP MRTR — requestState survives emit→reduce→re-input byte-identical; resume is a fresh AgInput carrying the echoed requestState.
  19. A2UI component streaming — a createSurface(components, dataModel) + updateComponents + updateDataModel + deleteSurface sequence round-trips through ui.surface.* / ui.data-model with the opaque components/dataModel/value payloads byte-identical (Layer-A: no component-schema interpretation); the streamed adjacency-list and the id:"root" survive verbatim; catalogId round-trips by reference.
  20. Malformed input at a trust boundary — an input that is not a well-formed envelope is a typed error to the caller and occupies no seq slot; a well-formed envelope that fails validation is reported to the caller and not folded, and a stream carrying it mid-sequence folds to the same result, with the resync condition false, as that stream with the event removed and later seqs renumbered.
  21. Reasoning-inclusive usage identity (draft.3) — a Gemini usageMetadata {promptTokenCount: 109, candidatesTokenCount: 22, thoughtsTokenCount: 125, totalTokenCount: 256} folds to usage {inputTokens: 109, outputTokens: 147, reasoningTokens: 125, totalTokens: 256}; the same object without thoughtsTokenCount folds byte-identically to draft.2; an already-inclusive shape (prompt + candidates + toolUsePrompt == total with thoughtsTokenCount > 0) is NOT double-added. A Gemini Live usageMetadata {promptTokenCount: 609, responseTokenCount: 25, thoughtsTokenCount: 256, totalTokenCount: 634} folds to usage {inputTokens: 609, outputTokens: 281, reasoningTokens: 256, totalTokens: 890, totalTokensRaw: 634}; a Live object whose total already counts thoughts ({promptTokenCount: 6037, responseTokenCount: 248, thoughtsTokenCount: 490, totalTokenCount: 6775}) folds to totalTokens: 6775 with no totalTokensRaw key; the two summed into one AgUsage fold to inputTokens: 6646, outputTokens: 1019, reasoningTokens: 746, totalTokens: 7665, totalTokensRaw: 7409; no candidatesTokenCount object in this item yields totalTokensRaw. On every replay golden whose usage carries a provider-reported totalTokens, inputTokens + outputTokens + (toolUseInputTokens ?? 0) == totalTokens. Wherever a replay golden’s usage carries totalTokensRaw, it also carries totalTokens, and the two differ.
  22. Forward-compatible ingest (draft.4) — take the stream S = turn.start (seq 0), message.start (1), text.start/text.delta/text.end (2-4), message.end (5), turn.done (6). Insert one frame X at seq 2 and shift the later seqs, for each X in: (a) an event of undefined type named per §0.3 (zz.start); (a′) an event whose final segment is outside §0.3’s closed set (zz.probe); (b) reasoning.start with an unknown mode; (c) content.block whose block type is unknown; (d) tool.done with an unknown outcome; (e) message.remove with id:"*" and no turnId. A consuming ingest followed by a reducer MUST yield the resync condition false, a result structurally equal (INV-FOLD) to the fold of S, and exactly one ext.agjson.ignored event at seq 2 whose ignoredType is X’s type and whose raw deep-equals X. An unknown key inside turn.done.usage and inside a content.block block survives into the fold, except an own __proto__ member (§13.7). With an unknown top-level member zzEvt added to every event of S, no record, block or turn of the fold carries zzEvt; the same member inside a messages.snapshot memory record survives on the folded record (§5, what a built record carries). Controls: a frame whose seq jumps by 5 still sets the resync condition; an input that is not a well-formed envelope is a typed error to the caller and advances nothing; a frame carrying an own __proto__ key at the top level and inside a block yields an event whose prototype the wire did not select and which holds no such member at any depth (§13.7). An ignored message.start or turn.start is outside this item: the §12 freeze protects producers on draft.4 or later, and a pre-freeze producer or persisted history can still leave an opener unfolded.
  23. Unmapped native value (draft.4) — a normalizer fed a native terminal whose finish reason has no AgJSON target emits turn.done.finishReason "other" or "unknown" and turn.done.finishReasonRaw equal to the native value byte for byte, and every event it emits passes producer-strict AgEvent validation. A normalizer fed a native block reason outside "safety", "blocklist" and "prohibited" emits prompt.blocked.reason "other" and prompt.blocked.reasonRaw equal to the native value byte for byte, a native value itself named “other” included; a block reason mapped to "safety", "blocklist" or "prohibited" carries no reasonRaw (draft.8).
  24. Tool-result error scoping and the ADK failure envelope (draft.4). On every replay golden, no tool.done event and no AgInput results[] entry carries errorText unless its outcome is "error". An ADK-targeting normalizer maps each case below as shown, and every event it emits passes AgEvent validation:
    • {error:"Function x is not found in the toolsDict."} → outcome:"error", isError:true, errorText equal to that string, and content a data block carrying the response verbatim.
    • {error:"x", error_code:"E_X"} → outcome:"error" and errorText:"x" and errorCode:"E_X" (§8.0 item 25).
    • {error:"x", error_code:"E_A", errorCode:"E_B"} → errorCode:"E_A" (the string error_code is read first).
    • {error:"x", isError:true} → outcome:"error", isError:true, no errorText and no errorCode.
    • {error:{code:"E"}} → outcome:"error" with no errorText.
    • A {error:"This tool call requires confirmation, please approve or reject."} response on an event whose actions.requestedToolConfirmations names the same call id → one hitl.ask{kind:"approval"}, and no outcome:"error" for that call.
    • {error:"This tool call is rejected."} → outcome:"denied" with no errorText.
    • An MCP {content:[…], isError:true} → outcome:"error" with isError:true.
    • {result:null}, {error:""}, {error:null}, {error:false}, {error:0} and {status:"error", error_message:"x"} → outcome:"ok".
  25. Framework pause and completion closure (draft.4) — for each fixture a normalizer’s framework has (ADK: a Workflow that pauses on RequestInput, the same Workflow completing, a Workflow whose terminal node is an LlmAgent, a plain requireConfirmation pause, a plain credential pause, a plain requestInputTool pause), with one Normalizer per invoke: reduce(events).needsResync is false; every opened turn has exactly one terminal and no event targets it after that terminal; a pause closes turn.done {outcome:"paused", finishReason:"paused"} as the turn’s last event, with one ask per pending request whose toolCallId is the answering id — from push() on the host-completion event of §8.0 obligation 4 when it is fed, else from flush() — and an invocation that emits after the pause signal (a credential pause followed by a content-less report carrying usage — the model’s own, or a streaming aggregator’s tail, which also carries the finish reason — and a further model round, by an after-agent callback, or by a later sub-agent; a request-input pause followed by a callback; a confirmation in a non-last sub-agent followed by the next) folds without a resync in both modes; a stream cut after the adk_request_* call with no pause signal closes turn.abort from flush() and never turn.done; with the host-completion event of §8.0 obligation 4 fed, a completed invoke closes turn.done {outcome:"success"} from push(); without it, a completed Workflow invoke — an event emitted from within a workflow node never closes it as success (§8.0 item 26) — or an invoke whose stream is cut short closes turn.abort from flush(), never success (INV-FLUSH (2): no pause signalled, no invocation error); a turn that a normalizer not configured for that event closed as success on a final response that carries no branch and no ADK node info, with no ask pending, before the invocation met its first invocation error, keeps that close (§8.0 items 26 and 35). Every replay golden folds as follows, where a host that keeps §8.0 host obligation 4 feeds the host-completion event to a normalizer configured for it and a host that does not keep it does neither: a golden whose run the normalizer closes on the framework’s own events folds unchanged either way; a golden whose run it does not close that way closes as obligation 4 directs when the host keeps it, and with turn.abort from flush() when the host does not. Two further ADK fixtures are bound by no other criterion of this item: a run that continues after a final response (a SequentialAgent of two answering sub-agents; an agent whose after-agent callback appends content after its final response) folds without a resync with and without the host-completion event, and no message.start or block event names a turn after that turn’s terminal (§8.0 ‘Ids across invokes’); the reference ADK normalizer closes each one’s first turn as turn.done {outcome:"success"} from push() on that final response when it is not configured for the host-completion event.
  26. Interim-narration marker (draft.4) — text.start{phase:"interim"} → text.delta → text.end{} folds to a text block with phase:"interim"; reasoning.start{} → reasoning.delta → reasoning.end{phase:"interim"} folds to a reasoning block with phase:"interim"; a phase value this version does not document folds verbatim; a stream with no phase folds byte-identical to draft.3; and an OpenAI Responses response with two message items, phase:"commentary" then phase:"final_answer" (each on response.output_item.added), yields phase:"interim" on the first item’s text.start only, while both text blocks keep providerMetadata.phase verbatim; a native phase of null or "" yields neither phase nor providerMetadata.phase. The emit/re-input leg (no phase in a native payload) binds only implementations that ship an emit surface.
  27. Re-delivery never folds twice (draft.4) — within one invoke, a re-delivered event (0 < seq <= lastSeq), a second block-creating *.start for an id already present, a delta or block-creating event into a sealed message or into any message of a closed turn, a message.start naming a closed turn (INV-MSG), a message.start naming an id the fold already holds as a message, a tool.done with more:true for a toolCallId whose final (more-less) result the invoke already landed, and a second more-less tool.done for one toolCallId each leave needsResync true and the fold equal to the fold of the events before it; a later invoke restarting at seq 0 that reuses a block id folds normally, one reusing a message id the fold still holds parks as INV-MSG directs (the message set is fold-wide; a folded messages.snapshot that omits the id or a message.remove releases it), and a later invoke’s message.start naming a turn that an earlier invoke of the fold closed leaves needsResync true and the fold equal to the fold of the events before it; and a re-sent turn.start plus a second turn.done{paused} for a turn already closed paused, with refreshed asks[], does not park: the fold keeps one record for the turn, and that record carries the later asks; and any other turn.done, turn.error or turn.abort for a closed turn — a success, error, rejected or aborted outcome after any terminal, any terminal after turn.error or turn.abort, or a second turn.done{paused} with no turn.start naming the turn since its terminal — leaves needsResync true and the fold equal to the fold of the events before it. Across a messages.snapshot, closure follows the fold’s turn records (INV-MSG), for terminals as for message.start: after a snapshot that carries turn X with an outcome, a message.start naming X, a tool.done whose messageId adopts a message of X, and a turn.done, turn.error or turn.abort naming X other than the paused refresh each leave needsResync true and the fold equal to the fold of the events before it, whether the reducer closed X before the snapshot or joined at it; a snapshot that omits turns after X closed keeps X closed; a snapshot that carries X without an outcome, or carries turns: [], leaves X open, and the same message.start or terminal folds; and a completed invoke re-sent from seq 0 after a snapshot of its own fold leaves needsResync true and the fold equal to the snapshot’s. The reference suite checks the §8.0 producer duties on its corpus: on every replay golden, block-creating *.start ids are unique within each invoke, no two message.start events of one invoke name one id and none names an id an earlier tool.done.messageId of the invoke named, no message.start follows the terminal of the turn it names, and no turnId carries more than one turn.done, turn.error or turn.abort; on every committed resume golden folded after the golden of the invoke it resumes, no turn id or message id of one invoke recurs in the other.
  28. Host-appended events (draft.4) — every replay golden’s push + flush output followed by a host-appended paused hitl.ask turn numbered from the last seq plus 1 folds with needsResync false, and the appended turn appears in turns (§8.0 host obligation 5).
  29. Forward-compatible records and inputs — (records) The stored AgMessage {id:"m", role:"assistant", zzTop:1, content:[T1, U, TR, T2]}, where T1 = {type:"text", text:"a", zzKey:"k"}, U = {type:"zz"}, TR is a tool-result whose content holds {type:"zz"}, and T2 = {type:"text", text:"b"}, reads as a record with content [T1, T2] and with zzTop and zzKey intact. It produces exactly two reports, U at ["content",1] and TR at ["content",2], and inserting them at their indices reproduces the stored record. [R1, R2] with R2.scope = "zz" reads as [R1] with one report at [1]. An object without id, role or content is not materialized and is reported. Every message folded from the recorded corpus reads back unchanged with no report. (inputs) A resume whose answers[1].status is "zz" is rejected whole with unknown-value at ["answers",1,"status"]. kind:"zz" is rejected with unknown-value at ["kind"]. A start whose run.reasoning.mode is "zz", or whose messages[0].content[1].type is "zz", is rejected with unknown-value at that path; a start whose run.reasoning.effort is "xhigh" (a string outside the documented values) is accepted, and the checked input carries "xhigh" unchanged. kind:9 and an answer with no status are rejected as malformed. A start whose protocol is "foo" is rejected as malformed at ["protocol"], and so is one that also carries kind:"zz". A version whose major is not 1 is rejected as major-mismatch at ["version"]. kind:"zz" with threadId:5 is rejected as malformed at ["threadId"], and kind:"zz" with messages:5 as unknown-value at ["kind"]. A start whose messages[0].content is [{type:"zz", text:5}, {type:"text"}] is rejected as malformed at ["messages",0,"content",1,"text"], and with the two elements swapped as malformed at ["messages",0,"content",0,"text"]. A start whose messages[0].role is "zz" is rejected as unknown-value at ["messages",0,"role"]. A resume whose uiActions[0] is an mcp-app ui/open-link interaction with params.url:5 is rejected as malformed at a path that begins ["uiActions",0,"params"]. An answer that carries one unknown field is accepted with that field intact.
  30. No credential material in authentication requests (draft.4) — driven with an ADK-generated OAuth2 authentication request whose authorization URI query carries state and nonce, and whose credential carries a client secret, access and refresh tokens, a PKCE code verifier, an authorization code, an authorization response URI and standalone state and nonce members, an ADK normalizer emits none of those values, raw or JSON-escaped, at any depth and as neither key nor value, in any event it emits — including the hitl.ask and its metadata, the credential-request call’s arguments (streamed and assembled) and result, and the folded paused asks[] — except that state and nonce appear inside an authorization-request-URI member, or inside that member’s serialization in the streamed arguments, and every such member equals the ADK-issued URI byte for byte.
  31. Credential objects omitted from shared state (draft.4) — for a normalizer targeting ADK, synthetic native events whose actions.stateDelta carries framework credential objects (as an entry’s value and nested within it, in both spellings, including an ADK auth configuration stored whole under one key, whose credential objects sit one level down; a credential whose only member besides authType is resourceRef; and a serviceAccount credential whose serviceAccount member is {useDefaultCredential: true}; and a state map that is itself a framework credential object and also holds an ordinary key, in both spellings), temp:-prefixed entries, an own __proto__ key and ordinary entries yield, per native event, exactly one state.delta whose patch deep-equals the ordinary entries alone ({} when none remain, and {} when the state map is itself a framework credential object), and the same sequence of event types as the same events with those entries removed. No emitted event and no part of reduce(events).result contains a value from an omitted entry, and no omitted key appears in reduce(events).result.state or as an own key of any emitted patch. An ordinary entry of a state map that is not itself a framework credential object, including an object whose authType is not one of ADK’s credential types, that has no credential member, or whose resourceRef is null, is carried byte-identical. push() does not throw on a state map that is not an object.
  32. Credential material off provider-raw carries (draft.4) — for a normalizer targeting ADK, fresh per-invoke synthetic natives of a resumed invoke carry a typed ADK auth configuration whose API-key, OAuth2 and service-account credentials, in both spellings, hold distinct secret leaves: as actions.agentState.input, as the event output with a text part equal to its JSON serialization, at output.result with its rendering, and in the event’s customMetadata; they also carry an untyped reply inside a response named adk_request_credential nested in actions.agentState.input. No secret leaf appears, raw or JSON-escaped, in any emitted event or in the concatenation of any one text block’s deltas; the authScheme, credentialKey, authType, done and nodeInfo values and the nested response’s id and name are byte-identical to the native; every member of a carried value outside those credential objects and that response is byte-identical to the native; the emitted event types equal those emitted for the same natives with their rendering text parts removed; every event passes producer-strict validation, push() does not throw and the fold does not resync. On every replay golden the rule changes no byte of the serialized stream. An untyped reply outside a named response and a model’s restatement of a reply are not covered (§13.9).
  33. Ext segment reservation (draft.4) — no <vendor> segment of an ext.<vendor>.<key> event type that a normalizer outside the reference SDK emits is on §12’s reserved list, unless the whole type is one this specification names (for example ext.anthropic.frame or ext.langgraph.custom); for the reference SDK’s normalizers, which own the reserved segments, the check is instead that the set of segments they emit minus §12’s reserved list is empty. This item checks the reserved list only; it does not establish that an emitter owns an unreserved segment.
  34. Partial-frame carry (draft.4) — a native frame that maps onto existing AgJSON vocabulary only in part, and whose remaining consumer-facing fields have no other named carrier (§8 item 22), yields its home event(s) and then exactly one ext.<vendor>.frame {kind, frame} whose frame deep-equals the native frame’s JSON form (§13.7); a frame whose remainder rides a named carrier yields no ext.<vendor>.frame. For the Claude Agent SDK, a model_refusal_fallback frame yields its message.remove events before exactly one ext.anthropic.frame {kind:"model_refusal_fallback"}, and no replay golden carries an ext.anthropic.frame whose frame is a result frame, an assistant frame, a permission_denied notice, or a user frame whose every content block is a tool_result.
  35. Sealed-message finalizers and merges (draft.4) — within one invoke, each of text.end, reasoning.end, reasoning.opaque and tool.args.assembled for a block of a sealed message, or of any message of a closed turn, leaves needsResync true and the fold equal to the fold of the events before it; the same events before that message.end and before that turn’s terminal fold normally. message.metadata and turn.done {messageId, messageMetadata} naming a sealed message leave needsResync false and merge onto that message as their §5 rows state. (Delta and block-creating events into a sealed message or into any message of a closed turn, and a message.start naming a closed turn: item 27.)
  36. Nested-turn closure (draft.4) — for every subagent.start a normalizer emits, the output of push() + flush() contains exactly one turn.done | turn.error | turn.abort with that explicit turnId, carrying no usage, before that turn’s subagent.done with no event between them, except that a nested turn closed at flush may have no subagent.done (INV-TURN); no nested turnId equals a turn.start turnId; and folding the stream with its subagent.done events removed yields a structurally identical AgReduceResult (INV-FOLD).
  37. Shared-state fold (draft.4) — from no working copy, state.delta {cfg:{a:1,b:2}} then state.delta {cfg:{a:5}} fold to {cfg:{a:5}}; state.delta {k:null} folds to a present k whose value is null; a state.delta whose patch is a scalar leaves the working copy unchanged without a resync; and a state.delta whose patch is a non-empty JSON Patch array, against no working copy, sets needsResync. (ADK reads a stored null as a present value, so treating null as a deletion would make the fold disagree with ADK’s own state.)
  38. ADK shared-state fixture (draft.4) — folding an ADK normalizer’s golden whose native stream rewrites part of an object-valued key yields a working copy deep-equal to ADK’s own session state read after the run, and the fold does not resync; a normalizer whose framework has no key-addressed shared state records this item N/A.
  39. Kept-open tool-result snapshot fold (draft.4) — the stream turn.start, message.start, tool.start{c1}, tool.done{c1, more:true, content:[A], outcome:"error", isError:true, errorText:"e", structuredContent:S, uiData:U, _meta:M, toolMetadata:T, providerMetadata:{p:1}}, tool.done{c1, content:[B], outcome:"ok", providerMetadata:{q:2}} folds to exactly one tool-result block for c1 with content [B], outcome "ok", no isError, errorText, structuredContent, uiData or preliminary, _meta M, toolMetadata T and providerMetadata {p:1, q:2}, with no resync; a kept-open result carrying structuredContent S followed by a final {outcome:"error", isError:true, errorText:"boom", content:[E]} folds with no structuredContent; and a kept-open result carrying uiData U followed by a final that carries uiData: null folds to a tool-result block whose uiData member is present with the value null.
  40. Flush honesty (draft.4) — each reference normalizer, fed a native stream cut off mid-turn with an open text block, an open reasoning block and a partially streamed tool call, emits from flush() only events of type text.end, reasoning.end, step.done, subagent.done, message.end, turn.abort, turn.error and ext.<vendor>.* — and turn.done only as the paused release INV-FLUSH (2) directs, and turn.start and message.start only for the one error turn INV-FLUSH (3) admits after such a release — each ext.<vendor>.* flush event carrying, as the value of one member of its payload, a native frame or native value fed to the normalizer before flush(), less any member §8.0 forbids a normalizer to emit, and preceding every turn.abort and turn.error that flush emits — and every turn it opened carries an outcome after the fold. An OpenAI-Agents-targeting normalizer fed function_call → response.completed (usage U) → tool_called → tool_approval_requested → end of stream emits exactly one terminal for that turn: a turn.done whose outcome.type is "paused", whose asks name the askId of the hitl.ask the normalizer emitted for that call, and whose usage equals U; fed the same stream without tool_approval_requested, it emits turn.abort (reason stream-truncated) for that turn and a message.end whose usage equals U. The askId is the one §8.0 item 37 binds. Framework-specific legs follow §8’s applicability scoping.
  41. MCP Apps view locator carry (draft.4) — for every native tool result carrying MCP Apps _meta.ui (the reference suite runs it over the replay goldens), the normalizer’s tool.done for that call carries _meta.ui deep-equal to the native ui member.
  42. Kept-open results are snapshots (draft.4) — a normalizer whose native source delivers more than one result for one tool call emits each tool.done for that toolCallId as the full current result: every result channel the native current result populates is present on the event, and a channel the native current result lacks is absent. A Vercel-AI-SDK-targeting normalizer fed a tool that yields V1, yields V2 and returns emits tool.done{more:true, structuredContent:V1}, tool.done{more:true, structuredContent:V2} and a final tool.done{structuredContent:V2} with no more; fed a tool that yields V1 and then throws E, it emits a final tool.done{outcome:"error", isError:true} carrying E’s message as errorText and no structuredContent. A normalizer that never emits more than one tool.done per call satisfies this item trivially. Framework-specific legs follow §8’s applicability scoping.
  43. Never-opened terminals (draft.4) — a turn.error whose turnId X no event opened folds to no turn record for X, with needsResync false; after a messages.snapshot with no turns whose message carries turnId X and threadId T, a turn.done{turnId:X, outcome:{type:"paused", asks:[a]}} folds to exactly one record for X, whose threadId is T and whose asks deep-equal [a]; when that message carries no threadId, the same turn.done folds to no record for X, with needsResync false; turn.start{turnId:X, threadId:T}, then messages.snapshot{messages:[], turns:[]}, then turn.done X folds to no record for X, with needsResync false, and to a result structurally identical to the fold of the same stream without its turn.start; turn.start{turnId:X, threadId:T}, then a messages.snapshot with no turns, then turn.done X folds to exactly one record for X, whose threadId is T; and turn.start X followed by turn.done X carrying usage U folds to exactly one record for X, whose usage deep-equals U. On every replay golden, each turn.done, turn.error and turn.abort of an invoke follows a turn.start or subagent.start with the same turnId in that invoke.
  44. MCP resource links (draft.5) — binds the normalizers that §8.0 item 31 binds. Feed such a normalizer a tool result whose content[] holds N MCP resource_link parts, each with a string uri. It emits exactly N resource-link blocks in tool.done.content, in native order. Each block’s uri, name, title, description, mimeType, size, annotations and _meta deep-equal the native members where present and fitting (item 31) and are absent where the native member is absent or does not fit. No provider-raw block in that tool.done carries a member type equal to "resource_link". A native part carrying icons, a size that is not an integer, or an annotations object carrying a member AgAnnotations does not define is followed by exactly one provider-raw block carrying exactly those members (reduced per item 28), and the resource-link carries none of them; a native part carrying none of these is followed by none. Fed a part typed resource_link whose uri is absent or not a string, it emits no resource-link block for that part and exactly one provider-raw block whose raw deep-equals the part after item 28’s reductions, its type included, and push() does not throw. Framework-specific legs follow §8’s applicability scoping.
  45. Host records are side metadata (draft.5) — this item binds producers and consumers. On every replay golden of a normalizer targeting the Claude Agent SDK, no event’s providerMetadata has a top-level member named any of these keys: context_usage, usage_report, user_message_uuid, user_message_uuids, resume_reason, aborted, supersedes, resourceLinks, the permission-denial keys decisionReasonType, decisionReasonCode and decisionReason, or the notice siblings level, preventContinuation and toolUseId. Folding any golden with every _meta member and every message.metadata event removed yields turns[*].usage and messages[*].usage deep-equal to the full fold.
  46. OpenAI handoff round release (draft.5) — an OpenAI-Agents-targeting normalizer is fed function_call c1 (transfer_to_X) → function_call c2 (transfer_to_Y) → response.completed (usage U) → handoff_requested c1 → handoff_occurred c1 → one complete round of the target agent → end of stream. In the push() that consumes handoff_occurred it emits c1’s tool.done, then the source round’s message.end without usage, then exactly one turn.done for the source turn. That turn.done carries the deferred outcome and finishReason, and its usage equals U. The normalizer emits no tool.done for c2 from push() or flush(), and flush() emits no event naming the source turn. The folded output has needsResync false, and the source turn record has outcome.type "success". Fed the same stream with a tool_output for c2 after handoff_occurred, it emits no tool.done for c2 and the fold has needsResync false. Fed it with a tool_output for c2 before handoff_occurred, it emits c2’s tool.done and releases the close when the round’s last result lands. Fed it with a program call of the source round still without a result, it keeps the close deferred at handoff_occurred, and flush() releases it per INV-FLUSH (2). Fed leg a’s stream with a third function_call f (lookup) in the source round and tool_called f, but no output for f, before handoff_occurred c1, it emits no event owned by the source turn (its turnId, INV-OWNER) other than c1’s tool.done, the handoff event and ext.<vendor>.* carries in the push() that consumes handoff_occurred, emits no tool.done for c2 or f, and flush() emits the source round’s message.end with usage equal to U followed by exactly one turn.abort with reason stream-truncated. Fed that stream with a tool_output for f after handoff_occurred, in the push() that consumes f’s tool_output it emits f’s tool.done, then the source round’s message.end without usage, then exactly one turn.done whose outcome.type is "success" and whose usage equals U; it emits no tool.done for c2, and the fold has needsResync false; fed the same stream without c2, it emits no source message.end or terminal at handoff_occurred and releases the close in the same way. Fed the first stream with tool_approval_requested f and its hitl.ask in place of tool_called f, it emits no source message.end or terminal at handoff_occurred, and flush() emits exactly one turn.done whose outcome.type is "paused" and whose asks name f’s ask and no other call. Fed the first stream with f’s only run-item carrying a name the normalizer does not map, it behaves as it does with tool_called f. Framework-specific legs follow §8’s applicability scoping.
  47. Cross-invoke tool result and Claude deferred pause (draft.5) — for each two-invoke fixture pair a normalizer’s framework has (Claude: a deferred tool followed by its resume allowed, and by its resume denied; OpenAI Agents SDK: an approval-gated tool call followed by its resume approved, and by its resume rejected; Vercel AI SDK: a streamText call whose initial pass settles a prior call’s approval, as a synthetic pair until a capture exists), with one Normalizer per invoke and one threadId passed to both: each invoke folded alone, and the pair folded through one Reducer, leave needsResync false; the resuming invoke has no tool.start for the carried toolCallId and exactly one tool.done without more:true for it, whose turnId names a turn.start earlier in that same invoke and no turn of the earlier invoke, and whose messageId names no message of the earlier invoke ("<toolCallId>:result" on the Claude pairs and the synthetic reducer leg) and folds to a role:"tool" message; the combined fold holds one tool-call and one tool-result block for that id, in different turns, and the resume leaves the earlier invoke’s turn record unchanged. For Claude, the deferring invoke’s last terminal is turn.done {outcome:"paused", finishReason:"paused"} from push() with no finishReasonRaw and exactly one ask {kind:"approval", toolCallId:<deferred id>}, preceded by a hitl.ask with the same askId; the allowed resume lands outcome:"ok", and the denied resume lands outcome:"denied" with neither errorText nor isError. A reducer leg binds every conformant reduce(): a synthetic invoke of turn.start, message.start, tool.start, message.end, hitl.ask and a paused turn.done, followed by an invoke restarting at seq 0 with turn.start, tool.done {turnId, messageId:"x:result"} and a closing turn.done, folds with needsResync false and places the result in a role:"tool" message of the second turn. A second reducer leg binds every conformant reduce(): four invokes, where the first opens a call x and closes its turn paused, the second lands x’s result and closes its turn, the third opens a call x again and closes its turn paused, and the fourth lands that result and closes its turn, with each landing invoke’s turnId unique to that invoke and its result’s messageId equal to "<turnId>:x:result", fold with needsResync false into two role:"tool" messages, each in the turn of the invoke that landed it. A landing invoke that leaves its turn open is not covered by this item.
  48. Record events on unopened turns (draft.5) — for each record event E (prompt.blocked, guardrail.result, agent.capabilities, source, handoff, display.required): E naming turn X alone folds to no record for X, with needsResync false; E, then turn.start{turnId:X, threadId:T}, then turn.done X folds to exactly one record for X whose threadId is T, carrying E’s landing; after a messages.snapshot with no turns whose message carries turnId X and threadId T, E then turn.done X folds to exactly one record for X whose threadId is T; message.start{turnId:X, threadId:T} then display.required X folds to exactly one record for X whose threadId is T and whose displayRequired holds that entry; E, then a messages.snapshot with no turns, then tool.done{turnId:X, messageId:M} sets needsResync and creates no message M; after turn.start and turn.done{usage:U} of turn X, a turn.error with no turnId sets needsResync and leaves X’s usage deep-equal to U and its outcome unchanged; and in no fold does a turn record or message carry a threadId that no event carried for its turn.
  49. Opener first for record events (draft.5) — on every replay golden, each prompt.blocked, guardrail.result, agent.capabilities, source, handoff and display.required of an invoke names, or resolves by messageId to, a turn that a turn.start or subagent.start opened earlier in that invoke.
  50. Cache-inclusive inputTokens and per-object usage scope (draft.5) — an Anthropic result frame with usage {input_tokens: 2, cache_read_input_tokens: 6858, cache_creation_input_tokens: 187, output_tokens: 73}, total_cost_usd: 0.0166347 and a modelUsage entry {inputTokens: 6, cacheReadInputTokens: 13716, cacheCreationInputTokens: 4061, outputTokens: 275, costUSD: 0.0156577} folds to a turn terminal whose usage carries inputTokens: 7047, cacheReadTokens: 6858, cacheWriteTokens: 187, outputTokens: 73, costUsd: 0.0166347, costScope: "query", cumulative false or absent, and a byModel entry {inputTokens: 17783, cacheReadTokens: 13716, cacheWriteTokens: 4061, outputTokens: 275, costUsd: 0.0156577, cumulative: true}; the same frame with both cache counters absent folds with inputTokens: 2 and no cache key. On every replay golden, every AgUsage object at any depth that carries cacheReadTokens or cacheWriteTokens satisfies inputTokens >= (cacheReadTokens ?? 0) + (cacheWriteTokens ?? 0); on every Claude replay golden each turn-terminal and message-level inputTokens equals the native frame’s input_tokens + cache_read_input_tokens + cache_creation_input_tokens, no turn terminal’s usage carries cumulative: true, every turn-terminal usage carrying costUsd carries costScope: "query", every byModel entry carries cumulative: true, and no message.end.usage built from a cassette without stream events carries outputTokens. Across the two terminals of a streaming-input invoke, each byModel entry’s counters and the top-level costUsd are non-decreasing while the top-level per-turn counters need not be, and the last terminal’s byModel output count equals the sum of the invoke’s top-level outputTokens. A turn.done whose usage carries costScope and byModel folds them verbatim into the turn record.
  51. Persistable projection (draft.5; memory scopes draft.6) — the reference SDK’s persistable projection of AgReduceResult omits every turn’s displayRequired[] and every memory record whose scope is neither thread nor declared, and changes nothing else: for a fold whose turn carries displayRequired and whose memory holds one record of each of the four scopes, the reference projection (toPersistableWithReport; toPersistable returns the projection alone) — called with no memoryScopes, which declares none — has no displayRequired key on that turn, keeps exactly the thread record and reports the agent, user and skill records by (scope, key) in the fold’s order through toPersistableWithReport’s typed report; called with { memoryScopes: ["user"] } it keeps the user and thread records in the fold’s order and reports the other two; the fold is not mutated by any call; for every replay golden the projection equals the fold with displayRequired and every non-thread memory record omitted, and the report lists exactly those records (empty for every committed golden). Binds the reference SDK’s reducer package only — a host’s own projection is governed by §13.3 and §13.11, which have no §10 item.
  52. No provider credit token (draft.5) — a normalizer whose framework exposes Anthropic stop_details (claude-agent-sdk; vercel-ai, which carries a finish step’s providerMetadata.anthropic.stopDetails on the step’s message.metadata) fed a complete assistant frame whose stop_details holds fallback_credit_token at its top level and nested at depth, a tool_result_meta entry whose remedy holds the same key, and a tool call whose input holds the same key as user content: JSON.stringify of the emitted events and of reduce(events).result() contains no occurrence of the token value; the folded message’s messageMetadata.stop_details deep-equals the native object less every fallback_credit_token and keeps recommended_model verbatim; the tool_result block’s _meta["anthropic/toolResultMeta"] deep-equals the entry less that key; and the tool call’s input keeps the user-authored key and value.
  53. Reported-fix carriage and durability (draft.5) — a reported, non-waiting vendor fix is carried, less what §13.10 forbids, to a host-readable home the §5 table folds, never to providerMetadata, and never lifted from tool-authored content; each reference normalizer asserts its instances and every reducer asserts the round-trip. (a) claude: a user frame with one tool_result and tool_result_meta:[{id:T, remedy:{kind:"zz_future_kind"}}] yields exactly one tool.done for T whose folded _meta["anthropic/toolResultMeta"] deep-equals the entry, keeps a sibling MCP key, drops a tool-authored key under the reserved anthropic/ prefix, and has no providerMetadata["anthropic/toolResultMeta"]. (b) claude: an assistant frame carrying the six remedy-shaped wrapper keys api_error, api_error_params{remedy}, api_error_code, error_details, advisor_model and attribution_agent folds those keys, by their wire names, onto the first block’s _meta when the frame opens with text or thinking, else onto the message via message.metadata; none appears under providerMetadata. (c) claude: a complete assistant frame with a non-null stop_details closes with turn.done{messageId, messageMetadata:{stop_details}} naming that message, and the folded message’s messageMetadata deep-equals it. (d) openai: a response error carrying misalignment M yields, before that turn’s turn.error, a role:"notice", noticeSource:"adapter" message with one text block whose text equals M.detailed_explanation and whose _meta["openai/misalignment"] deep-equals M; with no misalignment, no notice. (e) google-adk: a waiting credential request yields one hitl.ask{kind:"auth"} per pending request in turn.done.outcome.asks[] and no turn.error (item 25). (f) a tool result whose structuredContent or own _meta carries {remedy:{kind:"mcp_needs_auth", url:"https://evil.example"}} yields no harness remedy key in _meta (no key under the normalizer’s reserved prefix) and that URL in no harness carrier — a reserved-prefix _meta key, message.metadata or an ext.<vendor>.* event; the tool’s own _meta rides verbatim on the tool.done as tool-authored bytes. (g) for each of (a)–(d), a fresh reducer fed messages.snapshot{messages, turns} built from the first fold’s result() yields a result() structurally equal to it (INV-FOLD).
  54. Error-outcome retriable (draft.5) — folding turn.start{turnId:"t"} then turn.error{turnId:"t", message:"m", code:"c", retriable:false} yields turns[0].outcome deep-equal {type:"error", message:"m", code:"c", retriable:false}; the same stream without retriable yields an outcome with no retriable key; a non-terminal error{retriable:true} pushed before the turn.error leaves the outcome’s retriable as the turn.error carried it; and the folds of the api-error-auth and defer-tool-sonnet5-resume-unavailable goldens each carry exactly one errored turn with outcome.retriable === false.
  55. Host-supplied partition root (draft.5) — a normalizer constructed with threadId T, where T equals no id it mints, fed each of its replay goldens’ native streams through push() then flush(), emits every turn.start, message.start and artifact.start with threadId T and no event whose turnId or parentTurnId is T; reduce() of that output yields every turns[*].threadId, messages[*].threadId and artifacts[*].threadId equal to T, nested turns included, with needsResync false; the same normalizer constructed without threadId emits output deep-equal to the committed golden.
  56. Memory record metadata (draft.8) — take the stream turn.start (seq 0), memory.write {scope:"thread", key:"k", value:{a:1}, _meta:{provenance:{source:"user"}}, zzTop:1} (1), memory.write {scope:"thread", key:"k", patch:[{op:"replace", path:"/a", value:2}]} (2), memory.write {scope:"thread", key:"k", patch:[{op:"replace", path:"/a", value:3}], _meta:{x:1}} (3), memory.write {scope:"thread", key:"k", value:{a:4}} (4), turn.done (5). After seq 3 the fold’s memory holds exactly one record structurally equal (INV-FOLD) to {scope:"thread", key:"k", value:{a:3}, turnId, _meta:{x:1}} — zzTop absent, the seq-1 _meta kept through the seq-2 patch and replaced as a unit by seq 3; after seq 4 the record carries no _meta. Incremental and batch folds are structurally identical, the resync condition is false, toPersistable keeps _meta and readStoredAgMemoryRecords reads it back with no report.
  57. ADK prompt block → prompt.blocked (rc.1) — each vector is a sequence of ADK events of one invocation, each carrying its invocationId, in the non-streaming shape (§8.0 item 34). An event {errorCode:"SAFETY", usageMetadata} with no finishReason, no content parts and no errorMessage yields exactly one prompt.blocked{reason:"safety"} with no reasonRaw or safety, and exactly one terminal, whose seq is higher than the record’s; the same event with an errorMessage yields the same record before exactly one terminal. errorCode "OTHER", "IMAGE_SAFETY", "JAILBREAK" and "BLOCK_REASON_UNSPECIFIED" each yield prompt.blocked{reason:"other"} with reasonRaw equal to the value byte for byte. No prompt.blocked is emitted for {errorCode:"SAFETY", finishReason:"SAFETY"}, {errorCode:"OTHER", finishReason:"OTHER"}, {errorCode:"429", errorMessage}, {errorCode:"UNKNOWN_ERROR", errorMessage}, or {errorCode:"SAFETY"} with a text part. An event that carries an a2a:response entry in customMetadata, a prompt-block errorCode and no finishReason or content parts (a remote agent’s prompt block, relayed) yields no prompt.blocked. With the host-completion event of §8.0 host obligation 4 fed to a normalizer configured for it, events SAFETY, PROHIBITED_CONTENT and SAFETY, none carrying errorMessage, yield three records in that order before the one terminal; for a normalizer not configured for that event, each event’s record names its own top-level turn — the first the invocation’s turn, the later two the turns opened after each close — each before that turn’s terminal. The reference ADK normalizer, when it is not configured for the host-completion event, closes the turn of an event {errorCode, usageMetadata} with no errorMessage, finishReason, content parts, branch or ADK node info, arriving with no ask pending, before the invocation met its first invocation error, with turn.done {outcome:{type:"success"}} from push() on that event, after its record: finishReason "safety_blocked" with no finishReasonRaw for SAFETY, "other" with no finishReasonRaw for OTHER, and "unknown" with finishReasonRaw equal to the value byte for byte for JAILBREAK. A SAFETY event without errorMessage that arrives while an ask is pending yields its record before the turn’s one terminal. An event carrying both promptFeedback.blockReason and the errorCode shape yields exactly one record. A SAFETY event without errorMessage that carries ADK node info, followed by the host-terminal event of §8.0 host obligation 1, yields one record and then turn.error. reduce() over each vector that yields a record lands, on each turn a record names, AgTurnRecord.promptBlocked equal to the last record emitted into that turn. Every emitted event passes producer-strict AgEvent validation.
  58. Block finish reason whatever the outcome (rc.1) — for each native reason or refusal signal a normalizer’s mapping sends to "safety_blocked" or "refusal", a normalizer fed a native terminal carrying it, in the order its framework delivers its frames, which it closes with a turn.done whose outcome is not paused, emits exactly one turn.done for the turn, with the mapped finishReason; every event it emits passes producer-strict AgEvent validation, and reduce() of the stream yields needsResync false and that turn record’s finishReason equal to it; a normalizer that defers a round’s close meets the same assertions for a close released after the round’s pending tool results land; a following round of the same invocation that carries no such reason or signal does not carry that finishReason; the item constrains nothing about outcome.type (§4, “A block is not an outcome”).
  59. OpenAI built-in round and hosted-MCP approval (rc.1) — an OpenAI-Agents-targeting normalizer fed, in the order @openai/agents delivers them (the raw items, response_done, response.completed with usage U, then the run-items), a round whose response lists a web_search_call, an mcp_call for an MCP tool T with server_label S and arguments X, and a message, emits exactly one turn.start and one terminal for that round, a turn.done with outcome.type "success" and usage U, containing a tool-call and a tool-result block for each built-in call, the mcp_call’s tool.start carrying name T and serverName S and its tool.args.assembled input deep-equal to X parsed as JSON, and no turn.abort; fed a resolved mcp_call that carries no providerData.status, once with an object and once with an empty string as its providerData.error, it emits that call’s tool.done with outcome "error" each time; fed a round whose response lists two mcp_approval_request items for an MCP tool T on server S with arguments X, each followed by its tool_called and tool_approval_requested run-items, then end of stream, it emits for each request a tool.start whose toolCallId is the request’s id, whose name is T and whose serverName is S, with a tool.args.assembled input deep-equal to X parsed as JSON, and for that round exactly one terminal, a turn.done whose outcome.type is "paused", whose asks are exactly approval_<id> for each request id, each with toolCallId equal to that id, and whose usage equals U, and no tool.done for either request id; fed a request that the invoke answers itself — its tool_called run-item followed by an mcp_approval_response run-item approving it, with no tool_approval_requested run-item — it emits no hitl.ask for that request and exactly one tool.done for its id, with outcome "ok", in the turn that holds the request’s tool.start; for the resuming invoke, one mcp_approval_response run-item naming a request id with approve:false yields exactly one tool.done for that id with outcome "denied", and one with approve:true yields exactly one with outcome "ok", each per INV-XINV; and fed a client-executed built-in round followed by a second model round, it closes the first round only after its tool_output lands and the second round’s events name a turn derived from the second response’s id; and fed a round whose terminal response.output lists a web_search_call for which no run-item streams, followed by the invoke’s next model response, it emits that round’s terminal before the next response’s turn.start. Each invoke folded alone, and the pair folded through one Reducer, leave needsResync false.
  60. ADK invocation errors (rc.1) — for each fixture a normalizer’s framework has (ADK runAsync, each in the default and the SSE streaming mode, every response-level block below carrying a finish message so that its event carries errorMessage: a SequentialAgent whose first sub-agent’s model call throws a rate-limit error that ADK yields in-band and whose second sub-agent answers; the same with three sub-agents, the second and third answering; a Workflow node with a retry configuration that yields an in-band error and then succeeds; a ParallelAgent with one branch yielding an in-band error and one answering, fed in both arrival orders; a lone in-band error ending the run; a Workflow node whose unrecovered in-band error is followed by the host-terminal event of the run’s throw; a confirmation pause in a non-last sub-agent, then an Interactions-route failed status in the next sub-agent; a host-terminal event after a credential pause; after a signalled pause, an in-band error, then a host-terminal event; a request-input call whose pause is not signalled, then an in-band error; a ParallelAgent with two branches yielding different in-band errors, fed in both arrival orders; a Workflow with two parallel nodes, one of which meets a response-level block after the other has completed, fed with the other node starting first and, in the SSE streaming mode only, with the blocked node starting first; a Workflow whose entry runs one agent twice concurrently without a sub-branch, both runs ending on a response-level block; a SequentialAgent whose first sub-agent yields an in-band rate-limit error and whose second sub-agent then pauses, once on a credential request and once on a request-input call with skipSummarization; a plain agent whose node tool’s node yields an in-band error and then succeeds on retry; a LoopAgent of two iterations whose agent meets the same response-level block each time; a LoopAgent of two iterations over a built-in Chrome model, each iteration ending on an in-band error whose event carries turnComplete: true; a LoopAgent of two iterations whose sub-agents are an agent relaying a remote agent (RemoteA2AAgent) followed by an agent that replies in each iteration, each remote run ending on a response-level block with no streamed text and the same code, message and usage, once with usage and once without; a SequentialAgent whose first sub-agent answers and whose second sub-agent’s model call yields an in-band rate-limit error, and a LoopAgent of two iterations whose sub-agents are an agent that replies followed by an agent relaying a remote agent (RemoteA2AAgent) whose run ends on a response-level block, each with a usage report on every model call that returns a response; an agent relaying a remote agent whose remote model call streams text and then ends on a response-level block, and one whose remote finish rides its text chunk; in the SSE streaming mode only: a credential pause, then a content-less usage report, then an in-band error in a later step; an in-band error that the pausing step’s model stream yields after its pause was signalled, followed by a further model round; a response-level safety block that carries a finish message, once with usage on the finish chunk, once with usage on a later chunk, and once after streamed text with no usage; a LoopAgent of two iterations whose first model call meets a response-level block with no usage and no streamed text and whose second model call streams text and ends on the same block; a LoopAgent of two iterations over the Interactions route, each iteration ending on an in-band error whose event carries turnComplete: true. OpenAI: a host-terminal event while an approval round’s close is deferred), with one Normalizer per invoke, bare and, for a normalizer configured for the host-completion event of §8.0 host obligation 4, with that event fed: reduce(events).needsResync is false; every opened turn has exactly one terminal, and no event targets it after that terminal; each in-band invocation error yields exactly one non-terminal error event, carrying its errorCode as code, from the push() that received it, and flush() emits no error event; the text of each message folds once, as it was streamed (item 9); no turn of an invocation that met an invocation error closes turn.done {outcome:"success"} after the invocation met it; with no signalled pause, the invocation’s turn closes with exactly one turn.error, after every content event of the invocation, carrying the host-terminal event’s code when one was fed and otherwise the first in-band error’s, with usage equal to the field-wise sum of the invocation’s usage reports as §8.0 items 4 and 35 count them, an event that §8.0 item 35 names as repeating an in-band invocation error counted once with the event it repeats (on an SSE leg with a function-call round only the presence of usage is asserted: where ADK’s aggregator closes that round with a content-less report, that report repeats the round’s usage), and the two orders of each ParallelAgent and parallel-Workflow fixture yield the same terminal type and the same usage, and with one erroring branch the same code; when an item-26 pause was signalled and the invocation met an invocation error, the asking turn closes turn.done {outcome:"paused", finishReason:"paused"} with one ask per pending request whose toolCallId is the answering id, and exactly one other top-level turn, whose turn.start follows that close and which carries one message with no content, closes turn.error; when a host-terminal event releases an item-14 round’s close, the released turn.done carries the deferred round’s finishReason (INV-FLUSH (2)), and exactly one other top-level turn closes turn.error; a turn whose pause was not signalled closes with that turn.error and no paused close; in bare mode flush() emits turn.start and message.start only for that error turn; two such invokes folded by one reducer yield distinct error-turn ids; for the SequentialAgent whose first sub-agent answers and the LoopAgent whose replying agent precedes its relaying agent, in each of which a final response that carries no branch and no ADK node info precedes the first invocation error, a normalizer not configured for the host-completion event that closed a turn as turn.done {outcome:"success"} on that final response meets the criteria above, except that in place of the criterion beginning “with no signalled pause” it meets this one: that turn.done was emitted before the push() that received the first invocation error; every error event and exactly one turn.error, after every content event of the invocation and carrying the code the displaced criterion names, name one other top-level turn, whose turn.start follows that close; and the usage of that turn.done and of that turn.error, an absent usage counting as zero, sum field-wise to the invocation’s usage reports as §8.0 items 4 and 35 count them; a normalizer that did not close on it meets the criteria above unchanged, and with the host-completion event fed each of the two yields one turn, closing turn.error with that sum.
  61. ADK relay bookkeeping off carries (rc.1) — for a normalizer targeting ADK, fresh synthetic natives in one fold, at least two of each shape: (a) a relayed event that completes a turn, whose customMetadata holds a2a:request (with distinct secret leaves at configuration.pushNotificationConfig.token, configuration.pushNotificationConfig.authentication.credentials, a metadata member, message.metadata.adk_user_id, message.metadata.adk_session_id and a forwarded text part), a2a:response (a Task at "completed" whose status carries no message and whose history repeats those leaves), a2a:task_id and a2a:context_id (each holding its own distinct secret leaf) and a member other; (b) a relayed event that does not complete a turn, whose customMetadata holds an a2a:request with its own distinct leaves, as copied from a remote agent’s metadata; (c) an event with no branch and no nodeInfo whose only unmapped value is a customMetadata holding only those four keys; (d) an event the normalizer cannot map, whose customMetadata, and in a second instance whose snake_case custom_metadata, holds those four keys with their own distinct leaves, so that it takes the ext.google.unparsed path. No secret leaf appears, raw or JSON-escaped, in any emitted event or in the concatenation of any one text block’s deltas; no emitted provider-raw block’s raw.customMetadata has any of the four keys; other is byte-identical to the native; in shape (c) no provider-raw block carries a customMetadata member; in shape (d) no secret leaf appears in the ext.google.unparsed event, and its native carries no emptied customMetadata and no emptied custom_metadata; the emitted events, and the fold of them, deep-equal those produced, with the same host-supplied invoke id, from the same natives with the four keys removed and with the metadata member removed where that removal empties it; push() does not throw. On every replay golden the rule changes no byte of the serialized stream.
  62. Relayed request echo omitted (rc.1) — for a normalizer targeting ADK, fresh synthetic relayed natives, at least two of each shape, each shape built once with its bookkeeping under customMetadata and once under custom_metadata; in each positive shape the a2a:response records a submitted status whose message is in the user role, in the spelling the shape names, and that status.message carries distinct planted leaves in a text part, a functionCall’s arguments, a functionResponse’s response and a thought part, with one content part for each of its parts: (i) a streaming status update (kind "status-update"); (ii) a task (kind "task") at "submitted" as the only event of a non-blocking send, whose metadata member holds only the relay’s entries, once with turnComplete false and once true; (iii) such a task with artifact parts beside that message, whose content is the artifact parts followed by the message’s parts; (iv) such a task that holds no artifact part, with the message’s parts, marked as thought, as the event’s content; (v) a task in the a2a-sdk 1.x spelling (no kind, "TASK_STATE_SUBMITTED", a "ROLE_USER" message) that holds no artifact part, with that message’s parts, marked as thought, as the event’s content; and, as controls, none of which carries an errorCode, at least two each of (a) an agent-role status update at "submitted" carrying text, a functionCall and a functionResponse, (b) a user-role status update at "working" carrying a functionResponse, once not marked as thought and once marked as thought, after an event of the same leg that carries its call, (c) shape (i)’s parts on an event with no a2a:response, (d) a task at a state other than "submitted" with a user-role status message, (e) a task at "submitted" with a user-role status message that holds an artifact part, with that artifact’s parts as the event’s content, (f) the a2a-sdk 1.x spelling at "TASK_STATE_WORKING" with a "ROLE_USER" message carrying a functionResponse, once not marked as thought and once marked as thought, after an event of the same leg that carries its call, (g) the a2a-sdk 1.x spelling at "TASK_STATE_SUBMITTED" with a "ROLE_AGENT" message, and (h) the a2a-sdk 1.x spelling at "TASK_STATE_SUBMITTED" with a "ROLE_USER" message whose recorded task holds an artifact part, with that artifact’s parts as the event’s content. Each instance, after any event its leg places before it, runs through a fresh normalizer with the same host-supplied invoke id, and “serialized” below means with every ext.google.relay-echo-omitted event removed and the later seqs renumbered. No planted leaf appears, raw or JSON-escaped, in any emitted event or in the concatenation of any one text block’s deltas; each positive instance emits exactly one ext.google.relay-echo-omitted event, whose parts equals the number of parts omitted, whose only member beyond the base envelope is parts, whose seq is greater than that of the turn.start of the turn the instance maps into and less than that of the turn’s terminal event, and which holds no leaf; its events, serialized, are byte-identical to those emitted from the same native with its content parts after the first k and their long-running tool ids removed, and that stripped native emits no such event; each control emits no such event, and its events, serialized, are byte-identical to those emitted from the same native with its a2a:response entry removed and with its metadata member removed when that empties it; in controls (b) and (f) the tool.done is present and folds against its call; over one fold of every instance, push() does not throw, every event passes producer-strict validation, and reduce() ends with no park, no resync and no tool call without its result.
  63. Native-selected prototypes (rc.1) — each normalizer, fed the natives of every replay golden its framework has and one native of its framework reaching each of tool input, a tool result and shared state where the framework has the shape, and usage where the framework’s native usage holds a map the normalizer carries whole, with an own __proto__ member planted at every object node beside at least one member the normalizer carries into an emitted event member of that kind, once per value leg (an object, null, an array, a primitive), and planted inside every JSON text the normalizer parses out of a native string (tool-call arguments, partial JSON), plus, where the framework has the shape, a native RFC-6902 operation whose path holds that token (control: the same patch with that operation removed) and a native object built with a null prototype or as a class instance, emits events in which a walk of own member names, including every value parsed out of such a string, finds no __proto__ at any depth; no emitted operation holds that token; every emitted object has the ordinary object or array prototype; the events equal those emitted for the unplanted natives, where a string member the normalizer builds by serialising a native value is compared after a parse and the same own-member omission and a string that does not parse is compared as it is; the planted member’s sibling appears in an emitted event member of that kind in both runs, else the test fails; and push() and flush() do not throw and the event-type sequence is unchanged. The assertions are made on emitted events, never on reduce(events).result; schema validation is not the detector. In JavaScript the prototype assertions run when assigning an object to a computed __proto__ member of a plain object changes that object’s prototype, and are recorded N/A with the check’s result when it does not; outside JavaScript they are N/A; the own-member walk and the equality run everywhere. A framework with no such native shape records the operation, foreign-prototype and usage legs N/A, as item 29’s input leg allows; the shared-state leg binds at the ADVANCED profile.
  64. Wire-selected prototypes at the fold (rc.1) — a reducer fed, without a decode, events in which an own __proto__ member is planted at every object node of every JSON-valued member of every event type the fold stores (the four value legs of item 63; the planted set includes turn.done.outcome.asks[].schema, turn.done/turn.error/message.end usage and a byModel entry, block citations, tool-result content[] blocks, messages.snapshot records, state.snapshot, both state.delta shapes and memory.write), yields a result in which a walk of own member names finds no __proto__ at any depth and every object has the ordinary object or array prototype, with the resync condition false, that deep-equals the fold of the same events without the member, deep-equals the fold of the same events after a consuming ingest, and is identical for batch and incremental folds (INV-FOLD); a state.delta or memory.write RFC-6902 operation whose path or from holds that token, or whose target or from location must exist and names a member the working copy or the record does not own, leaves the working copy or the record unchanged with the resync condition true and a result that is still returned, while add of such a name creates an own member; Object.prototype gains no member. The prototype assertions run under item 63’s prototype-effect condition (item 31 states the shared-state case for ADK natives).
  65. Wire-selected prototypes at the decode (rc.1) — a consuming ingest of a closed event and of an ext.* event, a reader of a stored AgMessage and of an AgMemoryRecord array, and a receiver of an AgInput and of an AgClientCapabilities or AgHitlAnswer carried by a request, each given a value holding an own __proto__ member at the top level and nested (the four value legs of item 63), return a value with no such member at any depth, every object of which has the ordinary object or array prototype, whose other members deep-equal the input’s, and which is returned, not rejected or stubbed; for the reader, inserting each reported value at its index reproduces the stored record less that member. A negative control shows the same input through a bare JSON.parse keeps the member. The input and request legs bind implementations that ship such a receiver (as item 29’s input leg); the prototype assertions run under item 63’s prototype-effect condition.
  66. ADK interrupted close (rc.1) — a normalizer targeting ADK, fed a Live native stream in which an event carrying interrupted: true precedes the interrupted generation’s usage-only report and its turnComplete, emits for that generation’s turn exactly one terminal, a turn.abort whose reason is "interrupted", from push() or flush(); fed the same stream cut after the usage-only report, before turnComplete, it emits that terminal by the end of flush(); neither emits a later turn.done for that turn. Every emitted event passes producer-strict AgEvent validation. (Informative: on the reference corpus each such turn.abort directly follows a message.end carrying the turn’s usage.)
  67. Terminal usage coverage (rc.1) — for every top-level turn of every replay golden whose turn.done carries usage, for each of inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, toolUseInputTokens and totalTokens present on both objects, whatever either object’s cumulative flag, the terminal’s value is at least the last message.end.usage folded for each of the turn’s own messages (those whose message.start names the turn); every nested turn’s terminal and every turn.abort carries no usage. (Informative: a top-level turn.error is outside the first clause — a framework may report a budget-crossing response in costUsd and not in the terminal’s token counters, §4 costUsd scope.)
  68. ADK internal metadata off carries (rc.2) — for a normalizer targeting ADK, fresh synthetic natives in one fold, at least two of each shape: (a) an event carrying an id-bearing function call, once with partial: true and once final, whose customMetadata holds __adk_internal_fc_hmac (an object keyed by the call’s id whose value is "v1:" followed by 64 lowercase hex characters, distinct per instance), __adk_internal_restored_event: true, a third entry named with the prefix holding its own distinct leaf, a member other holding a nested object, and a member host whose nested value itself holds a key beginning with __adk_internal_; (b) an event with no branch and no nodeInfo whose only unmapped value is a customMetadata holding only entries named with the prefix; (c) an event the normalizer cannot map, whose customMetadata, and in a second instance whose snake_case custom_metadata, holds those entries beside other, so that it takes the ext.google.unparsed path. No emitted provider-raw block’s raw.customMetadata, and no ext.google.unparsed event’s native customMetadata or custom_metadata, has a top-level entry whose name begins with __adk_internal_; no stamp of shape (a) and no leaf of the third entry appears, raw or JSON-escaped, in any emitted event or in the concatenation of any one text block’s deltas; other and host are byte-identical to the native, the nested key under host included; in shape (b) no provider-raw block carries a customMetadata member; in shape (c) the native carries no emptied customMetadata and no emptied custom_metadata; the emitted events, and the fold of them, deep-equal those produced, with the same host-supplied invoke id, from the same natives with the prefixed entries removed and with the metadata member removed where that removal empties it; push() does not throw. On every replay golden the rule changes no byte of the serialized stream.
  69. OpenAI refused computer action and screenshot bytes (rc.2) — an OpenAI-Agents-targeting normalizer fed, in the order @openai/agents delivers them, a client-executed computer_call round whose tool_output run-item carries executionStatus "executed" and whose rawItem.output is {type:"computer_screenshot", data: D} with D a data:image/png;base64,P URL, emits exactly one tool.done for that call with outcome "ok", no tool.done with outcome "denied" in the invoke, one file block whose source is {type:"base64", mediaType:"image/png", data: P} (P’s base64 decode begins with the bytes 89 50 4E 47 0D 0A 1A 0A) and providerMetadata.executionStatus "executed"; fed the same round whose run-item carries no executionStatus and whose rawItem.output is {type:"computer_screenshot", data: D, providerData:{approvalStatus:"rejected", message: M}}, once in the same invoke and once as the leading result of a resuming invoke (§5.0 INV-XINV), it emits exactly one tool.done for that call with outcome "denied", isError, errorText and errorCode absent, content holding exactly one text block whose text is M followed by one file block as above, and providerMetadata.approvalStatus "rejected"; fed a computer_call_result whose rawItem.providerData.status is "incomplete" and whose output carries no providerData (the SDK’s cancellation result), it emits no tool.done with outcome "denied"; fed one whose output.data carries no data: header, a file block whose data equals that value verbatim. Each invoke folded alone, and the resume pair folded through one Reducer, leave needsResync false and the thread holding exactly one tool-result block for the call; the committed approval-tool-gpt6sol-resume-reject replay (a function-tool rejection, no approvalStatus) folds byte-identically to its golden.
  70. ADK client-abort close (rc.3) — a normalizer targeting ADK, fed a runAsync native stream in the default and the SSE streaming mode that ends with an event whose errorCode is "INVOCATION_ABORTED" — one carrying no content; one answering one and one answering two dangling functionCall parts of the same model event; one answering a pending adk_request_confirmation call together with the call it guards — with no host-completion event (host obligation 4) and one Normalizer per invoke, two captured instances per shape and mode: reduce(events).needsResync is false; every opened turn has exactly one terminal, a turn.abort whose reason is "cancelled", emitted from the push() that received that event, after every content event of the invocation and after one tool.done {outcome:"error", isError:true, errorText:"Invocation was aborted by client."} per functionResponse part that event carries, the adk_request_confirmation call’s included; no error event and no turn.error is emitted for the invocation; no turn.done with outcome paused is emitted and the folded turn record carries no asks; flush() emits nothing for that turn; every emitted event passes producer-strict AgEvent validation. Fed the same confirmation stream cut before that event, flush() still closes the turn turn.done {outcome:"paused"} naming the ask (item 25 of this section), and fed a run that ends on an event carrying any other errorCode beside an errorMessage, the normalizer still emits one error and closes turn.error as §8.0 item 35 directs. (Informative: on the captured streams the message.end that precedes the turn.abort carries the turn’s usage whenever a model event of the turn carried usageMetadata.)

11. Resolved decisions

The forks; the spec leans to the smaller surface. Each item below is resolved as of draft.8 and states the decision the sections it names already carry; a later change to one is a change to those sections and follows §12. Items resolved by the feasibility audit are tagged [REVISED]; items resolved in favor of the MCP-Apps + A2UI UI-output model are tagged [A2UI].

  1. [A2UI] Shared state stays opaque both directions — no neutral bound-value channel yet. LangGraph values/updates stream modes ride state.snapshot/state.delta (output); the input-direction echo rides AgInput.state (all unknown). The A2UI per-surface data-model snapshot IS carried INPUT (the a2uiClientDataModel key on AgMessage.messageMetadata, re-homed from the old AgUiAction.dataModel field) and the server→surface push IS carried OUTPUT (ui.data-model); only live component-internal reactivity is the Layer-B loss.
  2. [REVISED] Tool-result channels = 4, one consumer each — content (model) · structuredContent (model, structured; always model-facing, NO audience flag) · uiData (surface/view, model-hidden) · sideData (app-only), with the §10 routing fixture. The old artifact tool-result channel is renamed sideData; the word artifact is now reserved for the A2A streamed entity (AgArtifact / artifact.*). The old modelStructured-collapse rationale is gone — the channel encodes audience.
  3. Async tools = minimal flags — more / willContinue / scheduling (when_idle/preempt/silent) only (no separate async-task lifecycle). [REVISED: the scheduling value interrupt is renamed preempt to keep the purged word out of the vocabulary.]
  4. [REVISED] Approval tokens are binding-gated — RECOMMENDED in this specification; a binding MAY make the token mandatory for side-effecting (state-changing) HITL approvals, and §13.1 governs its minting and validation whenever it is present. (Previously mislabeled “profile-gated”; no §9 profile gates tokens.)
  5. Capabilities are in-band — AgClientCapabilities (client→agent) + AgCapabilities (agent→client, now a defined interface), carried on the first turn via the agent.capabilities event.
  6. [NEW] Identity/lifecycle backbone — message.start/message.end partition the stream into messages; the event base carries turnId/messageId; turnId is dual-use (own-id + owning-turn cross-ref), parentTurnId is the subagent-parent link only; subagent.start is the sole nested-turn opener; turn-open is idempotent on turnId. branch was removed (re-addable additively if regenerate/edit-forks are specced).
  7. [A2UI] Agent-UI output = MCP Apps + A2UI — the two sanctioned agent-UI-OUTPUT paths are MCP Apps (static-HTML document surface, ui:// + text/html;profile=mcp-app resource block) and A2UI v1.0 (declarative component-tree + data-model stream). A2UI is the first-class declarative path: ui.surface.start|update|end + ui.data-model carry A2UI createSurface/updateComponents/deleteSurface/updateDataModel Layer-A-opaquely, keyed by surfaceId/catalogId. Two general-purpose constructs (state.snapshot/state.delta; hitl.ask.metadata/AgPausedAsk.metadata) are anchored on LangGraph (values/updates stream modes; interrupt(value: Any)). Surfaces are live-only (not in §1.3).
  8. [NEW] Surface-interaction un-merge — the old single merged AgUiAction is replaced by a shared AgSurfaceEnvelope (correlation: surface discriminant + surfaceId/toolCallId/turnId/threadId/_meta) + five per-spec-faithful constructs forming AgSurfaceInteraction: AgA2uiSurfaceAction / AgA2uiFunctionResponse / AgA2uiError (the three A2UI client→server legs, narrowed by the inner a2uiMessage discriminant action/function-response/error) (the error leg is upstream-faithful: exactly-one-of surfaceId | functionCallId), AgMcpAppViewMessage (MCP Apps view→host RPCs, nested on the verbatim JSON-RPC method), and AgOpenAiWidgetAction (OpenAI Apps SDK component→server RPCs, nested on method). surface carries the explicit discriminant a2ui | mcp-app | openai-app (reversing the old positional identification). callTool/setWidgetState stay spec-unique surface interactions, NOT the normalized tool-call. The A2UI action.timestamp/action.context become first-class fields on the A2UI arm (the old _meta-carrier bug is fixed); the client data-model snapshot is re-homed to AgMessage.messageMetadata.a2uiClientDataModel (decision 1). Two new output legs accompany the un-merge: ui.widget.result {surfaceId, callId, result} (the OpenAI callTool reply) and error.path? (JSON-Pointer) on ui.result / ui.action-result for the A2UI VALIDATION_FAILED.path. The un-merge is INPUT-surface-layer-only + these 2 small output legs; the isomorphic CORE is untouched.

12. Extensibility & versioning

protocol:"agjson", version semver — minor = additive-only: a minor release MAY add only what the ‘What is additive’ paragraph below permits in a minor, and MUST NOT change what an existing field or fold means or the members of a FROZEN set; any other change is a draft or release-candidate bump before 1.0 and a major after; an erratum (§15), which moves no version, is neither. Extension mechanisms:

Additive-minor only works if consumers honor §0.2’s ignore-unknown rule: a validating CONSUMER MUST use a lenient parse-known-else-skip mode; strict full-schema validation is a PRODUCER-side conformance check only.

What is additive. Additive in a minor: a new OPTIONAL field at any depth; a new event type named per §0.3; a new ext.<vendor>.<key> event. NOT additive (a draft or release-candidate bump before 1.0, a major after): a new value in a closed set, a new AgBlock type emitted to a client that has not declared it, or making an optional field required. A later minor MAY define a new AgBlock type together with an additive AgClientCapabilities field that declares support for it; a producer MUST NOT emit that block type to a client whose declared capabilities omit it, and on the input direction §0.2 governs unchanged. A consumer at draft.4 or later stubs an unknown event in place and keeps its seq slot (§0.2); a consumer before draft.4 drops it and parks on the gap. A producer emits only the values and types this specification defines at the version it implements; for a native value outside a FROZEN set it emits the set’s fallback and companion per §8.0. After 1.0, a change to what an existing field or fold means is a MAJOR.

Closed sets in major 1. Within major version 1 this specification adds no value to any set the table below classes FROZEN; new vocabulary for such a set lands only as an optional open-string companion field where the table names one (for example turn.done.finishReasonRaw). AgBlock has 16 kinds (§2, §9); a type discriminant nested inside another shape (AgSource, AgOutcome, AgToolDef.source, toolChoice, responseFormat) is a closed set of its own, not a block kind, and the table lists it as such. The §0.3 final-segment set is a naming rule for new event types, not a set a consumer checks. This table is the normative list of closed sets (the reference SDK checks its schema against it); it also classes the sets it lists that are not closed. A set the table classes OPEN STRING is not closed: a receiver accepts any string in that field and the checked input carries it unchanged; the values this specification names for it are documented, not exhaustive, and a producer MAY send others.

The direction column. It names the payload that can carry the set’s values: in an AgInput, at any depth (for example its messages, new or replayed, its run, answers, uiActions and results[]); out the event stream and the records folded from it; both either; — a naming rule that no payload carries as a value of its own (§0.3). It states carriage only, and changes neither a set’s class nor what §0.2 requires of a receiver of an input.

set where class direction companion anchor note
AgFinishReason AgFinishReason (export); AgClosedEvent[turn.done].finishReason, AgTurnRecord.finishReason FROZEN out turn.done.finishReasonRaw own
ToolOutcome ToolOutcome (export); AgClosedEvent[tool.done].outcome, AgBlock[tool-result].outcome FROZEN both none own
AgRole AgRole (export); AgMessage.role, AgClosedEvent[message.start].role FROZEN both none own
AgNoticeSource AgNoticeSource (export); AgMessage.noticeSource, AgClosedEvent[message.start].noticeSource FROZEN both none own
AgTrigger.kind AgTrigger.kind (AgClosedEvent[turn.start].trigger, AgTurnRecord.trigger) FROZEN out none (fallback unknown + ref) own
AgOutcome.type AgOutcome.type — a discriminated union, not z.enum (AgClosedEvent[turn.done].outcome, AgTurnRecord.outcome) FROZEN out none own
reasoning.start.mode (summarized|full) AgClosedEvent[reasoning.start].mode FROZEN out none own
AgOpaque.kind AgOpaqueKind (export); AgOpaque.kind FROZEN both none own
annotations.audience AgAnnotations.audience FROZEN both none verbatim MCP Role
AgCitation unit AgCitationUnit (a module-level enum in the citation head) FROZEN both none own
AgCitation bounds AgCitationBounds (a module-level enum in the citation head) FROZEN both none own
AgCitation.indexFrame citationHead.indexFrame (the head spread into every AgCitation arm) FROZEN both none own
AgAskKind AgAskKind (export); AgPausedAsk.kind, AgClosedEvent[hitl.ask].kind (rides inside turn.done.outcome.paused) FROZEN out none own
resumeBinding AgPausedAsk.resumeBinding, AgClosedEvent[hitl.ask].resumeBinding (the two declarations agree) FROZEN out none own
AgHitlAnswer.status AgHitlAnswer.status FROZEN in none transcribed MCP ElicitResult.action
code-result.outcome AgBlock[code-result].outcome (unknown → failed) FROZEN both none transcribed Gemini Outcome
uiVisibility AgBlock[tool-call].uiVisibility, AgToolDef.uiVisibility, AgClosedEvent[tool.start].uiVisibility FROZEN both none verbatim MCP Apps
handoff.kind (transfer|escalate) AgHandoffRecord.kind, AgClosedEvent[handoff].kind (the two declarations pinned alike) FROZEN out none own
AgCapabilities.profile AgCapabilities.profile FROZEN out none own
AgCapabilities.memoryScopes AgCapabilities.memoryScopes FROZEN out none own
prompt.blocked.reason AgClosedEvent[prompt.blocked].reason, AgTurnRecord.promptBlocked.reason FROZEN out reasonRaw own companion reasonRaw (draft.8)
guardrail target AgTurnRecord.guardrails[].target, AgClosedEvent[guardrail.result].target FROZEN out none own
guardrail action AgTurnRecord.guardrails[].action, AgClosedEvent[guardrail.result].action FROZEN out none own
AgMemoryRecord.scope / memory.write.scope AgMemoryRecord.scope, AgClosedEvent[memory.write].scope FROZEN (see §13.11) out none own
AgReasoningConfig.mode AgReasoningConfig.mode FROZEN in none own
AgReasoningConfig.effort AgReasoningConfig.effort OPEN STRING (documented values) in none own
toolChoice mode AgRunConfig.toolChoice (the mode enum of its union) FROZEN in none own
AgSurfaceEnvelope.surface AgSurfaceEnvelope.surface FROZEN in none own
AgDisplayMode AgDisplayMode (canonical export); the mcp-app and openai-app mode literals (input) and the ui.display-mode mode and granted fields (output) FROZEN both none verbatim MCP Apps
scheduling AgInputToolResult.scheduling FROZEN in none own
continuation AgClosedEvent[hitl.ask].continuation FROZEN out none own
ui.call.callableFrom AgClosedEvent[ui.call].callableFrom FROZEN out none own own spelling
AgEvent.type the AgClosedEvent arms (50) + ext.<vendor>.<key> ADDITIVE-EVENT out — own
AgInput.kind the AgInput union (3) FROZEN in none own
AgBlock.type AgBlock discriminant (§2; 16 kinds) BLOCK-KIND both — own
AgSource.type AgSource discriminant (3) FROZEN both none own
AgCitation.kind AgCitation discriminant (5) FROZEN both none own
AgToolDef.source.type AgToolDef.source discriminant FROZEN in none own
a2uiMessage the AgSurfaceInteraction legs FROZEN in none own own spelling
mcp-app method / openai-app method the surface-interaction arms FROZEN in none verbatim MCP Apps / Apps SDK
text.start.role AgClosedEvent[text.start].role FROZEN out none own
toolChoice tool / responseFormat json_schema single-value literals FROZEN in none own
ui/message params.role single-value literal of the MCP Apps ui/message arm FROZEN in none verbatim MCP Apps
source payload document a single-value literal (the third arm of the source payload) FROZEN out none own
§0.3 final segment §0.3 SPEC-INTERNAL — — own

Version negotiation. The current spec version is 1.0.0-rc.3. Emitters MUST populate AgInputEnvelope.version (and SHOULD surface the same value out-of-band) with the spec version they implement. A consumer MUST reject an envelope whose version differs in MAJOR component, and SHOULD accept any same-major version, ignoring unknown additive fields per §0.2’s consumer-ingestion posture. Any prerelease tag of the same major (-draft.*, -rc.*) is accepted under the same rule: same-major acceptance across prerelease tags is this specification’s policy, not a promise semver makes for a prerelease.

Vendor namespace ownership. The <vendor> segment of ext.<vendor>.<key> is the single dot-free token between ext. and the next dot; <key> is everything after that dot and may itself carry dots (§0.3; the reference schema enforces exactly this). The <vendor> segment MUST be a namespace the emitter owns. RECOMMENDED: one dot-free token the emitter controls — its package-registry scope written without the sigil (an npm scope @acme emits under acme), or a label derived from a domain it holds, such as example-com; a registry scope evidences ownership, it is not the test, and the reserved segments below are claimed by this specification regardless of any registry. A reversed domain does not fit this grammar: ext.com.example.k names the vendor com, a top-level-domain label that no emitter owns, so a type of that shape breaches the requirement above. Implementations MUST NOT emit under a segment they do not own. Reserved segments (claimed by this spec for its own vocabulary and for the reference SDK’s normalizers): agjson, anthropic, google, openai, langgraph, vercel. Collisions resolve to this list. RECOMMENDED bulk-carry shape: when a vendor’s wire exposes many distinct frame kinds with no existing AgJSON vocabulary home, prefer the single uniform key ext.<vendor>.frame{kind, frame} (§8 item 22) over minting one distinct <key> per frame kind.

Payload embedding. An ext.<vendor>.<key> payload is spread into the event object; a NON-object payload rides under the fixed key value; payload keys colliding with the engine-owned envelope (seq/type/id/turnId/messageId/parentId/_meta) are relocated verbatim under shadowed — the channel never drops payload bytes, less the one member §13.7 forbids an emitter to emit; a payload’s own shadowed key relocates into that bag (shadowed.shadowed) when collisions force the wrapper.

There is no in-band free-form custom/raw hole in the normalized vocabulary; the only escapes are the typed provider-raw block (lossless last resort, subject to the omissions and reductions of §8.0 items 28, 36 and 38 and §13.10) and a host-level bypass capability (a host relaying the framework’s native format verbatim, outside AgJSON entirely — out of scope for this spec).

13. Security Considerations

AgJSON is a transport for model-generated, framework-generated, and user-generated content crossing trust boundaries. This section states each party’s duties (RFC 3552 shape). Nothing here licenses an SDK to mutate payloads — byte-identical echoes are replay-load-bearing (§3.1, §12) — beyond the one omission §13.7 names.

13.1 Approval tokens (hitl.ask.token)

The side-effecting-approval token (§7, §11 item 4) is an anti-forgery credential: the AGENT HOST mints it, scopes it to one askId, and MUST validate on resume that (a) the token matches the ask, (b) it is unexpired (expiresAt), and (c) it is single-use (replay of a consumed token is rejected). Clients treat the token as opaque and MUST NOT log or persist it beyond the resume exchange. A binding that makes tokens mandatory (see §11 item 4) inherits these validation semantics.

13.2 Opaque replay blobs

reasoning.opaque values, providerMetadata, MRTR requestState, and encrypted/redacted provider blobs are provider-bound secrets in transit: implementations MUST echo them byte-identical (§3.1; an own __proto__ member inside such a value is the one omission, §13.7), MUST NOT attempt to decode or inspect them beyond that omission, SHOULD NOT log them, and SHOULD NOT persist them unencrypted at rest. They may embed provider-side keys or user content in sealed form.

13.3 UI payloads (MCP Apps HTML, A2UI trees, OpenAI widgets)

Surface payloads are model-controlled executable content. The Layer-B renderer — never the Normalizer or reduce() — owns sandboxing: MCP Apps text/html resources MUST render in an origin-isolated frame honoring the resource’s _meta.ui.csp when present (the host enforces the CSP; AgJSON only carries it); A2UI component trees resolve against the client’s own catalog only (catalogId is by-reference — a payload can never inject components the catalog lacks); display.required HTML carries the same ToS-render duty AND the same sandboxing duty. A renderer MUST treat all surface payloads as untrusted (XSS-bearing) input.

Duty bearer. The display.required carriage MUSTs (never dropped; folded to AgTurnRecord.displayRequired on its turn’s record, and held, never discarded, until the reducer knows that turn’s thread per §5.0 INV-OWNER) bind the normalizer and reducer. A Layer-B binding that DISPLAYS a turn’s grounded content MUST render its displayRequired records as the grounding provider’s terms require, and only to the end user those terms permit; non-rendering consumers (folds, evals, pipelines) carry the carriage duty only and are not in breach by not rendering.

Grounding records and the host. A turn’s grounding records — displayRequired[], sources[] and the grounded content they accompany — are governed by the grounding provider’s terms, which may limit how long they are stored, for whom, and whether they may be re-displayed. A host that persists a fold or later re-displays it MUST apply those terms to these records: it MUST NOT persist displayRequired records beyond the live turn unless the provider’s terms permit it, and MUST NOT re-display them to anyone but the end user the terms permit. §5 records them for the live render; it is not a licence to store them. The reference SDK’s persistable projection of AgReduceResult (toPersistable) omits turns[].displayRequired; a host opts back in only where the terms permit.

(Non-normative: a live message folds only after a message.start that names its turn and carries a threadId, so a folded grounded answer always makes its turn’s thread known and its displayRequired records appear beside it.)

13.4 URLs

Any URL a consumer takes from the wire and navigates to or renders as a hyperlink — whatever event, block, record or metadata bag carried it, and whether a model, a framework or a host relayed it — MUST be scheme-validated by that consumer before the navigation or the rendering: https:/http: (and application-registered schemes a host explicitly allows); javascript:, data:, and vbscript: MUST be rejected. This covers, for example, AgCitation and source URLs, resource-link uris, search-result URLs, a link a consumer lifts from a tool result, a host record or a provider-raw block, hitl.ask {kind:"url"} targets, a kind:"auth" ask’s authConfig.authorizationUrl and the authorization request URI carried in its metadata (§8.0 item 28(a)), and any model-supplied link; a URL the consumer composes itself is not taken from the wire. A fetch a host makes to render or resolve a wire URL — media from an AgSource url, a resource-link resolved through the server that issued it, a ui:// view locator (§13.3) — is not navigation and is governed by the host care below; the moment the same URL is offered as a hyperlink, the rule above applies. A host that fetches or previews a wire URL server-side MUST apply the care it gives any untrusted URL: it MUST NOT fetch a private or reserved address range, nor follow a redirect into one. A host that logs or persists a wire URL beyond the live turn MUST redact or drop its userinfo and SHOULD redact query values it can identify as secret-bearing (for example signature, credential and token parameters).

13.5 Push-notification credentials (SSRF)

AgRunConfig.pushNotification {url, token?, auth?} instructs an agent host to POST to a client-supplied URL bearing client-supplied credentials — the classic SSRF + credential-exfiltration shape (inherited from A2A, whose security guidance applies). A host honoring it MUST validate the URL against an allowlist or egress policy (no internal/link-local targets), MUST NOT attach credentials beyond those supplied in the config, and SHOULD verify ownership of the callback endpoint (e.g. a challenge round-trip) before delivering content.

13.6 Untrusted-source marker

provider-raw blocks, ext.<vendor>.<key> payloads, _meta values and messageMetadata values carry model- or framework-controlled bytes end to end by design (§12). Consumers MUST treat them as untrusted input: never eval, never render as HTML, never interpolate into shell/SQL/DOM sinks without the same escaping applied to any external input. Tool-result content, whatever block carries it, is model- or tool-controlled bytes and receives the same treatment.

13.7 Wire-selected prototypes

A consumer MUST NOT let wire data select the prototype or class of any object it materialises; a normalizer or any other emitter MUST NOT let a native value select the prototype or class of any object it emits; a reducer MUST NOT let an event select the prototype or class of any object it builds or stores (in JavaScript, an own __proto__ key MUST NOT become any object’s prototype). An object member named __proto__, at any depth of any value, is the one exception to §0.2’s pass-through of unknown fields: it is dropped and never passed through, at each of three points.

(1) An emitter — a normalizer, or any component that mints an event — MUST NOT emit an object member of that name at any depth of any event member, nor an RFC-6902 operation whose path or from holds that reference token; it omits the member, or the operation together with every later operation whose path lies beneath it (§8.0 item 28), carries the members beside it unchanged, and emits an array patch left with no operation as an empty patch, as item 28 directs. An encoder that serialises an event it did not mint is not an emitter under this point; it carries the event as it is.

(2) A consumer’s decode MUST omit it. A decode is a copy whose output holds no such member at any depth, whose every object has the ordinary object or array prototype, never one the input selected, and whose other members deep-equal the input’s; validating a value against a schema is not by itself a decode. The same rule binds a reader of a stored AgMessage or AgMemoryRecord, a receiver of an AgInput or of an AgClientCapabilities or AgHitlAnswer carried by any request (§0.2), and a consumer that parses JSON out of a string member (a tool.args.delta scratch buffer, JSON inside text), which applies the omission to what it parses. An otherwise valid event holding such a member is decoded with the member omitted; it is not rejected or skipped for that reason.

(3) A reducer MUST apply the omission to every event it folds, whether or not that event passed a decode: no record, block, state value or memory value of the fold holds such a member at any depth; every object the fold exposes has the ordinary object or array prototype, never one an event selected (an implementation that builds other objects internally conforms by exposing such copies); and an RFC-6902 operation whose path or from holds that token fails to apply (§5). A string member is not searched: text and tool.args.delta carry native text verbatim, and tool.args.assembled omits the member.

These omissions take precedence over every requirement of this specification to carry, echo, keep, report or reproduce a value verbatim, byte-identical or as received: §0.2’s stored records and inputs, §5’s built records, INV-DELTA’s usage carry, §8.0 item 25 (ADK tool results) and item 28 (shared state), §10 item 8 (the cumulative-usage fold) and item 22 (forward-compatible ingest), §12’s payload embedding, §13.2’s provider-bound values and this section’s preamble; §13.10’s authorship exception does not apply to them.

(Informative: a host that deep-merges or walks fold values by path guards constructor and prototype as it guards __proto__; this specification requires no omission of either member. The mapping is lossy for an application key literally named __proto__: a framework may store one, and it does not survive AgJSON. The reference SDK’s decode and result() return objects with the ordinary prototype.)

13.8 Shared state

A normalizer recognises only the framework credential objects §8.0 names; an application that stores secrets in shared state under its own shape has them carried unchanged, so a host that persists the shared-state working copy, renders it to a model, or restores framework state from it treats that copy with the care it gives the application’s own state store. Omitting a unit from a state.delta does not remove a value that an earlier event or a host-seeded state.snapshot already placed in the working copy. A working copy assembled across patches or operations can hold a shape that no single patch or operation carried; §8.0 item 28’s omissions apply to each event a normalizer emits, not to the folded working copy.

13.9 Resumed credential requests

A host that answers a framework’s credential request by resuming the run (for ADK, a functionResponse to adk_request_credential, or a plain-text reply the framework routes to the pending request) SHOULD treat every event of that run from the resuming invoke on as able to carry the reply it sent, whether or not the Normalizer recognised it — a Normalizer does not recognise an untyped reply, object or string, outside a response that names the credential-request call, nor a model’s restatement of a reply (§8.0 item 28) — and SHOULD apply to that invoke’s folded text, previews, persisted history, model context and any native relay the same handling it applies to the credential itself. A host SHOULD answer a framework’s credential request only in the typed credential shape that §8.0 item 28 reduces (for ADK, an auth configuration in the functionResponse to adk_request_credential), or out of band (for example through the authorization URL the ask carries), and not with an untyped reply that carries the secret; item 28 does not reach an untyped reply outside a response that names the credential-request call, including one the framework routes from the user’s own message. A framework may also have kept the reply in its own session store, or sent it to a model provider, before any event reached the Normalizer; no rule of this specification reaches those copies.

13.10 Remedy-shaped vendor data

A vendor frame may name a fix a host could offer next (re-authenticate, enable access, retry with another model, continue with an instruction). AgJSON defines no neutral remedy field; such data rides only a host-readable carrier: _meta (a vendor-namespaced key is RECOMMENDED, §0.4), messageMetadata (§3.2; turn.done.messageMetadata replaces the named message’s bag, §5, so a normalizer carries it there only on a message it gives no other messageMetadata), ext.<vendor>.<key>, a provider-raw block, or, for prose that passes §3’s admission test, a notice row (§3). A normalizer MUST NOT place it in providerMetadata unless the provider requires it to round-trip (§13.2 makes that channel uninspectable; the audit siblings of §8.0 items 19 and 21 are unaffected). Consumers MUST treat it as untrusted advisory input (§13.6): they MUST NOT act on it without an explicit user or operator action, MUST NOT derive an action by parsing its prose, and MUST render any prose as plain text attributed to its source (for a notice row, per §3), never to the agent. A fix that navigates to a URL, collects a credential, or widens a permission MUST be offered only through hitl.ask (kind:"url", "auth", or "approval"), subject to §13.1 and §13.4. A normalizer MUST NOT emit a provider-issued credit or bearer token (e.g. Anthropic stop_details.fallback_credit_token) in any event: it removes the key from every provider- or harness-originated value it carries, at any depth; a same-named key a model or tool authored is content and is carried as such.

13.11 Memory scopes

A memory.write of scope agent, user or skill records a write the producer’s framework made to its own cross-thread store — a LangGraph store entry, for example (the reference ADK normalizer carries ADK user: and app: state entries on state.delta under their prefixed keys, §8.0 item 30, and maps none of them to memory.write in 1.x) — and durable:true says that store keeps the record across threads; neither is a claim about what the AgJSON host keeps. Whether the host keeps such a record is the host’s: a host that persists a fold MUST document the non-thread scopes it persists and MUST persist every record of a declared scope and every scope thread record (§8.0 host obligation 7); its storage projection omits the rest (§5.0), and the omission is a typed result to the projection’s caller, never a silent skip (§0.2). An absent declaration declares no non-thread scope. A messages.snapshot never re-seeds an omitted non-thread record (§5), so a producer SHOULD NOT depend on a host persisting a scope the host has not declared. A scope thread record is bound to the fold’s partition root (§8.0 host obligation 6), not to a field the wire sets on a live write; where a messages.snapshot carries AgMemoryRecord.threadId, it is that root, never a facet placeholder. A memory operation the host itself executes at the model’s request (a memory tool) reports its result or refusal on that call’s tool.done, not as a memory.write. The reference SDK’s projection, called with no declaration, keeps scope thread only (§10 item 51).

13.12 Relayed requests

§8.0 item 36 keeps an ADK relay’s record of its send request, the remote agent’s raw reply and the remote task’s ids off the wire. §8.0 item 38 keeps ADK’s reserved __adk_internal_ entries, which ADK removes from the API responses, saved session files and A2A messages it produces, off the wire. Those two items reach only those entries: every other customMetadata entry, whether the host, a plugin or a remote agent wrote it, is carried as §8.0 otherwise requires (item 28’s reductions applied). A host therefore SHOULD NOT place secrets in ADK customMetadata, and SHOULD treat a provider-raw block it persists, or forwards to an end user, as able to hold framework-, host- or peer-written data it has not reviewed. A framework may also have kept a relay’s request in its own session store, or sent it on to an upstream A2A caller, before any event reached the Normalizer, and any copy a host reads, stores or relays from native events outside the Normalizer keeps these entries; no rule of this specification reaches those copies, and a host SHOULD NOT log them and SHOULD NOT persist or relay them unencrypted, as §13.2 asks of provider-bound secrets. §8.0 item 36 also omits a forwarded message that a remote agent repeats as a submitted status sent in the "user" role; it does not reach a remote agent that restates the request in its own voice, an echo that an intermediate relay re-voices, a repeated message in any other shape, a repeat in a relayed event whose content no longer holds the recorded artifacts’ parts first and then at most one part for each part of that message (for example after a plugin, a callback or a part converter that maps one A2A part to several changed it; in such an event the omission can also remove parts that are not the repeat), a copy the framework keeps in its own session store or model context (for ADK, every repeat it yields as a non-partial event, such as the reply to a non-blocking send), or a copy a host reads or relays from native events outside the Normalizer. A host that relays to a remote agent SHOULD remove a repeated forwarded message before the framework stores or yields it (for @google/adk, an afterRequestCallbacks entry that deletes status.message from a submitted status or task whose status.message.role is "user"; for ADK-Python, an after_request interceptor that drops the event converted from such a status update).

14. References

Normative external surfaces are pinned to the revisions below; “verbatim” clauses in this spec are relative to these pins.

(The [MCP] and [MCP Apps] pins fix the vocabulary this spec restates; they do not select a negotiated MCP protocol generation. AgJSON carries no MCP handshake or negotiation field; a producer whose MCP client negotiates an earlier revision (2025-11-25 or before) emits only what that revision defines, so fields new in 2026-07-28, such as the MRTR requestState, are simply absent. The 2025-11-25 URL-mode completion signal (elicitationId, notifications/elicitation/complete) has no AgJSON carrier; a producer that keeps it uses ext.<vendor>.* or providerMetadata (§12). MCP Apps is versioned independently of core; its 2026-01-26 is also the Apps ui/initialize protocol version.)

(The [A2UI] pin fixes the A2UI names and shapes this specification restates in §3 and §4 — including the three A2UI AgSurfaceInteraction legs, ui.call, ui.action-result, ui.surface.*, ui.data-model and a2uiClientDataModel — and the A2UI field names §0.4 lists (dataModel, surfaceId, catalogId, sourceComponentId, functionCallId). Those names and value sets are this specification’s own frozen vocabulary for that snapshot’s shapes (§12 classes ui.call.callableFrom and a2uiMessage as its own spelling): they are normative as written here, and an upstream rename or removal after the pinned commit does not change them. Where the restated text differs from the snapshot, the restated text governs. The differences include these: the snapshot declares a function’s execution boundary only in catalog metadata, as callableFrom with the values clientOnly, remoteOnly and clientOrRemote and the default clientOnly, while ui.call.callableFrom carries that boundary on the call, and an absent ui.call.callableFrom states no boundary; ui.call and ui.action-result carry a surfaceId, the surface address of §4, that the snapshot’s callFunction and actionResponse messages lack, and the function-response leg’s surfaceId is a synthesized correlation (§6); ui.action-result admits value and error together or neither, where the snapshot’s actionResponse carries exactly one, and its error carries a path that the snapshot’s closed error lacks; an AgA2uiError admits code VALIDATION_FAILED keyed by functionCallId, which the snapshot admits only keyed by surfaceId; ui.surface.start carries toolCallId, which the snapshot’s createSurface lacks; and ui.result is the host’s own echo, with no counterpart in the snapshot. The snapshot’s version token, "v1.0", is also the token of later v1.0 text, so it does not tell the snapshot from that text.)

(Publish gate: every URL above must resolve at first publish; the OpenAI Apps snapshot date is re-verified each release.)

15. Revision history (informative)

This section is informative (§0). It records each revision’s note as published, oldest first, and the policy for corrections. Errata. An erratum corrects the text of a published revision — a wrong cross-reference, a misstated example, a sentence that says less or other than the rule it names — and never what a field, an event or a fold means; a change a reasonable implementer would read as changing a requirement, including the resolution of an ambiguity, is not an erratum but a revision under §12 (a draft or a release candidate before 1.0; after 1.0, the vehicle §12 and the release process name for its class). An erratum is applied in place, and its entry here names the date, the revision it corrects and the sentence it changed; it moves neither AgInputEnvelope.version nor the reference SDK’s AGJSON_VERSION, so a producer that declares a revision declares its corrected text.

draft.2 (2026-08-15) adds two constructs, both additive (an absent field/role is byte-identical to draft.1): grant modes on the HITL ask (grantModes on hitl.ask/AgPausedAsk, grantModeId on AgHitlAnswer, the hitl.grantModes client capability — §7), letting an asker declare scope-qualified accept variants (anchored on the Claude Agent SDK’s permission_suggestions/PermissionUpdate.destination surface and the OpenAI Agents approve(…, {alwaysApprove}) option; spec-process record: typescript-sdk#16); and the notice role (§3, §3.2) — a persisted, non-conversational transcript row with noticeSource provenance, promoting the Claude informational carry (§8.0 item 21) and giving host/adapter-injected annotations a first-class home.

draft.3 (2026-09-02) defines what AgUsage.outputTokens MEANS (§4 outputTokens inclusion note, §8.0 item 24, §10 item 21): every generated token including reasoning, with reasoningTokens a breakdown of it — the convention Anthropic, OpenAI, the OpenTelemetry GenAI conventions and the Vercel AI SDK already use. This is the first draft revision that is NOT byte-identical for an existing producer: a normalizer over a provider that reports reasoning as a sibling counter (Gemini usageMetadata.thoughtsTokenCount) now folds it into outputTokens, so a Gemini/ADK turn.done.usage.outputTokens on a thinking turn rises by exactly reasoningTokens; inputTokens, reasoningTokens and totalTokens are unchanged, and Anthropic/OpenAI/Vercel-derived usage is byte-identical. No wire shape changes; draft.2 envelopes remain accepted (same major, §12). The input side (inputTokens vs cache counters) is explicitly OUT of scope for draft.3.

draft.4 (2026-09-23) makes §0.2’s forward-compatibility rule executable: an event a consumer ignores occupies its seq slot (§5 INV-SEQ) and is reported to the caller, never silently skipped (§5); unknown fields pass through at every depth; §12 lists which additions are additive and freezes the lifecycle closed sets within major 1; turn.done gains the optional finishReasonRaw companion (§4, §8.0). §0.2 also binds readers of stored AgMessage and AgMemoryRecord. An unreadable content element or record is omitted and reported, and the view is never persisted. Records carried in events keep the whole-event rule, so until element-level handling of messages.snapshot is specified, a message read from storage and the same message inside a snapshot can fold differently. §0.2 also binds receivers of inputs: an input that fails the schema other than by an unknown field is a typed rejection before any action, classified unknown-value, malformed or major-mismatch. It also makes errorText scoping normative (§2.2): a producer sets errorText only on an outcome:"error" result, which is byte-identical for every existing producer. It adds §8.0 item 25 and §10 items 22-24. An ADK functionResponse whose response carries Gemini’s error member now normalizes to outcome:"error" with isError:true, or to outcome:"denied" for a declined approval, where it was "ok" before; ADK’s confirmation placeholder is unchanged. This revision is NOT byte-identical for the google-adk producer. It also changes how reduce() folds a state.delta object patch (§5): each top-level key is replaced whole, which is how ADK and LangGraph’s default channels apply a keyed update, where draft.3 merged one level deep; re-folding an earlier stream that rewrote part of an object-valued key now keeps only that key’s latest value. It also folds a kept-open tool.done as a snapshot (§5): the payload — content, outcome, isError, structuredContent, uiData, sideData, errorText, errorCode, pendingInput — is replaced as a unit, so an omitted payload field clears; _meta, toolMetadata and dynamic are kept unless re-sent; and providerMetadata merges by key. A normalizer carries an MCP Apps result’s _meta.ui on tool.done._meta, so a view locator survives a later snapshot (§2.1), which is byte-identical for every existing producer that carries _meta.ui. Under INV-TURN and INV-FLUSH a flush never emits a success turn.done: a deferred round releases as paused when an approval is outstanding, else a non-success terminal verbatim, else turn.abort with its usage on message.end; a flush may add lifecycle closes but never content. draft.4 also corrects four restatements that disagreed with §2 and the normative reduce(): the §5 message.start row now lists every field the reducer records, §0.7 and §5 name AgReduceResult.memory, and the §1.3 cross-ref column is renamed “Owner (cross-ref)” and states which id each entity carries. It extends INV-MSG to the four block-finalizing events: a straggler text.end, reasoning.end, reasoning.opaque or tool.args.assembled into a sealed message, or into any message of a closed turn, now parks the reducer, as a straggler delta does (§5.0 INV-MSG, §10 item 35). It also states a rule the reference reducer applies: a message.start naming a closed turn parks the reducer, and a turn stays closed across a 0-restart until a messages.snapshot (§5.0 INV-MSG, §10 item 27); a normalizer mints turn and message ids that no earlier invoke of the thread could have produced (§8.0). It also states that a partly-mapped vendor frame rides ext.<vendor>.frame whole beside its home, which is authoritative (§8 item 22, §10 item 34); defines structuredContent as model-facing with delivery to the model left to the host (§2.1); and reserves the vercel segment (§12, §10 item 33). It also corrects three restatements that implied a paused turn stays open (the §3 resume comment, §6 Pattern 4 and §8.0 item 12): the paused turn.done closes the turn, and the resuming invoke’s events open a new one. It also records ADK’s pause family as one mapping that closes paused from push() (§8.0 item 26), and lets a host tell the Normalizer that a framework run returned normally (§8.0 host obligation 4), so a framework with no in-band run terminal can close success from push() while flush() still never does. It also adds one optional field, additive (an absent phase is byte-identical to draft.3): phase on text.start, text.end, reasoning.start, reasoning.end and on the text and reasoning blocks (§2, §4, §5, §8.0 item 27, §10 item 26) — a normalized, never-replayed view of a producer’s own per-block marker that a block is interim narration between steps (OpenAI Responses phase:"commentary"; the Claude Agent SDK’s narration flag). It narrows INV-SEQ’s backward tolerance to the 0-restart: a repeated or backward seq above 0 is a re-delivery and parks (§5.0, §10 items 2 and 27); INV-BLOCK’s id uniqueness is scoped to the invoke and covers a second final tool.done; §8.0 host obligation 5 admits host-appended events after flush() and leaves end-of-invoke detection to the host transport (§10 item 28). It also closes nested (subagent) turns with the ordinary terminals: a nested turn, opened by subagent.start, is closed by exactly one turn.done | turn.error | turn.abort carrying its turnId, immediately followed by subagent.done, which is now a bracket close with no fold effect (§4, §5.0 INV-TURN, §5, §8.0 item 29, §10 item 36). No event type, field or enum value is added. This is NOT byte-identical for producers that emit subagent.*: each nested bracket gains its terminal (no usage; finishReason: "unknown" when the framework reports none) before subagent.done. Folds of pre-draft.4 streams are unchanged, except that a terminal naming a turn the reducer has not seen opened, including a turn that a later messages.snapshot carrying turns omitted, now folds onto no turn record, and a terminal naming a turn seen only in a snapshot’s messages folds onto a record carrying that message’s threadId, never the turnId (§5.0 INV-OWNER). It also keeps credential material out of every event a normalizer emits for an ADK authentication request; omits from state.delta and state.snapshot the entries that hold framework credential objects, and ADK’s invocation-scoped temp: entries, and still emits the event; and reduces framework credential objects, and a response that names a framework’s credential-request call, to their non-secret members inside provider-raw carries, dropping the text a framework renders from a value so reduced (§8.0 item 28, §10 items 30-32); and it adds host guidance for shared state and resumed credential requests (§13.8, §13.9). On the input side it adds one optional client capability, uiResources.viewMessageTurns (§3, §6); an absent flag declares nothing, as in draft.3. draft.3 envelopes remain accepted (same major, §12). Erratum to draft.2: “additive” there means an absent field or role is byte-identical to draft.1; under §12 a new AgRole value is not additive for consumers, and a pre-draft.2 consumer cannot fold notice messages. Clarification to draft.4 (2026-09-25, no version change): when a state.delta key-replace patch or a state.snapshot is itself a framework credential object, §8.0 item 28(b)’s unit is the whole patch or snapshot, emitted as {}; this is byte-identical to the reference SDK’s 0.7.0 output.

draft.5 (2026-09-25) changes how reduce() folds a messages.snapshot (§5.0 INV-MSG, §5, §10 item 27): a turn is closed while its turn record carries an outcome, so a snapshot no longer clears closure — one that carries a turn with an outcome keeps it closed, and one that omits turns keeps every closure. A message.start, or a tool.done adopting a message, into such a turn after a snapshot now parks the reducer where draft.4 folded it; a host that means to reopen a turn carries its record without an outcome. This change alters no producer’s bytes. draft.5 also widens the resource-link block with MCP’s optional name, title, description and size (additive, §12); adds §8.0 item 31 (MCP resource links in tool results) and §10 item 44; adds §2.1’s host-record note, and extends §13.4’s consumer scheme validation to resource-link uris and to links a consumer takes from a tool result; and moves §8.0 item 21’s notice siblings and item 19’s audit copy from providerMetadata to host-only _meta (§10 item 45). Output is NOT byte-identical to draft.4 for google-adk (MCP resource links in tool.done.content) or for claude-agent-sdk: host-only wrapper keys move to _meta, and on a frame whose first block is a tool call they arrive on a message.metadata event. draft.5 also gives §8.0 item 14 a second in-stream release point: when an OpenAI Agents step is resolved by a handoff, a plain function_call of the round that is still without a result and has had no run-item streamed for it stops being pending at handoff_occurred, no tool.done is emitted for it, and the round’s deferred close is released with its deferred outcome, finish reason and usage as soon as no call of the round is still pending; §8.0 “Ids across invokes” names the ext.<vendor>.* route for a result that arrives after its turn closed, and §10 gains item 46. This revision is NOT byte-identical for the openai-agents producer on a stream where a handoff resolves a round containing such a call (with @openai/agents-core 0.18.0: a second transfer call in one response, or a function call the SDK suppressed and never ran): a source turn that folded {type:"aborted", reason:"stream-truncated"} now folds with its deferred outcome, and its usage moves from the round’s message.end to its turn.done. No wire shape changes, and draft.4 envelopes remain accepted (same major, §12). draft.5 also lands a tool result that a later invoke delivers for a call an earlier invoke opened in that later invoke’s own turn, as a role:"tool" message (§5.0 INV-XINV, §10 item 47), and maps a Claude deferred tool to a paused turn with one approval ask (§8.0 item 32). It is NOT byte-identical for the claude-agent-sdk producer on a deferred tool, which 0.7.0 closes as success with finishReason:"unknown" and finishReasonRaw:"tool_deferred" and no hitl.ask (the ext.anthropic.result-meta carry is unchanged), nor for the vercel-ai producer on a result its initial pass delivers for a call an earlier invoke opened, which 0.7.0 emits without a messageId. draft.5 also defines the INPUT side of AgUsage (§4): inputTokens includes cache reads and writes, with cacheReadTokens/cacheWriteTokens its breakdowns — the revision draft.3 deferred; binds cumulative to the object it sits on; adds the optional costScope companion for a cost whose scope differs from its object’s token counters (§4, §12-additive); and corrects §8.0 item 4 to the per-turn scope of a Claude turn terminal’s counters. This is NOT byte-identical for the claude-agent-sdk producer: every Claude inputTokens rises by its cache counters, its turn terminals carry cumulative: false and costScope: "query", and an assistant message’s placeholder outputTokens is omitted when partial messages are off; OpenAI, Gemini/ADK and Vercel usage is byte-identical. A persisted Claude turn record — one @silverprotocol/claude-agent-sdk produced — is pre-draft.5 exactly when its top-level usage carries a costUsd key (a 0 included) and no costScope (a record without usage has nothing the repair touches); add cacheReadTokens + cacheWriteTokens to its inputTokens and, for each byModel entry, that entry’s own cacheReadTokens + cacheWriteTokens to the entry’s inputTokens; read its top-level cumulative as false and its costUsd as carrying costScope: "query", and leave each entry’s cumulative as stored. The turn record’s token counters and cost then read as a draft.5 producer’s, and §10 item 50’s identity holds on the record and on each entry; a host that rewrites the row stores the repaired counters and costScope: "query" together, which ends the marker. A persisted Claude message record has no exact marker: inputTokens < cacheReadTokens + cacheWriteTokens identifies some pre-draft.5 rows, not all. draft.5 also defines Gemini Live usage (§4 outputTokens inclusion note, §8.0 item 24, §10 item 21): the Live API’s output counter responseTokenCount excludes reasoning, so thoughtsTokenCount always folds into outputTokens; the Live API’s reported total can exclude reasoning, so over the Live API totalTokens is inputTokens + outputTokens + (toolUseInputTokens ?? 0) and a differing provider total rides the new optional AgUsage.totalTokensRaw (additive, §12). This is NOT byte-identical for the google-adk producer on the Gemini Live path; it is byte-identical for every other producer and every committed golden (the draft.3 candidatesTokenCount guard and its §10 item 21 legs are unchanged). draft.5 also changes how reduce() lands the six record events (prompt.blocked, guardrail.result, agent.capabilities, source, handoff, display.required) for a turn it holds no record for (§5.0 INV-OWNER, §10 items 48 and 49): a record event lands on a record carrying the turn’s thread, taken from a turn.start, subagent.start, message.start or messages.snapshot of that turn; until the reducer knows that thread it holds the landing and result() omits the record, so no turn record carries a threadId that no event carried. A later opener now sets a held record’s thread and a nested record’s parentTurnId. A terminal or record event with no turnId resolves to the sole open turn, never a closed one; with no open turn or several, its owner is unresolvable and the reducer parks. A normalizer opens a turn before any event that belongs to it, record events included (§5.0 INV-TURN, §8.0). Erratum to draft.4: its note said a terminal naming a turn seen only in a snapshot’s messages folds onto a record carrying that message’s threadId; the 0.7.x reference reducer did so only when no record event had reached that turn first, otherwise it kept a record whose threadId was the turnId, and §10 item 43’s manifest row claimed the rule for that ordering. Folds of recorded goldens are unchanged. draft.5 also records display.required on the turn for the live render, not for replay; §13.3 binds a host’s persistence and re-display of a turn’s grounding records to the grounding provider’s terms and conditions, and the render to those terms; §5.0 lets a host’s storage projection omit turns[].displayRequired, and the reference SDK gains the additive toPersistable projection (§10 item 51). This part of the revision is byte-identical on the wire and in reduce() for every producer; no golden changes. draft.5 also makes a second terminal for a closed turn a reduce()-error → snapshot-resync, whatever its outcome, except a host’s paused refresh — a re-sent turn.start plus a turn.done{paused} with refreshed asks for a turn closed paused, which folds onto the one record as draft.4’s §10 item 27 already required (§5.0 INV-MSG, INV-TURN, §8.0 host obligation 5, §10 item 27); INV-TURN’s one-terminal rule binds a host’s own events as well as a normalizer’s, and across a messages.snapshot closure follows the fold’s turn records for terminals as for message.start. No event, field or closed-set value changes, and no committed golden folds differently; a stream that closes one turn twice, which draft.4 left unspecified, now parks. draft.5 also adds §8.0 item 33 (a fix the framework waits on is a hitl.ask; a reported fix rides, less what §13.10 forbids, in a carrier that folds) and §13.10 (remedy-shaped vendor data is advisory and never auto-executed; provider credit tokens are never emitted), names messageMetadata in §13.6, and adds §10 items 52–53. The reference SDK already emits every carry those items pin (0.7.0, 0.7.1), so this text moves no producer’s bytes. Editorial sweep (2026-09-26): §5 state.delta states that a key-replace patch never removes a member; §12’s LangGraph clause reads per task; §8.0 item 29 and §4 define handoff transfer and escalate from the producers’ semantics; §13.4 and §13.6 gain host-care sentences; the turn.done row names its messageMetadata fold a whole-value replace. One fold change: AgOutcome’s error variant gains retriable? (additive, §12), copied from turn.error as the producer set it — the fold of a committed claude-agent-sdk golden whose turn.error carries retriable:false (an API authentication failure; a deferred tool no longer available) now carries outcome.retriable:false; a reader that validates a stored AgTurnRecord with a pre-0.8.0 core’s schema drops the field. draft.5 also states whose value threadId is (§1.2; §8.0 Partition root and host obligation 6; §10 item 55): byte-identical for every producer, no schema change.

draft.6 (2026-09-26) says what a non-thread memory scope records — a write to the producer’s own cross-thread store, never a claim about the host (§2, §4, §5, §13.11) — lets a host’s storage projection omit the non-thread scopes it has not declared it persists (§5.0, §8.0 host obligation 7), carries the declaration on the additive AgCapabilities.memoryScopes (§3), and extends the reference projection to take it (§10 item 51). Byte-identical on the wire and in reduce() for every producer; no golden changes. A stored fold written before this note keeps every scope its host kept; its durable flag reads as the producer’s statement about its own store. draft.6 also widens the subject of §13.4’s consumer scheme-validation rule to any URL a consumer navigates to or renders as a hyperlink, whatever carried it (the scheme predicate is unchanged) — the former carrier list becomes its examples, a kind:"auth" ask’s authorization URL on either carrier is now covered, and a fetch made to render or resolve a wire URL is routed to the host care that follows.

draft.7 (2026-09-26) moves the ADK paused close (§8.0 item 26) to the invocation’s end — from push() on the host-completion event, otherwise from flush() per INV-FLUSH (2), which gains the pause-signal condition — because a credential or request-input pause need not end an ADK invocation; INV-TURN, §8.0 ‘Ids across invokes’, item 12 and §10 items 25 and 40 read accordingly. Every recorded cassette serializes byte-identically; six engine fixtures that parked on hosts without the completion event now fold, and a paused turn’s usage covers the invocation’s later rounds. Landed as a defect-class fix (ruled 2026-09-26): a fold that parked now closes; no field’s meaning changes.

draft.8 (2026-09-27) — the closing draft. §12 classifies every closed set for major 1 and reconciles the AgBlock union at 16 kinds; a new block type is emittable only to a client that declares it; the per-client version gate and its 1.0.0-draft.3 default are withdrawn (no producer implemented them, and a receiver rejects an absent version as malformed); any same-major prerelease tag is accepted; after 1.0 a meaning change is a major. Every recorded stream replays byte-identically in serialization and in reduce(); no golden changes. A producer’s bytes move only where §10 item 23’s prompt.blocked.reasonRaw companion applies, and a fold only where a prompt.blocked carried reasonRaw or a memory.write carried _meta, neither of which 0.10.0 copied onto its record. Closing housekeeping, text only: §12 states the ext.<vendor>.<key> grammar its schema enforces (one dot-free vendor token; the key may carry dots) and retires the reversed-domain recommendation; §13.11 no longer offers ADK user:/app: keys as a memory.write example (they ride state.delta, §8.0 item 30, for all of 1.x); §11 is resolved and names no host; the draft notes move here with the errata policy; §0 says what is informative; the draft.6 note’s verb is corrected. No field, fold, golden, schema or §10 item changes. It closes §10 as a conformance definition: every framework-specific item states whom it binds (§8.0 items 9-11, §10 items 10, 11, 13, 17, and the §8.0 applicability list), §8.0 items 9 and 10 describe Pydantic AI’s actual replace-not-accumulate semantics, and item 25 names the streaming-aggregator variant of its credential shape. A recalled-memory signal has no AgJSON event: the Claude Agent SDK’s memory_recall frame rides ext.anthropic.frame byte-preserved, a host-executed memory-tool read reports on that call’s tool.done (§13.11), and no second framework surfaces one on its stream. A memory.read event would need read added to §0.3’s closed set of lifecycle finals (§12: a closed-set value is not additive); this draft neither adds nor reserves it. Revisit when a second producer or a consumer reader appears, with a scope mapping (Anthropic personal|team|organization is not memory.write’s agent|user|skill|thread) defined first. Two fold changes, both additive record fields (§12): AgTurnRecord.promptBlocked.reasonRaw, copied from prompt.blocked (§10 item 23), and AgMemoryRecord._meta, copied from memory.write (§10 item 56) — a stream whose memory.write carried _meta re-folds with it where 0.10.0 discarded it, and a reader that stores a strict parse’s output drops the field; the reference reader, which keeps unknown fields per §0.2, does not. Landed under an explicit ruling (2026-09-27) that a new optional record field populated from a field the wire already declares is additive under §12, not a meaning change. §5 now states what a built record carries; no golden changes. Before publication (2026-09-27) it corrects the closing draft’s text: item 10 names the content-block leg the reference Anthropic normalizer exercises, item 25 names the aggregator tail’s finish reason, item 23 declares prompt.blocked.reasonRaw (a schema field 0.11.0 ships), and §5’s built-record rule is keyed on the record type — a deliberate narrowing of its rule that the reference reducer satisfies; no wire, fold or golden changes. It also aligns the text with the §12 table’s class for AgReasoningConfig.effort: §3 types it string (an open string with documented values), §0.2’s example list and item 29’s input vector move to AgReasoningConfig.mode, and the reference schema widens the field from a four-value enum to string (0.11.0); a receiver on an earlier core rejects an undocumented value with unknown-value. Erratum to draft.8 (2026-09-27, no version change): §12’s closed-set paragraph regains, as a parenthetical, the sentence that the reference SDK checks its schema against the table — dropped at the draft.8 landing while no such check existed, left out of the published text although a check that reads the table shipped with 0.11.0, and restored now that the reference SDK’s spec-drift gate reads the table and compares its sets; and §3’s mcp-app mode union is reordered to AgDisplayMode’s order (inline, pip, fullscreen). The reorder is order only; neither edit changes a set, a meaning, the wire, a fold or a golden. Erratum to draft.8 (2026-09-28, no version change): the comment on "safety_blocked" in §4’s AgFinishReason block ended with a cross-reference to a mandatory §10 conformance item, although the item it concerns, §10 item 7, is a recommendation; the comment now ends “see §10 item 7”. The edit is to a comment; it changes no set, meaning, wire, fold or golden. Erratum to draft.8 (2026-09-28, no version change): §9’s sentence on advertising a profile named AgCapabilities.profile as the field through which a client or an agent advertises its profile, although only an agent’s capabilities carry that field; it now says that an agent advertises its profile there and that AgClientCapabilities has no profile field. The edit changes no field, set, meaning, wire, fold or golden.

rc.1 (2026-10-13) — the first release candidate. It revises draft.8. The errata policy above now names a release candidate, beside a draft, as the vehicle for a revision before 1.0, and the header, §0 and this section now call these notes revision notes rather than draft notes. §12 defines the class OPEN STRING its table already uses for AgReasoningConfig.effort: a receiver accepts any string in such a field and the checked input carries it unchanged; the documented values are not exhaustive, and a producer may send others. This change moves no wire, schema or fold byte, and every recorded stream replays byte-identically under it; a receiver on a core from 0.7.0 to 0.10.x still rejects an undocumented value with unknown-value, and an earlier core’s schema rejects it without a typed code. §12’s AgDisplayMode row now reads both in its direction column and names the output fields beside the input literals; the sentence naming the table the normative list of closed sets now adds that the table also classes the sets it lists that are not closed; §3’s effort comment now says that an absent value is unset and a JSON null is malformed under §0.2. This change alters no field, event, fold rule, golden or schema. §12’s table now defines its direction column as the payload that can carry a set’s values, and eleven more of its cells move to match (ToolOutcome, AgNoticeSource, AgOpaque.kind, the AgCitation unit, bounds, indexFrame and kind, and code-result.outcome to both; resumeBinding and the memory scope to out; AgSurfaceEnvelope.surface to in), beside the AgDisplayMode row’s both above; §2.2’s output-only flag list names the results[] entry it excludes. This change alters no field, event, fold or closed set. §12’s table now lists two single-value literals that the schema already closes: the role of an MCP Apps ui/message view message and the document arm of the source payload. This change alters no value, field, event or fold. This revision adds §8.0 item 34 and §10 item 57: on @google/adk (observed at 2.1.0) a prompt block in StreamingMode.NONE reaches a normalizer as an event whose errorCode is a BlockedReason value with no finishReason and no content, and an ADK normalizer now emits one prompt.blocked for each such event into the turn open when it arrives or, after a success close, the top-level turn it opens, before that turn’s terminal; this change alters no terminal the reference ADK normalizer emits, and §10 item 57 records the reference ADK normalizer’s success close on such an event when it is not configured for the host-completion event, binding the reference only. A prompt.blocked that an ADK normalizer emits from promptFeedback is likewise one per event and never names a turn after that turn’s terminal. In SSE mode at 2.1.0 a prompt block met by the run’s own model call reaches a normalizer at most as a content-less, usage-only event, so no record is emitted and that event closes no turn. This change is a revision and alters no schema, closed set or fold rule. No recorded golden carries either shape, so every golden replays unchanged under this change; on a blocked invoke each record is one more event, and the invoke’s later events move up one seq per record. Evidence is fixture-only. An event relayed from a remote agent is not read as a prompt block: at 2.1.0 the relay drops the finish reason that tells a prompt block from a response-level one. Block signal (rc.1). §4 states that a safety block or a refusal is read from prompt.blocked or from the finishReason of the turn.done that closes the turn, on the block’s event or when a deferred close is released, never from outcome.type; that a normalizer closing a block with turn.done carries that finish reason whatever its outcome, except that a paused close may carry "paused" where no rule directs the deferred finish reason; that a block carried only by a native finish reason is not signalled when no turn.done carrying it closes the turn, for example when the reason is not mapped to a block value or not delivered, on a paused close that carries "paused", or when the turn closes as turn.abort or turn.error by another path; that a block whose own event closes the turn as turn.error carries no finish reason, and carries the framework’s native code in code where the framework’s error exposes one; and that it does not classify guardrail.result (§10 item 58). This change resolves an ambiguity and alters no fold rule. The reference OpenAI Agents normalizer now reads a refusal from the completed response and clears it when the next response starts, so a refusal delivered after its round’s terminal no longer closes that round as "stop" or labels the round after it; no recorded stream changes. The reference Claude Agent SDK normalizer now closes a refusal that the Claude Code CLI reports as an API error (an error frame whose stop reason is refusal, followed by a success result marked is_error, or such a result alone, whose stop reason is refusal) with turn.done carrying finishReason "refusal", an error outcome and a safety entry {category:"refusal", blocked:true}, in place of turn.error; a refusal whose result never arrives still closes with turn.error at flush(); no recorded stream changes. ADK in-band invocation errors (an event carrying errorCode and errorMessage) no longer close the turn at once: ADK keeps running after one (a SequentialAgent’s next sub-agent, a retried Workflow node, a callback or a further model round), so the immediate close put later output after the terminal and parked reduce() (INV-MSG; §8.0 ‘Ids across invokes’). A normalizer now emits the non-terminal error event when the error arrives and closes the invocation’s turn with one turn.error at the invocation’s end, carrying the host-terminal event’s code when one was fed, otherwise the first in-band error’s, and the invocation’s accumulated usage; such a turn never closes as success, the host-completion event included (§8.0 item 35 and host obligations 1 and 4, INV-TURN, INV-FLUSH (2) and (3), §10 items 40 and 60, §7). An invocation that met an invocation error and whose pause was signalled by its end closes the asking turn paused, with its asks, and records the error on a new top-level turn (§8.0 item 26); this gives an item-26 pause the release §8.0 item 14 gives a deferred OpenAI round on a host-fed error, and OpenAI’s own in-band errors are unchanged. ADK’s Live path is outside the rule. Landed as a meaning change (ruled 2026-09-28): an ADK invocation that met an in-band error folds as an error even when a later sub-agent or a retried Workflow node answered; in the SSE streaming mode the aggregate that repeats such an error as a model call’s stream closes reports no further error and adds no further usage, and in either mode neither does an event with which RemoteA2AAgent closes a remote run when it repeats an error of that same run and carries the same a2a:task_id entry as the event it repeats. No recorded stream carries an in-band invocation error, so every recorded stream replays byte-identically under this change, in serialization and in reduce(), and no golden changes. On a host that feeds no completion event, the reference ADK normalizer now opens another top-level turn for content that follows a final response it closed on, instead of naming the closed turn (§8.0 ‘Ids across invokes’): two engine fixtures that parked now fold, every recorded cassette serializes byte-identically, and no field’s meaning changes; landed as a defect-class fix. §8.0 item 35 states the same shape for an invocation error: the earlier success close stands, and the error closes on the other turn. It also corrects two restatements that stated the close of every completed invoke without the host-completion event as the flush close — §10 item 25’s completion criterion, now scoped to the Workflow and cut-short runs it tests, with item 35’s kept success close stated in it, and host obligation 4’s last clause, now scoped to the turns still open at flush() — records in item 25, binding the reference normalizer only, its success close on the first final response of its two further fixtures when not configured for the host-completion event, and corrects item 35’s cross-reference, which now names item 26 for the node-info condition alone; no requirement moves for any other implementer. The [A2UI] reference (§14) now pins the A2UI v1.0 Candidate at commit 1348a92a on the canonical repository, in place of a v1.0 tag that upstream never published. §14 now states that the A2UI names in §3 and §4 are this specification’s own frozen vocabulary for that snapshot’s shapes, that the restated text governs where it differs from the snapshot, and names those differences, among them ui.call.callableFrom’s placement on the call (absent, it states no boundary), the surfaceId on ui.call, ui.action-result and the function-response leg, ui.action-result’s value/error cardinality and error.path, VALIDATION_FAILED keyed by functionCallId, ui.surface.start.toolCallId and ui.result. This change alters no wire field, value, default or fold, and an implementation built against the draft.8 text needs no change for it. The [OpenAI Apps] reference (§14) now cites the archived 2026-06-11 capture of the Apps SDK reference page, in place of the Apps SDK landing page it had linked, whose own text carries none of the pinned surface and which now redirects to upstream’s Plugins page; it also names the reference’s current location. The snapshot month, every restated window.openai member and display mode, and every closed set are unchanged. §8.0 item 20’s lifecycle clause now states the outcome of the OpenAI built-in lifecycle rather than its source: a built-in call stays, within its invoke, in the turn of the round that produced it, a resolved hosted call closes with its status mapped to outcome, a hosted MCP call and a hosted-MCP approval request name the MCP tool in tool.start rather than the run-item’s type, and a hosted-MCP approval request stays a pending call named by its approval ask when the framework asks, and closes on the approval’s answer (ok when approved, which records the approval and not the gated call’s result, or denied); §8.0 item 14 now names the built-in run-items a deferred round waits for and releases such a round before the next response starts, and a new §10 item tests both. This change alters no field, event, closed set or golden. Landed as a defect fix: the reference ADK normalizer now matches a model call’s closing aggregate to the text that call streamed, so the aggregate no longer renders that text a second time or parks the fold (§8.0 item 3, §10 item 9, INV-MSG). Landed as a defect fix: the reference OpenAI Agents normalizer dropped a refusal’s text, which §8.0 ‘Graceful degradation’ already forbade; it now carries that text as one text block in its round’s message. Landed as a defect fix: the reference OpenAI Agents normalizer closed a round whose terminal response lists a client-executed built-in call before that call’s result landed, which draft.8’s §8.0 item 14 already forbade (a round’s close waits for its pending tool results); it now defers that close. Landed as a defect fix: the reference OpenAI Agents normalizer dropped a built-in call’s native inputs, outputs and error outcome, and a web answer’s citations, which §8.0 ‘Graceful degradation’ already forbade; it now carries them. Landed as a defect fix: the reference Claude Agent SDK normalizer dropped the stop_details of a refusal that the Claude Code CLI reports as an API error, which §8.0 item 33 already forbade; it now carries them, less any provider credit token (§13.10), on the refused message’s metadata. A normalizer targeting ADK no longer carries an A2A relay’s bookkeeping entries (a2a:request, a2a:response, a2a:task_id, a2a:context_id) from an event’s customMetadata; every other entry still rides (§8.0 item 36, §10 item 61, §13.12). A relayed status in which a remote agent repeats the forwarded request in the "user" role no longer reaches the stream as the relay’s own content (§8.0 item 36, §10 item 62, §13.12), and §13.9 now recommends answering a credential request only in its typed shape. The relayed-echo omission in §8.0 item 36 keys on the sender role and the submitted state: A2A defines a message in the "user" role as one the client sent, and the observed echo is the remote executor’s first, submitted status; a user-authored tool result that a remote workflow relays at another state is the remote agent’s output and is carried. Neither omission is a defect fix, since no sentence of draft.8 prescribed either one; both are producer rules ruled on 2026-09-28, and no field, fold rule or closed set moves. No recorded stream carries a relayed event, so every recorded stream replays byte-identically under these changes, in serialization and in reduce(), and no golden changes. §13.7 names the three points at which an own __proto__ member is omitted (emit, decode, fold), binds each party against a data-selected prototype, requires the ordinary prototype of every object a decode returns or a fold exposes, defines a decode by its effect, and bounds the verbatim, byte-identical and as-received carries accordingly (§0.2, §5, §8.0 items 25 and 28, §10 items 8 and 22, §12, §13.2, §13’s preamble); §5 fails an RFC-6902 operation naming that token and resolves pointer tokens by own members as [RFC 6901] reads them, so a replace, remove, move, copy or test naming an inherited member (toString, constructor) that applied before rc.1 now fails to apply, while add of such a name creates an own member as [RFC 6902] requires. History: the reference SDK’s consumer decode (ingestAgEvent, 0.6.4 on), its stored-record readers and input receiver (0.7.0 on) and item 28’s shared-state carry (draft.4 on, first shipped 0.7.0; §10 item 31 pinned its fold result for ADK state) already omitted the member, and a consumer that decodes with AgEvent.parse builds an ext.* event with the ordinary prototype on zod 4.4.0 or later, which 0.12.3 requires; before 0.12.3 some normalizer paths carried it from natives and four sites used a native key as an emitted object’s prototype ([GHSA-4pcf-49g3-h325]; both stopped in 0.12.3); until the rc.1 cohort release the reducer kept the member as inert data in a fold fed without a decode, an RFC-6902 operation whose path named it or an inherited member applied after any decode, and core’s emit helpers passed natives through; the wire encoder, which is not an emitter, serialises an event as it is. From that release the reducer applies the omission at push(), core’s emit helpers apply it to engine-built events, and the operation rule is new. No recorded stream moves. rc.1 also corrects other restatements that disagreed with the rules they restate, among them: the §5 rows for turn.done (safety replaces the turn’s value), tool.args.assembled (providerMetadata merges by key), content.block and data (transient on either carrier skips), tool.start (the members the tool-call block declares), agent.capabilities, source and memory.write, and the verb of the text.end, reasoning.end, reasoning.opaque.delta and artifact.end rows; §1.2’s three-id sentence and §1.3’s root threadId; §2’s AgMeta type, structuredContent delivery and the tool.done member list; §8.0 items 22, 24, 26 and 27; and §9’s finish-reason wording. rc.1 does not decide what a normalizer emits for the call that §8.0 item 25 (a)’s pause placeholder answers; this remains open. Fold rules (rc.1): three changes to what the normative reduce() does — a message.start naming an id the fold already holds as a message parks (INV-MSG and the message.start row), where the reference reducer of earlier releases replaced the message; a tool.done with more:true for a call whose final result the invoke already landed parks (INV-BLOCK and §10 item 27), where the reference reducer of earlier releases appended a second tool-result block; a nested turn’s threadId is its parent’s thread when that thread is known, else the fold’s root thread, and a record with neither known is held until one becomes known, or until a messages.snapshot that carries turns discards it (the subagent.start row and INV-OWNER), where earlier revisions gave it the fold’s root thread only when its parent was never opened (§8.0 item 16), and the reference reducer of earlier releases, when the fold held no turn record for the parent, took the thread of the first turn whose threadId was not its turnId, else the parentTurnId label. No committed golden carries the repeated-id shape; no committed golden carries the more:true shape; the four committed goldens that reach the nested-turn change fold identically. Beside the repeated-id park, a normalizer must not emit, within an invoke, a message.start naming a message id an earlier message.start or tool.done.messageId of the invoke named, and a host must not deliver among its own events a message.start whose id is such an id or a message id that one of its own earlier invokes of the thread named, whether or not the fold has since released it (§5.0). Further requirement moves (rc.1): INV-TURN’s flush list closes an interrupted turn as §8.0 item 17 directs, in place of a turn.error carrying the interruption, and INV-FLUSH (2)’s asks arm and §8.0 item 26’s flush arm close an interrupted turn whose pause was not signalled the same way, in place of turn.abort (reason: stream-truncated); a normalizer targeting ADK must set reason to "interrupted" on an interrupted turn’s turn.abort, whether the turn closes from push() or from flush(), and may carry the turn’s usage on the preceding message.end (§8.0 item 17); §10 item 66 tests that close; the pending long-running call is defined per turn (host obligation 4); §10 item 24’s errorCode bullet now follows §8.0 item 25’s MUST, and item 40 names the ask the normalizer emitted, where earlier revisions named approval_<callId>, its askId now the one §8.0 item 37 binds; §10 item 16’s artifact leg now binds reducers; §8.0 item 25 now requires errorCode, when a string error_code or errorCode sibling is present, on a response it maps to "error" that carries no isError:true, with the string error_code read before errorCode, and its isError sentence covers any response that carries isError:true; §8.0 item 25 (b) now requires denied, with no errorText, isError or errorCode, for a declined approval that carries no isError:true and is not the pause placeholder; INV-XINV names §8.0 item 25 (b)’s identification of a declined approval, for a normalizer targeting ADK, as its one exception to never inferring "denied" from result text; §8.0 item 37 binds the approval ask’s askId and toolCallId for a run-item that carries a callId; §9’s CORE arm reads refuse MAY / pass-through MUST; §13.4’s host care carries keywords; §3’s AgInputEnvelope.turnId carries a normalizer MAY and a host MUST NOT, with host obligation 5’s re-send excepted; §12’s minor rule carries its keywords; §0.2 lets a consumer bound nesting depth and ignore, in its seq slot, a well-formed envelope past the bound (the reference ingest reports such an envelope as an ignored event and the reducer counts the slot); item 35 defines its final-response shape; §8.0 item 25’s isError sentence binds at MUST, tested by §10 item 24’s isError bullet; §10 gains item 67, which tests on the reference corpus §4’s relation between a turn.done‘s usage and its messages’ message.end.usage; §10 item 36 excepts a nested turn closed at flush from the subagent.done bracket; display.required moves to the EXTENDED row (§9), a precaution, since §13.3 already binds any displayer; item 25 (a)’s MUST NOT now excepts a response that itself carries isError:true; INV-XINV states the same-invoke preliminary for a call an earlier invoke opened as a kept-open result. Changed in this revision: an empty JSON Patch array ([]) against an absent working copy, or against an unseeded memory record, applies as a no-op and sets no resync condition, where earlier revisions made it a reduce()-error → snapshot-resync (§5 state.delta and memory.write rows). Rows that now state, as the reference folds them, cases earlier revisions left undefined or stated loosely (a reducer that folded such a case otherwise no longer conforms): INV-TURN’s duplicate-open merge and INV-OWNER’s parentTurnId fill (a subagent.start for a turn whose record result() already returns gives that record its parentTurnId when it has none, which no earlier release’s reference reducer does), the fold rules for ending a block and for the positionless events, reconcile-by-id, the candidate-0 fallbacks of the turn.done and message.metadata rows, the adopted tool message, the landings a messages.snapshot that carries turns discards or matches to an entry it carries, and the artifact rows. Restatements corrected to what the reference folds, moving no requirement: §1.3’s id sentence and the built-record _meta sentence. Landed as a defect fix: the reference reducer discarded a held display.required landing when a messages.snapshot that carries turns folded, which draft.8’s display.required row and §13.3 already forbade; it now keeps it as INV-OWNER states; it also now counts, when it resolves the sole open turn (INV-OWNER), every turn it has seen opened and not closed, where it counted only turns holding a record, and no longer marks a call as landed when that call’s final result parks (INV-BLOCK). Landed as a defect fix: the reference’s JSON-value validator recursed once per nesting level, so the reference ingest could throw on an envelope at a depth its bound admits, instead of reporting the stub §5’s trust boundary names; the validator now applies the same rules in one iterative pass with the same acceptance, and the ingest, the input check and the stored-record readers report a validator throw as a failed parse — the stub in the event’s slot, a malformed input, a reported record — and never throw.

rc.2 (2026-10-14) — ADK internal metadata, and the OpenAI refused computer action with its screenshot bytes. It revises rc.1. A normalizer targeting ADK no longer carries, from an event’s customMetadata under either spelling, a top-level entry whose name begins with __adk_internal_, the prefix ADK reserves for its own components and removes from the API responses, saved session files and A2A messages it produces (§8.0 item 38, §10 item 68, §13.12); every other entry still rides. This change is a revision: it alters no schema, closed set or fold rule, no recorded stream carries such an entry, and every recorded stream replays byte-identically under it; a producer that declares rc.1 may still carry these entries. A customMetadata that held only such entries yields no provider-raw block, so a stream in which it occurs is one event shorter than at rc.1 and its later seq values renumber. §8.0 item 20 maps the @openai/agents rejected computer result (output.providerData.approvalStatus:"rejected", no executionStatus) to tool.done {outcome:"denied"} with the SDK’s rejection message as a text block, where rc.1 read it as "ok" (the 0.12.4 release note’s known gap), and states that the computer screenshot’s data: URL lands as base64 bytes with its media type, as AgSource already requires; the tool-search sentence keeps "ok"-always on its own; §10 item 69 carries the legs, built from engine natives at agents-core 0.20.0 (the rejected shape byte-identical to its 0.19.0 run, the resumed shape to its 0.18.0 and 0.19.0 runs). No closed set changes and no recorded replay moves (no recorded stream reaches the arm); a consumer at any 1.0.0-* tag validates the new close unchanged.

rc.3 (2026-10-16) — the ADK client-abort seal. It revises rc.2. §8.0 item 35 no longer classes an event whose errorCode is "INVOCATION_ABORTED" — the seal @google/adk 2.3.0 yields and stores when a client abort ends a runAsync invocation — as an in-band invocation error: a normalizer targeting ADK closes the invocation’s turn from push() on that event with one turn.abort whose reason is "cancelled", after the seal’s functionResponse parts have closed their calls as item 25 directs, and emits no error or turn.error for it; §8.0 item 26 now says when an ask stops being pending (its adk_request_* call answered within the invocation), so a sealed ask closes nothing paused; §10 item 70 tests both over captured 2.3.0 streams. This change is a revision — a requirement move on a shape no recorded stream carries: reason stays an open string (§12), no schema, closed set or fold rule changes, and every recorded stream replays byte-identically under it; a host that stops reading after it aborts still closes at flush under INV-FLUSH — stream-truncated, or paused while an ask was pending once ADK had signalled the pause — and runLive is unchanged (item 17). The reference’s e2e pin moves to @google/adk 2.3.0.