---
title: Tools and approvals
description: "Give an agent tools, ask for approval before they run, and interrupt or retry a turn."
---

## Add tools and approval

```ts 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](/agents/introduction#define-the-agent).

### Read the handler context

```ts 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()`](/reference/ai#handlercontextcontract)
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](/schedulers/overview).
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](/server/timers#provider-limits); 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

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

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

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