---
title: Introduction
description: Build an AI SDK agent whose messages, streaming, tools, and recovery live in an A2 event log.
---

## Install

```bash
pnpm add experimental-a2 ai zod
```

## Define the agent

```ts 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()`](/reference/ai#agentoptions). The same
agent can run [FX's or Pi's loop](/agents/harnesses) instead.

## Expose the route

```ts 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:

```tsx 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:

```tsx 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()`](/reference/ai#useagentoptions).

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

```ts 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',
})
```

```ts 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:

```ts 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`](/reference/ai#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](/agents/conversations#add-your-own-events-and-state).

## What happens after a send

One generating user message becomes a durable sequence:

```text
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](/agents/conversations#inbox-and-turn-controls).

The hook's actions read [`AIState`](/reference/ai#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.
