---
title: Conversations
description: "Compact long conversations, add your own events and state, and control delivery, the inbox, and turns."
---

## Compact long conversations

```ts server/compacted.ts
import { agent } from 'experimental-a2/ai/server'

export const compactedAssistant = agent({
  name: 'compacted-agent',
  model: 'openai/gpt-5.6-terra',
  harness: {
    type: 'ai-sdk',
    // optional: compaction is automatic for supported Gateway models
    compaction: {
      thresholdTokens: 80_000,
      instructions: 'Preserve exact IDs.',
    },
  },
})
```

Compaction belongs to the AI SDK harness, and is automatic for supported
Gateway models. When the estimated prompt crosses the threshold, A2 sends one
summary request to the active model with the same instructions, tools, and
provider settings, keeping the prefix cacheable.
Later requests get the summary plus everything after it; the raw log and
`state.messages` stay complete, and the summary request never appears there as
a turn. Successful compaction is permanent across failure, interruption, retry,
and recovery, and a failed summary leaves the previous context intact.

The default threshold is 75% of the context window from the Gateway model
catalog, lowered for a larger `generation.maxOutputTokens`; it limits input
context, not output. `thresholdTokens` overrides it, `instructions` adds
guidance to the summary prompt, and `false` turns off compaction and the catalog
lookup, keeping recorded summaries. A resolver can choose per generation, for
example from `session.state(settingsReducer)`. Custom `shouldCompact(context)`
and `compact(context)` callbacks receive the typed messages and the model-ready
`modelMessages`, summary included.

An AI SDK global provider that resolves model IDs to models outside AI
Gateway, and Gateway fallback model lists, need an explicit `thresholdTokens`
(for a fallback list, one that fits every model it may use). A custom `generate` keeps compaction off unless you
configure it. Set a threshold too when importing a large conversation: a
session does not compact before the catalog lookup lands, so an oversized first
prompt is sent as-is, never truncated, and fails with the provider's error.
Provider-executed tools, structured output, and forced tool choices skip
automatic compaction, and configuring it for them throws; use custom callbacks.

The first generation with each Gateway model in a session records
`ai.model.metadata.requested`; a separate handler fetches the catalog and
records `ai.model.metadata.resolved`, without delaying or failing the
generation. `state.modelMetadata` holds each model's limits and status:
pending, resolved, or unavailable (absent from the catalog). A lookup that
exhausted its retries stays pending without a new lookup each turn, so inspect
that handler's failure or set a threshold. The estimate is serialized UTF-8
bytes divided by four, calibrated by the previous same-model call's usage. It is
not a tokenizer, makes no remote calls, and does not precisely measure new image
or audio tokens.

Compaction waits until earlier tool calls have final results and all A2-owned
tool work has settled, including work from stopped or superseded turns, with one
extra state read before each candidate compaction. Dead-lettered tool work
defers it until repair or session close. Queued messages stay queued. The
summary request runs none of your generation callbacks, stream transforms, or
tool hooks and never executes local tools; an empty summary or a summary tool
call fails the generation, as do provider context-limit errors. See
[`agent()`](/reference/ai#agentoptions) for every option.

### Show compaction in the conversation

```tsx app/agent/message-view.tsx
'use client'
import type { UIMessage } from 'ai'
import { isCompactionPart, type TranscriptMessage } from 'experimental-a2/ai'

const compactionLabel = {
  running: 'Compacting…',
  completed: 'Context compacted',
  failed: 'Compaction did not complete',
  interrupted: 'Compaction stopped',
}

export function MessageView({
  message,
}: {
  message: TranscriptMessage<UIMessage>
}) {
  return (
    <article>
      {message.parts.map((part, index) => {
        if (isCompactionPart(part))
          return (
            <details key={index}>
              <summary>{compactionLabel[part.data.status]}</summary>
              {part.data.summary ? <p>{part.data.summary}</p> : null}
            </details>
          )
        return part.type === 'text' ? <p key={index}>{part.text}</p> : null
      })}
    </article>
  )
}
```

The reducer marks each compaction with a `data-a2-compaction` part in the
assistant response. Its `data` holds a `status` (`running`, then `completed`,
`failed`, or `interrupted`) and, when known, `inputTokens`, `thresholdTokens`,
`summary`, and `usage`. The part lives in state, so it survives server
rendering, reloads, and other tabs, and never enters model context. Because
`state.messages` is `TranscriptMessage<M>[]`, `isCompactionPart()` narrows even
when your message type declares its own data parts. `state.compaction` holds the
current or latest compaction, and `indicator` is `compacting` while one runs.
See [`isCompactionPart()`](/reference/ai#iscompactionpartpart) for how the part
settles.

## Store assistant metadata

```ts server/metadata-assistant.ts
import type { LanguageModelUsage, UIMessage } from 'ai'
import { agentContract } from 'experimental-a2/ai'
import { agent } from 'experimental-a2/ai/server'

export type MetadataAssistantMessage = UIMessage<{
  status: 'writing' | 'complete'
  usage?: LanguageModelUsage
}>

export const metadataAssistant = agent({
  contract: agentContract<MetadataAssistantMessage>({
    name: 'metadata-assistant',
  }),
  model: 'openai/gpt-5.6-terra',
  harness: {
    type: 'ai-sdk',
    messageMetadata: ({ part }) => {
      if (part.type === 'start') return { status: 'writing' as const }
      if (part.type === 'finish') {
        return { status: 'complete' as const, usage: part.totalUsage }
      }
      return undefined
    },
  },
})
```

A narrower `UIMessage` type, carried by `agentContract()`, keeps generation
status and usage on the assistant message. The synchronous
`harness.messageMetadata` callback gets the `generate` context plus the current `TextStreamPart` as
`part`. What it returns for `start` and `finish` is logged with the stream and
shallow-merged onto the assistant message, so the example shows `writing` at
once and keeps final usage. `useAgent<typeof metadataAssistant>()` types
`messages` with it.

## Add your own events and state

Apps carry more than the conversation: a plan, a selected ticket, a chosen
model. Put those events and their state in a shared `agentContract()`. It takes
a core `contract()`'s `events`, and its `reducer` takes the same `name`,
`initialState`, and `fold`:

```ts support-desk.ts
import { z } from 'zod'
import { agentContract } from 'experimental-a2/ai'

const noFlags: string[] = []

export const supportDesk = agentContract({
  name: 'support-desk',
  events: {
    'plan.changed': z.object({ plan: z.enum(['free', 'pro']) }),
    'ticket.flagged': z.object({ ticketId: z.string() }),
  },
  reducer: {
    name: 'support-desk.v1',
    initialState: { plan: 'free' as 'free' | 'pro', flagged: noFlags },
    fold: (state, event) =>
      event.type === 'plan.changed'
        ? { ...state, plan: event.payload.plan }
        : event.type === 'ticket.flagged'
          ? { ...state, flagged: [...state.flagged, event.payload.ticketId] }
          : state,
  },
})
```

Keep this module isomorphic. `fold` sees your state and every event, never the
transcript. The session's state is `AIState` merged with yours, so a key that
shadows one of `AIState`'s is a type error. The reducer's `name` is its snapshot
identity: rename it when `fold` changes.

Serve it with `agent({ contract })`. Every resolver receives your state as
`state`:

```ts server/support-desk.ts
import { agent } from 'experimental-a2/ai/server'
import { supportDesk } from '../support-desk'

export const supportDeskAgent = agent({
  contract: supportDesk,
  model: ({ state }) =>
    state.plan === 'pro'
      ? { model: 'openai/gpt-5.6-terra', reasoning: 'high' }
      : { model: 'openai/gpt-5.6-luna', speed: 'fast' },
  handlers: {
    'ticket.flagged': async ({ event }) => {
      // your side effect; event.id makes a stable idempotency key:
      // await notifyOnCall(event.payload.ticketId, { idempotencyKey: event.id })
    },
  },
})
```

`state` comes from the snapshot the generation already captured, so it costs no
extra read. Each model step resolves again, so a change during a turn applies
from the next step. A resolver that throws fails the turn with its message, like
a provider error; `retry()` runs it again.

In the browser, pass the contract to `useAgent()`. This import is real, not
type-only, because the browser folds your state too:

```tsx app/support/desk.tsx
'use client'
import { useAgent } from 'experimental-a2/ai/react'
import { supportDesk } from '@/support-desk'

export function Desk({ id }: { id: string }) {
  const { state, push, sendMessage } = useAgent({
    api: '/api/support',
    id,
    contract: supportDesk,
  })

  return (
    <section>
      <p>
        {state.plan} plan, {state.flagged.length} flagged tickets
      </p>
      <button
        onClick={() =>
          void push({ type: 'ticket.flagged', payload: { ticketId: 'T-1' } })
        }
      >
        Flag T-1
      </button>
      <button onClick={() => void sendMessage('Summarize the flagged tickets.')}>
        Ask
      </button>
    </section>
  )
}
```

Browsers push a shared contract's application events like any A2 client. The
agent refuses only its own server-authored `ai.*` events; the route's
`authorize` decides the rest. Here the plan comes from your billing code, not
the browser:

```ts app/api/support/route.ts
import { supportDeskAgent } from '@/server/support-desk'

function handle(request: Request): Promise<Response> {
  // here's where you'd do auth, or any other checks:
  return supportDeskAgent.fetch(request, {
    authorize: (operation) =>
      operation.type !== 'push' ||
      operation.events.every((event) => event.type !== 'plan.changed'),
  })
}

export const GET = handle
export const POST = handle
```

Server appends, such as
`supportDeskAgent.session(id).append({ type: 'plan.changed', payload })`, are
trusted and skip `authorize`. `push()` also accepts any `inputs` builder, and
`handlerContext(supportDesk)` types application events inside tools.

The contract-free client, `createAgentClient()` or `useAgent({ api | client })`
with a type-only import, folds only the built-in reducer. It accepts
`agent({ name })`, or `agent({ contract })` with an `agentContract()` that has
no `reducer`; the types enforce this through `AI_STATE_REDUCER`.

When the agent belongs inside something larger, such as a shared chat room,
keep two sessions. The room's handler appends each message the agent should see
to the agent's session with `inputs.message()`. Event IDs are unique across a
store's sessions, so derive the message ID from the room event's ID with
`idempotentId()`; a retried handler then converges on the same message. A tool
the agent calls to speak appends to the room with an `idempotentId()` of its
`toolCallId`. Neither fold reads the other's state.

### Replace generation, not durability

```ts server/custom-generation.ts
import { convertToModelMessages, streamText } from 'ai'
import { agent } from 'experimental-a2/ai/server'

export const customGeneration = agent({
  name: 'custom-generation',
  model: 'openai/gpt-5.6-terra',
  harness: {
    type: 'ai-sdk',
    generate: async ({ event, messages, model, instructions, signal }) => {
      const result = streamText({
        model,
        messages: await convertToModelMessages(messages),
        abortSignal: signal,
        ...(instructions === undefined ? {} : { instructions }),
      })

      return result.toUIMessageStream({
        originalMessages: messages,
        generateMessageId: () => event.payload.responseMessageId,
      })
    },
  },
})
```

`harness.generate` replaces only the model-facing step, for fixtures, provider routing,
or a different AI SDK assembly. It receives the resolver context (with
`messages` already compacted), the resolved settings, `modelMessages`, and the
step's `generationId`, and returns exactly one model step as a
`ReadableStream<UIMessageChunk>` that writes the assistant message
`event.payload.responseMessageId`. A tool-aware replacement sends tool
definitions without running local `execute` functions. A2 still owns durable
progress, tool execution, approval, continuation, interruption, and failure.
`messageMetadata` applies only to the default generator, so a custom `generate`
emits its own metadata.

## Checkpoints and recovery reads

Samples from here on import `assistant`, the agent from
[Agents](/agents/introduction#define-the-agent):

```ts server/read-agent.ts
import type { UIMessage } from 'ai'
import type { TranscriptMessage } from 'experimental-a2/ai'
import { assistant } from './assistant'

export async function readMessages(
  sessionId: string,
): Promise<TranscriptMessage<UIMessage>[]> {
  const snapshot = await assistant.session(sessionId).state()
  return snapshot.state.messages
}
```

`session(id).state()` returns `AIState` and its log index, ready for server code
or client hydration. Tool inputs, outputs, and provider metadata live in message
parts; `state.tools` keeps only identities and execution status.
`reduceAIState()` is the reducer's pure transition over confirmed `AIState`, for
projecting raw event histories.

Model, instruction, compaction, and custom-generation callbacks receive prompt
messages, the triggering request, and your contract's `state`, never a raw
history. Read any other reducer with `session.state(yourReducer)`, at the
captured prompt boundary unless you pass `through`. Recovery reads at fixed
boundaries too: a tool recovered in a fresh process sees its original prompt,
and A2 rebuilds a missing or invalid reducer snapshot from the log.

While tool arguments stream, `input-streaming` parts carry a best-effort partial
JSON `input`, so a command string can grow before its closing quote. It is a
display draft, not validated input; `tool-input-available` supplies the
completed input.

## Delivery semantics

Model steps and tools run inside at-least-once A2 handlers. A successful
completion commits atomically with its terminal events, and deterministic IDs
make scheduling and lifecycle appends effectively once. An incomplete model
attempt is marked `superseded` before its replacement starts. An expired claim
or superseded attempt keeps its abort reason through model, resolver,
compaction-policy, and tool calls, and retries at the cost of one failed
attempt; an explicit interruption stays terminal. A retried tool can stream
different preliminary output under the same tool-call ID.

Provider calls and external side effects sit outside that transaction, so
recovery may repeat a call whose effect landed before its handler committed.
Give side-effecting tools their own idempotency, normally keyed by the AI SDK
`toolCallId`. A fast tool can also start before its model step fails; the
failure blocks the continuation but cannot undo the effect.

For a tool whose effect must not repeat and has no idempotency key, such as a
shell command, set `toolRecovery: { yourTool: 'report' }`. When a crash, a
timeout, Stop, or Pause now may have cut a call short, A2 does not run it again,
even on resume; the model gets an error result saying the call was interrupted
and may have partly run. A call that failed before reaching the tool still
retries, and so does one where A2 itself fails while or just after the tool
runs. Every other tool uses the default, `'rerun'`. See
[`agent()`](/reference/ai#agentoptions) for the exact cases.

Configure a [production scheduler](/schedulers/overview) as for any A2 server. It
recovers an incomplete generation after the original serverless invocation
disappears.

## Inbox and turn controls

```tsx app/agent/[sessionId]/queue.tsx
'use client'
import { useAgent } from 'experimental-a2/ai/react'
import type { assistant } from '@/server/assistant'

export function Queue({ sessionId }: { sessionId: string }) {
  const { state, queue, steer, pause, resume } = useAgent<typeof assistant>({
    api: '/api/agent',
    id: sessionId,
  })

  return (
    <section>
      <ol aria-label="Queued messages">
        {state.inbox.items.map((item) => (
          <li key={item.id}>
            {item.message.parts.map((part, index) =>
              part.type === 'text' ? <span key={index}>{part.text}</span> : null,
            )}
            <button onClick={() => void queue.sendNow(item.id)}>Send now</button>
            <button
              onClick={() =>
                void queue.move({ inputId: item.id, beforeId: null })
              }
            >
              Move to end
            </button>
            <button onClick={() => void queue.remove(item.id)}>Remove</button>
          </li>
        ))}
      </ol>
      <button onClick={() => void steer('Check the billing service first.')}>
        Steer
      </button>
      {state.inbox.paused ? (
        <button onClick={() => void resume()}>Resume</button>
      ) : (
        <button onClick={() => void pause({ when: 'after-turn' })}>
          Pause after this turn
        </button>
      )}
    </section>
  )
}
```

`sendMessage()` puts a message in the durable inbox, `state.inbox.items`. The
controller admits one input at a time and adds it to `state.messages`; pending
inputs never enter the model prompt, and an edit after admission is rejected.
The log stores a message once, in the command that sent it or the edit that
last changed it. `state.active` is
the logical turn: its ID, its `input`, and `phase`. The turn ID holds
across model steps, tools, and a pause. Stop ends the turn and admits the next
input when admission is running.

:::note
This input protocol requires matching client and server versions. Start a new
agent session when adopting it; existing logs stay readable, but finish pending
work with the runtime that created it before upgrading.
:::

`queue.edit(message)` replaces a pending input's content; edits are
last-write-wins in log order. An edit keeps the message's identity and role;
changing the role returns `role-mismatch`. `queue.move()` takes `beforeId: null`
for the end. `queue.remove()` keeps the original submission in the log, so
replay cannot recreate the prompt. Removing an input that is no longer pending
(admitted, removed, or never queued) returns `not-pending`, as does an edit that
loses to admission; a Stop aimed at a finished turn returns `stale-turn`.

`steer()` puts new input right after the turn's earlier steering, or first
when there is none, so steering hands off in the order it was sent. If the observed turn
is still active, it finishes its current model response and that response's
tools, including authorized deferred provider results, then hands off before
another model step. Everything steering that turn enters together as one new
turn: the earlier messages as context and the last one generating, so the model
answers them once. Output produced after the click stays, and unanswered
approval and input requests close, so the unapproved tool never runs; a denied
approval never blocks the handoff. With no active turn, `steer()` sends like
`sendMessage()`.

`queue.sendNow()` makes the same handoff with a queued message, after the
steering sent before it, keeping its ID and latest accepted content and
promoting passive user input to generation.
Non-user context returns `not-user-input`, and a stale observed turn returns
`stale-turn` without changing the queue. Send now uses the accepted content, so
save editor text first. `steer()` and `queue.sendNow()` both preserve pause: a
paused queue keeps the selected input first until Resume. Stop remains the
immediate interruption.

A queued item's optional `afterStepOf` records the observed turn for steering.
Editing keeps it, removing the item cancels it, and queue order decides which
inputs are admitted at the handoff: the run at the front that steers the same
turn. See [`inputs`](/reference/ai#inputs) for the
full handoff rules.

### Input identity

New-input commands from `inputs.message()`, `inputs.seed()`, and
`inputs.steer()` use the message ID as their event ID, and hand-written commands
must too. Caller IDs cannot start with the reserved `a2.ai:` prefix and must be
unique across the store; include the contract and session when deriving them
with `idempotentId()`. An identical submission replays without new work, even
after removal or compaction. Reusing an ID with different content, action, or
session rejects with `PARTIAL_DUPLICATE_BATCH`: use `queue.edit()` to change a
pending input and a fresh ID for a new one.

### Optimistic conversation display

Every client folding the AI reducer, an extended `agentContract()` included,
applies pending commands to `state` with no extra I/O, so render the
conversation and queue from `state`. An idle, unpaused send appears in
`state.messages` at once with `state.starting: true`: a turn awaiting
acceptance, not a running model. Later sends, and every send while paused, wait
in `state.inbox.items`; steering, and Send now aimed at an active turn, appear
in `state.messages` after the current response instead. Another client's
accepted turn can move a speculative send back into the queue, while steering
stays visible as a message. A pending Stop clears `state.active` at once.
Close an editor as soon as its save is pushed, and keep the draft if the push
fails. See
[Optimistic AI client state](/reference/ai#optimistic-ai-client-state).

### Persistence and application are separate

A push confirms that the command is persisted; `ai.control.decided` confirms its
application, with `commandId` (the submitted event's ID) and an `outcome` of
`applied` or `rejected`. A rejection carries a `reason` such as `not-pending` or
`stale-turn`.
`state.receipt` is the most recent user command receipt. To correlate several
in flight, each action resolves to its persisted command events, whose `id` is
the receipt's `commandId`, or to an empty array when there is nothing to
address, such as Stop with no active turn. A rejection is a normal result and
spends no handler retry.

### Pause and resume

`pause({ when: 'after-turn' })` closes inbox admission and lets the active turn
finish. `pause({ when: 'now' })` also cancels current model and tool work;
`state.active.phase` is `pausing` while old work settles, then `paused`.
`resume()` resumes retained work before admitting another input.

A turn paused with Pause now keeps its steering until Resume. Every
`steer()` or `queue.sendNow()` sent while it's paused targets it, and on Resume
they enter together as one turn; the paused turn's own work does not resume.

Resume starts a fresh model request from durable state; it does not resume the
provider's old stream or undo completed tool effects. While paused, accepted
tool calls are retained with their outcomes recorded, and the turn holds no
model or tool claim. A cancelled tool without a terminal result can run again
after resume, so use the stable `toolCallId` for external idempotency.
