Build a research agent
Gather sources, produce a cited brief, and connect the agent to your application.
Build a research workspace where an agent finds relevant documents, reads the evidence, and answers with source IDs. Cereal runs the model-and-tool loop. Your application serves the source tools, authenticates members, and decides what each workspace can access.
The runnable examples/research-agent contains a small source collection, a Node server, and a browser client. Code excerpts are extracted from that source during the docs build. The documents are fixtures, not live web search.
| Step | You can verify |
|---|---|
| 1. Run the agent | Search and source reads lead to an answer citing the launch evidence. |
| 2. Stream the research | Send a follow-up and refresh into the same conversation. |
| 3. Scope workspace access | Grace cannot read Ada's sources or conversation. |
| 4. Recover and debug | Reconnect the browser and inspect a stopped turn. |
| 5. Deploy | Serve signed callbacks at your application's HTTPS origin. |
| Optional: control publication | A tool-call hook decides when a person must approve. |
Start with the TypeScript quickstart. The same provider, agent, and source collection carry through the tutorial. For an SDK-free first session, use the HTTP quickstart.
examples/research-agent/
src/
agent.ts source tools, agent definition, publication hook
setup.ts optional early provider initialization
quickstart.ts one terminal research session
app.ts authentication and application routes
browser.ts transcript, streaming, optional decisions
server.ts Node server and development channel
sync.ts production configuration registrationThe agent and its tools
export function createResearchAgent() {
return defineAgent({
agent: {
slug: "research-agent",
name: "Research agent",
model: {
provider: primary,
model: process.env.RESEARCH_MODEL || "anthropic/claude-sonnet-4.6"
},
system:
"Research the user's question using search_sources and read_source. " +
"Read the sources before drawing conclusions. Cite source IDs and distinguish evidence from inference. " +
"Only call publish_brief when explicitly asked to publish. It returns a publication preview, not a live publication.",
identity: { required: true, scopesAllowed: ["user"] },
policies: {
toolCalls: { default: "allow" },
decisions: { expiresInSeconds: 3600, onExpire: "deny" }
}
},
tools: [
tool({
name: "search_sources",
description: "Search titles and text in the signed-in member's workspace.",
annotations: { readOnlyHint: true },
input: z.object({ query: z.string().min(1) }),
handler: async ({ query }, ctx) => {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
return sources
.filter((source) => source.workspaceId === ctx.session?.metadata?.workspaceId)
.filter((source) =>
terms.some((term) => `${source.title} ${source.text}`.toLowerCase().includes(term))
)
.map(({ id, title }) => ({ id, title }))
}
}),
tool({
name: "read_source",
description: "Read a source in the signed-in member's workspace by its ID.",
annotations: { readOnlyHint: true },
input: z.object({ sourceId: z.string() }),
handler: async ({ sourceId }, ctx) => {
const source = sources.find(
(item) =>
item.id === sourceId && item.workspaceId === ctx.session?.metadata?.workspaceId
)
if (!source) return { found: false }
return { found: true, id: source.id, title: source.title, text: source.text }
}
}),
tool({
name: "publish_brief",
description:
"Preview publication of a brief after a person approves. Does not publish or store content.",
annotations: { idempotentHint: true },
input: z.object({
title: z.string(),
body: z.string(),
sourceIds: z.array(z.string()).min(1)
}),
handler: async ({ title, body, sourceIds }, ctx) => {
const permitted = sourceIds.every((id) =>
sources.some(
(source) =>
source.id === id && source.workspaceId === ctx.session?.metadata?.workspaceId
)
)
if (!permitted) return { preview: false, reason: "Source not found" }
return { preview: true, title, body, sourceIds }
}
})
],
hooks: {
// #region decision-hook
onToolCall: async (call) => {
if (call.name !== "publish_brief") return { decision: "allow" }
return { decision: "ask", ask: { prompt: "Publish this research brief?" } }
}
// #endregion decision-hook
}
})
}Ada's workspace has launch-plan and security-review; Grace's has private-roadmap.
Source tools filter by the workspace asserted in verified session metadata. The publication
tool returns a preview after the hook decision; no external content is published.
The fixture application uses HTTP Basic authentication with passwords you configure. Use HTTPS outside your laptop and replace this sign-in with your own before serving real users.
Continue with streaming the research.