Research team
A lead agent delegates investigation, fact-checking, and synthesis to focused agents and shows the handoffs.
View source ↗Build it from an empty Ayjnt project
Follow the steps in order. Every source block below is the actual checked example file, so the tutorial and runnable project cannot quietly drift apart.
Scaffold and install
Create an empty harness and add only the dependencies this example needs.
bunx ayjnt new research-team --empty
cd research-team
bun install
# delete agents/alive after adding the files belowGive each agent one responsibility
The researcher extracts candidate facts and the reviewer removes risky claims. Their public methods are the internal RPC contract.
Create agents/researcher/agent.ts with this source:
import { Agent } from "ayjnt";
export type Finding = {
source: string;
title: string;
facts: string[];
fetchedAt: number;
};
type State = { completed: number };
export default class ResearcherAgent extends Agent<State> {
override initialState: State = { completed: 0 };
async investigate(url: string): Promise<Finding> {
const parsed = new URL(url);
const response = await fetch(parsed, {
headers: { "user-agent": "ayjnt-orchestration-example/1.0" },
});
if (!response.ok) throw new Error(`${parsed.hostname} returned ${response.status}`);
const html = await response.text();
const title = /<title[^>]*>([^<]+)<\/title>/i.exec(html)?.[1]?.trim() ?? parsed.hostname;
const text = html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ");
const facts = text
.split(/[.!?]\s+/)
.map((sentence) => sentence.trim())
.filter((sentence) => sentence.length >= 45 && sentence.length <= 240)
.slice(0, 8);
this.setState({ completed: this.state.completed + 1 });
return { source: parsed.toString(), title, facts, fetchedAt: Date.now() };
}
}
Orchestrate with class-safe RPC
The lead imports each target class and calls this.agent(TargetClass, instance), receiving autocomplete without binding-name strings.
Create agents/lead/agent.ts with this source:
import { Agent, callable } from "ayjnt";
import ResearcherAgent from "../researcher/agent.ts";
import ReviewerAgent from "../reviewer/agent.ts";
import type { Review } from "../reviewer/agent.ts";
type Stage = "queued" | "researching" | "reviewing" | "complete" | "failed";
type Run = {
id: string;
question: string;
sources: string[];
stage: Stage;
log: Array<{ at: number; agent: string; message: string }>;
brief?: string;
error?: string;
};
type State = { runs: Run[] };
export default class LeadAgent extends Agent<State> {
override initialState: State = { runs: [] };
@callable()
async start(question: string, sources: string[]): Promise<string> {
const id = crypto.randomUUID();
const run: Run = {
id,
question: question.trim(),
sources: sources.map((source) => new URL(source.trim()).toString()),
stage: "queued",
log: [],
};
this.setState({ runs: [run, ...this.state.runs] });
await this.execute(id);
return id;
}
private async execute(id: string): Promise<void> {
const run = this.find(id);
try {
this.patch(id, {
stage: "researching",
log: [...run.log, event("lead", `Delegated ${run.sources.length} source(s).`)],
});
const researcher = await this.agent(ResearcherAgent, id);
const findings = await Promise.all(
run.sources.map((source) => researcher.investigate(source)),
);
this.append(id, "researcher", `Returned ${findings.reduce((sum, finding) => sum + finding.facts.length, 0)} candidate facts.`);
this.patch(id, { stage: "reviewing" });
const reviewer = await this.agent(ReviewerAgent, id);
const reviews = await Promise.all(findings.map((finding) => reviewer.review(finding)));
this.append(id, "reviewer", `Accepted ${reviews.reduce((sum, review) => sum + review.accepted.length, 0)} attributed facts.`);
this.patch(id, {
stage: "complete",
brief: synthesize(run.question, reviews),
});
this.append(id, "lead", "Synthesized the reviewed findings.");
} catch (error) {
this.patch(id, {
stage: "failed",
error: error instanceof Error ? error.message : String(error),
});
}
}
private find(id: string) {
const run = this.state.runs.find((candidate) => candidate.id === id);
if (!run) throw new Error("Run not found.");
return run;
}
private patch(id: string, patch: Partial<Run>) {
this.setState({ runs: this.state.runs.map((run) => run.id === id ? { ...run, ...patch } : run) });
}
private append(id: string, agent: string, message: string) {
const run = this.find(id);
this.patch(id, { log: [...run.log, event(agent, message)] });
}
}
function event(agent: string, message: string) {
return { at: Date.now(), agent, message };
}
function synthesize(question: string, reviews: Review[]) {
const facts = reviews.flatMap((review) =>
review.accepted.map((fact) => `- ${fact} ([source](${review.source}))`)
);
const cautions = [...new Set(reviews.flatMap((review) => review.cautions))];
return [`# ${question}`, "", ...facts, "", "## Caveats", ...cautions.map((item) => `- ${item}`)].join("\n");
}
Show every handoff
The browser interface displays stages, agent attribution, source links, and the final synthesized brief.
Create agents/lead/app.tsx with this source:
import { useState } from "react";
import { useAgent } from "@ayjnt/lead";
export default function Lead() {
const agent = useAgent();
const [question, setQuestion] = useState("What does this project make easier?");
const [sources, setSources] = useState("https://github.com/northclock/ayjnt");
const state = agent.state;
if (!state) return <main style={styles.main}>Connecting…</main>;
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const urls = sources.split("\n").map((item) => item.trim()).filter(Boolean);
await agent.call("start", [question, urls]);
};
return (
<main style={styles.main}>
<header style={styles.header}><div><small style={styles.eyebrow}>MULTI-AGENT</small><h1 style={styles.h1}>Research team</h1></div><span>lead → researcher → reviewer</span></header>
<form onSubmit={submit} style={styles.form}>
<input value={question} onChange={(event) => setQuestion(event.target.value)} style={styles.input} required />
<textarea value={sources} onChange={(event) => setSources(event.target.value)} style={styles.textarea} />
<button style={styles.primary}>Run research</button>
</form>
<section style={styles.runs}>
{state.runs.map((run) => (
<article key={run.id} style={styles.card}>
<div style={styles.cardHead}><h2>{run.question}</h2><span style={styles.stage}>{run.stage}</span></div>
<div style={styles.pipeline}>
{["lead", "researcher", "reviewer"].map((name, index) => (
<div key={name} style={styles.agent}><b>{String(index + 1).padStart(2, "0")}</b><strong>{name}</strong><span>{run.log.filter((item) => item.agent === name).length} events</span></div>
))}
</div>
<ol style={styles.log}>{run.log.map((entry, index) => <li key={index}><code>{entry.agent}</code>{entry.message}</li>)}</ol>
{run.brief && <pre style={styles.brief}>{run.brief}</pre>}
{run.error && <p style={styles.error}>{run.error}</p>}
</article>
))}
</section>
</main>
);
}
const styles = {
main: { fontFamily: "system-ui, sans-serif", maxWidth: 980, margin: "0 auto", padding: "42px 22px", color: "#172033" },
header: { display: "flex", justifyContent: "space-between", alignItems: "end", paddingBottom: 22, borderBottom: "1px solid #dfe2e7" },
eyebrow: { color: "#225dd8", fontFamily: "monospace", letterSpacing: "0.14em" },
h1: { margin: "6px 0 0", fontSize: 38 },
form: { display: "grid", gridTemplateColumns: "1fr 1fr auto", gap: 8, padding: "22px 0" },
input: { padding: 11, border: "1px solid #d2d7de", borderRadius: 8 },
textarea: { padding: 11, border: "1px solid #d2d7de", borderRadius: 8, minHeight: 45 },
primary: { border: 0, borderRadius: 8, background: "#225dd8", color: "white", padding: "0 16px" },
runs: { display: "grid", gap: 14 },
card: { border: "1px solid #dfe2e7", borderRadius: 12, padding: 18, background: "#fbfbf8" },
cardHead: { display: "flex", justifyContent: "space-between", alignItems: "center" },
stage: { background: "#e5ebf6", borderRadius: 99, padding: "6px 8px", fontFamily: "monospace", fontSize: 9 },
pipeline: { display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 8, margin: "17px 0" },
agent: { display: "grid", gap: 4, padding: 12, background: "#eef1f6", borderRadius: 8 },
log: { padding: 0, listStyle: "none", fontSize: 12, color: "#5e6775" },
brief: { whiteSpace: "pre-wrap" as const, lineHeight: 1.6, background: "#172033", color: "#e8ebf1", padding: 17, borderRadius: 8, fontSize: 12 },
error: { color: "#b94b40" },
};
Run the harness
bun run build
bun run dev
# open http://localhost:8787/lead/demoThe build step generates bindings, migrations, client hooks, and environment types before the runtime starts.
Project shape
Verify your result
- Enter a question and two reachable article URLs.
- Watch the stage move through researcher, reviewer, and lead.
- Inspect the attributed facts and caveats in the final brief.