Composition

A single agent is just a function. Real systems are built by wiring agents together — calling sub-agents, invoking tools, running work in parallel, and exposing the whole thing back to the LLM for function-calling. Because everything is TypeScript, composition is just function composition.

Calling another agent

Reference another agent by its path. chidori.callAgent("summarizer.ts", ...) resolves to agents/summarizer.ts:

// agents/research_pipeline.ts
import type { Chidori } from "chidori";

export async function agent(input: { question: string }, chidori: Chidori) {
  const raw      = await chidori.tool("web_search", { query: input.question });
  const summary  = await chidori.callAgent("summarizer.ts", { document: JSON.stringify(raw) });
  const verified = await chidori.callAgent("fact_checker.ts", { claims: summary });
  return { answer: verified, sources: raw };
}

Sub-agents share the parent runtime context and call log, and their output flows back as a plain value.

Invoking tools

Tools are .ts modules in the tools/ directory. Each module exports a tool metadata object (a ToolDefinition with name, description, and parameters as a JSON Schema) and a run function:

tools/search.ts

import type { Chidori, ToolDefinition } from "chidori";

export const tool: ToolDefinition = {
  name: "web_search",
  description: "Search the web and return results.",
  parameters: {
    type: "object",
    properties: {
      query: { type: "string" },
      maxResults: { type: "number" },
    },
    required: ["query"],
  },
};

export async function run(
  args: { query: string; maxResults?: number },
  chidori: Chidori,
) {
  const response = await chidori.http("https://api.search.example/search", {
    method: "GET",
    query: { q: args.query, limit: args.maxResults ?? 5 },
  });
  return response.results;
}

Call it from an agent:

const results = await chidori.tool("web_search", { query: "rust programming", maxResults: 5 });

MCP-backed remote tools are also supported and are invoked the same way.

Letting the LLM pick tools

Pass a list of tool names to chidori.prompt() and the LLM can call them autonomously during its response:

const answer = await chidori.prompt(
  "Research this question and verify your findings: " + question,
  { tools: ["web_search", "summarizer", "fact_checker"] },
);

Sub-agents can be exposed as tools too — this is how you build recursive multi-agent systems without a special framework.

Parallel fan-out

chidori.parallel(fns) runs an array of functions concurrently (Promise.all semantics) and returns their results in order. Use it for independent searches, multi-audience summaries, or any embarrassingly parallel step:

// Search multiple queries in parallel
const results = await chidori.parallel(
  queries.map((q) => () => chidori.tool("web_search", { query: q })),
);

// Generate summaries for different audiences concurrently
const summaries = await chidori.parallel(
  ["technical", "executive", "general"].map(
    (a) => () => chidori.prompt("Summarize for " + a + " audience:\n" + doc),
  ),
);

Jinja templates for complex prompts

For prompts with conditionals, loops, or reusable fragments, render a Jinja template with chidori.template() and pass the result into chidori.prompt():

prompts/research.jinja

You are a {{ role }} research assistant.

Answer the following question: {{ question }}

{% if sources %}
Use these sources:
{% for source in sources %}
- [{{ source.title }}]({{ source.url }}): {{ source.snippet }}
{% endfor %}
{% endif %}

{% if format == "detailed" %}
Provide a comprehensive analysis with citations.
{% else %}
Provide a concise answer in 2-3 sentences.
{% endif %}
const answer = await chidori.prompt(
  await chidori.template("prompts/research.jinja", {
    role: "senior",
    question,
    sources,
    format: "detailed",
  }),
);

A composed example

A research pipeline that plans queries, fans out searches, optionally fetches sources, renders a Jinja prompt, and fact-checks the result:

import type { Chidori } from "chidori";

export async function agent(
  input: { question: string; depth?: string },
  chidori: Chidori,
) {
  const queries: string[] = await chidori.prompt(
    "Generate 3 search queries for: " + input.question +
      "\nReturn as a JSON array of strings.",
    { type: "json" },
  );

  const allResults = await chidori.parallel(
    queries.map((q) => () => chidori.tool("web_search", { query: q })),
  );

  let sources: unknown[] = [];
  if (input.depth === "deep") {
    const urls = allResults.flatMap((batch) => batch.slice(0, 2).map((r) => r.url));
    sources = await chidori.parallel(
      urls.map((u) => () => chidori.tool("fetch_url", { url: u })),
    );
  }

  const answer = await chidori.prompt(
    await chidori.template("prompts/research.jinja", {
      question: input.question,
      results: allResults,
      sources,
    }),
    { model: "claude-opus", temperature: 0.3 },
  );

  const final = await chidori.prompt(
    "Fact-check this answer. Return the corrected version.\n\n" + answer,
    { type: "final" },
  );

  return { answer: final, sources: allResults, queries_used: queries };
}

Every step is ordinary TypeScript. Every side effect is a logged, replayable chidori host function call.

Was this page helpful?