Skip to main content
Cloudflare Agents is the TypeScript framework for building stateful AI agents on Durable Objects — one isolated instance per user or conversation, running globally on Cloudflare’s network.
  • What it ships — durable per-instance state (SQLite), the AI SDK tool-calling loop, AIChatAgent + the useAgentChat React hook, hibernation/wake, scheduling, WebSockets, and a built-in MCP client (this.addMcpServer, this.mcp.getAITools()).
  • What it doesn’t ship — a way for that edge agent to act as a real account: sign up for a SaaS tool, hold a funded card, or spend within a budget you control, per end-user.
That’s the half Naive adds. You keep the Agents SDK’s orchestration; Naive gives each agent instance a tenant identity, a virtual card, 1,000+ third-party connections, and an Account Kit that bounds exactly what the agent can do — enforced server-side, with human approval on the sensitive actions.

How the pairing works

  • Cloudflare Agents ships a first-class MCP client: call this.addMcpServer(name, url, …) and every tool from that server becomes available to the agent; this.mcp.getAITools() hands them straight to streamText / generateText.
  • Naive mints per-user MCP sessions — short-lived, revocable SSE endpoints whose tool list is the fused native + third-party toolset, already filtered by that user’s Account Kit.
  • So the integration is: per agent instance, mint a Naive session → addMcpServer with its SSE URL + scoped bearer → every tool call runs as that user, gated server-side.
  Cloudflare Agent (Durable Object)      Naive
  ─────────────────────────────────      ─────
  onChatMessage ── streamText ── loop
        │  model picks an MCP tool

  this.mcp.getAITools()  ──────────▶  /mcp/sse/sess_…   (per-user session)
        ▲  addMcpServer(sse + bearer)     │  AccountKit-gated, scoped to one user
        │                                 ▼
  one DO instance = one end-user    connect GitHub · issue a $50 card · run a capability

                                    sensitive? → 202 pending_approval (human-in-the-loop)
Tested against: Naive API v2 (hosted MCP server over SSE, per-user sessions) and @usenaive-sdk/server 0.x for the one-time control-plane script; Cloudflare agents 0.12.x, @cloudflare/ai-chat 0.7.x, workers-ai-provider (current), and the AI SDK (ai v5+: streamText, convertToModelMessages, stepCountIs, toUIMessageStreamResponse). Wrangler with nodejs_compat.Version assumptions: the Agents AIChatAgent requires the new_sqlite_classes migration in wrangler.jsonc for message persistence. Naive’s hosted MCP server speaks SSE, so this guide pins transport: { type: "sse" } (Cloudflare’s default "auto" also negotiates it). Pin your versions and set the model to one your account can access.

Prerequisites

  • A Naive API key (nv_sk_...) — get one from the dashboard.
  • A Cloudflare account with Workers + the wrangler CLI (npm create cloudflare@latest).
  • A model — this guide uses the Workers AI binding (no extra key); swap any AI SDK provider (@ai-sdk/openai, etc.) if you prefer.
  • Node ≥ 20 for the control-plane script.
# In your Worker project
npm install agents @cloudflare/ai-chat ai workers-ai-provider

# For the one-time provisioning script (run locally / in CI)
npm install @usenaive-sdk/server
wrangler secret put NAIVE_API_KEY   # nv_sk_live_...

Minimal viable integration

The shortest path to a Cloudflare Agent that can actually transact: define a policy and provision a user (control plane, once), wire the Worker, then connect the per-user Naive session inside the agent.
1

Define the policy, then provision a user

An Account Kit is the spend/capability policy. Here a tenant user gets a card (capped at $500, approval required), the vault, and an allowlist of apps. Everything the agent does is bounded by this kit — server-side. These are one-time control-plane calls (run anywhere with Node — they don’t belong in the edge runtime):
// provision.ts — run once.
import { Naive } from "@usenaive-sdk/server";

const naive = new Naive({ apiKey: process.env.NAIVE_API_KEY! });

// Control plane: a reusable policy template.
const pro = await naive.accountKits.create({
  name: "Pro",
  primitives_config: {
    cards: { enabled: true, requiresApproval: true, defaults: { spending_limit_cents: 50000 } },
    vault: { enabled: true },
  },
  connections_config: { mode: "allowlist", toolkits: ["github", "gmail", "stripe"] },
});

// Provision one of your end-users and assign the kit.
const alice = await naive.users.create({
  external_id: "user_123",
  email: "alice@acme.com",
  account_kit_id: pro.id,
});

console.log(alice.id); // → set as the NAIVE_USER_ID var for the Worker
2

Configure the Worker

Register the agent as a Durable Object with the SQLite migration AIChatAgent needs, bind Workers AI, and pass the user id through vars (the API key is a secret):
// wrangler.jsonc
{
  "name": "naive-agent",
  "main": "src/index.ts",
  "compatibility_date": "2026-06-30",
  "compatibility_flags": ["nodejs_compat"],
  "ai": { "binding": "AI" },
  "durable_objects": {
    "bindings": [{ "name": "NaiveAgent", "class_name": "NaiveAgent" }]
  },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["NaiveAgent"] }],
  "vars": { "NAIVE_USER_ID": "usr_..." }
}
3

Connect the session — and let the agent transact

In the agent, mint a per-user session and connect it as an MCP server. The bearer lives only in the request headers (never the URL) and expires. AIChatAgent waits for MCP connections before each turn, so this.mcp.getAITools() returns the full toolset:
// src/index.ts
import { AIChatAgent } from "@cloudflare/ai-chat";
import { routeAgentRequest } from "agents";
import { createWorkersAI } from "workers-ai-provider";
import { streamText, convertToModelMessages, stepCountIs } from "ai";

interface Env {
  AI: Ai;
  NaiveAgent: DurableObjectNamespace;
  NAIVE_API_KEY: string;
  NAIVE_USER_ID: string;
}

export class NaiveAgent extends AIChatAgent<Env> {
  async onStart() {
    // Connection is restored from storage on wake — only connect once.
    if (this.getMcpServers().servers["naive"]) return;

    // Mint a per-user Naive MCP session (SSE), scoped by the user's Account Kit.
    const res = await fetch(
      `https://api.usenaive.ai/v1/users/${this.env.NAIVE_USER_ID}/sessions`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${this.env.NAIVE_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ ttl_ms: 24 * 60 * 60 * 1000 }), // max 24h
      },
    );
    const session = await res.json();

    // Naive's tools become this agent's tools — gated server-side for this one user.
    await this.addMcpServer("naive", session.mcp.url, {
      id: "naive", // stable id → readable tool keys, single connection per instance
      transport: { type: "sse", headers: session.mcp.headers },
    });
  }

  async onChatMessage(onFinish: Parameters<typeof streamText>[0]["onFinish"]) {
    const workersai = createWorkersAI({ binding: this.env.AI });

    const result = streamText({
      model: workersai("@cf/zai-org/glm-4.7-flash"), // adjust to a model you can access
      system:
        "You act on this user's real account through Naive tools. " +
        "Connect apps and spend only within their Account Kit.",
      messages: await convertToModelMessages(this.messages),
      tools: this.mcp.getAITools(), // Naive's tools, discovered over MCP
      stopWhen: stepCountIs(10), // cap tool-call rounds for the agentic loop
      onFinish,
    });

    return result.toUIMessageStreamResponse();
  }
}

export default {
  fetch: async (req: Request, env: Env) =>
    (await routeAgentRequest(req, env)) ?? new Response("Not found", { status: 404 }),
};
Ask the agent to “Connect my GitHub, then issue a $50 virtual card called ‘Ads budget’.” It discovers GitHub (naive_connections_connect), returns a connect link for Alice to authorize, and attempts the card (naive_cards_create) — a real card on Alice’s account, capped by her kit. The loop is Cloudflare’s; the real-world actions are Naive’s.
That’s the moat: the same edge agent that would otherwise just describe spending money now issues a policy-bounded card on a specific user’s account.

Extension: human-in-the-loop spend

Because the kit set cards.requiresApproval: true, the agent cannot silently spend. The enforcement is server-side, so it holds no matter what the edge runtime does — the tool result comes back as a frozen approval (HTTP 202) instead of a live card:
{
  "status": "pending_approval",
  "approval_id": "65589c8b-e033-4a65-b16c-379211c94429",
  "action": "cards.create",
  "primitive": "cards",
  "title": "Issue virtual card \"Ads budget\"",
  "message": "This action requires human approval before it executes."
}
The immediate 202 response uses action; approval records from approvals.list() or approvals.get() use action_type. Resolve it out of band — and on approval, Naive replays the frozen action server-side. A small control-plane handler (or the dashboard / CLI) does it:
import { Naive } from "@usenaive-sdk/server";

const client = new Naive({ apiKey: process.env.NAIVE_API_KEY! }).forUser(process.env.NAIVE_USER_ID!);

const { approvals } = await client.approvals.list({ status: "pending" });
for (const a of approvals) {
  // ...show a.title / a.action_type to a human in your UI...
  await client.approvals.approve(a.id); // API replays cards.create → real card
  // or: await client.approvals.deny(a.id, { reason: "over budget" });
}
See Approvals for the full lifecycle (pending → executed / failed / denied).
Approvals are only enforced for agent (API-key / MCP) calls on real tenant users. A human acting in your dashboard, and agent calls on the operator’s own default user, bypass the gate — so end-user agents stay governed while your own automation isn’t slowed down.

Going multi-tenant: one agent per end-user

The realistic shape: each Durable Object instance is one of your end-users. Route by your user id, store the matching Naive user id in agent state, and mint the session for that user:
import { getAgentByName } from "agents";

// In your app's request handler — instance name = your end-user's id.
const agent = await getAgentByName(env.NaiveAgent, currentUser.id);
await agent.setUser(naiveUserId); // a callable method that stores it + connects the session
Inside the agent, mint the session against the stored user id instead of the env var, and manage the lifecycle:
  • Refresh on expiry — when a session lapses, removeMcpServer("naive") then addMcpServer with a freshly minted session.
  • Revoke on logout — call DELETE /v1/users/:id/sessions/:id (or client.sessions.revoke(id)) to kill the endpoint immediately, independent of the DO.
Same Account Kit, same approval gates — now isolated per end-user, each in its own globally distributed instance.

What stays enforced

No matter where the agent runs, the policy is enforced where it matters — on Naive’s servers, not in your prompt or your Worker config:
  • Identity — every action runs as a specific tenant user, fully isolated from your other users.
  • Capability bounds — the Account Kit decides which primitives and which apps the agent can touch (allowlist / blocklist / per-tool).
  • Scoped spend — virtual cards are capped per card and per user; the model can’t raise its own limit.
  • Human-in-the-loop — sensitive actions (cards, domains, KYC, formation, connecting an app) freeze as approvals until a human says yes.
  • Revocable — the session is short-lived and killable on demand; revoke and the agent loses real-world reach instantly, mid-flight.

Next steps

  • Sessions — per-user MCP sessions for edge / untrusted runtimes
  • MCP server — the hosted Naive MCP endpoint and its tools
  • Account Kits — author spend/capability policy
  • Approvals — the human-in-the-loop lifecycle
  • SDK overview — the full Naive client surface for the control plane