@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());

See reference → Streaming.

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.