Skip to content
renkudocs
GuidesDurable agents

Durable agents

renku/agents exposes the Cloudflare Think harness, tool helpers, and SDK types. renku/ai supplies the model provider. Application code owns authorization, agent definitions, participation, tool grants, budgets, and public message delivery.

On this page

Export a Think subclass with workerExports and declare its SQLite Durable Object in worker.exports, just like other renku authorities. Override getModel using createAI({ binding: this.env.AI }). Think 0.20.2 and Agents 0.29.0 are pinned with AI SDK 7.

The upstream Think API is experimental: https://developers.cloudflare.com/agents/harnesses/think/. Worker bundles normalize bare Node builtin imports to node: specifiers and bridge CommonJS builtin requires through static imports, preserving callable default exports; configure nodejs_compat.

Browser imports of server capabilities remain prohibited.

Starting from the getting-started app, save this class as src/agent.ts. It uses the AI binding, disables MCP, network and shell helpers, and restricts the turn to no active tools and one step.

The HTTP handler returns 404. Invoke accept only through trusted internal Worker RPC after checking your application principal and inference budget. Reuse the same id and prompt when recovering an uncertain admission; inspectSubmission and cancelSubmission support application recovery. This example does not publish an application message.

src/agent.ts
import { Think, type TurnContext } from "renku/agents";
import { createAI } from "renku/ai";

type Env = { AI: Ai };

export class ExampleAgent extends Think<Env> {
  override includeMcpTools = false;
  override sendReasoning = false;
  override workspaceBash = false as const;
  override fetchTools = false as const;
  override maxSteps = 1;

  // Admission is internal Worker RPC, never a public HTTP endpoint.
  override fetch() {
    return Promise.resolve(new Response(null, { status: 404 }));
  }

  override getModel() {
    return createAI({ binding: this.env.AI })("@cf/zai-org/glm-4.7-flash");
  }

  override async beforeTurn(_context: TurnContext) {
    return {
      activeTools: [],
      maxSteps: 1,
      maxOutputTokens: 256,
      instructions: "Answer briefly. Do not call tools.",
    };
  }

  // Call only after your application has authorized the principal and budget.
  async accept(id: string, prompt: string) {
    return this.submitMessages([
      { id, role: "user", parts: [{ type: "text", text: prompt }] },
    ], { submissionId: id, idempotencyKey: id });
  }
}

Replace the getting-started config with this file and keep its existing document, home, counter and streaming modules. Build with bun run --cwd apps/my-app build. Real inference requires an AI-enabled Cloudflare deployment and consumes its model budget.

renku.config.ts
import { bindings, defineConfig } from "renku";

export default defineConfig({
  worker: {
    name: "my-app",
    compatibilityDate: "2026-10-06",
    compatibilityFlags: ["nodejs_compat"],
    env: { ASSETS: bindings.assets(), AI: bindings.ai() },
    exports: { ExampleAgent: { type: "durable-object", storage: "sqlite" } },
  },
  workerExports: { ExampleAgent: "./src/agent.ts" },
  routes: [{
    layout: "./src/document.tsrx",
    children: [{ index: true, page: "./src/home.tsrx" }],
  }],
});

Use submitMessages with a stable submissionId and idempotencyKey. Persist application intent before external calls, inspectSubmission to recover, and cancelSubmission to stop a turn.

Keep application effects idempotent separately: a recovered inference must not duplicate a posted message. antgrade coordinates six-turn discussion budgets in Conversation objects and runs Think through internal RPC; browser clients continue using renku local-first sync.

openSyncSocket accepts beforeWrite in its resolved SyncOptions. It awaits the hook after authorization and before admitting a nonempty command batch, for both WebSocket and RPC exchanges.

Persist a Durable Object alarm there, then write work intents synchronously from finalize in the same message transaction. A failed hook admits no writes.

The alarm can safely find no work when a command is rejected. beforeWrite is a socket-adapter hook, not an openSync storage hook, and must not start inference or rely on waitUntil for durability.

Supply an optional transient callback in resolved SyncOptions and call openSyncSocket.publishTransient() when live state changes. The adapter reauthorizes every recipient, sends at most 64 KiB of JSON, and includes the current snapshot after socket exchanges so reconnecting clients recover.

ReplicaSnapshot.transient is shared across tabs, cleared on disconnect, and never persisted in local SQLite or mutation receipts. Applications own payload validation, expiry, source permissions, and any server-side recovery record. Keep final messages in the normal idempotent sync protocol.

Think onChunk and parsePartialJson from renku/agents can extract a provisional reply body from finish_reply tool input; never expose raw tool JSON, reasoning, or unrelated tool results. Providers that buffer tool arguments will show Thinking until the body becomes available.

Do not expose agent RPC or model configuration directly to untrusted clients. Resolve a distinct application agent principal, recheck permissions before tools and publication, and keep instructions and tool results out of public replica projections.

Restrict activeTools explicitly when using Think: its built-in workspace tools are otherwise available. Configure a bounded tool loop, disable unused MCP and network tools, and keep inference traces free of private payloads.

You can register a bounded Think agent and separate durable inference from application admission and publication.

Continue to HTTP endpoints and MCP

← All guides · Explore the docs source (access required) →