Open Agent Loops

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:

  1. Wrap an agent as a tool the orchestrator can call.
  2. Add a second specialist and route between them — one chat, many agents.
  3. Give a sub-agent its own tools so it does real work in isolation.
  4. 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:

examples/agent-as-tool-tutorial/step1.ts
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:

examples/agent-as-tool-tutorial/step2.ts
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 isolated

This 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:

examples/agent-as-tool-tutorial/step3.ts
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:

examples/agent-as-tool-tutorial/step4.ts
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.

On this page