Deep Agents
AgentContextOrchestratorRetrievalText2SQLToolbox
Recipes

Agent as Tool

Delegate bounded sub-tasks to a specialist agent exposed as a regular AI SDK tool

Turn any agent into a callable tool with asTool(). The parent agent sees it as a normal tool; under the hood it forks the context, runs a one-shot generate(), and returns the result without touching the parent's persisted thread.

Scenario: Research-Backed Article Writer

A writer agent drafts articles. When it needs facts, it calls a researcher agent exposed as a tool. The researcher queries a search API, synthesizes findings, and hands a brief back to the writer.

import { groq } from '@ai-sdk/groq';
import { tool } from 'ai';
import z from 'zod';

import {
  ContextEngine,
  InMemoryContextStore,
  agent,
  role,
  user,
} from '@deepagents/context';

// -- Researcher agent --------------------------------------------------------

const researchStore = new InMemoryContextStore();
const researchContext = new ContextEngine({
  store: researchStore,
  chatId: 'researcher',
  userId: 'system',
}).set(
  role(
    'You are a research assistant. Gather facts, cite sources, and return a concise brief.',
  ),
);

const webSearch = tool({
  description: 'Search the web for a query and return results.',
  inputSchema: z.object({ query: z.string() }),
  execute: async ({ query }) => {
    // Replace with your real search API
    return `[stub] Top results for "${query}"`;
  },
});

const researcher = agent({
  name: 'researcher',
  context: researchContext,
  model: groq('gpt-oss-20b'),
  tools: { webSearch },
});

// -- Writer agent ------------------------------------------------------------

const writerStore = new InMemoryContextStore();
const writerContext = new ContextEngine({
  store: writerStore,
  chatId: 'writer',
  userId: 'system',
}).set(
  role(
    'You are a staff writer. Draft well-structured articles backed by research.',
  ),
  user(
    'Write a 500-word article about the impact of LLMs on software testing.',
  ),
);

const writer = agent({
  name: 'writer',
  context: writerContext,
  model: groq('gpt-oss-20b'),
  tools: {
    research: researcher.asTool({
      toolDescription:
        'Research a topic and return a fact brief. Pass the topic as input.',
      outputExtractor: async (result) => result.text,
    }),
  },
});

const stream = await writer.stream({});
for await (const part of stream.toUIMessageStream()) {
  if (part.type === 'text-delta') {
    process.stdout.write(part.delta);
  }
}

What Happens at Runtime

writer.stream()
  ├─ model decides to call research("LLMs in software testing")
  │    └─ asTool() forks researchContext
  │         └─ researcher.generate() with forked context + user input
  │              ├─ model calls webSearch("LLMs software testing")
  │              └─ returns synthesized brief
  ├─ writer receives the brief as a tool result
  └─ writer drafts the article using the brief

The fork means the researcher's messages never leak into the writer's stored conversation, and vice versa.

Scenario: Multi-Step Data Pipeline

A coordinator agent orchestrates two specialist agents — one extracts data, the other transforms it — each exposed as a tool.

const extractor = agent({
  name: 'extractor',
  context: extractorContext,
  model: groq('gpt-oss-20b'),
  tools: { queryDatabase },
});

const transformer = agent({
  name: 'transformer',
  context: transformerContext,
  model: groq('gpt-oss-20b'),
});

const coordinator = agent({
  name: 'coordinator',
  context: coordinatorContext,
  model: groq('gpt-oss-20b'),
  tools: {
    extract: extractor.asTool({
      toolDescription:
        'Query raw data from the database. Pass the question as input.',
    }),
    transform: transformer.asTool({
      toolDescription:
        'Clean and reshape raw data. Pass the raw data as input, describe the desired format in output.',
    }),
  },
});

The coordinator calls extract first, pipes the result into transform, and combines both outputs into a final report.

asTool() Options

OptionTypeDescription
toolDescriptionstringOverride the auto-generated tool description
outputExtractor(result) => T | Promise<T>Transform the sub-agent result before returning it to the parent
metadataJSONObjectNot sent to the model; surfaces as toolMetadata on tool call/result and UI message parts
toModelOutputTool['toModelOutput']Narrow a structured output down to what the model should read

When outputExtractor is omitted, the tool returns the sub-agent's final text (result.text).

outputExtractor may return any serializable value, not just a string. Pair a structured return with toModelOutput so the parent model reads only the answer while the UI keeps the full payload:

analyst.asTool<{ answer: string }>({
  metadata: { kind: 'subagent' },
  outputExtractor: (result) => ({ answer: result.text.trim() }),
  toModelOutput: ({ output }) => ({
    type: 'text',
    value: typeof output === 'string' ? output : output.answer,
  }),
});

The sub-agent's own context is what gets forked, once per invocation. To give it a system prompt of its own rather than the caller's, build that context from the sub-agent's own fragments and hand it to agent({ context })asTool() forks it for each call, so invocations stay isolated from each other.

Pass the output type explicitly when you combine outputExtractor with toModelOutput. TypeScript contextually types toModelOutput before outputExtractor can infer it, so without the type argument the output collapses to string.

The typeof output === 'string' guard is required: when the sub-agent throws a non-abort error, asTool() returns a diagnostic string regardless of the extractor's return type.

Errors and cancellation

A failing sub-agent returns an An error thrown from a tool call. string so the parent model can recover. An AbortError is re-thrown instead, producing a tool-error part rather than a tool result the model would mistake for an answer.

When to Use asTool()

Use asTool() when the parent agent needs a bounded, self-contained sub-task completed — research a topic, extract data, generate a summary. The sub-agent runs to completion and returns a result; it does not persist state or participate in the parent's ongoing conversation.

For open-ended strategic guidance where the advisor sees the full conversation, use asAdvisor() instead.

On this page