AI
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 for the
protocol and complete examples.
agentContract(options)
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 }). Otherwise
agent({ name }) builds one on the server.
eventscannot replace built-in event names.messageSchemavalidates a narrowerUIMessagein message payloads; without it, the type argument types messages.reducer.foldseesXand every event, never the transcript, and runs after the AI fold on the server and in every client, so optimistic display carries through.- An
initialStatekey thatAIStatealso has throws aTypeErrorand fails to typecheck. - The reducer name is
AI_STATE_REDUCER(a2.ai.state.v27) withoutreducer, else${reducer.name}+a2.ai.state.v27, so checkpoints advance when either fold changes.AgentReducertypes that name (socreateAgentClient()accepts a plainagentContract()) and carriesXtoagent()'s resolvers.
See Add your own events and state.
TranscriptMessage<M>
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, 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 localexecuteruns inside the ambient scope. Call it anywhere belowexecute, including awaited helpers and async iteration; elsewhere it throws aTypeError. - Typed for the built-in protocol by default. Pass the shared
agentContract()to type application events; a mismatch with the executing agent throws aTypeError. eventis theai.tool.execution.requestedwork event, whose payload holds the admittedcall,generation,turnId, and executionversion.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,needsApprovalpredicates, input lifecycle callbacks) pass through untouched, durable execution passes no AI SDKcontextoption, and model-phase hooks see tools without localexecute. needsApprovalandgeneration.toolApprovalpredicates are re-evaluated on recovery for the same durable call: keep them deterministic over the input.
See Tools and approvals.
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.decidedconfirms application with{ commandId, outcome, reason? }on the existing stream.outcomeisappliedorrejected; reasons includenot-pending,role-mismatch,stale-turn,not-found,not-user-input, andclosed.state.receiptholds the latest user receipt. - Trust. Browser ingress accepts user messages, approval and input
responses, interruptions, and explicit retries.
inputs.requestInput(),inputs.toolResult(), and non-userinputs.seed()messages are trusted server appends; only built-in handlers append generation requests and AI lifecycle events. No builder covers session lifecycle: appendai.session.createdandai.session.closedfrom 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()orappend()assigns, kept across transport retries. Caller IDs are nonempty, never start with the reserveda2.ai:, and are unique across the store; identical events replay and conflicting reuse rejects before append. A2's own IDs are opaque, fixed-length, deterministica2.ai:values; an assistant message's ID is its initial model-request event's ID. - Passive input.
generate: falseis valid browser input: it admits the user message toAIState.messageswithout 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, whatevergeneratesays. Model output is generation progress, notai.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(nullwhen 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 atlastSeenIndex. - 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
requestIdandgenerationId, copied from the pending request, so a late response cannot satisfy a newer step. - Retry.
ai.retry.requestedcarries{ messageId, responseMessageId, retryId }(oneretryIdper user action) and becomes anai.generation.requestedwith reasonretry. 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.requestedrequiresresponseMessageId, plussourceGenerationIdfor a tool continuation.ai.generation.failedsetsstepLimit: truewhenmaxStepsrejects a continuation. - Interruption.
ai.message.interruptedis{ messageId, requestId, reason?, lastSeenIndex? }or{ messageId, generationId, reason?, lastSeenIndex? }: copyactiveRequestIdbefore the generation starts,activeGeneration.generationIdafter. 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 alterAIState, satisfy pending work, or reactivate the response. Tool results completed at or beforelastSeenIndexstay completed; a final tool result racing a generation failure wins in either order, and the generation stays failed. Interruption and failure dropinput-streamingtool drafts from the projection and later prompts (raw progress stays); other tool parts keep their terminal handling.
See Inbox and turn controls and Interrupt and retry.
reasoningLevels
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.
isCompactionPart(part)
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.requestedappends 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.completedsettles it in place withsummaryandusage. If its generation ends first:interrupted(Stop, Pause now, session close) orfailed. A pending Stop previewsinterruptedon 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.
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.messagesafter 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 atlastSeenIndex, until its decision; a rejection restores them. - Later local edits stay visible through earlier receipts. Pending commands
retire on their
ai.control.decidedreceipt or session close; failed pushes roll back. - Server reads and folds hold confirmed entries only (
starting: false). Custom reducers that callreduceAIState()or nest the AI fold don't get this display.
See 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> 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
pausedstatus and thepausingandpausedphases mean retained work. activeProjectionlives 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-streamingtool 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.
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)
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 A2ServerServes 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 with one, under every harness; a value or per-generation async resolver |
tools | AI SDK tool() definitions; local tools read context with handlerContext() |
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(), or pi() |
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) |
Contracts. A contract not built by agentContract() throws a TypeError
and fails to typecheck (serve custom contracts with createServer()). Messages
are typed through AIMessageOf. 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.
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() and pi() 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.
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.
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.
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); the controller checks it against the
admitted call (fetch rejects it from browsers).
toolRecovery
'report'skipsexecutewhen 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
executehas 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 aTypeErrorat construction.
See 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.modelMetadatawithout 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. Automaticai.compaction.requestedevents also record the pre-compactioninputTokenEstimate, the calibratedinputTokens, andthresholdTokens(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.messagesstays intact, marked with adata-a2-compactionpart;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.
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.
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.
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
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), 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.
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()
fx(): Harness<'fx'>The FX 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 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 })
pi({
sandbox: ({ session, signal }) => SandboxSession | Promise<SandboxSession>,
}): Harness<'pi'>The Pi 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 aHarnessSandboxContext) resolves the session's AI SDK sandbox session (Experimental_SandboxSessionfromai). It runs for each local tool execution, whoseexecutereceives the sandbox asexperimental_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 asandboxfunction throws aTypeError. - 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
readoredit, and a command whose output passes 4 MiB is stopped and failsbash; both errors tell the model to read in parts. Files arrive through the sandbox'sreadFile, 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)
createAgentClient<typeof agent>(options: {
api: ClientApi
gcTime?: number
participant?: string
reconnect?, fetch?, webSocket?, // as createClient
}): AgentClient // an A2Client over the built-in AI protocolA 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 }), oragent({ contract })with anagentContract()withoutreducer. Extended contracts usecreateClient({ 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.
experimental-a2/ai/react
useAgent(options)
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 })
)): UseAgentResultOne 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, readingglobalThis.fetchper request.client: yours, for a customfetch, headers, orgcTime. Both accept only built-in-name agents.contract: also folds its application state, one client per contract and route;stateisAIState & Xandpushaccepts its events. PasscreateClient({ contract, api })asclientto share it outside React.- Contracts not built by
agentContract()use coreuseSession()andinputs. - 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.