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.

  • 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() accepts a plain agentContract()) and carries X to agent()'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 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.

inputs

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

InputEvents
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 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.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.

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.

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.

FieldWhat it holds
messagesTranscriptMessage<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)
activenull or { turnId, input, phase }, where input is the admitted input's { id, role, generate }
statusgeneration status
startinga speculative optimistic turn
receiptthe latest user command receipt
pendingQueueCommandsthe unresolved queue commands, for the optimistic display
sessionsession lifecycle
pendingApprovals, pendingInputspending tool approvals and application input
toolstool activity, identity and status only; payloads live in message parts
compactionthe current or latest compaction
modelMetadataeach model's pending, resolved, or unavailable limits
usage, errorusage per generation; the last error
activeGenerationthe started generation
activeRequestIdthe server-authorized generation request; fences delayed requests before start
activeRequestReason, activeRequestSourceGenerationIdthat request's reason and source generation
activeResponseMessageIdthe requested response, until activeGeneration exists
activeProjectionthe indexed generation frontier, for exact interruption cutoffs
responseOwnersRecord<string, { generationId: string; requestId: string; attempt: number }>: each response's latest owner, so a superseded owner's late events change nothing
terminalRequestIds, terminalGenerationsoptional 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.

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 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.

OptionWhat it does
namethe built-in protocol, with optional server-only events and messageSchema
contracta shared agentContract(), when clients fold application state
modelan AI Gateway model ID, or ModelSettings with one, under every harness; a value or per-generation async resolver
toolsAI SDK tool() definitions; local tools read context with handlerContext()
instructionsa value or per-generation async resolver
maxStepsa positive integer capping one complete assistant response, under every harness; no limit by default
harnessthe agent's loop: { type: 'ai-sdk', … } (AISDKHarness, the default), fx(), or pi()
harness.generationper-step settings, or a resolver
harness.messageMetadatamaps default stream parts to message metadata
harness.generatereplaces the default model step
harness.compactionautomatic for supported Gateway requests by default
progressdurable batching: maxChunks (128), maxDelayMs (0)
toolLaneone 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
handlersapplication event handlers; naming an event the agent handles throws a TypeError
store, scheduler, telemetryas 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' 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.

harness.compaction

ValueBehavior
omittedautomatic for supported Gateway requests
falseno 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; 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 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)

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.

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 })
)): 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:

MemberDoes
messagesstate.messages
statusstate.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.