---
title: AI
description: "`experimental-a2/ai`, `experimental-a2/ai/server`, `experimental-a2/ai/client`, and `experimental-a2/ai/react`."
---

## `experimental-a2/ai`

The isomorphic AI protocol: event schemas, the `AIState` reducer, pure input
builders, and `agentContract()` for extending both. Its peer dependency, AI SDK
v7, is used as types only here. See [Agents](/agents/introduction) for the
protocol and complete examples.

### `agentContract(options)`

```ts
agentContract<M extends UIMessage = UIMessage>(options: {
  name: string
  events?: Record<string, StandardSchemaV1>
  messageSchema?: StandardSchemaV1<M>
  reducer?: {
    name: string // snapshot identity: rename it when fold changes
    initialState: X // its keys may not shadow AIState's
    fold: (state: X, event: ContractEvent) => X
  }
}): Contract<Events, {}, AgentReducer<M, Events, X>>
```

A core contract with the built-in AI protocol plus `events`; the session's
state is `AIState<M> & X`. Share it when a client needs application events or
state: serve it with `agent({ contract })` and use it with
[`useAgent({ api, id, contract })`](#useagentoptions). Otherwise
`agent({ name })` builds one on the server.

- `events` cannot replace built-in event names.
- `messageSchema` validates a narrower `UIMessage` in message payloads; without
  it, the type argument types messages.
- `reducer.fold` sees `X` and every event, never the transcript, and runs after
  the AI fold on the server and in every client, so optimistic display carries
  through.
- An `initialState` key that `AIState` also has throws a `TypeError` and fails
  to typecheck.
- The reducer name is `AI_STATE_REDUCER` (`a2.ai.state.v27`) without
  `reducer`, else `${reducer.name}+a2.ai.state.v27`, so checkpoints advance when
  either fold changes. `AgentReducer` types that name (so
  [`createAgentClient()`](#createagentclientoptions) accepts a plain
  `agentContract()`) and carries `X` to `agent()`'s resolvers.

See [Add your own events and state](/agents/conversations#add-your-own-events-and-state).

### `TranscriptMessage<M>`

```ts
type TranscriptMessage<M extends UIMessage> = Omit<M, 'parts'> & {
  parts: Array<M['parts'][number] | CompactionPart>
}
```

The message type of `AIState<M>['messages']` and `deriveUIMessages()`: `M` plus
the [compaction part](#iscompactionpartpart), so `isCompactionPart()` narrows
even when `M` declares its own data parts.

### `AIMessageOf<Events>`

The `UIMessage` type a contract's input commands (`ai.control.requested`)
carry, such as `AIMessageOf<typeof contract.events>`; `UIMessage` otherwise.
`agent()` types messages with it.

### `handlerContext(contract?)`

Returns the current tool execution's durable handler context, an
`AgentToolContext<D>` with `event`, `attempt`, `session`, and `signal`.

- Tools are plain AI SDK `tool()` definitions, passed through uninterpreted;
  each local `execute` runs inside the ambient scope. Call it anywhere below
  `execute`, including awaited helpers and async iteration; elsewhere it throws
  a `TypeError`.
- Typed for the built-in protocol by default. Pass the shared `agentContract()`
  to type application events; a mismatch with the executing agent throws a
  `TypeError`.
- `event` is the `ai.tool.execution.requested` work event, whose payload holds
  the admitted `call`, `generation`, `turnId`, and execution `version`.
  `ctx.session.schedule()` uses the executing agent's scheduler.
- Built per attempt, never persisted, never sent to the model or a
  provider-executed tool; each retry gets a fresh one.
- Model-phase tool options (`contextSchema`, dynamic descriptions,
  `needsApproval` predicates, input lifecycle callbacks) pass through untouched,
  durable execution passes no AI SDK `context` option, and model-phase hooks see
  tools without local `execute`.
- `needsApproval` and `generation.toolApproval` predicates are re-evaluated on
  recovery for the same durable call: keep them deterministic over the input.

See [Tools and approvals](/agents/tools).

### `inputs`

Pure typed builders of `ai.control.requested` commands, for `append()` and
`push()`. The `useAgent()` actions build the browser ones.

| Input | Events |
| --- | --- |
| `inputs.message(message, { generate? })` | queues input; `generate: false` records passive context at admission |
| `inputs.seed(message)` | queues passive context; non-user roles require a trusted append |
| `inputs.approval(response)` | answers a pending tool approval |
| `inputs.input(response)` | answers a pending application input |
| `inputs.requestInput(request)` | requests application input from trusted server code |
| `inputs.retry(options)` | retries a failed response |
| `inputs.stop({ turnId, lastSeenIndex? })` | ends the observed turn and advances when admission is open |
| `inputs.steer({ turnId, message, lastSeenIndex? })` | queues the new input right after the observed turn's earlier steering, or first, for a handoff after its current step; the turn's steering enters together, its last generating input starting the turn |
| `inputs.queue.edit({ message })` | changes a pending input; its ID stays fixed |
| `inputs.queue.move({ inputId, beforeId })` | moves pending input before an ID; `null` means the end |
| `inputs.queue.remove({ inputId })` | removes pending input; its submission stays in the log |
| `inputs.queue.sendNow({ inputId, turnId, lastSeenIndex? })` | promotes an existing queued input, after the observed turn's earlier steering, for a handoff after the current step; preserves pause |
| `inputs.pause({ when: 'now' \| 'after-turn' })` | pauses execution now or inbox admission after the turn |
| `inputs.resume()` | resumes retained work, then inbox admission; a turn paused now with steering hands it off instead |
| `inputs.toolResult(result)` | records a deferred provider-executed tool result from trusted server code |
| `inputs.interrupt(options)` | interrupts a specific generation/request owner |

- **Receipts.** A push confirms persistence; `ai.control.decided` confirms
  application with `{ commandId, outcome, reason? }` on the existing stream.
  `outcome` is `applied` or `rejected`; reasons include `not-pending`,
  `role-mismatch`, `stale-turn`, `not-found`, `not-user-input`, and `closed`.
  `state.receipt` holds the latest user receipt.
- **Trust.** Browser ingress accepts user messages, approval and input
  responses, interruptions, and explicit retries. `inputs.requestInput()`,
  `inputs.toolResult()`, and non-user `inputs.seed()` messages are trusted
  server appends; only built-in handlers append generation requests and AI
  lifecycle events. No builder covers session lifecycle: append
  `ai.session.created` and `ai.session.closed` from trusted server code.
- **IDs.** Message, seed, and steer commands use the input message ID as their
  event ID, and must. Approval, input-response, and retry builders use stable
  interaction IDs; other commands use the ID `push()` or `append()` assigns,
  kept across transport retries. Caller IDs are nonempty, never start with the
  reserved `a2.ai:`, and are unique across the store; identical events replay
  and conflicting reuse rejects before append. A2's own IDs are opaque,
  fixed-length, deterministic `a2.ai:` values; an assistant message's ID is its
  initial model-request event's ID.
- **Passive input.** `generate: false` is valid browser input: it admits the
  user message to `AIState.messages` without a turn. The next generating user
  message includes the passive ones before it; later ones wait. `inputs.seed()`
  sets the same flag. Trusted assistant and system seeds are context, not queue
  cutpoints, and never scheduled, whatever `generate` says. Model output is
  generation progress, not `ai.message.created`, so a response never starts a
  turn.
- **Queue.** Edits are last-write-wins in log order, keep identity and role,
  and need no confirmation between edits; after admission they return
  `not-pending`. Removal keeps the submission in the log, so replay cannot
  recreate it.
- **Steer and Send now.** Steering prioritizes new input even after the
  observed turn ends; a matching active turn yields after its current model
  response and tool batch settle, including authorized deferred provider
  results (unanswered approval and input waits can close; a denied approval
  never blocks the handoff); otherwise it starts when idle or waits first
  behind a different active turn. Both the reprioritization and the later
  handoff commit atomically. Send now keeps the queued input's ID and content,
  needs an observed `turnId` (`null` when idle), accepts only user-role input,
  and promotes passive input; missing targets and stale turns reject before the
  queue changes. Neither resumes a pause or cuts output at `lastSeenIndex`.
- **Resume** uses a fresh model request and execution version. Tools without a
  terminal outcome can rerun; key external effects on their stable
  `toolCallId`.
- **Approvals and input.** Request and response payloads carry the active
  `requestId` and `generationId`, copied from the pending request, so a late
  response cannot satisfy a newer step.
- **Retry.** `ai.retry.requested` carries `{ messageId, responseMessageId,
  retryId }` (one `retryId` per user action) and becomes an
  `ai.generation.requested` with reason `retry`. It reruns the failed model
  call, keeping earlier completed steps, their tool results, and committed
  compactions, and drops the failed call's uncommitted step. Forks and
  edit-and-resend are application-owned.
- **Generation events.** `ai.generation.requested` requires
  `responseMessageId`, plus `sourceGenerationId` for a tool continuation.
  `ai.generation.failed` sets `stepLimit: true` when `maxSteps` rejects a
  continuation.
- **Interruption.** `ai.message.interrupted` is `{ messageId, requestId,
  reason?, lastSeenIndex? }` or `{ messageId, generationId, reason?,
  lastSeenIndex? }`: copy `activeRequestId` before the generation starts,
  `activeGeneration.generationId` after. A2 writes the reason
  `'steered after step'` itself when steering ends a response, so an
  interruption you send can't use it. A request-owned interruption survives
  that request starting first. An older or mismatched message/owner pair is a
  no-op. An accepted one fences its owner for good: later events stay in raw
  history but cannot alter `AIState`, satisfy pending work, or reactivate the
  response. Tool results completed at or before `lastSeenIndex` stay
  completed; a final tool result racing a generation failure wins in either
  order, and the generation stays failed. Interruption and failure drop
  `input-streaming` tool drafts from the projection and later prompts (raw
  progress stays); other tool parts keep their terminal handling.

See [Inbox and turn controls](/agents/conversations#inbox-and-turn-controls)
and [Interrupt and retry](/agents/tools#interrupt-and-retry).

### `reasoningLevels`

```ts
type ReasoningLevel = 'provider-default' | 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
const reasoningLevels: readonly ReasoningLevel[]
```

Every `ReasoningLevel`, in order, for pickers. A `model` option returns one in
[`ModelSettings`](#modelsettings).

### `isCompactionPart(part)`

```ts
isCompactionPart(part: { type: string }): part is CompactionPart

type CompactionPart = {
  type: 'data-a2-compaction'
  id: string // the compacting generation's ID
  data: {
    status: 'running' | 'completed' | 'failed' | 'interrupted'
    inputTokens?: number
    thresholdTokens?: number
    summary?: string
    usage?: LanguageModelUsage
  }
}
```

The transcript part that marks a compaction.

- Folding `ai.compaction.requested` appends it to the active response before
  the step it compacts for (or opens the response with it). It counts toward
  that step's recovery base, so Stop cutoffs, failures, and retries keep it.
- `ai.compaction.completed` settles it in place with `summary` and `usage`. If
  its generation ends first: `interrupted` (Stop, Pause now, session close) or
  `failed`. A pending Stop previews `interrupted` on the client.
- A superseded worker attempt's part is removed; the replacement compacts again
  if needed.
- `convertToModelMessages()` skips data parts by default; model context never
  contains it.

See [Show compaction in the conversation](/agents/conversations#show-compaction-in-the-conversation).

### `events` and `createEvents(options?)`

The built-in Standard Schema definitions, both isomorphic: `events` for the
default AI SDK `UIMessage`, `createEvents({ messageSchema })` for a more
specific message type.

### Optimistic AI client state

Every client folding an AI reducer (`agentContract({ reducer })` included)
shows pending send, edit, move, remove, Send now, steer, pause, resume, and
Stop commands in `state.messages`, `state.inbox`, and `state.starting`, with no
extra I/O or raw-history reads.

- An idle, unpaused send joins the conversation immediately with
  `starting: true` (a speculative turn, not model execution); other sends
  queue. Turn identities come only from accepted decisions; confirmed turns
  win.
- Steer, and Send now against an active turn, show in `state.messages` after
  the current response, not the inbox, until admission; they stay out of the
  prompt. Several steering inputs show in the order they were sent,
  and the display survives receipt and hydration, including while paused.
- A pending Stop clears the displayed `state.active`, generation, and pending
  approvals and inputs, previews an idle status (paused when the inbox is), and
  freezes the retained projection at `lastSeenIndex`, until its decision; a
  rejection restores them.
- Later local edits stay visible through earlier receipts. Pending commands
  retire on their `ai.control.decided` receipt or session close; failed pushes
  roll back.
- Server reads and folds hold confirmed entries only (`starting: false`).
  Custom reducers that call `reduceAIState()` or nest the AI fold don't get
  this display.

See [Optimistic conversation display](/agents/conversations#optimistic-conversation-display).

### `AIState`

The type of the AI reducer's `initialState` and `fold`, server
`session.state()`, and client snapshots. Hydrate with state and its log index
together, so seen history never folds twice. The AI fold ignores application
events. Encoding is internal; no event or tool payload is truncated.

| Field | What it holds |
| --- | --- |
| `messages` | [`TranscriptMessage<M>`](#transcriptmessagem) values; server-side, without pending inputs |
| `inbox` | `{ paused, items }`; items are `{ id, role, message, generate, afterStepOf? }`, where `afterStepOf` is an observed steering turn that hands off after its current step (removal cancels it, admission consumes it) |
| `active` | `null` or `{ turnId, input, phase }`, where `input` is the admitted input's `{ id, role, generate }` |
| `status` | generation status |
| `starting` | a speculative optimistic turn |
| `receipt` | the latest user command receipt |
| `pendingQueueCommands` | the unresolved queue commands, for the optimistic display |
| `session` | session lifecycle |
| `pendingApprovals`, `pendingInputs` | pending tool approvals and application input |
| `tools` | tool activity, identity and status only; payloads live in message parts |
| `compaction` | the current or latest compaction |
| `modelMetadata` | each model's pending, resolved, or unavailable limits |
| `usage`, `error` | usage per generation; the last error |
| `activeGeneration` | the started generation |
| `activeRequestId` | the server-authorized generation request; fences delayed requests before start |
| `activeRequestReason`, `activeRequestSourceGenerationId` | that request's reason and source generation |
| `activeResponseMessageId` | the requested response, until `activeGeneration` exists |
| `activeProjection` | the indexed generation frontier, for exact interruption cutoffs |
| `responseOwners` | `Record<string, { generationId: string; requestId: string; attempt: number }>`: each response's latest owner, so a superseded owner's late events change nothing |
| `terminalRequestIds`, `terminalGenerations` | optional `Record<string, true>` and `Record<string, 'completed' \| 'failed' \| 'interrupted' \| 'superseded'>` fences, kept across snapshots and recovery |

- The `paused` status and the `pausing` and `paused` phases mean retained work.
- `activeProjection` lives from generation start until the response completes,
  is interrupted or superseded, or a retry or later generation replaces it,
  including while a step waits on tool, approval, or input barriers, while
  paused, and while a failed generation awaits retry or interruption. Ordered
  progress applies only new chunks. It is plain JSON, so checkpoints serialize
  and clone, but their size grows with retained progress.
- `input-streaming` tool parts carry best-effort partial JSON for display until
  the completed input arrives; it never authorizes execution or changes
  interruption.

See [What happens after a send](/agents/introduction#what-happens-after-a-send).

### `deriveUIMessages(history)` and `reduceAIState(state, event)`

`deriveUIMessages()` folds a raw event history into `TranscriptMessage<M>[]`.
`reduceAIState()` is the event-at-a-time form the standard reducer uses. Both
project the same chunk-only protocol synchronously. A message's content is in
the command that brought it in, so a fold needs each `ai.message.created`'s
command before it; one without throws. Only A2's control lane writes
`ai.message.created`.

## `experimental-a2/ai/server`

### `agent(options)`

```ts
agent({
  name: string, // or contract: agentContract(...), never both
  events?, messageSchema?, // with name: server-only application events
  model: string | ModelSettings | (context => string | ModelSettings), // an AI Gateway model ID
  tools?,
  instructions?,
  maxSteps?,
  harness?: // the agent's loop; defaults to { type: 'ai-sdk' }
    | { type: 'ai-sdk', generation?, generate?, compaction?, messageMetadata? }
    | Harness, // fx() or pi()
  progress?,
  toolLane?: string, // run local tool executions one at a time on this lane
  toolRecovery?: { [tool]?: ToolRecovery }, // 'rerun' (default) | 'report'
  handlers?, // application event handlers
  store?, scheduler?, telemetry?,
}): Agent<Events, State, Name> // an A2Server
```

Serves an agent as one ordinary A2 server, returning an `A2Server` (`fetch`,
`session(id)`, `drain()`, `contract`). Server-only: the browser gets a throwing
stub.

| Option | What it does |
| --- | --- |
| `name` | the built-in protocol, with optional server-only `events` and `messageSchema` |
| `contract` | a shared `agentContract()`, when clients fold application state |
| `model` | an AI Gateway model ID, or [`ModelSettings`](#modelsettings) with one, under every harness; a value or per-generation async resolver |
| `tools` | AI SDK `tool()` definitions; local tools read context with [`handlerContext()`](#handlercontextcontract) |
| `instructions` | a value or per-generation async resolver |
| `maxSteps` | a positive integer capping one complete assistant response, under every harness; no limit by default |
| `harness` | the agent's loop: `{ type: 'ai-sdk', … }` (`AISDKHarness`, the default), [`fx()`](#fx), or [`pi()`](#pisandbox) |
| `harness.generation` | per-step settings, or a resolver |
| `harness.messageMetadata` | maps default stream parts to message metadata |
| `harness.generate` | replaces the default model step |
| `harness.compaction` | automatic for supported Gateway requests by default |
| `progress` | durable batching: `maxChunks` (128), `maxDelayMs` (0) |
| `toolLane` | one lane for every local tool execution, one at a time in admission order; model steps keep their own lane |
| `toolRecovery` | `'rerun'` (default) or `'report'` per tool, the harness's own tools included |
| `handlers` | application event handlers; naming an event the agent handles throws a `TypeError` |
| `store`, `scheduler`, `telemetry` | as for any A2 server; `telemetry` covers Core and AI in one trace ([span catalogue](/reference/telemetry#the-span-catalogue)) |

**Contracts.** A contract not built by `agentContract()` throws a `TypeError`
and fails to typecheck (serve custom contracts with `createServer()`). Messages
are typed through [`AIMessageOf`](#aimessageofevents). `typeof` the agent types
`useAgent()` and `createAgentClient()` through a type-only import; `Name`, its
reducer name, decides whether the contract-free client accepts it.

**AI Gateway.** For Gateway models, A2 merges `caching: 'auto'` into
`generation.providerOptions.gateway`, and automatic compaction uses the same
default; your values take precedence and other options are kept. A custom
`generate` receives your original settings and owns its caching. Gateway calls
also send an `x-session-affinity` header, stable for the session and free of
the raw session ID, so supporting providers can reuse their cache; it selects
no provider and guarantees no hit. Override or suppress it (`undefined`) in
`generation.headers`, in any casing, not on the model: A2's per-call default
overrides model headers. See
[Gateway automatic caching](https://vercel.com/docs/ai-gateway/models-and-providers/automatic-caching).

**`harness`** is the agent's loop. `{ type: 'ai-sdk' }` (`AISDKHarness`), the
default, runs one AI SDK `streamText()` step per durable generation, with the
`generation`, `messageMetadata`, `generate`, and `compaction` options below.
[`fx()`](#fx) and [`pi()`](#pisandbox) run a coding agent's own loop instead.
The four options exist only in `{ type: 'ai-sdk' }`; passing one to `agent()`
itself throws a `TypeError` naming where it belongs.

**`fetch`** accepts reads and pushes of `ai.control.requested`, except the
server-authored `request-input` and `tool-result`, with `send`, `edit`, and
`steer` limited to user messages. With `contract` it also accepts the
contract's events; `events` given with `name` stay server-only. Other pushed
events answer `400`. `fetch(request, { authorize })` adds per-operation policy.

**Model steps.** Under `{ type: 'ai-sdk' }`, each durable generation runs one AI
SDK `streamText()` step, whose chunks A2 persists once in durable progress
batches. It passes the model ID to the AI SDK, which resolves it through its
global provider, AI Gateway unless replaced. Generation requests
share one durable lane, so a session's model calls never overlap, and a later
user message waits until the active response and its tool loop reach a terminal
event (a failed response, until retried or interrupted). A `model`,
`instructions`, or `generation` resolver that throws or returns an invalid
value fails the turn once, like a provider error, without retrying; that
attempt's `ai.generation.started` omits `model` if it never resolved, and
`retry()` runs the step again. Store failures (`A2Error`) still retry durably.
See [What happens after a send](/agents/introduction#what-happens-after-a-send).

**Resolver context.** `model`, `instructions`, and `generation` resolvers get
`{ event, messages, state, session, signal }`, where `state` is the
application state (`{}` without a contract `reducer`) at the captured
snapshot, read for free from the model-context checkpoint. Compaction,
`messageMetadata`, and `generate` callbacks also get `modelMessages`, the
model-ready prompt with committed summaries. No context exposes raw history or
an `execution` argument. `context.session.state(reducer)` reads at the captured
prompt frontier by default; `context.session.state()` returns `AIState` with
its log index.

**`harness.generation`** holds per-step settings such as `temperature`,
`maxOutputTokens`, `topP`, provider options, and tool approval policy, as the
provider supports them. It excludes `stopWhen`, tool execution callbacks, tool
callers, sandbox execution, and the tool approval secret; everything else,
`toolsContext` included, reaches the AI SDK untouched. Model-step timeouts work;
tool-execution timeouts don't. A resolver returns `AgentGenerationSettings` (or
a promise) once per attempt, before compaction and model dispatch, and also
governs tool approvals and automatic-compaction compatibility. Static settings
are validated at construction, resolved ones before use; failures and
cancellation follow model resolution.

**`harness.messageMetadata(context)`** synchronously maps each default stream part to
your `UIMessage` metadata type. It gets the `generate` context plus the
`TextStreamPart` as `part`; its `generation` includes A2's Gateway defaults.
Metadata for `start` and `finish` parts is recorded with progress and
shallow-merged onto the assistant message (use `finish`'s `totalUsage` for
final usage). A custom `generate` skips it. See
[Store assistant metadata](/agents/conversations#store-assistant-metadata).

**`harness.generate(context)`** gets the resolver context plus the step's
`generationId`, `modelMessages`, and the resolved `model`, `instructions`,
`tools`, and `generation`; `messages` and `modelMessages` reflect this step's
compaction. It returns exactly one model step as a
`ReadableStream<UIMessageChunk>` writing message
`event.payload.responseMessageId`; A2 keeps tool execution, continuation, and
the durable lifecycle. See
[Replace generation, not durability](/agents/conversations#replace-generation-not-durability).

**Tools.** Independent authorized tool handlers run concurrently with no
fixed limit. Results and generation completion arrive in either order; a
continuation needs the closed step and every required terminal result.
Single-owner terminal events commit atomically with the parent handler's
completion and carry its durable cause; many-owner joins and preliminary
outputs append immediately and idempotently. Provider calls and external side
effects are at least once: give them their own idempotency. An approved
provider-executed call can satisfy the join from its approval response, and a
provider tool with declared deferred-result support waits for its provider
result, which trusted server code submits with
[`inputs.toolResult(result)`](#inputs); the controller checks it against the
admitted call (`fetch` rejects it from browsers).

**`toolRecovery`**

- `'report'` skips `execute` when the previous attempt lapsed (its claim
  expired after a crash, timeout, or frozen process), recording an error result
  saying the call was interrupted and may have partly run; the model continues
  from it. A2 can't tell whether the lapsed attempt reached the tool, so a call
  that never started is reported too.
- Once `execute` has started, a cancelling Stop or Pause now, or a failure A2
  would otherwise retry (such as a transient scheduler send failure), records
  the same result; resuming after Pause now doesn't rerun the call.
- These still retry and run the tool: an attempt that failed with an error
  before `execute` (such as a failed store read), and a failure inside A2 while
  or just after the tool runs (its abort subscription, its result validation).
- An unknown tool name, a tool without `execute`, or another value throws a
  `TypeError` at construction.

See [Delivery semantics](/agents/conversations#delivery-semantics).

**`harness.compaction`**

| Value | Behavior |
| --- | --- |
| omitted | automatic for supported Gateway requests |
| `false` | no compaction and no model metadata discovery |
| `{ thresholdTokens?, instructions? }` | tunes automatic compaction. `thresholdTokens`: a positive safe integer. `instructions`: added to A2's summary prompt; the agent's instructions stay |
| `{ shouldCompact(context), compact(context) }` | a custom policy |
| `(context: AgentResolverContext) => CompactionOptions \| Promise<CompactionOptions>` | resolves once per generation to a valid policy or `false`; can await `context.session.state(reducer)` |

- Static options are validated at construction, resolved ones before their
  generation; each session uses its own value.
- Discovery: without an explicit threshold, the first generation batches a
  metadata request into its start write, and an independent handler records the
  model's limits from the public Gateway catalog in `state.modelMetadata`
  without delaying dispatch (shared one-hour cache, five-second timeout; no new
  request per turn, even after exhausted retries). The default threshold is 75%
  of the context window, lowered for a larger output allowance. While it is
  unknown, the first request proceeds and context-limit errors fail normally.
- Direct or custom providers, fallback model lists, and custom generators need
  explicit configuration. Provider-executed tools, structured output, and
  forced tool choices skip implicit compaction and reject explicit automatic
  policies.
- The estimate is serialized bytes of messages, tools, and instructions,
  excluding message and part provider options (they still reach the model
  unchanged); not a tokenizer. A preceding same-model completion calibrates it
  to reported input usage until a smaller prompt, retry, compaction, or model
  change.
- Completed generations record `inputTokenEstimate`. Automatic
  `ai.compaction.requested` events also record the pre-compaction
  `inputTokenEstimate`, the calibrated `inputTokens`, and `thresholdTokens`
  (custom policies omit them), at no extra model call or round trip.
- The built-in summary is one extra model request that keeps the request prefix
  for caching and runs no application callbacks, transforms, or tool hooks.
  A2 stores built-in summaries apart from your typed messages, so a summary
  needs no message metadata and need not match `messageSchema`.
  `state.messages` stays intact, marked with a
  [`data-a2-compaction` part](#iscompactionpartpart);
  `state.compaction?.status === 'running'` shows activity.
- Successful compaction is permanent across failure, interruption, retry, and
  recovery. It defers while tool exchanges or A2-owned tool work are unsettled
  (one coordinator state read per candidate), and while dead-lettered tool
  work awaits repair and settlement or session close.

See [Compact long conversations](/agents/conversations#compact-long-conversations).

**Harnesses.** Under `fx()` and `pi()`, each model call the harness makes is one
durable step of the response, with the same events, progress, steering,
interruption, `maxSteps`, and recovery as any step. Tool calls it makes become
ordinary A2 tool calls, executed by A2's tool handlers, and `toolRecovery` may
name the harness's own tools, such as Pi's `bash`. `model` is resolved per step
as for any agent. `instructions` and `tools` reach the harness; how they
combine with its own prompt and tools depends on the harness. Tool approval
policy lives in `harness.generation`, so a harness agent's tools run without
approval, and a tool that sets `needsApproval` is rejected. So is a
provider-defined tool (`type: 'provider'`), which the engine can't run. A user
message reaches the harness as its text and its images, which A2 fetches from
their `https:` or `data:` URLs; any other file fails the turn. See
[Images and files](/agents/harnesses#images-and-files).

A harness resumes after a crash at the interrupted step. Finished model calls
are not made again and finished tool calls do not run again. A2 rebuilds the
harness for each step from the log, and stores nothing outside it. Each agent
keeps, in its process's memory, the harness's state at the end of the last
response it ran there, FX's checkpoint or Pi's session, so a warm process reads
and replays only the log since; a cold one reads and replays the session from
its start. A saved state counts only once the step that wrote it is committed,
so a stale or unusable one costs a longer rebuild, never correctness. See
[Harnesses](/agents/harnesses).

**`progress`.** A batch is written as soon as the previous write finishes;
chunks arriving mid-write share the next batch, up to `maxChunks` (128).
`maxDelayMs` (default 0) lets a batch collect that long after the previous
write; boundary chunks and `maxChunks` write sooner. Raising it trades token
latency for fewer progress events.

### `ModelSettings`

```ts
type ModelSettings = {
  model: string // an AI Gateway model ID
  reasoning?: ReasoningLevel
  speed?: 'fast' | 'standard'
}
```

A model with its reasoning level and speed, returned by a `model` option or
resolver. `reasoning` becomes the AI SDK `reasoning` call option
([`reasoningLevels`](#reasoninglevels)), FX's effort, or Pi's thinking level. `speed: 'fast'` sets
`providerOptions.gateway.speed` to prefer AI Gateway's faster tier when the
model has one; `'standard'` removes a configured speed. Both override the same
settings in `generation`. See [Choose the model](/agents/introduction#choose-the-model).

### `runWithHandlerContext(ctx, fn)`

The test seam for context-reading tools: runs `fn` with `ctx` as the ambient
handler context, so tests can call `execute` directly. Inside `fn`,
`handlerContext()` returns `ctx`, with or without a contract argument.

## `experimental-a2/ai/fx`

### `fx()`

```ts
fx(): Harness<'fx'>
```

The [FX](https://fx.sh) harness for `agent({ harness })`, running FX's kernel
from `libfx` (an optional peer dependency) in the server process. FX adds no
system prompt and brings no tools: `instructions` is the whole system prompt
and the agent's `tools` are all FX can call. A call's input goes through its
tool's schema, as under `{ type: 'ai-sdk' }`: `execute` receives the schema's
output, defaults included, and input the schema rejects goes back to the model
as an error, without running the tool. `reasoning` becomes FX's effort and
`speed: 'fast'` its fast tier. FX compacts its own context; a compaction
appears in the transcript as a `data-a2-compaction` part with its summary.

Every model call FX makes goes through A2, which records it as a step. To
continue a response, A2 restores FX's latest checkpoint and replays the
response so far from the log: recorded model responses and tool results answer
the kernel without network calls or tool runs, then FX makes its next model
call. Replay grows with the response, roughly 0.1 to 0.5 seconds for one of 80
to 200 tool calls. When libfx can't take a checkpoint, A2 replays from an
earlier one.

FX's model calls carry the same [AI Gateway](#agentoptions) defaults as the
AI SDK harness: `caching: 'auto'`, under any Gateway options FX sets itself,
and the session's stable `x-session-affinity` header in place of FX's own,
which changes every time A2 rebuilds the kernel. A2 leaves every other byte of
the request as FX wrote it, so it adds caching only where FX's own options come
last in the request, as libfx writes them; otherwise the request goes
unchanged.

Credentials come from `AI_GATEWAY_API_KEY`, or Vercel's OIDC token on Vercel.
This entry point is server-only.

## `experimental-a2/ai/pi`

### `pi({ sandbox })`

```ts
pi({
  sandbox: ({ session, signal }) => SandboxSession | Promise<SandboxSession>,
}): Harness<'pi'>
```

The [Pi](https://pi.dev) harness for `agent({ harness })`, running Pi's own
agent session from `@earendil-works/pi-coding-agent` (with
`@earendil-works/pi-ai`, both optional peer dependencies). Pi brings its system
prompt and its `read`, `write`, `edit`, and `bash` tools, which run in the
session's sandbox. `instructions` is added to Pi's prompt. The agent's `tools`
are added to Pi's; a tool with one of their names replaces Pi's. Every call
first passes Pi's own checks: Pi coerces its arguments to the tool's JSON Schema
(the string `"5"` becomes `5` for a number) and rejects what that schema
forbids, such as a property it does not list. A call to one of the agent's tools
then goes through the tool's schema, as under `{ type: 'ai-sdk' }`: `execute`
receives the schema's output, defaults included. Input either check rejects goes
back to the model as an error, without running the tool. `reasoning` becomes
Pi's thinking level and `speed: 'fast'` asks AI Gateway for the model's fast
tier; `model` is one of the Gateway models Pi's catalog lists. Pi works in the sandbox's working directory; its `bash` runs with the
sandbox's environment, never the server's. Pi compacts its own context; a
compaction appears in the transcript as a `data-a2-compaction` part with its
summary.

- `sandbox` (`HarnessSandbox`, given a `HarnessSandboxContext`) resolves the
  session's AI SDK sandbox session (`Experimental_SandboxSession` from `ai`).
  It runs for each local tool execution, whose `execute` receives the sandbox
  as `experimental_sandbox`, Pi's tools and the agent's alike, and for a step
  until Pi's session records the sandbox's working directory.
- It must return the same sandbox for a session every time, and concurrent tool
  calls call it concurrently. A resolver failure fails that attempt, which A2
  retries. A2 never closes the sandbox. `pi()` without a `sandbox` function
  throws a `TypeError`.
- For each step, A2 restores Pi's session from the end of the last response,
  replays the response so far from the log, and lets Pi make its next model
  call. Pi's tool calls are ordinary A2 tool calls: A2 runs Pi's real tool
  implementations against the sandbox and records their results.
- Pi's tools move at most 4 MiB from the sandbox to your server per file read
  or command. A larger file fails `read` or `edit`, and a command whose output
  passes 4 MiB is stopped and fails `bash`; both errors tell the model to read
  in parts. Files arrive through the sandbox's `readFile`, so implement it as a
  stream for the limit to bound the download too.

`pi()` takes `PiOptions`. Credentials come from `AI_GATEWAY_API_KEY`, or
Vercel's OIDC token on Vercel. This entry point is server-only.

## `experimental-a2/ai/client`

### `createAgentClient(options)`

```ts
createAgentClient<typeof agent>(options: {
  api: ClientApi
  gcTime?: number
  participant?: string
  reconnect?, fetch?, webSocket?, // as createClient
}): AgentClient // an A2Client over the built-in AI protocol
```

A contract-free client for an `agent()` route, typed by a type-only import of
the server module. It pushes the built-in AI controls only.

- Accepts only agents whose reducer has the built-in name
  (`typeof AI_STATE_REDUCER`): `agent({ name })`, or `agent({ contract })` with
  an `agentContract()` without `reducer`. Extended contracts use
  `createClient({ contract })`, so the client folds the same state.
- Use it for pushes outside React, such as a first message before navigating,
  and pass it to `useAgent({ client })` to share its sessions.

See [Start a session from another page](/agents/introduction#start-a-session-from-another-page).

## `experimental-a2/ai/react`

### `useAgent(options)`

```ts
useAgent<typeof agent>(options: {
  id: string
  initial?: InitialSnapshot<AIState>
  participant?: string
} & ({ api: string } | { client: AgentClient })): UseAgentResult

useAgent(options: {
  id: string
  initial?: InitialSnapshot<State>
  participant?: string
} & (
  | { api: string; contract: Contract } // a shared agentContract(), imported for real
  | { client: A2Client } // createClient({ contract, api })
)): UseAgentResult
```

One agent session in React: its live optimistic `AIState` and actions that
fill in protocol identities.

- `api`: one shared client per route for the page's lifetime, reading
  `globalThis.fetch` per request. `client`: yours, for a custom `fetch`,
  headers, or `gcTime`. Both accept only built-in-name agents.
- `contract`: also folds its application state, one client per contract and
  route; `state` is `AIState & X` and `push` accepts its events. Pass
  `createClient({ contract, api })` as `client` to share it outside React.
- Contracts not built by `agentContract()` use core `useSession()` and
  `inputs`.
- Keep one options form per call site, unchanged between renders.

The result is the session result (`state`, `index`, `connection`, `push`,
`events`, `hydrated`, `reconnect`, `loadHistory`, `history`) plus:

| Member | Does |
| --- | --- |
| `messages` | `state.messages` |
| `status` | `state.status` |
| `indicator` | `'starting'` for an optimistic turn awaiting acceptance, `'compacting'` while a summary runs, `'thinking'` while the current step has no visible part (reasoning and step boundaries don't count), else `null` |
| `sendMessage(message, { generate? })` | queues a string (one text part) or a `UIMessage` |
| `steer(message)` | steers the active turn after its current step, or sends when idle |
| `stop()` | stops the active turn at the rendered frontier |
| `approve(approval)`, `deny(approval, { reason? })` | answers a pending approval |
| `respond(input, value)` | answers a pending application input |
| `retry()` | retries the failed response with a fresh `retryId` |
| `pause({ when })`, `resume()` | pauses and resumes |
| `queue.edit(message)`, `queue.move({ inputId, beforeId })`, `queue.remove(inputId)`, `queue.sendNow(inputId)` | edits the inbox |

Every action returns a `PushResult`, like `push()`: it resolves to the
persisted command events (each `id` is its receipt's `commandId`), rejects
when the push fails, and its `.confirmed` resolves when the stream delivers
them back. Actions read identities from the render that created them, so
`stop()` and `queue.sendNow()` carry the frontier the user saw. An action with
nothing to address (`stop()` with no active turn, `retry()` with no failed
one) resolves to an empty array. Actions are rebuilt every render; their
identity is not stable.
