@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.

ContractMethodWhat it unlocks
Conversationalmessages()Seed or override the message history per call.
HasToolstools()Return Tool[] made available to the model each step.
HasStructuredOutputschema(s)Constrain output to a validated schema.
HasMiddlewaremiddleware()Wrap every prompt/response in an async chain.
HasProviderOptionsproviderOptions(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:

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:

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:

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:

  1. Consistency with other Roost DO primitives. ChannelDO, RateLimiterDO, and StatefulAgent all use the same DO convention — a thin fetch router exposing typed members — so operators have one mental model for "a Roost Durable Object."
  2. No competing lifecycle assumptions. When two frameworks both want to own alarm(), hibernation behaviour, or blockConcurrencyWhile semantics, one has to yield. Owning the DO directly removes the question.
  3. 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