Tools and approvals

Give an agent tools, ask for approval before they run, and interrupt or retry a turn.

Add tools and approval

server/with-tools.ts
import { tool } from 'ai'
import { z } from 'zod'
import { agent } from 'experimental-a2/ai/server'

const tools = {
  closeTicket: tool({
    description: 'Close a resolved support ticket',
    inputSchema: z.object({ ticketId: z.string() }),
    execute: async ({ ticketId }, { toolCallId }) => {
      // your side effect; toolCallId makes a stable idempotency key:
      // await closeTicket(ticketId, { idempotencyKey: toolCallId })
      return { ticketId, closed: true }
    },
  }),
}

export const toolsAssistant = agent({
  name: 'support-tools',
  model: 'openai/gpt-5.6-terra',
  tools,
  harness: {
    type: 'ai-sdk',
    generation: {
      toolApproval: {
        closeTicket: 'user-approval',
      },
    },
  },
})

Tools are ordinary AI SDK tool() definitions, passed through untouched. Approval policy lives in harness.generation.toolApproval, beside the other per-step settings. Later examples that import assistant use the agent from Agents.

Read the handler context

server/set-reminder.ts
import { tool } from 'ai'
import { handlerContext, inputs } from 'experimental-a2/ai'
import { z } from 'zod'

export const setReminder = tool({
  description: 'Set a reminder for yourself',
  inputSchema: z.object({
    delaySeconds: z.number().int().min(1).max(6 * 24 * 60 * 60),
    reminder: z.string(),
  }),
  execute: async ({ delaySeconds, reminder }, { toolCallId }) => {
    const ctx = handlerContext()
    await ctx.session.schedule(
      `reminder:${toolCallId}`,
      { delay: `${delaySeconds}s` },
      ...inputs.message({
        id: `reminder:${ctx.event.id}`,
        role: 'user',
        parts: [{ type: 'text', text: `Reminder: ${reminder}` }],
      }),
    )

    return { scheduled: true, delaySeconds }
  },
})

handlerContext() returns the running tool's durable handler context: event, attempt, session, and signal. It works anywhere below execute, including awaited helpers, and throws outside a tool execution. Pass a shared agentContract() to type application events; A2 checks it against the executing agent. See handlerContext() for the event payload.

This tool schedules a reminder as a new user message. ctx.session belongs to the executing agent, so schedule() uses that agent's scheduler: add setReminder to its tools and configure a scheduler. Naming the timer after toolCallId and the message after the triggering event's ID makes a retry converge on the same timer and message, even if a provider reuses tool-call IDs. The six-day maximum is Vercel Queues' delay limit; adjust it for your scheduler. A transient or ambiguous scheduler failure retries the handler with the same identity and target time; a deterministic rejection or a missing scheduler is an ordinary tool error.

To test a context-reading tool, call it inside runWithHandlerContext(ctx, fn) from experimental-a2/ai/server; within fn, handlerContext() returns ctx.

Answer approvals

app/tools/[sessionId]/approval-controls.tsx
'use client'
import { useAgent } from 'experimental-a2/ai/react'
import type { toolsAssistant } from '@/server/with-tools'

export function ApprovalControls({ sessionId }: { sessionId: string }) {
  const { state, approve, deny } = useAgent<typeof toolsAssistant>({
    api: '/api/tools',
    id: sessionId,
  })

  return state.pendingApprovals.map((approval) => (
    <div key={approval.approvalId}>
      <span>Allow tool call {approval.toolCallId}?</span>
      <button onClick={() => void approve(approval)}>Allow</button>
      <button
        onClick={() => void deny(approval, { reason: 'Denied by the user' })}
      >
        Deny
      </button>
    </div>
  ))
}

Instead of running a tool that needs approval, A2 records the AI SDK's approval request as ai.approval.requested, and the reducer adds it to state.pendingApprovals. approve() and deny() copy the request, message, generation, and approval IDs from it, so a delayed click cannot authorize a later model step. The decision updates the approval part in the message, and a built-in handler then runs the approved tool or records the denied result.

A policy can also be a function that returns 'user-approval' or 'approved' from the validated input. It runs during the model step and again on recovery for the same call, so keep it deterministic over the input; an impure predicate can fork one call's flow.

state.pendingInputs works the same way for application-defined input: answer one with respond(input, value). The value stays in raw history and state.pendingInputs; to make it model context, resolve instructions dynamically or replace generate.

How tools run

Validation errors, execution errors, denials, and preliminary and final outputs all become durable ai.tool.result events; a preliminary output keeps the tool running until the final one. Dynamic-tool, provider-executed, provider metadata, and tool metadata fields carry through to the message. Independent tools run concurrently as soon as their inputs and authorization are ready, with no fixed limit. Set toolLane: 'tools' when local tools must run one at a time, for example because they share a sandbox. The next model step starts once the current one closes and every sibling tool has a success, error, or denied result.

An approved provider-executed call can satisfy that join through its response. A provider tool with deferred-result support waits for its provider result: your authenticated callback submits it from trusted server code with inputs.toolResult(result), and the controller checks it against the admitted call. Browser pushes reject it.

Interrupt and retry

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

export function TurnControls({ sessionId }: { sessionId: string }) {
  const { state, status, stop, retry } = useAgent<typeof assistant>({
    api: '/api/agent',
    id: sessionId,
  })

  if (status === 'failed')
    return <button onClick={() => void retry()}>Retry</button>
  return state.active ? (
    <button onClick={() => void stop()}>Stop</button>
  ) : null
}

stop() sends the log frontier the rendered state showed, not whatever arrived before the click was handled. The client previews Stop at once (a rejection restores the turn), and the response rewinds to that frontier, keeping the partial text the user saw. Tools completed by then stay completed, and accepted tools still awaiting completion become output-error. Tool calls whose arguments were still streaming are drafts: they leave the conversation and later prompts, while their progress stays in the raw log. Later events cannot reactivate a stopped response.

Once Stop is persisted, A2 aborts the turn's running model and tool work. Cancellation is cooperative: tools must honor their abort signal. A Stop for an older turn cannot cancel its successor.

retry() reruns the failed model call with a fresh retryId for each click: resending one retry is safe, and a later retry stays distinct. Earlier completed steps, their tool results, and the committed compaction summary remain; the failed call's uncommitted step leaves the prompt and response. Later user messages stay queued until the failed response is retried or interrupted. Conversation forks and edit-and-resend of admitted messages are yours to build.

The lower-level inputs.interrupt() takes exactly one owner, so a delayed interruption cannot stop later work. Before the generation starts, pass activeRequestId as requestId and activeResponseMessageId as messageId. Once activeGeneration exists, pass its generationId and responseMessageId instead. After the generation completes, copy the owner from a pending approval, pending input, or running tool while the response waits.

Append from the server

Webhooks, jobs, and other backends start turns with the same inputs and append():

server/append-message.ts
import { inputs } from 'experimental-a2/ai'
import { assistant } from './assistant'

export async function appendServerMessage(sessionId: string, text: string) {
  await assistant.session(sessionId).append(
    ...inputs.message({
      id: crypto.randomUUID(),
      role: 'user',
      parts: [{ type: 'text', text }],
    }),
  )
}

A connected browser still receives the events over SSE, without an optimistic overlay for an interaction that began elsewhere.

The built-in inputs

The hook's actions build their commands with inputs, the same pure, isomorphic builders you pass to raw push() and server append(): message() and the passive seed() for messages; approval(), input(), and requestInput() for pending requests; retry(), stop(), steer(), interrupt(), pause(), and resume() for turns; queue.edit(), queue.move(), queue.remove(), and queue.sendNow() for the inbox; and toolResult() for deferred provider results. See inputs for each signature, ID rules, and payloads.

Browser pushes accept user messages, approval and input responses, interruptions, and retries. inputs.requestInput(), inputs.toolResult(), and non-user inputs.seed() messages are for trusted server appends. inputs has no session lifecycle methods: the log begins with the first append, and apps that use ai.session.created or ai.session.closed append them from trusted server code.