Tools and approvals
Give an agent tools, ask for approval before they run, and interrupt or retry a turn.
Add tools and approval
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
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
'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
'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():
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.