@roostjs/ai
Why agents are classes, why v0.3 integrates Cloudflare Agents SDK semantics via Roost-native primitives, and the opt-in contract pattern.
Why Class-Based Agents
Agents could be plain functions: const agent = createAgent({ instructions, tools });.
This is the approach several AI SDKs take. Roost makes agents classes for reasons that
become apparent once an application has more than one or two of them.
Class-based agents have inherent identity. When you have a ResearchAssistant, a
SupportAgent, and a BillingConcierge, their names appear in logs, error messages,
and test assertions. Decorators — @Model, @MaxSteps, @Temperature, @Provider,
@Stateful — attach to the class rather than hiding inside an options object, making
configuration visually prominent. The static fake() and restore() methods work on
the class as a whole, so a test can replace a specific agent type without touching
other agents in the same suite.
Instance-scoped conversation memory is cleaner too: a new agent instance per conversation
means the instance holds that conversation's history. For durable state across requests,
StatefulAgent persists the conversation into Durable Object storage — same mental
model, different lifetime.
The Opt-In Contract Pattern
v0.3 replaces inline fields (messages = [...], tools = [...]) with a set of
opt-in contracts. Each contract is a plain TypeScript interface the Agent base
class detects at runtime through a type predicate.
| Contract | Method | What it unlocks |
|---|---|---|
Conversational | messages() | Seed or override the message history per call. |
HasTools | tools() | Return Tool[] made available to the model each step. |
HasStructuredOutput | schema(s) | Constrain output to a validated schema. |
HasMiddleware | middleware() | Wrap every prompt/response in an async chain. |
HasProviderOptions | providerOptions(provider) | Per-provider option bag. |
import { Agent } from '@roostjs/ai';
import type { Conversational, HasTools, HasStructuredOutput } from '@roostjs/ai';
class Support extends Agent implements Conversational, HasTools, HasStructuredOutput {
instructions() { return 'help'; }
messages() { return priorHistory; }
tools() { return [new LookupTool()]; }
schema(s) { return { reply: s.string() }; }
}Each contract is optional; the agent works without any of them. Detection happens
once per prompt() call through predicates (isConversational, hasTools,
hasStructuredOutput, hasMiddleware, hasProviderOptions) exported from the
package. The runtime cost is a handful of property existence checks.
The pattern keeps the Agent base class small — it does not have to understand
tools, schemas, or middleware by default — while making the capabilities a subclass
opts into visible in its implements clause. Grep-ability is a design goal: every
agent that uses tools can be located by searching for implements.*HasTools.
The Agentic Loop
prompt() does not simply send a message and return a response. It enters a loop.
The agent sends the current message history to the model; if the model returns tool
calls, the agent executes those tools, appends the results, and sends the updated
history back. This continues until the model emits a final response without tool
calls or until maxSteps is reached.
Two knobs shape the loop:
@MaxSteps(n)bounds the number of model calls perprompt(). Unbounded loops are a common failure mode in agent frameworks; v0.3 requires an explicit ceiling.@Provider([...])enables provider failover inside the loop. If a call to the primary provider fails with a transient error, the next provider in the list is tried before the step is considered failed.MaxStepsExhaustedandAllProvidersFailedevents fire on exhaustion so downstream code can react.
A research agent that searches the web, summarises three pages, and synthesises an
answer makes several sequential tool calls inside one prompt() — the caller sees
the final synthesised reply, not the intermediate steps.
Multi-Provider Strategy
v0.2 routed everything through Workers AI. v0.3 supports four first-class providers, each as its own adapter:
WorkersAIProvider— direct binding, zero external hop.GatewayAIProvider— Cloudflare AI Gateway for observability, caching, rate limiting; external providers route through Gateway by default.AnthropicProvider,OpenAIProvider,GeminiProvider— native adapters for features Gateway cannot fully expose (reasoning, response format, tool choice nuances).FailoverProvider— ordered list tried in sequence.
The decision is operational, not architectural. Start with the direct binding in development. Move to Gateway in production when observability and caching become worth roughly 10 ms of added latency. Add native adapters when a specific provider feature (extended thinking on Claude, strict tool choice on OpenAI) is unavailable through Gateway.
Failover is explicit. @Provider([Lab.Anthropic, Lab.WorkersAI]) on an agent
class means Workers AI is the backstop, tried only when the Anthropic call
fails. This keeps the cost picture predictable — the expensive provider is the
default, the cheap local provider is the fallback — while guaranteeing the
agent keeps responding if the preferred provider is down.
Stateful vs Stateless Agents
Agent is stateless from the platform's perspective: the instance holds its own
message history, but that history is discarded when the request ends. This is the
right default for synchronous request/response flows — a support bot answering a
single question, a classification agent, a content generator.
StatefulAgent (from @roostjs/ai/stateful) is a Durable Object. The DO's
storage holds the conversation, schedule, workflow handles, sub-agent
relationships, and memory. Reach for StatefulAgent when any of these are true:
- The conversation needs to span more than one HTTP request.
- The agent needs to run scheduled methods (
@Scheduledoragent.schedule(when, method, payload)). - The agent needs to spawn typed sub-agents and aggregate their results.
- The agent exposes an MCP server (long-lived transport).
- The agent uses HITL approval flows that outlive the request that initiated them.
- The agent accumulates long-term memory (knowledge tier, skill registry).
Sessions (tree-structured message store with compaction and FTS) comes with the
RemembersConversations mixin — opt in per agent class rather than paying the
storage cost on every DO.
Roost-Native Integration (Philosophy)
v0.3 integrates the semantics of every Cloudflare Agents SDK primitive — Sessions, Schedule, Workflows, sub-agents, MCP, HITL — through Roost-native implementations rather than by inheriting the SDK's base classes.
StatefulAgent implements DurableObject directly. Sub-agent RPC uses
@roostjs/cloudflare's DurableObjectClient. MCP uses
@modelcontextprotocol/sdk transports without the SDK's McpAgent base.
Workflows bind to @roostjs/workflow, not to the SDK's workflow primitive.
This matters for three reasons:
- Consistency with other Roost DO primitives.
ChannelDO,RateLimiterDO, andStatefulAgentall use the same DO convention — a thinfetchrouter exposing typed members — so operators have one mental model for "a Roost Durable Object." - No competing lifecycle assumptions. When two frameworks both want to own
alarm(), hibernation behaviour, orblockConcurrencyWhilesemantics, one has to yield. Owning the DO directly removes the question. - Portability across the monorepo. Events, queue, broadcast, and workflow are Roost-first. Agent primitives plug into them as peers, not through SDK glue.
Users coming from the Cloudflare Agents SDK gain the Laravel DX without losing CF-native power. Users coming from Laravel's AI SDK find the same contracts, decorators, and fakes they are used to — running on Workers.
Testing Philosophy
Provider mocking does not scale. Once an application has a dozen agents, five tools, three media primitives, and a few RAG pipelines, mocking the underlying providers requires understanding every request shape, response shape, and streaming protocol. It also couples the tests to the provider wire format, which changes more often than the agent's behaviour.
v0.3 replaces provider mocking with class-level fakes and feature-level fakes.
Support.fake(['canned reply']); // canned string or resolver fn
Image.fake(); // feature-level fake
Files.fake();
SupportAgent.fake().preventStrayPrompts(); // throw on unmatched .prompt()Assertions read like English:
Support.assertPrompted('password reset');
Image.assertNothingGenerated();
Audio.assertNothingGenerated();For HasStructuredOutput agents, fake() without explicit responses auto-generates
schema-valid data — no need to hand-craft fixtures that match the schema.
For DO-backed agents, TestStatefulAgentHarness wires a MockDurableObjectState,
a mock clock, and an env bag without spinning up a real DO. This keeps the test
loop fast (< 50 ms per test) and deterministic.
The pattern is the same across every primitive: Agent.fake(), Image.fake(),
Audio.fake(), Transcription.fake(), Embeddings.fake(), Reranking.fake(),
Files.fake(), Stores.fake(), each with assertX/restore() siblings. One
mental model covers the entire package.
Further Reading
- @roostjs/ai reference — every primitive with signatures and canonical examples.
- @roostjs/ai guides — task-oriented how-tos.
- Tutorial: build a stateful agent — from empty project to deployed agent with tools, sessions, streaming, and tests.
- @roostjs/cloudflare concepts — DO patterns —
the DO conventions
StatefulAgentfollows. - @roostjs/events concepts — how
@roostjs/aidispatches every primitive as an event class. - @roostjs/broadcast concepts — the WebSocket infrastructure behind streaming fan-out.