@roostjs/ai Guides
Task-oriented instructions for agents, tools, streaming, queueing, sessions, workflows, sub-agents, MCP, HITL, RAG, and testing.
How to create an AI agent
Extend Agent and implement instructions(). Register AiServiceProvider in
your app config, then instantiate and call prompt().
import { Agent } from '@roostjs/ai';
export class SupportAgent extends Agent {
instructions(): string {
return 'You are a helpful customer support agent for Acme Inc. Be concise.';
}
}
const agent = new SupportAgent();
const response = await agent.prompt('How do I reset my password?');
console.log(response.text);Each instance holds its own in-memory conversation history. For durable
cross-request state, upgrade to StatefulAgent.
See reference → Prompting.
How to register multiple providers with failover
Pass an array to @Provider. The runtime tries providers in order on
transient errors; AllProvidersFailed fires when every entry fails.
import { Agent, Provider, Lab } from '@roostjs/ai';
@Provider([Lab.Anthropic, Lab.OpenAI, Lab.WorkersAI])
export class Resilient extends Agent {
instructions(): string { return 'Be helpful.'; }
}Configure the native adapters in config/app.ts:
export default {
ai: {
providers: {
anthropic: { apiKey: env.ANTHROPIC_KEY },
openai: { apiKey: env.OPENAI_KEY },
},
default: [Lab.Anthropic, Lab.OpenAI],
},
};See reference → Failover.
How to call tools from an agent
Implement Tool for user-defined tools, or instantiate provider tools
(WebSearch, WebFetch, FileSearch). Return a mix from tools() on an
agent that implements HasTools.
import { Agent, type HasTools, type Tool, type ToolRequest, WebSearch } from '@roostjs/ai';
import type { schema } from '@roostjs/schema';
class OrderLookupTool implements Tool {
constructor(private db: Database) {}
name() { return 'order_lookup'; }
description() { return 'Look up an order by ID.'; }
schema(s: typeof schema) { return { orderId: s.string() }; }
async handle(req: ToolRequest) {
const order = await this.db.findOrder(req.get<string>('orderId'));
return order ? `${order.status} · ${order.updatedAt}` : 'not found';
}
}
export class Research extends Agent implements HasTools {
constructor(private db: Database) { super(); }
instructions() { return 'Research assistant.'; }
tools() { return [new OrderLookupTool(this.db), new WebSearch()]; }
}See reference → Tools.
How to stream a response
agent.stream(input) returns a StreamableAgentResponse — both an async
iterable of StreamEvent and a Response-compatible body. Narrow on
event.type before reading payload fields.
import { Research } from '../../agents/research';
export async function POST({ request }: { request: Request }) {
const { message } = await request.json<{ message: string }>();
const stream = new Research(db).stream(message);
return new Response(stream, {
headers: { 'content-type': 'text/event-stream' },
});
}For Vercel AI SDK clients (useChat), use .usingVercelDataProtocol():
return new Response(new Research(db).stream(message).usingVercelDataProtocol());How to queue a long-running prompt
Call .queue() instead of .prompt(). It returns a thenable
QueuedPromptHandle backed by @roostjs/queue. Decorate with queue metadata.
import { Agent, Queue, MaxRetries, Backoff, JobTimeout } from '@roostjs/ai';
@Queue('ai')
@MaxRetries(3)
@Backoff('exponential')
@JobTimeout(300)
export class Summarize extends Agent {
instructions() { return 'Summarize long documents.'; }
}import { Summarize } from '../../agents/summarize';
// Fire and forget — resolves when the worker picks up the job.
const handle = new Summarize().queue(documentText);
handle.then((response) => persist(response.text));See reference → Queueing.
How to persist conversation history
Extend StatefulAgent and apply the RemembersConversations mixin. Declare
the Durable Object binding in wrangler.jsonc. Conversations persist in DO
storage with tree-structured history, compaction, and FTS.
import { StatefulAgent, RemembersConversations } from '@roostjs/ai/stateful';
import { Stateful } from '@roostjs/ai';
@Stateful({ binding: 'SUPPORT_AGENT' })
export class Support extends RemembersConversations(StatefulAgent) {
instructions(): string { return 'Helpful support agent.'; }
}{
"durable_objects": {
"bindings": [{ "name": "SUPPORT_AGENT", "class_name": "Support" }]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["Support"] }
]
}Export the class from your Worker entry so the runtime can instantiate the DO:
export { Support } from './agents/support-do';See reference → Sessions.
How to schedule a method to run daily
Use @Scheduled(cron) on a StatefulAgent method. The decorator registers
the method on class load; the agent's DO alarm fires at the configured cron
time.
import { StatefulAgent } from '@roostjs/ai/stateful';
import { Stateful, Scheduled } from '@roostjs/ai';
@Stateful({ binding: 'DIGEST_AGENT' })
export class Digest extends StatefulAgent {
instructions() { return 'Daily digest generator.'; }
@Scheduled('0 9 * * *')
async sendDigest() {
const response = await this.prompt('Summarize the last 24 hours.');
await send(response.text);
}
}For ad-hoc scheduling, call agent.schedule(when, method, payload):
await agent.schedule(60, 'sendReminder', { orderId: 42 });See reference → Schedule.
How to run a method as a durable Workflow
Decorate a StatefulAgent method with @Workflow({binding}). The runtime
replaces the method with a call that creates a @roostjs/workflow instance;
re-runs replay prior step.do() results from the checkpoint cache.
import { StatefulAgent } from '@roostjs/ai/stateful';
import { Stateful, Workflow } from '@roostjs/ai';
@Stateful({ binding: 'REPORT_AGENT' })
export class Report extends StatefulAgent {
instructions() { return 'Generate reports.'; }
@Workflow({ binding: 'REPORT_FLOW' })
async processReport(step, reportId: string) {
const raw = await step.do('fetch', () => this.fetchRaw(reportId));
const parsed = await step.do('parse', () => this.parse(raw));
await step.sleep('cooldown', '1 minute');
return await step.do('summarize', () => this.prompt(parsed));
}
}See reference → Workflows and
@roostjs/workflow reference.
How to spawn a sub-agent and aggregate results
From inside a StatefulAgent method, call this.subAgent(OtherAgent) to get
a typed client. Mark the parent class @SubAgentCapable().
import { StatefulAgent } from '@roostjs/ai/stateful';
import { Stateful, SubAgentCapable } from '@roostjs/ai';
import { Summarizer } from './summarizer';
@Stateful({ binding: 'ORCHESTRATOR' })
@SubAgentCapable()
export class Orchestrator extends StatefulAgent {
instructions() { return 'Coordinate research.'; }
async investigate(topic: string) {
const summarizer = this.subAgent(Summarizer);
const [overview, details] = await Promise.all([
summarizer.summarize(topic),
summarizer.detail(topic),
]);
return { overview, details };
}
}Sub-agent depth is tracked; nesting beyond SUB_AGENT_MAX_DEPTH raises
SubAgentDepthExceededError. See
reference → Sub-agents.
How to consume a remote MCP server
McpClient.connect opens a connection to an MCP server. Merge its tools into
your agent's tools() output.
import { Agent, type HasTools } from '@roostjs/ai';
import { McpClient } from '@roostjs/ai/mcp';
const github = await McpClient.connect({
url: 'https://mcp.github.com',
transport: 'streamable-http',
auth: { type: 'bearer', token: env.GITHUB_MCP_TOKEN },
});
export class Bug extends Agent implements HasTools {
instructions() { return 'Triage bugs.'; }
async tools() { return [...(await github.tools())]; }
}Aggregate several servers behind a single prefix-namespaced McpPortal:
import { McpPortal } from '@roostjs/ai/mcp';
const portal = new McpPortal([
{ prefix: 'gh', client: github },
{ prefix: 'jira', client: await McpClient.connect({ url: 'https://mcp.jira.com' }) },
]);
await portal.callTool('gh.search_code', { q: 'roost' });See reference → MCP.
How to expose an agent as an MCP server
createMcpHandler(AgentClass, options) returns a Worker fetch handler. Export
it as the default export to serve MCP over a public URL.
import { createMcpHandler } from '@roostjs/ai/mcp';
import { Bug } from './agents/bug';
export default createMcpHandler(Bug, {
transport: 'streamable-http',
path: '/mcp',
authorize: (req) => req.headers.get('x-api-key') === env.MCP_API_KEY,
});See reference → MCP.
How to require human approval before a destructive action
Call requireApproval(this, step, payload) inside a StatefulAgent method.
The call persists a pending approval and returns once the approval is decided
via approve from another surface (a reviewer UI, an MCP elicitation, or a
custom route).
import { StatefulAgent } from '@roostjs/ai/stateful';
import { Stateful, RequiresApproval } from '@roostjs/ai';
import { requireApproval } from '@roostjs/ai/hitl';
@Stateful({ binding: 'TREASURER' })
export class Treasurer extends StatefulAgent {
instructions() { return 'Handle payouts.'; }
@RequiresApproval('charge')
async chargeCustomer(customerId: string, amountCents: number) {
const decision = await requireApproval(this, 'charge', { customerId, amountCents });
if (decision.status !== 'approved') return { skipped: true };
await this.doCharge(customerId, amountCents);
return { charged: true };
}
}See reference → HITL.
How to query RAG knowledge alongside a conversation
Build a RAGPipeline with a vector store and an embedding pipeline; query it
inside the agent, then weave the hits into the next prompt.
{
"ai": { "binding": "AI" },
"vectorize": [{ "binding": "VECTORIZE", "index_name": "policy-docs" }]
}import { Agent } from '@roostjs/ai';
import { RAGPipeline, EmbeddingPipeline, SemanticChunker } from '@roostjs/ai/rag';
import { AIClient, VectorStore } from '@roostjs/cloudflare';
export class Docs extends Agent {
private rag: RAGPipeline;
constructor(env: Env) {
super();
this.rag = new RAGPipeline(
new VectorStore(env.VECTORIZE),
new EmbeddingPipeline(new AIClient(env.AI)),
new SemanticChunker(),
{ namespace: 'policy-docs', topK: 5 },
);
}
instructions() {
return 'You are a docs assistant. Ground answers in retrieved context.';
}
async ask(question: string) {
const hits = await this.rag.query(question);
const context = hits.map((h) => h.chunk.text).join('\n\n');
const response = await this.prompt(`Context:\n${context}\n\nQuestion: ${question}`);
return response.text;
}
}See reference → Vector Stores.
How to test an agent without hitting the AI binding
Call fake() with canned responses (strings or resolver functions). Use
assertPrompted to verify inputs. preventStrayPrompts() throws on any call
not matched by a canned response — useful as a final CI guard.
import { describe, it, expect, afterEach } from 'bun:test';
import { Support } from '../src/agents/support';
afterEach(() => Support.restore());
describe('Support', () => {
it('answers reset-password questions', async () => {
Support.fake([
'Visit /account/reset to reset your password.',
]).preventStrayPrompts();
const response = await new Support().prompt('How do I reset my password?');
expect(response.text).toContain('reset');
Support.assertPrompted('reset');
});
it('handles order lookups with a resolver', async () => {
Support.fake([(p) => `Order ${p.input.match(/\d+/)?.[0]} shipped.`]);
const response = await new Support().prompt('Check order 42');
expect(response.text).toBe('Order 42 shipped.');
});
});For DO-backed agents, use TestStatefulAgentHarness:
import { TestStatefulAgentHarness } from '@roostjs/ai/testing';
const { agent, clock } = TestStatefulAgentHarness
.for(Support)
.withSessions()
.withMockClock()
.build();
await agent.sendDigest();
clock.advance(3600_000);See reference → Testing.