Skip to content
runlot

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

FrameworkModelcompatibilityDateState that has to outlive a request
Vercel AI SDK (ai)runlot(id)default is enoughnone of its own
Mastra (@mastra/core)runlot(id)2026-03-17 or latermemory and workflow snapshots: PostgresStore on env.db
OpenAI Agents SDK (@openai/agents)aisdk(runlot(id))2026-03-17 or laternone of its own
LangGraph (@langchain/langgraph)call env.ai.run in a node2026-03-17 or latercheckpoints: PostgresSaver on env.db
Cloudflare Agents SDK (agents)runlot(id) inside the Agentdefault is enoughits 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.

runlot.json
{ "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#compatibilitydate

What 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 SDKenv.ai.run
system messagessystem
maxOutputTokensmaxTokens
reasoningreasoning.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, penaltiesnot sent; an unsupported warning
toolsdefinitions with toolMode: "return"; every call of the step comes back as a tool-call part
toolChoice required or a named toolnot sent; an unsupported warning
toolChoice nonetools not sent — unless the transcript already has tool calls, which need their definitions; then they are sent with an unsupported warning
usageinputTokens, 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
errorsAPICallError 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

src/index.ts
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

runlot.json
{ "main": "src/index.ts", "compatibilityDate": "2026-03-17" }
src/index.ts
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

runlot.json
{ "main": "src/index.ts", "compatibilityDate": "2026-03-17" }
src/index.ts
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

runlot.json
{ "main": "src/index.ts", "compatibilityDate": "2026-03-17" }
src/index.ts
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:

runlot.json
{
  "durable_objects": { "bindings": [{ "name": "Researcher", "class_name": "Researcher" }] },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Researcher"] }]
}
src/index.ts
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 onStateUpdate getter 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 override onStateUpdate yourself, rename it to onStateChanged instead.
  • @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 with ctx.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 is env.ai and has a different shape; use the provider as above.
  • cloudflare:email, which the SDK imports, resolves to a stub; send mail with env.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.

On this page