Agent frameworks
Run Mastra, the Vercel AI SDK, the OpenAI Agents SDK and LangGraph in your worker, with `env.ai` as the model through `@runlot/ai-sdk-provider`. Which compatibilityDate each needs, where each keeps its state, and how the provider sends tool calls.
An agent framework is a library that runs inside your worker. The framework runs the loop — call the model, run the tools it asks for, call the model again — and env.ai is the model. @runlot/ai-sdk-provider turns env.ai into an AI SDK model, which is the model type Mastra, the AI SDK and the OpenAI Agents SDK all accept.
@runlot/ai-sdk-provider implements the AI SDK's provider specification v4, which AI SDK 7 reads. AI SDK 5 and 6 refuse it with UnsupportedModelVersionError.npm install @runlot/ai-sdk-provider ai@7import { createRunlot } from "@runlot/ai-sdk-provider";
const runlot = createRunlot({ ai: env.ai });
const model = runlot("anthropic/claude-sonnet-5");The binding is the credential. There is no API key, and each model call is metered and billed like any other env.ai.run call. Model IDs are the ones runlot ai models lists.
| Framework | Model | compatibilityDate | State that has to outlive a request |
|---|---|---|---|
Vercel AI SDK (ai) | runlot(id) | default is enough | none of its own |
Mastra (@mastra/core) | runlot(id) | 2026-03-17 or later | memory and workflow snapshots: PostgresStore on env.db |
OpenAI Agents SDK (@openai/agents) | aisdk(runlot(id)) | 2026-03-17 or later | none of its own |
LangGraph (@langchain/langgraph) | call env.ai.run in a node | 2026-03-17 or later | checkpoints: PostgresSaver on env.db |
Cloudflare Agents SDK (agents) | runlot(id) inside the Agent | default is enough | its Durable Object: setState, this.sql, schedule() (runs; not supported yet, see below) |
Compatibility date
nodejs_compat does not provide every Node built-in at every date: node:http arrives on 2025-08-15, node:child_process and node:worker_threads on 2026-03-17. Mastra, the OpenAI Agents SDK and LangGraph import those at load time, so they need 2026-03-17 or later.
{ "compatibilityDate": "2026-03-17" }Without a date the default is 2024-09-23, for runlot deploy and for runlot dev alike. When the worker imports a built-in that its date does not provide, the build stops and names the module and the date to set:
The worker imports node:http (from 2025-08-15), node:worker_threads (from 2026-03-17), which nodejs_compat
provides only from those dates; runlot.json names no compatibilityDate, so the default 2024-09-23 applies.
The worker could not load at that date. Set "compatibilityDate": "2026-03-17" (or later) in runlot.json.
See https://docs.runlot.io/docs/deploy/bundle#compatibilitydateWhat the provider sends
The framework keeps its own tool loop. Each step is one env.ai.run call with toolMode: "return": the provider passes the framework's tools as definitions only, env.ai makes one model call and hands back every tool call the model asked for in that step, and the framework runs them. Your tools' execute functions run in your worker, inside the framework; env.ai never runs them.
On the next step the provider sends the model its own earlier turn and the results as native tool blocks, not as text:
[{"role":"user","content":"Weather in Seoul and Busan?"},
{"role":"assistant","content":[{"type":"tool_call","id":"c1","name":"weather","args":{"city":"Seoul"}},
{"type":"tool_call","id":"c2","name":"weather","args":{"city":"Busan"}}]},
{"role":"user","content":[{"type":"tool_result","callId":"c1","data":{"city":"Seoul","tempC":18}},
{"type":"tool_result","callId":"c2","data":{"city":"Busan","tempC":21}}]}]So the model sees its tool use the way it produced it, several calls in one step come back together, and a Claude turn's thinking goes back with its signature (providerMetadata.runlot.signature on the AI SDK reasoning part). Every result carries providerMetadata.runlot.transcript: "structured". The env.ai.run side of this is described under Running your own tool loop.
What it still cannot send: an image or a file in a user message (the provider refuses it rather than dropping it), reasoning produced by another provider (dropped with an unsupported warning), and provider-executed tools.
A project that lends its members' subscriptions. A lent Claude or Codex subscription cannot carry tool blocks or stop at a step's tool calls, so these calls are never offered to a lender. With Credit fallback on they run on credit; with it off they are refused with 503 byos_unavailable, and the message says why. A plain prompt without tools is lent as before.
runlot dev. A logged-in runlot dev serves these calls through the development credit lane, without streaming: generateText and agent.generate work, streamText and agent.stream are refused by name, as every streamed call on that lane is. In Studio, these calls take the credit relay rather than your subscription.
An older runtime. Where env.ai does not announce contentParts in env.ai.features, the provider falls back to writing earlier tool calls and results into the text ([tool call weather id=c1] {"city":"Seoul"}) and returns at most one tool call per step. Such a call carries a compatibility warning and transcript: "lowered-text".
The rest of the mapping:
| AI SDK | env.ai.run |
|---|---|
| system messages | system |
maxOutputTokens | maxTokens |
reasoning | reasoning.effort; provider-default sends nothing |
responseFormat: { type: "json", schema } | responseFormat JSON Schema. On a stream it runs without streaming and arrives as one chunk; with tools it is refused, as env.ai.run refuses it |
runlot(id, { cache, metadata }) | cache, metadata on every call through that model |
topP, topK, stopSequences, seed, penalties | not sent; an unsupported warning |
| tools | definitions with toolMode: "return"; every call of the step comes back as a tool-call part |
toolChoice required or a named tool | not sent; an unsupported warning |
toolChoice none | tools not sent — unless the transcript already has tool calls, which need their definitions; then they are sent with an unsupported warning |
| usage | inputTokens, cachedInputTokens → cacheRead, cacheWriteInputTokens → cacheWrite; a count env.ai did not report is undefined, not 0. providerMetadata.runlot.usageComplete says whether the numbers are the exact total |
| errors | APICallError with statusCode and data.code (no_credit, rate_limited, …); only rate limits and upstream failures are retryable |
Embeddings go through runlot.embeddingModel("google/gemini-embedding-001", { taskType }), which calls env.ai.embed.
Vercel AI SDK
import { generateText, stepCountIs, tool } from "ai";
import { z } from "zod";
import { createRunlot } from "@runlot/ai-sdk-provider";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const runlot = createRunlot({ ai: env.ai });
const r = await generateText({
model: runlot("anthropic/claude-sonnet-5"),
system: "Be brief.",
prompt: "Weather in Seoul?",
tools: {
weather: tool({
description: "Current weather for a city",
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => ({ city, tempC: 18 }),
}),
},
stopWhen: stepCountIs(4),
});
return Response.json({ text: r.text });
},
};streamText works the same way; its text arrives as env.ai streams it.
Mastra
{ "main": "src/index.ts", "compatibilityDate": "2026-03-17" }import { Agent } from "@mastra/core/agent";
import { createTool } from "@mastra/core/tools";
import { Memory } from "@mastra/memory";
import { PostgresStore } from "@mastra/pg";
import { Pool } from "pg";
import { z } from "zod";
import { createRunlot } from "@runlot/ai-sdk-provider";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const runlot = createRunlot({ ai: env.ai });
const agent = new Agent({
id: "marketer",
name: "marketer",
instructions: "Write short campaign copy.",
model: runlot("anthropic/claude-sonnet-5"),
tools: {
weather: createTool({
id: "weather",
description: "Current weather for a city",
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => ({ city, tempC: 18 }),
}),
},
memory: new Memory({ storage: new PostgresStore({ id: "main", pool: new Pool({ db: env.db }) }) }),
});
const r = await agent.generate("Weather in Seoul?", { maxSteps: 4 });
return Response.json({ text: r.text });
},
};import { Pool } from "pg" is @runlot/pg in a Runlot build, so new Pool({ db: env.db }) is your project's database and @mastra/pg keeps its tables there. Configure storage on every Mastra instance that keeps memory or runs workflows: the default store lives in the worker's memory, which a park or a release clears. A workflow that suspends with suspend() and resumes on a later request needs the same PostgresStore.
OpenAI Agents SDK
{ "main": "src/index.ts", "compatibilityDate": "2026-03-17" }import { Agent, run, setTracingDisabled, tool } from "@openai/agents";
import { aisdk } from "@openai/agents-extensions/ai-sdk";
import { z } from "zod";
import { createRunlot } from "@runlot/ai-sdk-provider";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
setTracingDisabled(true);
const runlot = createRunlot({ ai: env.ai });
const agent = new Agent({
name: "marketer",
instructions: "Be brief.",
model: aisdk(runlot("openai/gpt-6-luna")),
tools: [
tool({
name: "weather",
description: "Current weather for a city",
parameters: z.object({ city: z.string() }),
execute: async ({ city }) => JSON.stringify({ city, tempC: 18 }),
}),
],
});
const r = await run(agent, "Weather in Seoul?", { maxTurns: 4 });
return Response.json({ text: r.finalOutput });
},
};The SDK's own OpenAI provider needs an OpenAI API key, which Runlot does not hand out; use it through aisdk(). Turn tracing off, or point it at your own exporter: its default exporter sends traces to OpenAI.
LangGraph
{ "main": "src/index.ts", "compatibilityDate": "2026-03-17" }import { Annotation, Command, END, interrupt, START, StateGraph } from "@langchain/langgraph";
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";
import { Pool } from "pg";
const State = Annotation.Root({
topic: Annotation<string>(),
draft: Annotation<string>(),
approved: Annotation<boolean>(),
});
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const checkpointer = new PostgresSaver(new Pool({ db: env.db }));
await checkpointer.setup();
const graph = new StateGraph(State)
.addNode("write", async (s) => {
const r = await env.ai.run("anthropic/claude-sonnet-5", { prompt: `Draft a post about ${s.topic}` });
return { draft: r.text };
})
.addNode("review", async (s) => ({ approved: interrupt({ draft: s.draft }) }))
.addEdge(START, "write")
.addEdge("write", "review")
.addEdge("review", END)
.compile({ checkpointer });
const config = { configurable: { thread_id: new URL(request.url).searchParams.get("thread") ?? "t1" } };
if (request.method === "POST") {
return Response.json(await graph.invoke(new Command({ resume: true }), config));
}
return Response.json(await graph.invoke({ topic: "spring sale" }, config));
},
};A LangGraph node can call env.ai.run directly, so the graph needs no model adapter. LangChain chat models (BaseChatModel) have no Runlot adapter.
Cloudflare Agents SDK
An Agent is a Durable Object. Declare it in runlot.json like any other class, and route to it with routeAgentRequest or getAgentByName:
{
"durable_objects": { "bindings": [{ "name": "Researcher", "class_name": "Researcher" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Researcher"] }]
}import { Agent, getAgentByName, routeAgentRequest } from "agents";
import { createRunlot } from "@runlot/ai-sdk-provider";
import { generateText } from "ai";
export class Researcher extends Agent<Env, { runs: number }> {
initialState = { runs: 0 };
// Required on Runlot today; see below. An accessor where Agent has a method, hence:
// @ts-expect-error TS2423
get onStateUpdate() { return Agent.prototype.onStateUpdate; }
async ask(prompt: string) {
const runlot = createRunlot({ ai: this.env.ai });
const { text } = await generateText({ model: runlot("anthropic/claude-haiku-5-5"), prompt });
this.setState({ runs: this.state.runs + 1 });
return text;
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const routed = await routeAgentRequest(request, env); // WebSockets at /agents/researcher/<name>
if (routed) return routed;
const agent = await getAgentByName(env.Researcher, "default");
return Response.json({ text: await agent.ask("Summarise RFC 6455 in one line.") });
},
};What works on Runlot, measured with agents 0.33.0 on 2026-11-09: setState and this.state, this.sql, this.schedule() (a 15-second task ran 15 seconds later), onConnect and onMessage over a WebSocket, env.ai through createRunlot({ ai: this.env.ai }), and state across the project stopping while idle: state and rows were there after the next request, and a task scheduled for after the stop started the object again and ran on time.
It is not a supported target yet. These are the gaps:
- Without the
onStateUpdategetter above, every Agent fails on first use with[Agent] Cannot override both onStateChanged and onStateUpdate. Runlot wraps the methods of your Durable Object class, and the SDK reads the wrappers as overrides. If you overrideonStateUpdateyourself, rename it toonStateChangedinstead. @callable()methods cannot be called from a client. A call over the WebSocket answers{"error":"Method add is not callable","success":false,"type":"rpc"}, because the wrapper is not the decorated method. Call the method through the stub from your worker instead ((await getAgentByName(env.Researcher, name)).add(2, 3)).- A message on a WebSocket that stayed open while the project was stopped breaks that Agent. The message starts the object again, but without its name: the SDK throws
Probe could not determine its Durable Object name, the message is lost, and every later call to that Agent fails the same way until the project is deployed again. Until this is fixed, have a client open a new socket instead of sending on one that has been idle for 5 minutes or more. - A WebSocket the client closes never finishes closing. The object's close handler runs, but the client receives no close frame and waits until its own timeout. A close the Agent starts (
connection.close(code, reason)) arrives normally. This holds for every Durable Object socket accepted withctx.acceptWebSocket, which is what the SDK uses. - An Agent that has stopped while idle cannot start while Runlot's control plane is recovering from a database restart (lease quarantine), until that recovery ends. A project without Durable Objects starts normally in the same window; a running Agent keeps running.
- The SDK's examples call
env.AI, Cloudflare's Workers AI binding. Runlot's binding isenv.aiand has a different shape; use the provider as above. cloudflare:email, which the SDK imports, resolves to a stub; send mail withenv.email.
Long runs
A request is cut when a release replaces the worker, and an idle project parks, which clears everything held in memory. Do not run a long agent inside one HTTP request. Enqueue the work and run it in a queue consumer, or step it from a Durable Object alarm, and keep the framework's state in env.db so the next step can continue from it.