Introduction
Build an AI SDK agent whose messages, streaming, tools, and recovery live in an A2 event log.
Install
pnpm add experimental-a2 ai zodDefine the agent
import { agent } from 'experimental-a2/ai/server'
export const assistant = agent({
name: 'support-agent',
model: 'openai/gpt-5.6-terra',
instructions: 'Help the customer. Be concise.',
})agent() is the whole server: the AI protocol, the agent's loop, and A2's
store, scheduler, and handlers. name is its identity. The loop is the
agent's harness. By default it's { type: 'ai-sdk' }: each response runs as
durable one-step streamText() generations, tuned with harness.generation
and maxSteps, listed under agent(). The same
agent can run FX's or Pi's loop instead.
Expose the route
import { assistant } from '@/server/assistant'
export const GET = assistant.fetch
export const POST = assistant.fetchGET streams events and POST accepts the browser's pushes. The route never
calls the model: fetch accepts user messages and controls, and rejects every
server-authored event with 400. The route is where you'd do auth, or any
other checks, before assistant.fetch(request, { authorize }) applies
per-operation policy.
Render the conversation
Fold the initial state on the server and hand it to a client component:
import { assistant } from '@/server/assistant'
import { Chat } from './chat'
export default async function AgentPage({
params,
}: {
params: Promise<{ sessionId: string }>
}) {
const { sessionId } = await params
const initial = await assistant.session(sessionId).state()
return <Chat sessionId={sessionId} initial={initial} />
}An empty session folds to the initial AIState; its log is created when the
first message is pushed. useAgent() keeps that state live and returns the
actions a chat needs:
'use client'
import type { FormEvent } from 'react'
import { useState } from 'react'
import type { StateSnapshot } from 'experimental-a2'
import type { AIState } from 'experimental-a2/ai'
import { useAgent } from 'experimental-a2/ai/react'
import type { assistant } from '@/server/assistant'
const indicatorLabel = {
starting: 'Starting…',
thinking: 'Thinking…',
compacting: 'Compacting…',
}
export function Chat({
sessionId,
initial,
}: {
sessionId: string
initial: StateSnapshot<AIState>
}) {
const { messages, status, indicator, sendMessage } =
useAgent<typeof assistant>({ api: '/api/agent', id: sessionId, initial })
const [draft, setDraft] = useState('')
const [error, setError] = useState<string | null>(null)
async function send(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault()
const text = draft.trim()
if (!text) return
setDraft('')
setError(null)
try {
await sendMessage(text)
} catch (cause) {
setDraft(text)
setError(cause instanceof Error ? cause.message : String(cause))
}
}
return (
<main>
<section aria-label="Conversation">
{messages.map((message) => (
<article key={message.id}>
<strong>{message.role === 'user' ? 'You' : 'Agent'}</strong>
{message.parts.map((part, index) =>
part.type === 'text' ? <p key={index}>{part.text}</p> : null,
)}
</article>
))}
{indicator ? (
<p role="status">{indicatorLabel[indicator]}</p>
) : null}
</section>
<form onSubmit={send}>
<input
aria-label="Message"
value={draft}
onChange={(event) => setDraft(event.currentTarget.value)}
/>
<button disabled={!draft.trim() || status === 'closed'}>Send</button>
</form>
{error ? <p role="alert">{error}</p> : null}
</main>
)
}The import type keeps server code out of the bundle. The hook returns the
session's state plus messages, status, indicator, and actions; see
useAgent().
sendMessage() takes a string, sent as one text part with a fresh message ID,
or a complete UIMessage. Every action returns the same result as push(): it
resolves once the command events are stored, and .confirmed resolves when the
stream delivers them back.
Pass { generate: false } to add a user message without starting a turn, as in
sendMessage('Order #4411 shipped this morning.', { generate: false }). The
next generating user message includes earlier passive messages in its model
context; passive messages sent after it wait for the next one.
Start a session from another page
Wait for the durable append before navigating, so the destination's server
render sees the message. createAgentClient() is the same client outside React:
'use client'
import { createAgentClient } from 'experimental-a2/ai/client'
import type { assistant } from '@/server/assistant'
export const assistantClient = createAgentClient<typeof assistant>({
api: '/api/agent',
})'use client'
import { inputs } from 'experimental-a2/ai'
import { assistantClient } from './client'
export async function openAgent({
sessionId,
text,
router,
}: {
sessionId: string
text: string
router: { push(href: string): void }
}): Promise<void> {
await assistantClient.session(sessionId).push(
...inputs.message({
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text }],
}),
)
router.push(`/agent/${sessionId}`)
}Pass the same client to the destination's hook,
useAgent<typeof assistant>({ client: assistantClient, id: sessionId, initial }),
so it picks up the live session the push created and reconciles the server
render without replacing it. Idle client session objects expire after five
minutes by default; set gcTime on createAgentClient() to change that.
Choose the model
model is an AI Gateway model ID, under every harness, or ModelSettings to
choose its reasoning level and speed with it:
import { agent } from 'experimental-a2/ai/server'
export const fastAssistant = agent({
name: 'fast-support',
model: { model: 'openai/gpt-5.6-terra', reasoning: 'low', speed: 'fast' },
})speed: 'fast' prefers AI Gateway's faster tier when the model offers one.
reasoningLevels from experimental-a2/ai lists the levels for a picker. See
ModelSettings. A resolver can also choose the
model for each generation from your app's state, such as a plan; see
Add your own events and state.
What happens after a send
One generating user message becomes a durable sequence:
browser ai.control.requested durable input command
server ai.message.created input admitted
server ai.control.committed accepted state changes
server ai.control.decided command receipt
server ai.generation.requested scheduled from durable facts
server ai.generation.started
server ai.generation.progress batched UIMessageChunk[]
server ai.tool.called when present
server ai.approval.requested when present
server ai.tool.result after concurrent execution or denial
server ai.generation.completed closes this model step
server ai.generation.requested after all sibling tools are terminal
... another one-step generation
server ai.message.completed
server ai.generation.requested next queued user turn, when presentTool results and generation completion can arrive in either order; the next
step waits for both. The SSE stream delivers these events to the same reducer,
which applies the streamed chunks to the current UIMessage; the exported
deriveUIMessages() does the same over a raw history.
Model calls for a session never overlap. A message sent while a response is active enters the durable log immediately, but its turn waits until that response, including every tool step and approval, finishes. Inbox order is arrival order and can be edited; see Inbox and turn controls.
The hook's actions read AIState's ownership
bookkeeping, such as activeGeneration and activeRequestId, for you; the
lower-level inputs builders need it explicitly. A2-generated IDs use the
reserved a2.ai: namespace; treat them as opaque.