Introduction

Build an AI SDK agent whose messages, streaming, tools, and recovery live in an A2 event log.

Install

pnpm add experimental-a2 ai zod

Define the agent

server/assistant.ts
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

app/api/agent/route.ts
import { assistant } from '@/server/assistant'

export const GET = assistant.fetch
export const POST = assistant.fetch

GET 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:

app/agent/[sessionId]/page.tsx
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:

app/agent/[sessionId]/chat.tsx
'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:

app/agent/client.ts
'use client'
import { createAgentClient } from 'experimental-a2/ai/client'
import type { assistant } from '@/server/assistant'

export const assistantClient = createAgentClient<typeof assistant>({
  api: '/api/agent',
})
app/agent/open-agent.ts
'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:

server/fast-assistant.ts
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 present

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