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),
),
);Each entry is a thunk — a function returning a promise. The functions start concurrently and results come back in input order.
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.
