Agent as a Tool
A short tutorial — wrap an agent as a tool another agent can call, then build a single-chat orchestrator that routes between specialist sub-agents.
An agent as a tool is the smallest unit of multi-agent work: you take a whole
runAgent — a model, a role, its own tools — and hand it to another agent as
one callable tool. The parent (an "orchestrator" or "team lead") delegates a task,
the sub-agent does the work in its own isolated session, and only its final
answer comes back. The parent's conversation never fills up with the sub-agent's
scratch work — that context isolation is the whole point.
Nothing in the loop changes. agentAsTool is composition over runAgent, just
like skills and goal loops: the orchestrator is
an ordinary runAgent call, and each specialist is an ordinary Tool in its
tools array.
This tutorial starts from the multi-turn chat loop and grows one program across four steps:
- Wrap an agent as a tool the orchestrator can call.
- Add a second specialist and route between them — one chat, many agents.
- Give a sub-agent its own tools so it does real work in isolation.
- Watch the sub-agents by rendering each one's event stream, attributed by name.
Each step below shows the whole program so far — the lines it adds are highlighted.
Step 1 — Wrap an Agent as a Tool
agentAsTool takes the same things runAgent does — a model, a system role,
optional tools — plus a name and description the parent model sees. It
returns a Tool whose argument is a single task string. When the orchestrator
calls it, a child runAgent runs with that task as its prompt, and the child's
final answer becomes the tool result.
By default each call runs in a fresh session (a new in-memory store, keyed by
the tool call), so the sub-agent starts clean every time and its work never leaks
into the parent's thread. Here the lead has one specialist, a researcher:
import {
agentAsTool,
AgentEventType,
runAgent,
SessionMemoryStore,
} from "@open-agent-loops/core";
import type { AgentEvent } from "@open-agent-loops/core";
import { OpenAICompatibleModel } from "@open-agent-loops/core/providers/openai";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
const apiKey = process.env.LLM_API_KEY;
if (!apiKey) {
console.error("Set LLM_API_KEY (see .env.example).");
process.exit(1);
}
// DeepSeek V4 tool-calls cleanly; GLM emits broken empty-key tool args.
const model = new OpenAICompatibleModel({
apiKey,
baseURL: process.env.LLM_BASE_URL ?? "https://api.featherless.ai/v1",
model: process.env.LLM_MODEL ?? "deepseek-ai/DeepSeek-V4-Flash",
thinking: "on",
});
// A specialist sub-agent, wrapped as a tool the orchestrator can call. By default
// each call runs in its OWN fresh session — the sub-agent burns its own context
// and only its final answer returns, so the lead's thread stays clean.
const researcher = agentAsTool({
name: "researcher",
description: "Researches a question and reports concise findings.",
model,
system: "You are a meticulous researcher. Answer the task directly and concisely.",
});
// The orchestrator's system prompt: it routes work to its specialists.
const system = [
"You are the team lead. You coordinate specialists to answer the user.",
"Delegate fact-finding to the `researcher` tool, then answer the user yourself.",
].join("\n");
// Render every event the loop emits, including the dimmed reasoning channel. The
// `→`/`←` lines show the lead calling the sub-agent and the finding it gets back.
function render(e: AgentEvent) {
switch (e.type) {
case AgentEventType.ReasoningDelta:
process.stdout.write(`\x1b[2m${e.text}\x1b[22m`);
break;
case AgentEventType.TextDelta:
process.stdout.write(e.text);
break;
case AgentEventType.ToolStart:
console.log(`→ ${e.toolName}(${JSON.stringify(e.args)})`);
break;
case AgentEventType.ToolEnd:
console.log(`← ${e.toolName} [${e.isError ? "error" : "ok"}]: ${e.result}`);
break;
}
}
// Multi-turn: one memory + one sessionId for the lead, reused every turn.
const memory = new SessionMemoryStore();
const sessionId = "agent-as-tool-tutorial";
const rl = createInterface({ input, output });
while (true) {
const prompt = (await rl.question("\nyou › ")).trim();
if (prompt === "" || prompt === "exit") break;
process.stdout.write("lead › ");
await runAgent({
model,
memory,
sessionId,
system,
prompt,
tools: [researcher],
onEvent: render,
});
}
rl.close();Run it and ask a question:
bun run examples/agent-as-tool-tutorial/step1.ts
you › what does the agent loop stop on?You'll see the lead call → researcher({ task: ... }), the finding come back as
← researcher [ok]: ..., and the lead fold that into its own reply. Because the
lead reuses one memory and sessionId, the next message continues the same
conversation — while each researcher call stays isolated.
Step 2 — Add a Second Specialist
A multi-agent system is just more than one specialist behind one orchestrator. Add
an editor sub-agent and the lead now routes: researcher gathers rough
notes, editor turns them into a clean answer. Each is its own agent with its own
role — you could even give each a different model. The highlighted lines add the
editor and tell the lead the order to call them in:
import {
agentAsTool,
AgentEventType,
runAgent,
SessionMemoryStore,
} from "@open-agent-loops/core";
import type { AgentEvent } from "@open-agent-loops/core";
import { OpenAICompatibleModel } from "@open-agent-loops/core/providers/openai";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
const apiKey = process.env.LLM_API_KEY;
if (!apiKey) {
console.error("Set LLM_API_KEY (see .env.example).");
process.exit(1);
}
// DeepSeek V4 tool-calls cleanly; GLM emits broken empty-key tool args.
const model = new OpenAICompatibleModel({
apiKey,
baseURL: process.env.LLM_BASE_URL ?? "https://api.featherless.ai/v1",
model: process.env.LLM_MODEL ?? "deepseek-ai/DeepSeek-V4-Flash",
thinking: "on",
});
// A specialist sub-agent, wrapped as a tool the orchestrator can call. By default
// each call runs in its OWN fresh session — the sub-agent burns its own context
// and only its final answer returns, so the lead's thread stays clean.
const researcher = agentAsTool({
name: "researcher",
description: "Researches a question and reports concise findings.",
model,
system: "You are a meticulous researcher. Answer the task directly and concisely.",
});
// A second specialist. Each sub-agent is just another agent with its own role —
// you could even give it a different model. The lead picks who to call.
const editor = agentAsTool({
name: "editor",
description: "Rewrites rough notes into a clear, well-structured answer.",
model,
system:
"You are a sharp editor. Turn the given notes into a clear, concise answer. Do not invent facts.",
});
// The orchestrator's system prompt: it routes work to its specialists.
const system = [
"You are the team lead. You coordinate specialists to answer the user.",
"First call `researcher` to gather facts, then pass its notes to `editor` to",
"polish, then give the user the editor's version.",
].join("\n");
// Render every event the loop emits, including the dimmed reasoning channel. The
// `→`/`←` lines show the lead calling the sub-agent and the finding it gets back.
function render(e: AgentEvent) {
switch (e.type) {
case AgentEventType.ReasoningDelta:
process.stdout.write(`\x1b[2m${e.text}\x1b[22m`);
break;
case AgentEventType.TextDelta:
process.stdout.write(e.text);
break;
case AgentEventType.ToolStart:
console.log(`→ ${e.toolName}(${JSON.stringify(e.args)})`);
break;
case AgentEventType.ToolEnd:
console.log(`← ${e.toolName} [${e.isError ? "error" : "ok"}]: ${e.result}`);
break;
}
}
// Multi-turn: one memory + one sessionId for the lead, reused every turn.
const memory = new SessionMemoryStore();
const sessionId = "agent-as-tool-tutorial";
const rl = createInterface({ input, output });
while (true) {
const prompt = (await rl.question("\nyou › ")).trim();
if (prompt === "" || prompt === "exit") break;
process.stdout.write("lead › ");
await runAgent({
model,
memory,
sessionId,
system,
prompt,
tools: [researcher, editor],
onEvent: render,
});
}
rl.close();bun run examples/agent-as-tool-tutorial/step2.ts
you › explain how sub-agents stay isolatedThis is the "single chat, many agents" shape: one user-facing thread (the lead's session), several specialists reached as tools. The lead decides who to call and in what order — the same way it would pick between any two tools.
Step 3 — Give a Sub-agent Its Own Tools
Sub-agents earn their keep when they do real work you don't want cluttering the
main thread. Give the researcher a shell tool pointed at a local
knowledge base (team-notes.md). It can now grep and cat across several turns
— but all that churn happens inside its own session. The lead, and the transcript,
only ever see the distilled finding:
import {
agentAsTool,
AgentEventType,
runAgent,
SessionMemoryStore,
shellTool,
} from "@open-agent-loops/core";
import type { AgentEvent } from "@open-agent-loops/core";
import { OpenAICompatibleModel } from "@open-agent-loops/core/providers/openai";
import { bunShellBackend } from "./bun-backends";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
const apiKey = process.env.LLM_API_KEY;
if (!apiKey) {
console.error("Set LLM_API_KEY (see .env.example).");
process.exit(1);
}
// DeepSeek V4 tool-calls cleanly; GLM emits broken empty-key tool args.
const model = new OpenAICompatibleModel({
apiKey,
baseURL: process.env.LLM_BASE_URL ?? "https://api.featherless.ai/v1",
model: process.env.LLM_MODEL ?? "deepseek-ai/DeepSeek-V4-Flash",
thinking: "on",
});
// Run the researcher's shell in this tutorial's folder, where team-notes.md lives.
const here = dirname(fileURLToPath(import.meta.url));
// The researcher now drives a `shell` tool over the team's knowledge base. The
// grep/cat churn happens inside its own session; only the finding comes back.
const researcher = agentAsTool({
name: "researcher",
description: "Researches a question against the team's notes and reports findings.",
model,
system:
"You are a meticulous researcher. The team's knowledge base is in `team-notes.md`. " +
"Use the shell (grep/cat) to read it, then report only the relevant findings.",
tools: [shellTool(bunShellBackend({ cwd: here }))],
});
// A second specialist. Each sub-agent is just another agent with its own role —
// you could even give it a different model. The lead picks who to call.
const editor = agentAsTool({
name: "editor",
description: "Rewrites rough notes into a clear, well-structured answer.",
model,
system:
"You are a sharp editor. Turn the given notes into a clear, concise answer. Do not invent facts.",
});
// The orchestrator's system prompt: it routes work to its specialists.
const system = [
"You are the team lead. You coordinate specialists to answer the user.",
"First call `researcher` to gather facts, then pass its notes to `editor` to",
"polish, then give the user the editor's version.",
].join("\n");
// Render every event the loop emits, including the dimmed reasoning channel. The
// `→`/`←` lines show the lead calling the sub-agent and the finding it gets back.
function render(e: AgentEvent) {
switch (e.type) {
case AgentEventType.ReasoningDelta:
process.stdout.write(`\x1b[2m${e.text}\x1b[22m`);
break;
case AgentEventType.TextDelta:
process.stdout.write(e.text);
break;
case AgentEventType.ToolStart:
console.log(`→ ${e.toolName}(${JSON.stringify(e.args)})`);
break;
case AgentEventType.ToolEnd:
console.log(`← ${e.toolName} [${e.isError ? "error" : "ok"}]: ${e.result}`);
break;
}
}
// Multi-turn: one memory + one sessionId for the lead, reused every turn.
const memory = new SessionMemoryStore();
const sessionId = "agent-as-tool-tutorial";
const rl = createInterface({ input, output });
while (true) {
const prompt = (await rl.question("\nyou › ")).trim();
if (prompt === "" || prompt === "exit") break;
process.stdout.write("lead › ");
await runAgent({
model,
memory,
sessionId,
system,
prompt,
tools: [researcher, editor],
onEvent: render,
});
}
rl.close();bun run examples/agent-as-tool-tutorial/step3.ts
you › where does the agent loop live and when does it stop?The researcher might take three or four turns reading the file; the orchestrator sees one tool call and one answer. That's context isolation doing its job — a sub-agent can burn its whole context window on a messy task and hand back only the part that matters.
Step 4 — Watch the Sub-agents Work
A sub-agent emits its own event stream, exactly
like the top-level run. Pass it an onEvent and you can render it — here each
specialist's thinking and tool calls show indented under its name, while the lead's
stream stays at the top level:
import {
agentAsTool,
AgentEventType,
runAgent,
SessionMemoryStore,
shellTool,
} from "@open-agent-loops/core";
import type { AgentEvent } from "@open-agent-loops/core";
import { OpenAICompatibleModel } from "@open-agent-loops/core/providers/openai";
import { bunShellBackend } from "./bun-backends";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
const apiKey = process.env.LLM_API_KEY;
if (!apiKey) {
console.error("Set LLM_API_KEY (see .env.example).");
process.exit(1);
}
// DeepSeek V4 tool-calls cleanly; GLM emits broken empty-key tool args.
const model = new OpenAICompatibleModel({
apiKey,
baseURL: process.env.LLM_BASE_URL ?? "https://api.featherless.ai/v1",
model: process.env.LLM_MODEL ?? "deepseek-ai/DeepSeek-V4-Flash",
thinking: "on",
});
// Run the researcher's shell in this tutorial's folder, where team-notes.md lives.
const here = dirname(fileURLToPath(import.meta.url));
// Render a sub-agent's own event stream, indented and attributed by name. The
// closure is what supplies the identity — the event itself doesn't carry one yet.
function subRender(name: string) {
return (e: AgentEvent) => {
switch (e.type) {
case AgentEventType.AgentStart:
process.stdout.write(`\n\x1b[2m ┌─ ${name}\x1b[22m\n`);
break;
case AgentEventType.ReasoningDelta:
case AgentEventType.TextDelta:
process.stdout.write(`\x1b[2m${e.text}\x1b[22m`);
break;
case AgentEventType.ToolStart:
process.stdout.write(`\n\x1b[2m · ${name} → ${e.toolName}(${JSON.stringify(e.args)})\x1b[22m\n`);
break;
case AgentEventType.AgentEnd:
process.stdout.write(`\n\x1b[2m └─ ${name} done (${e.steps} steps)\x1b[22m\n`);
break;
}
};
}
// The researcher now drives a `shell` tool over the team's knowledge base. The
// grep/cat churn happens inside its own session; only the finding comes back.
const researcher = agentAsTool({
name: "researcher",
description: "Researches a question against the team's notes and reports findings.",
model,
system:
"You are a meticulous researcher. The team's knowledge base is in `team-notes.md`. " +
"Use the shell (grep/cat) to read it, then report only the relevant findings.",
tools: [shellTool(bunShellBackend({ cwd: here }))],
onEvent: subRender("researcher"),
});
// A second specialist. Each sub-agent is just another agent with its own role —
// you could even give it a different model. The lead picks who to call.
const editor = agentAsTool({
name: "editor",
description: "Rewrites rough notes into a clear, well-structured answer.",
model,
system:
"You are a sharp editor. Turn the given notes into a clear, concise answer. Do not invent facts.",
onEvent: subRender("editor"),
});
// The orchestrator's system prompt: it routes work to its specialists.
const system = [
"You are the team lead. You coordinate specialists to answer the user.",
"First call `researcher` to gather facts, then pass its notes to `editor` to",
"polish, then give the user the editor's version.",
].join("\n");
// Render every event the loop emits, including the dimmed reasoning channel. The
// `→`/`←` lines show the lead calling the sub-agent and the finding it gets back.
function render(e: AgentEvent) {
switch (e.type) {
case AgentEventType.ReasoningDelta:
process.stdout.write(`\x1b[2m${e.text}\x1b[22m`);
break;
case AgentEventType.TextDelta:
process.stdout.write(e.text);
break;
case AgentEventType.ToolStart:
console.log(`→ ${e.toolName}(${JSON.stringify(e.args)})`);
break;
case AgentEventType.ToolEnd:
console.log(`← ${e.toolName} [${e.isError ? "error" : "ok"}]: ${e.result}`);
break;
}
}
// Multi-turn: one memory + one sessionId for the lead, reused every turn.
const memory = new SessionMemoryStore();
const sessionId = "agent-as-tool-tutorial";
const rl = createInterface({ input, output });
while (true) {
const prompt = (await rl.question("\nyou › ")).trim();
if (prompt === "" || prompt === "exit") break;
process.stdout.write("lead › ");
await runAgent({
model,
memory,
sessionId,
system,
prompt,
tools: [researcher, editor],
onEvent: render,
});
}
rl.close();bun run examples/agent-as-tool-tutorial/step4.ts
you › where does the agent loop live and when does it stop?One detail to notice: the events themselves don't yet carry an agent identity —
the [researcher] / [editor] label comes from the closure that wrapped each
one. Giving the event a first-class agentId/agentName is the next step toward a
single-chat UI (e.g. assistant-ui) that renders
distinct sub-agent turns in one thread.
Recap
Starting from a plain chat loop, a few lines at a time you turned one agent into a
tool, added a second specialist and routed between them, gave a sub-agent its own
tools so it works in isolation, and made each sub-agent's stream observable. The
orchestrator is still just a runAgent call; each specialist is still just a
Tool. The loop never had to learn what a "sub-agent" is.
From here, the same specialists can be driven by a goal loop
(retry until an answer passes a grader) or fed by a live channel
(one orchestrator per Slack thread, with backpressure). The finished program is at
examples/agent-as-tool-tutorial/step4.ts.
Goal Loops
A short tutorial — drive runAgent across rounds with a grader that re-prompts until a goal is met, then compose a deterministic check with a model's judgment.
Channels
A short tutorial — connect a live, bursty transport (Slack, Discord) to the agent without letting a fast socket overwhelm a slow model. One bounded, coalescing queue does the impedance matching.