# Agent Tools

Agent tools let one chat agent dispatch another chat-capable sub-agent as part
of its work — per-run, parent-supervised delegation is the flagship use case
for facet-backed dynamic agents. The child is a real sub-agent with its own Durable Object storage,
messages, tools, resumable stream, and drill-in URL. The parent keeps a small
run registry so clients can render the child timeline, replay it after refresh,
and clean it up later.

Agent tools support `@cloudflare/think` agents and `AIChatAgent` subclasses.
`AIChatAgent` children run headlessly through `saveMessages()`, so they should
use server-side tools. Browser-provided client tools are not available during an
agent-tool turn unless you model that interaction as server-side state or a
separate parent-mediated workflow.

For Think children, prefer agent tools when the parent model or workflow
delegates work and you want retained child runs, event replay, abort bridging,
and UI drill-in. Use raw `subAgent(...).chat()` only for lower-level streaming
RPC where your code owns forwarding, cancellation, and replay policy. For the
full comparison, see [Choosing a turn API](https://github.com/cloudflare/agents/blob/main/docs/think/index.md#choosing-a-turn-api).

## Use an Agent as an AI SDK tool

Use `agentTool()` when the parent model should decide when to call the helper.

```ts
import { Think } from "@cloudflare/think";
import { agentTool } from "agents/agent-tools";
import { z } from "zod";

export class Researcher extends Think<Env> {
  getSystemPrompt() {
    return "Research the user's topic and end with a concise summary.";
  }
}

export class Assistant extends Think<Env> {
  getTools() {
    return {
      research: agentTool(Researcher, {
        description: "Research one topic in depth.",
        displayName: "Researcher",
        inputSchema: z.object({
          query: z.string().min(3)
        })
      })
    };
  }
}
```

### Use Valibot or another schema library

`agentTool()` accepts the AI SDK's flexible schema format. For Valibot, wrap the
schema with `valibotSchema()` so the AI SDK receives both runtime validation and
the JSON Schema required by the model. Use `@ai-sdk/valibot` v2 with AI SDK 6
and v3 with AI SDK 7:

```ts
import { valibotSchema } from "@ai-sdk/valibot";
import { agentTool } from "agents/agent-tools";
import * as v from "valibot";

const researchInput = valibotSchema(
  v.object({
    query: v.pipe(v.string(), v.minLength(3))
  })
);

const research = agentTool(Researcher, {
  description: "Research one topic in depth.",
  inputSchema: researchInput
});
```

You can also provide a Zod schema, a Standard JSON Schema-compatible schema, or
a raw JSON Schema wrapped with `jsonSchema()` from `ai`. A validation-only
Standard Schema is not sufficient for a tool input because it does not provide
the JSON Schema sent to the model.

The child can also be an `AIChatAgent`:

```ts
import { AIChatAgent } from "@cloudflare/ai-chat";
import { agentTool } from "agents/agent-tools";
import { convertToModelMessages, stepCountIs, streamText } from "ai";
import { z } from "zod";

export class Summarizer extends AIChatAgent<Env> {
  protected override formatAgentToolInput(input: { text: string }, request) {
    return {
      id: `agent-tool-${request.runId}-input`,
      role: "user",
      parts: [{ type: "text", text: `Summarize:\n\n${input.text}` }]
    };
  }

  async onChatMessage() {
    const result = streamText({
      model: this.env.MODEL,
      messages: await convertToModelMessages(this.messages)
    });
    return result.toUIMessageStreamResponse();
  }
}

export class Assistant extends AIChatAgent<Env> {
  async onChatMessage() {
    const result = streamText({
      model: this.env.MODEL,
      messages: await convertToModelMessages(this.messages),
      tools: {
        summarize: agentTool(Summarizer, {
          description: "Summarize long text in a separate retained agent.",
          inputSchema: z.object({ text: z.string() })
        })
      },
      stopWhen: stepCountIs(5)
    });
    return result.toUIMessageStreamResponse();
  }
}
```

The generated tool calls `this.runAgentTool(ChildAgent, ...)`, streams
`agent-tool-event` frames on the parent WebSocket, and returns the child
summary to the parent model. If the run fails, aborts, or is interrupted, the
tool returns a structured `AgentToolFailure` instead of an empty success value:

```ts
type AgentToolFailure = {
  ok: false;
  status: "error" | "aborted" | "interrupted";
  error: string; // human-readable, safe to surface
  retryable: boolean;
  // Present only when `status` is "interrupted":
  reason?: AgentToolInterruptedReason;
  childStillRunning?: boolean;
};

type AgentToolInterruptedReason =
  | "no-progress"
  | "window-exceeded"
  | "not-tailable"
  | "inspect-timeout"
  | "inspect-failed"
  | "recovery-deadline"
  | "budget-exceeded";
```

`retryable` is `true` only for an `interrupted` run — the child was reset or
superseded by a deploy or parent recovery and never reached a logical outcome,
so re-dispatching the same call can succeed. A genuine `error` or an intentional
`aborted` is `retryable: false`. This lets a parent prompt convention or an
orchestration harness re-run a transient interruption rather than reporting it
to the user as a final failure. `AgentToolFailure` is exported from `agents`.

On an `interrupted` run, `reason` gives a machine-readable cause and
`childStillRunning` reports whether the child was still working when the parent
stopped waiting (`true`) or has since been torn down (`false`). Branch on these
instead of parsing the `error` prose — for example, re-dispatch a `no-progress`
interrupt (the child may still self-heal) but reconnect to or surface a
`window-exceeded` one (the child was torn down). Both `reason` and
`childStillRunning` are also mirrored onto the `agent-tool-event` wire frame and
the `useAgentToolEvents()` run state.

For `Think` children that do workflow-style work without user-facing assistant
text, override `getAgentToolOutput()` and, if needed, `getAgentToolSummary()`.
Assistant text remains the default summary when present, but a Think agent-tool
run can complete successfully without emitting text chunks.
Persist any structured output before the child turn finishes, because
`getAgentToolOutput()` is read as soon as `saveMessages()` resolves. Keep
`getAgentToolSummary()` concise for display; the full structured value is stored
separately as the tool output.

```ts
export class Extractor extends Think<Env> {
  protected override getAgentToolOutput(runId: string) {
    const rows = this.sql<{ result_json: string }>`
      SELECT result_json FROM extraction_runs WHERE id = ${runId}
    `;
    return rows[0] ? JSON.parse(rows[0].result_json) : undefined;
  }

  protected override getAgentToolSummary(_runId: string, output: unknown) {
    return output ? "Extraction complete" : "";
  }
}
```

## Run an Agent tool imperatively

Use `runAgentTool()` for deterministic workflows, scheduled work, HTTP
handlers, or fan-out code.

```ts
const [a, b] = await Promise.allSettled([
  this.runAgentTool(Researcher, {
    input: { query: "HTTP/3" },
    parentToolCallId: toolCallId,
    displayOrder: 0
  }),
  this.runAgentTool(Researcher, {
    input: { query: "gRPC" },
    parentToolCallId: toolCallId,
    displayOrder: 1
  })
]);
```

`runAgentTool()` is idempotent by `runId`. Passing the same `runId` never starts
a duplicate child turn. Completed, failed, aborted, and interrupted runs are
retained until you explicitly clear them.

## Detached (background) runs

By default `runAgentTool()` **awaits** the child to terminal before returning.
For long-running work — large imports, video renders, deep research — that you
do not want to block the dispatching turn on, pass `detached`. The run is
dispatched, the current turn continues, and `runAgentTool()` returns a handle
immediately:

```ts
type DetachedRunAgentToolResult = {
  runId: string;
  agentType: string;
  status: "running" | "error"; // "error" only if dispatch itself was rejected
};
```

`detached: true` is fire-and-forget — observe the run through `agent-tool-event`
frames (the same ones `useAgentToolEvents()` consumes) and the global
`onAgentToolFinish()` hook. Pass an object to wire a targeted, durable
completion callback:

```ts
async startImport(input: ImportInput) {
  const { runId } = await this.runAgentTool(ImportAgent, {
    input,
    detached: { onFinish: "onImportDone", maxBudgetMs: 60 * 60 * 1000 }
  });
  return runId;
}

// Fires once, even if the Durable Object was evicted and rehydrated while the
// child ran. Referenced by METHOD NAME (like schedule()) — never a closure,
// which cannot survive eviction.
async onImportDone(run: AgentToolRunInfo, result: AgentToolLifecycleResult) {
  switch (result.status) {
    case "completed":
      await this.markImportReady(run.runId, result.summary);
      break;
    case "error":
      await this.markImportFailed(run.runId, result.error);
      break;
    case "interrupted":
      // reason "budget-exceeded" ⇒ the run hit its maxBudgetMs ceiling.
      // interrupted is soft: a child that finishes anyway re-fires this hook
      // with "completed", so make the handler idempotent.
      break;
  }
}
```

Key behaviors:

- **Durable completion.** Delivery survives eviction and deploys: a warm fast
  path delivers with low latency while the isolate is alive, and a
  self-scheduling reconcile backbone finalizes anything the fast path missed.
  Delivery is exactly-once on the happy path; under a crash it is at-least-once,
  so `onFinish` handlers must be idempotent.
- **Give-up vs. finish are independent.** A budget give-up is delivered as
  `status: "interrupted"`, `reason: "budget-exceeded"`. Because `interrupted` is
  soft, a child that completes after the give-up still re-fires `onFinish` with
  the real result — a premature give-up never hides a late completion.
- **Bounded.** Every detached run has an absolute `maxBudgetMs` ceiling
  (per-run, or the `detachedMaxBudgetMs` static option; default 24h). On expiry
  the parent gives up watching and tears the child down so an abandoned run
  cannot hold a `maxConcurrentAgentTools` slot forever.
- **No inherited signal.** A detached run must outlive the spawning turn, so it
  does **not** inherit `options.signal`. Cancel it explicitly:

```ts
await this.cancelAgentTool(runId); // idempotent; delivers onFinish "aborted"
```

### Notify the chat on completion (Think / AIChatAgent)

On a chat agent (`@cloudflare/think` or `AIChatAgent`) you usually want the model
to _react_ to a finished background run. Instead of wiring `onFinish` by hand,
pass `notify: true` — when the run finishes the agent injects a message into the
chat (idempotent per run + status, so an exactly-once finish never duplicates)
and the model takes its next turn with the result in context:

```ts
await this.runAgentTool(ResearchAgent, { input, detached: { notify: true } });
```

If your app routes or hides synthetic messages by `metadata.source`, pass your
own source:

```ts
await this.runAgentTool(ResearchAgent, {
  input,
  detached: { notify: { source: "research-background" } }
});
```

Override `formatDetachedCompletion(run, result)` to customize the injected text,
or return an empty string to suppress the notification for a given outcome. An
explicit `onFinish` takes precedence over `notify`.

### The `inspectAgentToolRun` contract

A child's `inspectAgentToolRun(runId)` returns the run's current status snapshot,
or `null`. **`null` does not mean "failed"** — it means the child has no record
of that run _yet_. This is normal immediately after dispatch (the child may
still be persisting its first row) and is also what a freshly-rehydrated child
returns before it has lazily reconciled a stale `running` row. Callers — and the
framework's own reconcile backbone — treat `null` as "not terminal, keep
watching within budget", never as a terminal failure. Only a non-`null`
inspection with a terminal `status` (`completed` / `error` / `aborted`)
finalizes a run.

## Report progress and milestones

A sub-agent running as an agent tool — awaited or detached — can report mid-run
progress so a parent can render a live status line, meter the run server-side, or
react to a named checkpoint before the run finishes. Call `reportProgress()` from
inside the child (for example, from a tool's `execute`):

```ts
export class ImportAgent extends Think<Env> {
  getTools() {
    return {
      ingest: tool({
        inputSchema: z.object({ url: z.string() }),
        execute: async ({ url }) => {
          // Ephemeral progress: drives a generic bar / phase / status line.
          await this.reportProgress({
            fraction: 0.6,
            phase: "ingesting",
            message: "Ingested 40k/80k rows"
          });
          // ...
        }
      })
    };
  }
}
```

`reportProgress()` is available on chat agents (`@cloudflare/think` and
`AIChatAgent`). It is a no-op with a development warning on the base `Agent`
class and when called outside an active agent-tool run, so the same child code is
safe to run standalone. The framework resolves the active run from the current
turn — you never thread a run id.

```ts
reportProgress<T>(
  progress: {
    fraction?: number; // 0..1 — drives a progress bar
    message?: string; // human-readable status line
    phase?: string; // coarse phase label, e.g. "ingesting"
    milestone?: string; // present ⇒ a durable milestone (see below)
    data?: T; // app-specific payload; live-only unless persisted
  },
  options?: { persist?: boolean }
): Promise<void>;
```

Ephemeral signals ride the child's own turn stream as a transient
`data-agent-progress` part, so they re-broadcast to the parent's connected
clients and surface on `AgentToolRunState.progress` through `useAgentToolEvents()`
— a background-runs tray can render a live bar, phase, and status line without
drilling in. Bursts are coalesced (latest-wins; a `fraction >= 1` frame always
flushes). The `data` field is live-only unless you pass `{ persist: true }`.

### Observe progress on the parent

Override `onProgress()` to meter, steer, or surface progress server-side. It
fires best-effort whenever a child progress signal is forwarded through the
parent, for both awaited and detached runs:

```ts
export class Assistant extends Think<Env> {
  override async onProgress(
    run: AgentToolRunInfo,
    progress: AgentToolProgressSnapshot
  ) {
    if (progress.milestone) {
      // A durable milestone landed — branch on it.
    }
    console.log(run.runId, progress.phase, progress.fraction);
  }
}
```

`onProgress()` is not durable: after eviction a detached run's latest snapshot is
reconstructed from `inspectAgentToolRun().progress` on reconcile rather than
re-firing the hook. The latest snapshot is also persisted on the child run row,
so a rehydrated parent can answer "where is this run" without having tailed the
live stream.

### Durable milestones

Naming a `milestone` promotes a signal from the ephemeral tier to a **durable**
one — there is still only one emit method:

```ts
await this.reportProgress({
  milestone: "sources-gathered",
  data: { sources: 2 }
});
```

A milestone is persisted as one row on the child with a monotonic per-run
`sequence`, and rides the stream as a **persisted** `data-agent-milestone` part
(unlike transient progress). It therefore survives eviction, replays on drill-in,
and is surfaced — deduped by `sequence` — on `AgentToolRunState.milestones` and
`inspectAgentToolRun().milestones`. `onProgress()` fires for milestones too, with
`progress.milestone` set, so a consumer can branch on milestone versus ephemeral
progress.

### Notify the chat on a milestone (Think / AIChatAgent)

For a detached run on a chat agent, `detached: { onMilestones }` surfaces a chat
message when a configured milestone lands, _before_ the run finishes. Each
`(runId, name)` fires at most once — whether observed live or reconciled after
eviction — so the deterministic id collapses warm and cold delivery to
at-most-once:

```ts
// "narrate" (default): inject a synthetic assistant status line — no model turn.
await this.runAgentTool(Researcher, {
  input,
  detached: { onMilestones: ["sources-gathered"] }
});

// "react": post a user-role turn so the model responds (steer, start dependent
// work). Costs a model turn.
await this.runAgentTool(Researcher, {
  input,
  detached: { onMilestones: { names: ["needs-approval"], mode: "react" } }
});
```

Override `formatDetachedMilestone(run, milestone)` to customize the wording, or
return an empty string to suppress a given milestone. Synthetic narrate messages
carry `metadata.source`, so clients can render them as an agent event rather than
a human turn.

### Resetting no-progress budget for detached runs

Once a detached child has reported at least one signal, the reconcile backbone
gives up if the run then goes silent for `detachedNoProgressBudgetMs` (default 1
hour; per-run override via `detached: { noProgressBudgetMs }`). This surfaces as
`status: "interrupted"`, `reason: "no-progress"`. A child that never reports is
bounded only by the absolute `detachedMaxBudgetMs` ceiling — a run is never
given up on merely for being slow. Set `noProgressBudgetMs` to `0` or `Infinity`
to disable the resetting window for a run.

### Deliver only progress and the final result

By default, the parent forwards every chunk of the child's stream to its
clients, and the child broadcasts the same chunks to its own connections. When
nobody watches the child's text as it streams (for example, a server-side
pipeline that only reads the result), pass `eventDelivery: "terminal"`:

```ts
const result = await this.runAgentTool(ResearchAgent, {
  input: { query: "Durable Objects" },
  eventDelivery: "terminal"
});
```

The run still produces `started`, progress, milestone, and terminal
(`finished`, `error`, `aborted`, or `interrupted`) events, and the result,
summary, and output are unchanged. Ordinary child chunks are not forwarded to the
parent's clients, are not replayed on reconnect, and are not broadcast by the
child. The child still stores its own conversation, so `getAgentToolChunks()`
keeps working. A client that drills in to the child sees the stored
conversation when it connects and the final messages when the run ends, but
does not see text stream live while the run is in flight. Use the default
`eventDelivery` for runs you expect users to watch. Detached runs do not
support `eventDelivery: "terminal"` and reject it.

## Render child timelines in React

`useAgentToolEvents()` is a headless hook. It subscribes to the existing parent
connection, deduplicates replay/live races, applies child `UIMessageChunk`
bodies to message parts, and groups sibling runs by parent tool call id. Each
run state carries `progress` and `milestones`, so a background-runs tray can
render a live bar, phase, and milestone chips without drilling in.

```tsx
import { useAgent, useAgentToolEvents } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";

const agent = useAgent({ agent: "Assistant", name: userId });
const { messages } = useAgentChat({ agent });
const agentTools = useAgentToolEvents({ agent });

for (const message of messages) {
  for (const part of message.parts) {
    if (part.type === "tool-call") {
      const runs = agentTools.getRunsForToolCall(part.toolCallId);
      // Render the child runs beside this tool call.
    }
  }
}
```

Imperative runs without a parent tool call are available as
`agentTools.unboundRuns`.

## Drill in and gate access

Agent tools are normal sub-agents. Connect to a retained child through the
parent route:

```ts
useAgent({
  agent: "Assistant",
  name: userId,
  sub: [{ agent: "Researcher", name: runId }]
});
```

Gate external access with the parent registry so guessed run ids cannot spawn
fresh child facets:

```ts
override async onBeforeSubAgent(_request, child) {
  if (!this.hasAgentToolRun(child.className, child.name)) {
    return new Response("Not found", { status: 404 });
  }
}
```

## Clear retained runs

Runs and child facets are retained by default for refresh, drill-in, and later
inspection. Delete them explicitly when clearing chat history or applying your
own retention policy:

```ts
await this.clearAgentToolRuns();
await this.clearAgentToolRuns({
  status: ["completed", "error", "aborted", "interrupted"]
});
await this.clearAgentToolRuns({ olderThan: Date.now() - 7 * 24 * 60 * 60_000 });
```

If a retained run is still `starting` or `running`, cleanup cancels the child
before deleting its facet.

## Interrupted runs and recovery

Agent-tool runs are retained in the parent. If the parent restarts (deploy,
eviction) while a child run is still `starting` or `running`, the parent does
not immediately abandon it. Startup recovery re-attaches to the live child and
tails its stream to the child's real terminal result: the child is a sub-agent
with its own `chatRecovery`, so it self-heals its interrupted turn while the
parent forwards its output and collects the final outcome. A completed child is
finalized in the parent without re-running already-finished work.

The re-attach wait is **progress-keyed**, not a fixed wall clock. Two static
`options` tune it:

| Option                                 | Default          | Behavior                                                                                                                                                                                                                          |
| -------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agentToolReattachNoProgressTimeoutMs` | `120000` (2 min) | How long the parent waits with **no** forward progress before giving up. Resets on every forwarded chunk, so a streaming child is followed through to terminal.                                                                   |
| `agentToolReattachMaxWindowMs`         | `Infinity`       | Optional hard wall-clock ceiling on a single re-attach. Uncapped by default, so a healthy, long-running child is never cut off; a runaway child is bounded by its own `chatRecovery` budgets. Set a finite value to impose a cap. |

Give-up outcomes map to the `AgentToolFailure` fields:

- A child that goes **silent** for a full no-progress window is sealed
  `reason: "no-progress"`, `childStillRunning: true`. This seal is **soft** —
  the child is left running, so re-dispatching the same `runId` can re-attach
  and collect it if it self-heals.
- If you set a finite `agentToolReattachMaxWindowMs` and it fires, the run is
  sealed `reason: "window-exceeded"`, `childStillRunning: false`, and the child
  is **torn down** (it has had its full window and is treated as exhausted).
- A child that cannot be tailed or inspected, or that exceeds the overall
  recovery deadline, is sealed with the matching `reason` so the parent tool
  call returns a structured failure instead of hanging indefinitely.

A genuinely hung child can never block recovery forever: the no-progress budget
bounds a silent child, and a content-runaway is bounded uniformly (live and
recovery) by the child's own `chatRecovery` `maxRecoveryWork` /
`shouldKeepRecovering`, not by a parent-only timer.

Monitor parent reconciliation through the `agentTool` observability channel:

```ts
import { subscribe } from "agents/observability";

const unsubscribe = subscribe("agentTool", (event) => {
  if (event.type === "agent_tool:recovery:row") {
    console.log("Recovered agent-tool row", event.payload);
  }
});
```

Raw `diagnostics_channel` subscribers should use the channel name
`agents:agent_tool`.
