Cereal docs
Build a research agent

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.

StepYou can verify
1. Run the agentSearch and source reads lead to an answer citing the launch evidence.
2. Stream the researchSend a follow-up and refresh into the same conversation.
3. Scope workspace accessGrace cannot read Ada's sources or conversation.
4. Recover and debugReconnect the browser and inspect a stopped turn.
5. DeployServe signed callbacks at your application's HTTPS origin.
Optional: control publicationA 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 registration

The agent and its tools

src/agent.ts
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.

On this page