stratus
Guides

Research Agent

Build an agent that delegates research tasks to specialized subagents

Build a research orchestrator that breaks complex questions into subtasks and delegates them to specialized subagents. Each subagent runs independently with its own tools, reports back, and the parent synthesizes the findings.

Quick start

Here is a minimal research agent with a single subagent. The full guide breaks this pattern into composable pieces.

quick-start.ts
import { Agent, run, subagent, tool } from "stratus-sdk/core";
import { AzureResponsesModel } from "stratus-sdk";
import { z } from "zod";

const model = new AzureResponsesModel({
  endpoint: process.env.AZURE_ENDPOINT!,
  apiKey: process.env.AZURE_API_KEY!,
  deployment: "gpt-5.2",
});

const webSearch = tool({
  name: "web_search",
  description: "Search the web for information",
  parameters: z.object({ query: z.string() }),
  execute: async (_ctx, { query }) => {
    const results = await searchAPI(query);
    return JSON.stringify(results.slice(0, 5));
  },
});

const researcher = new Agent({
  name: "web_researcher",
  model,
  instructions: "Search for information and return key facts with source URLs.",
  tools: [webSearch],
});

const researchSubagent = subagent({ 
  agent: researcher, 
  inputSchema: z.object({ topic: z.string() }), 
  mapInput: (params) => `Research: ${params.topic}`, 
}); 

const orchestrator = new Agent({
  name: "orchestrator",
  model,
  instructions: "Break questions into sub-questions. Use run_web_researcher for each.",
  subagents: [researchSubagent], 
});

const result = await run(orchestrator, "What is the current state of renewable energy?");
console.log(result.output);

What you'll build

A parent orchestrator that delegates to three domain-specific subagents:

Web Researcher

Searches the web and extracts key facts

Data Analyst

Performs calculations and data analysis

Summarizer

Condenses findings into structured reports

Step 1: Define subagent tools

Each subagent gets its own specialized tools. Keep tool sets small and focused -- a subagent with fewer tools produces more reliable results.

tools.ts
import { tool } from "stratus-sdk/core";
import { z } from "zod";

const webSearch = tool({
  name: "web_search",
  description: "Search the web for information",
  parameters: z.object({
    query: z.string().describe("Search query"),
  }),
  execute: async (_ctx, { query }) => {
    const results = await searchAPI(query);
    return JSON.stringify(results.slice(0, 5));
  },
});

const fetchPage = tool({
  name: "fetch_page",
  description: "Fetch and extract text from a web page",
  parameters: z.object({
    url: z.string().describe("URL to fetch"),
  }),
  execute: async (_ctx, { url }, options) => {
    const res = await fetch(url, { signal: options?.signal }); 
    const text = await res.text();
    return extractMainContent(text).slice(0, 3000);
  },
});

const calculate = tool({
  name: "calculate",
  description: "Evaluate a math expression",
  parameters: z.object({
    expression: z.string(),
  }),
  execute: async (_ctx, { expression }) => {
    return String(new Function(`return (${expression})`)());
  },
});

The fetchPage tool forwards options.signal to fetch(). When the parent run is cancelled, the HTTP request cancels too. See Cancellation with abort signal below.

Step 2: Create subagent definitions

Define one agent per research domain. Each gets a focused instruction set and only the tools it needs.

subagents.ts
import { Agent, subagent } from "stratus-sdk/core";

const webResearcher = new Agent({
  name: "web_researcher",
  model,
  instructions: `You are a web research specialist. Search for information,
    visit relevant pages, and extract key facts. Return factual findings
    with source URLs.`,
  tools: [webSearch, fetchPage],
});

const dataAnalyst = new Agent({
  name: "data_analyst",
  model,
  instructions: `You are a data analyst. Perform calculations, analyze numbers,
    and identify trends. Return precise numerical results.`,
  tools: [calculate],
});

const summarizer = new Agent({
  name: "summarizer",
  model,
  instructions: `You are a research summarizer. Take raw findings and synthesize
    them into a clear, structured summary with key takeaways.`,
});

The summarizer has no tools. It only needs the model to restructure and condense text. Not every subagent needs tool access.

Step 3: Wire subagents to the parent

Use subagent() to create typed bridges between the parent and each child agent. The inputSchema defines what the model passes in, and mapInput converts those parameters into a prompt string.

research-agent.ts
const researchSubagent = subagent({
  agent: webResearcher,
  inputSchema: z.object({
    topic: z.string().describe("What to research"),
  }),
  mapInput: (params) => `Research the following topic thoroughly: ${params.topic}`,
});

const analysisSubagent = subagent({
  agent: dataAnalyst,
  inputSchema: z.object({
    question: z.string().describe("The data question to answer"),
    data: z.string().describe("Relevant data or numbers to analyze"),
  }),
  mapInput: (params) => `Analyze: ${params.question}\n\nData: ${params.data}`,
});

const summarySubagent = subagent({
  agent: summarizer,
  inputSchema: z.object({
    findings: z.string().describe("Raw research findings to summarize"),
  }),
  mapInput: (params) => `Summarize these findings:\n\n${params.findings}`,
});

Step 4: Create the orchestrator

The parent agent sees each subagent as a callable tool named run_<agent_name>. Its instructions tell it when and how to use each one.

orchestrator.ts
import { Agent, run } from "stratus-sdk/core";

const researchOrchestrator = new Agent({
  name: "research_orchestrator",
  model,
  instructions: `You are a research orchestrator. When given a question:
    1. Break it into sub-questions
    2. Use run_web_researcher for factual lookups
    3. Use run_data_analyst for numerical analysis
    4. Use run_summarizer to compile findings
    Be thorough but efficient.`,
  subagents: [researchSubagent, analysisSubagent, summarySubagent], 
});

const result = await run(
  researchOrchestrator,
  "What is the current state of renewable energy adoption globally? Include market size, growth rates, and top countries.",
);

console.log(result.output);

Subagent names become tool names prefixed with run_. If your agent is named web_researcher, the parent calls it as run_web_researcher. Keep names short and descriptive.

Adding structured output

Get results in a typed format for downstream processing. Set outputType on the orchestrator to a Zod schema, and result.finalOutput is fully typed.

structured.ts
const ReportSchema = z.object({
  title: z.string(),
  summary: z.string(),
  keyFindings: z.array(z.object({
    finding: z.string(),
    source: z.string().optional(),
    confidence: z.enum(["high", "medium", "low"]),
  })),
  dataPoints: z.array(z.object({
    metric: z.string(),
    value: z.string(),
  })),
});

const researchOrchestrator = new Agent({
  name: "research_orchestrator",
  model,
  instructions: `...same as above...`,
  subagents: [researchSubagent, analysisSubagent, summarySubagent],
  outputType: ReportSchema, 
});

const result = await run(researchOrchestrator, "...");
console.log(result.finalOutput.keyFindings); // Typed array

When using outputType with subagents, the model calls subagents first, then produces the structured JSON in its final response. The subagents themselves return unstructured text unless they also have their own outputType.

Adding tracing

Monitor which subagents run and how long each takes. Wrap the run() call in withTrace() and inspect the resulting spans.

traced.ts
import { withTrace } from "stratus-sdk/core";

const { result, trace } = await withTrace("research_task", () =>
  run(researchOrchestrator, "Analyze the EV market in 2025")
);

// See which subagents were called
const subagentSpans = trace.spans
  .flatMap((s) => [s, ...s.children])
  .filter((s) => s.type === "subagent");

for (const span of subagentSpans) {
  console.log(`${span.name}: ${span.duration}ms`);
}
// subagent:web_researcher: 4523ms
// subagent:data_analyst: 1201ms
// subagent:summarizer: 2105ms

Cancellation with abort signal

Cancel long-running research when the user disconnects. The signal propagates through the orchestrator, into every active subagent, and down to their tool executions.

cancellable.ts
const ac = new AbortController();

// Cancel if user disconnects
req.on("close", () => ac.abort());

try {
  const result = await run(researchOrchestrator, question, {
    signal: ac.signal, 
  });
  res.json(result.finalOutput);
} catch (error) {
  if (error instanceof RunAbortedError) {
    console.log("Research cancelled by user");
  }
}

The abort signal propagates through to all subagent runs and their tool executions, so everything cancels cleanly. You do not need to wire up signals for each subagent individually.

Next steps

Edit on GitHub

Last updated on

On this page