Architecture

Building Research Agents with Claude

DNotifier Team11 min readDNotifier × Claude, part 4 of 5
Building Research Agents with Claude


Part of a series on running AI workflows across model providers — this one is the actual build.


Ask one Claude call to research a topic, write it up, and double-check its own claims, and you'll get something reasonable most of the time. You'll also get a model quietly doing three different jobs in one pass, with no clean way to see which part of that answer came from where, or to swap out just the fact-checking step without touching the writing.


Splitting that into three specialized agents — one that researches, one that writes, one that checks — fixes both problems, and it's a natural fit for Claude's own model lineup, since the three jobs genuinely call for different amounts of horsepower. This post builds it for real, using DNotifier's actual defineAgent and Workflow primitives.


The shape of it


[ Topic in ]


┌─────────────┐ ctx.state.findings
│ Research │──────────────────┐
│ Sonnet 5 │ │
└─────────────┘ ▼
┌─────────────┐ ctx.state.draft
│ Writer │──────────────────┐
│ Opus 5 │ │
└─────────────┘ ▼
┌─────────────┐
│ Fact-check │
│ Fable 5.1 │
└─────────────┘

Three agents, three different Claude models, one shared workflow state. Nobody re-explains the topic to the next step.


A topic comes in. A research agent gathers findings — pulling from DNotifier's connected knowledge base where relevant — and writes them into shared state. A writer agent turns those findings into an actual draft. A fact-check agent reads the draft back against the same findings and flags anything that isn't actually supported. The output is a report with its sourcing checked, not just a confident-sounding paragraph.


Step 1: Define the three agents


import { DNotifier } from "@dnotifier-realtime/dnotifier";

const researchAgent = DNotifier.defineAgent({
name: "research-agent",
model: "claude-sonnet-5",
async run(ctx) {
const findings = await ctx.sendAI({
message: {
text: `Research this topic and list the key findings as clear,
sourced bullet points: "${ctx.input.topic}"`,
},
});
ctx.state.findings = findings.text;
return findings.text;
},
});

const writerAgent = DNotifier.defineAgent({
name: "writer-agent",
model: "claude-opus-5",
async run(ctx) {
const draft = await ctx.sendAI({
message: {
text: `Using only these findings, write a clear, well-structured
report on "${ctx.input.topic}":

${ctx.state.findings}`,
},
});
ctx.state.draft = draft.text;
return draft.text;
},
});

const factCheckAgent = DNotifier.defineAgent({
name: "fact-check-agent",
model: "claude-fable-5-1",
async run(ctx) {
const review = await ctx.sendAI({
message: {
text: `Compare this draft against the original findings. List any
claim in the draft that isn't actually supported by the findings.
If everything checks out, say so plainly.


Findings:

${ctx.state.findings}


Draft:

${ctx.state.draft}`,
},
});
ctx.state.factCheckNotes = review.text;
return review.text;
},
});


The model assignment here isn't arbitrary. Research is a wide, exploratory step — Sonnet 5 handles it well at a third of Opus 5's price. Writing the actual report is the step where quality is most visible to whoever reads the output, so it gets Opus 5's deeper reasoning. Fact-checking is a narrower, more mechanical comparison task — reading two pieces of text against each other — and Fable 5.1's always-on adaptive thinking is well suited to that without paying Opus 5 rates for it.


Step 2: Wire them into a workflow


const researchWorkflow = new DNotifier.Workflow({
name: "research-report-pipeline",
description: "Researches a topic, drafts a report, and fact-checks it before returning",
observability: true,
async entry(ctx) {
await ctx.agents.run(researchAgent, { topic: ctx.input.topic });
await ctx.agents.run(writerAgent, { topic: ctx.input.topic });
await ctx.agents.run(factCheckAgent, { topic: ctx.input.topic });

return {
findings: ctx.state.findings,
report: ctx.state.draft,
factCheckNotes: ctx.state.factCheckNotes,
};
},
});

researchWorkflow.registerAgents([researchAgent, writerAgent, factCheckAgent]);

Each agent reads and writes the same ctx.state object, so the writer agent doesn't need the topic and findings passed to it explicitly through a return value — it just reads ctx.state.findings, already sitting there from the previous step. observability: true means every one of these steps — which model ran, what it produced, how long it took — shows up in the dashboard as it happens.


Step 3: Run it


const notifier = new DNotifier({
appId: process.env.DNOTIFIER_APP_ID,
secret: process.env.DNOTIFIER_SECRET,
userId: "research-system",
transport: "ws",
WebSocketImpl: WebSocket,
});

await notifier.connect();

const outcome = await notifier.runWorkflow(researchWorkflow, {
topic: "The current state of solid-state battery manufacturing",
});

console.log(outcome.report);
console.log(outcome.factCheckNotes);

What comes back isn't just a report — it's a report plus an explicit record of what got checked against the original research and what, if anything, didn't hold up. That second part is the piece a single undivided prompt tends to skip, because nothing in a one-shot call forces the model to go back and audit its own output against its own sources.


Handling what the fact-checker finds


In practice, you'll want to do something with factCheckNotes beyond logging it. A simple version: if the fact-check agent flags unsupported claims, send the draft back through the writer agent once with those notes attached, asking it to revise.


if (outcome.factCheckNotes.toLowerCase().includes("unsupported")) {
const revision = await ctx.agents.run(writerAgent, {
topic: ctx.input.topic,
revisionNotes: ctx.state.factCheckNotes,
});
ctx.state.draft = revision;
}

This is a one-pass revision loop, not a full agentic retry system — but it's often enough. The point isn't infinite self-correction; it's catching the specific, common failure mode where a writing-focused model states something more confidently than the underlying research actually supports.


Why three models beats one for this job


The cost math is real. Running every research pass through Opus 5 because the final writing step needs it is paying flagship rates for a step that doesn't need flagship depth. Tiering by task, not by pipeline, is where the savings actually come from.


The fact-check step needs independence, not brilliance. Having the same model that wrote the draft also grade its own homework is a weaker check than having a separate pass read it cold against the sources. It doesn't need to be the smartest model in the pipeline — it needs to actually do the comparison rather than skip it.


Debugging stays possible. When a report contains something wrong, the observability dashboard tells you which stage introduced it — research, writing, or a fact-check that missed something — instead of leaving you to guess inside one long undifferentiated prompt.


The pattern generalizes past research. Swap "research/write/fact-check" for "gather requirements/draft a plan/review the plan" and you have a project-scoping pipeline. Swap it for "summarize a call/draft follow-up notes/flag anything that needs a human" and you're close to a sales-ops assistant. The three-stage shape — gather, produce, verify — shows up constantly once you're looking for it.


Frequently asked questions


Do all three agents have to use Claude models?


No — the model field is set per agent, so nothing stops you from routing the fact-check step to a different provider entirely if you had a reason to. This example keeps everything on Claude because the cost-tiering across Sonnet 5, Opus 5, and Fable 5.1 already covers a wide enough range for most use cases.


What if the fact-check agent is wrong about something?


Treat its output as a flag for review, not an automatic edit, especially early on. The revision-loop pattern above works well once you've validated the fact-checker's judgment against real outputs from your own topics — don't assume it's perfectly calibrated on day one.


Can this pipeline pull from DNotifier's knowledge base instead of Claude's general knowledge?


Yes — if you've connected a knowledge base to the app, the research agent's sendAI() call can be grounded in that content the same way any other call is, which matters a lot if you're researching something internal rather than a general-knowledge topic.


How long does a run like this actually take?


Three sequential model calls, so expect it to take roughly three times a single call's latency, plus whatever the revision loop adds if it triggers. For an interactive product, that usually means running this as a background job rather than a synchronous request a user waits on.


Does this scale to more than three agents?


Yes — registerAgents and the shared ctx.state pattern hold up whether you have three agents or ten. The complexity that grows isn't the plumbing, it's making sure each agent's job stays narrow enough that adding a step actually helps instead of just adding cost.


The Bottom Line


That's the full arc: getting Claude connected in part one, weighing Anthropic's own Managed Agents platform against a model-agnostic layer in part three, and now an actual multi-model pipeline doing real work. One more in this series: what Claude's context window changes about how you should think about memory in the first place.


Read part one: Claude on DNotifier: Setup Guide. Explore dnotifier.com.


DNotifier × Claude

Part 4 of 5

  1. Part 1Claude on DNotifier: Setup Guide
  2. Part 2DNotifier's MCP Support, Explained
  3. Part 3Claude's Managed Agents vs. DNotifier
  4. Part 4Building Research Agents with Claude
  5. Part 5Long Context, Better Agent Memory

Related articles