TechFabricTechFabricHarness
Deployment

Cloudflare Workers + Sandbox

Deploy to Cloudflare Workers with Durable Object sessions and Cloudflare Sandbox.

The Cloudflare target emits an unbundled Worker entrypoint, a Durable Object session store, and a Cloudflare Sandbox container binding for finite jobs and persistent agents. Wrangler owns the Cloudflare bundle; TechFabric Harness emits the entry, bindings, and config. Each persistent agent/instance pair is routed to one Durable Object, which serializes turns and persists named sessions in Durable Object SQLite.

Finite runs remain authoritative in one Durable Object per run. A separate, singleton FabricHarnessRunRegistryObject stores only tenant-scoped run pointers so client.runs.list() can discover runs across those objects. Registry mirror writes are best-effort and never turn a successful run admission or settlement into a failure. A terminal pointer is an upsert, so it heals a start pointer lost during a transient registry fault.

Before production, run the live smoke workflow in your Cloudflare account to validate bindings, Durable Object persistence, R2 access, and Sandbox container startup with your account limits.

Quickstart

npx @fabric-harness/cli init --template cloudflare --dir my-edge-agent
cd my-edge-agent
npm install
npx fabric-harness dev --target cloudflare --port 8787

The template uses the Cloudflare Workers AI binding (env.AI) so the Worker can run inference without external model API keys.

Build

fh build --target cloudflare
cd .fabricharness/build/cloudflare
npm install

Output:

.fabricharness/build/cloudflare/
  dist/worker.ts
  wrangler.jsonc
  Dockerfile         # default Cloudflare Sandbox image
  manifest.json
  README.cloudflare.md

Develop locally

fh dev --target cloudflare

This wraps wrangler dev so the same bundler runs in dev and deploy. Two reload paths run simultaneously:

  • Wrangler watches the bundle's transitive import graph and reloads workerd on body edits to your agent files.
  • TechFabric Harness's structural watcher watches .fabricharness/jobs/ and .fabricharness/agents/, then regenerates the entry whenever the definition set changes.

Net result: you can edit job bodies, add jobs, or change triggers: { webhook: true } without restarting the dev server.

Or if you've already built once and just want raw wrangler:

npx wrangler dev

Deploy

fh build --target cloudflare
cd .fabricharness/build/cloudflare
npx wrangler deploy

Routes

The Cloudflare Worker exposes both execution models:

  • GET /health · GET /ready · GET /manifest
  • POST /jobs/:name — invoke a finite job and receive { result, runId }
  • POST /jobs/:name?wait=false — admit an asynchronous run; an Idempotency-Key reuses its run
  • GET /runs?status=&job=&limit=&cursor= — list tenant-visible finite runs in reverse chronological order
  • GET /runs/:runId · GET /runs/:runId/events?offset=0 — inspect finite runs and events
  • POST /runs/:runId/abort — abort an active finite run
  • POST /agents/:name/:instanceId?wait=false with { message, session? } — durably admit a persistent submission
  • GET /agents/:name/:instanceId/submissions/:submissionId?session=default — inspect settlement
  • GET /agents/:name/:instanceId/conversation?session=default&offset=0 — read the offset stream
  • POST /agents/:name/:instanceId/abort?session=default — abort queued or active submissions
  • GET /agents/:name/:instanceId/schedules · POST /agents/:name/:instanceId/schedules — list or create per-instance wake-up schedules
  • DELETE /agents/:name/:instanceId/schedules/:scheduleId — cancel a schedule
  • GET /agents/:name/:instanceId?session=default — inspect a named persistent session
  • DELETE /agents/:name/:instanceId?session=default — delete that named session
  • GET /sessions/:runId — inspect the stored run
  • GET /sessions/:runId/timeline · /metrics · /tasks · /approvals · /artifacts

Webhook trigger gating applies on the Worker too: in production mode (FABRIC_ENV=production), only definitions with triggers.webhook === true are exposed publicly.

Cron Triggers

Declare schedules on finite jobs:

export default defineAgent({
  name: 'daily-report',
  triggers: { schedule: '0 16 * * 1-5' }, // Cloudflare cron is UTC
  async run({ input }) {
    return buildReport(input);
  },
});

The Cloudflare build adds each unique expression to wrangler.jsonc and emits scheduled(). Scheduled runs use deterministic IDs, persist run state and offset events in a per-run Durable Object, and deduplicate repeated delivery of the same occurrence. HTTP and cron execution share the same job function and { runId, acceptedAt, statusUrl, eventsUrl } receipt contract. Their pointers are indexed in FABRIC_HARNESS_RUN_REGISTRY, the same registry used by HTTP runs.

Per-instance schedules (Durable Object alarms)

Cron Triggers address finite jobs. Persistent agents get the complementary primitive: a per-instance wake-up backed by the instance Durable Object's alarm. Schedules are stored as rows in the same Durable Object SQLite database as the instance's sessions and submissions, and the single platform alarm is multiplexed across them — it is always armed for the earliest pending wake-up.

# Wake instance "user-42" of agent "support" every hour with an instruction.
curl --fail --request POST http://localhost:8787/agents/support/user-42/schedules \
  --header 'content-type: application/json' \
  --data '{"everySeconds": 3600, "task": "Check for unresolved threads and follow up."}'

# One-shot, with a small structured payload.
curl --fail --request POST http://localhost:8787/agents/support/user-42/schedules \
  --header 'content-type: application/json' \
  --data '{"delaySeconds": 900, "task": "Send the promised recap.", "payload": {"thread": "t-123"}}'

at (epoch ms, ISO string, or Date), delaySeconds, and everySeconds control timing; everySeconds makes the schedule recurring (first fire uses at/delaySeconds when given, otherwise one interval from now). GET lists pending schedules and DELETE .../schedules/:scheduleId cancels one.

When the alarm fires, the Durable Object drains due schedules and admits each as a normal durable submission whose delivered message is a signal with type: "schedule":

{
  kind: 'signal',
  type: 'schedule',
  body: 'Check for unresolved threads and follow up.',   // the task
  attributes: { scheduleId, scheduledAt, intervalSeconds, payload },
  tagName: 'schedule',
}

The wake-up therefore flows through the same admission, FIFO, lease, cancellation, and settlement path as any HTTP or dispatch interaction: POST .../abort cancels an in-flight scheduled run, the durability policy of the agent applies, and the signal appears in the instance's conversation stream where useDelivery() can branch on type === 'schedule'.

Semantics and caveats:

  • Cloudflare alarms are at-least-once and retried on handler failure; a wake-up can be delivered more than once after a crash. Recurring schedules advance to their next occurrence when drained — before the run starts — so a crashed wake-up never blocks future occurrences, and occurrences missed while the object was down collapse into a single catch-up wake-up instead of rapid-fire replays.
  • Durable Object eviction is not a failure mode: the platform re-instantiates the object when its alarm fires. Startup also reconciles the alarm against stored schedules, repairing any drift left by a crash between the storage write and setAlarm. There is deliberately no watchdog.
  • Bounds (defaults, configurable on the library coordinator): minimum interval 1 second, payload ≤ 4 KiB, at most 128 schedules per instance. Sub-minute intervals burn Durable Object invocations; prefer Cron Triggers plus a dispatcher job for coarse fleet-wide work.
  • Deleting the instance (DELETE /agents/:name/:instanceId) clears its schedules and unsets the alarm. Schedules live in the instance's Durable Object storage: wiping that storage loses them, and they do not follow the instance if its Durable Object name changes.
  • This API is Cloudflare-specific by design. It is exposed by @fabric-harness/cloudflare (createCloudflareScheduleCoordinator, cloudflareScheduleMessage) and the generated Worker routes; the portable SDK has no Cloudflare dependency, and triggers.schedule on createAgent() remains unsupported — declare cron on a finite dispatcher job instead.

Hand-written Durable Object hosts

fh build --target cloudflare wires each persistent-agent instance Durable Object out of the same machinery: Durable Object SQLite persistence stores, a session store with the conversation-stream projection, a durable submission runner (per-session FIFO, attempt leases, crash reconciliation, canonical settlement), and the per-instance alarm schedule coordinator. A hand-written Durable Object — one not produced by fh build — gets the same behavior by composing createCloudflareDurableObjectHost from @fabric-harness/cloudflare instead of re-implementing the wiring.

Before (hand-rolled; the drain, admission, and cleanup wiring is yours to get right, and declared durability bounds are not enforced):

const store = createCloudflareDurableObjectSessionStore(ctx.storage.sql);
const schedules = createCloudflareScheduleCoordinator(ctx.storage.sql, ctx.storage);

async alarm() {
  await schedules.reconcile();
  const fired = await schedules.drain();
  for (const schedule of fired) {
    // Render the agent, init it over the store, prompt the session… by hand,
    // with no FIFO, lease, settlement, or maxAttempts/timeoutMs enforcement.
    await runInteraction(env, store, tenantId(), cloudflareScheduleMessage(schedule));
  }
}

After (the host packages drain → durable submission, the schedule routes, startup reconciliation, admission with enforcement, and instance-delete cleanup):

import {
  createCloudflareDurableObjectHost,
  type CloudflareDurableObjectHost,
} from '@fabric-harness/cloudflare';

export class TenantOperatorObject {
  private readonly host: CloudflareDurableObjectHost;

  constructor(ctx: DurableObjectState, env: Env) {
    this.host = createCloudflareDurableObjectHost({
      sql: ctx.storage.sql,
      alarm: ctx.storage,
      objectName: ctx.id.name, // '<agent>:<instanceId>'
      agents: { 'tenant-operator': tenantOperator }, // createAgent() results, by name
      waitUntil: (promise) => ctx.waitUntil(promise),
      execute: async (submission, options) => {
        // Your interaction body: render the persistent agent, init it over
        // this.host.store, and prompt its session, honoring options.signal,
        // options.onInputApplied, and options.takeJoinedInputs.
        return runInteraction(env, this.host.store, submission, options);
      },
    });
    ctx.blockConcurrencyWhile(() => this.host.reconcile());
  }

  async fetch(request: Request): Promise<Response> {
    // GET/POST/DELETE /agents/:name/:instanceId/schedules[/:scheduleId].
    // Returns undefined for other paths, so custom routes compose freely.
    return (
      (await this.host.handleSchedulesRoute(request)) ??
      new Response('not found', { status: 404 })
    );
  }

  async alarm(): Promise<void> {
    // Drains due schedules and admits each as a durable 'schedule'-signal submission.
    await this.host.alarm();
  }

  // Custom RPC methods are unaffected — the host never intercepts them.
  async nudge(nudge: TenantOperatorNudge): Promise<unknown> {
    const receipt = await this.host.admit({
      agent: 'tenant-operator',
      id: tenantIdFrom(this.host),
      message: nudgeSignal(nudge),
      wait: true,
    });
    return receipt.settlement?.result;
  }
}

What the host enforces, for alarm-triggered and RPC/HTTP-triggered interactions alike:

  • Every admission flows through the durable submission runner: per-session FIFO ordering, attempt leases with startup and per-request crash reconciliation, idempotent input application, and canonical settlement records readable through host.runner / host.executor.
  • When agents is provided, the default admission validator rejects unknown agents, validates initial data and instance-contact preconditions, ensures the instance identity, and stamps the agent's declared durability: { maxAttempts, timeoutMs } bounds onto the admission — the same policy the generated Worker applies. Pass validateInput to override; the override then owns durability stamping (see persistentAgentSubmissionDurability in the SDK).
  • host.deleteInstance({ agent, id, session? }) refuses while a submission is unsettled (returning the aborted ids), then clears all schedules and the alarm and deletes the instance's submissions, attachments, conversation stream, and instance identity.

What remains the host's composition, exactly as in the generated Worker: the execute body (model, sandbox, and tool wiring), every non-schedule HTTP route (submissions, conversation, abort, auth, rate limiting), and any custom RPC surface.

Choosing a Cloudflare sandbox mode

TechFabric Harness supports two Cloudflare sandbox modes.

Containers / Cloudflare Sandbox

Use this mode when your agent needs Linux shell commands, package managers, language toolchains, bash, grep, read, write, and edit tools.

export default {
  sandbox: {
    backend: 'cloudflare',
    mode: 'sandbox',
    binding: 'Sandbox',
    cwd: '/workspace',
  },
};

Computer / @cloudflare/computer

Use this early-preview mode when you want a lightweight, SQLite-backed durable Workspace and a Worker Loader-backed just-bash runtime. It provides the standard bash, grep, glob, read, write, and edit tools, but it is not a Linux container: native binaries, package managers, and language toolchains still require Cloudflare Sandbox.

export default {
  sandbox: {
    backend: 'cloudflare',
    mode: 'computer',
    loaderBinding: 'LOADER',
    cwd: '/workspace',
  },
};

The generated wrangler.jsonc adds:

{
  "compatibility_flags": ["nodejs_compat", "experimental"],
  "worker_loaders": [{ "binding": "LOADER" }]
}

Typed Workspace access is exported from @fabric-harness/cloudflare/computer. The generated Worker also exports WorkspaceServiceProxy and hosts one workspace in each Fabric session Durable Object. Existing @cloudflare/shell data is not migrated. The old shell-workspace configuration value is a deprecated alias for one release window.

If Cloudflare sandboxing isn't configured, the Worker falls back to Fabric's empty sandbox.

The build always gives an explicitly configured Cloudflare sandbox precedence over the lightweight virtual default added by the default-import defineAgent(). This means the same agent source can stay infrastructure-free locally and use the container or Computer workspace selected by the deployment target without changing its prompt and session code.

Verify the Sandbox contract

The runnable examples/with-cloudflare-sandbox project includes a sandbox-certification job. It exercises the same nine behaviors used by the other maintained remote sandbox adapters:

  • shell execution and binary file round trips;
  • working-directory and environment propagation;
  • stdout/stderr collection;
  • timeout and abort, including termination of the native Cloudflare process;
  • portable reference encoding, reconnect, and provider cleanup.

Build and run the Worker locally with the actual Cloudflare Sandbox container:

cd examples/with-cloudflare-sandbox
pnpm fh build --target cloudflare
cd .fabricharness/build/cloudflare
pnpm exec wrangler dev --local

In another terminal, request the report:

curl --fail --request POST http://localhost:8787/jobs/sandbox-certification \
  --header 'content-type: application/json' \
  --data '{"input":{"credentialed":false}}'

The response contains a schema-v1, secret-free report with one result per check. A passing report has ok: true, provider: "cloudflare-sandbox", and nine status: "passed" rows. Protected live CI uses credentialed: true and retains the same JSON as certification evidence. The generated container image and @cloudflare/sandbox dependency are kept on the supported 0.9 line so local and hosted results exercise the same SDK contract.

How it maps to Fabric primitives

Fabric primitiveCloudflare equivalent
Session storeDurable Object SQLite (one DO per persistent agent instance; finite runs route by run id)
Run discoveryTenant-filtered registry DO containing pointers; per-run DOs remain authoritative
SandboxEnvCloudflare Sandbox container binding via @cloudflare/sandbox, or a durable Computer workspace via @cloudflare/computer
Model providerenv.AI Workers AI binding via CloudflareWorkersAIModelProvider (optional; HTTP providers also work)
Webhook triggerPOST /jobs/:name
Schedule triggerWorker scheduled() + triggers.schedule
Per-instance wake-upDurable Object alarm + SQLite schedule rows (/agents/:name/:instanceId/schedules)
Persistent promptPOST /agents/:name/:instanceId
Health/manifestGET /health, GET /manifest

Workers AI binding (no API tokens)

@fabric-harness/cloudflare/workers-ai ships a ModelProvider that routes inference through env.AI.run() instead of HTTP. Zero API tokens, zero egress, runs at the edge. Workers AI accepts the OpenAI Chat Completions request body, so the provider serializes through the SDK's standard OpenAI helpers.

import { CloudflareWorkersAIModelProvider } from '@fabric-harness/cloudflare/workers-ai';

export default {
  async fetch(request: Request, env: Env) {
    const fabric = await init({
      modelProvider: new CloudflareWorkersAIModelProvider({
        binding: env.AI,
        defaultModel: '@cf/meta/llama-3.1-8b-instruct',
        // Optional: route through Cloudflare AI Gateway
        // gateway: { id: 'my-gateway', skipCache: false, cacheTtl: 3600 },
      }),
    });
    // ...
  },
};

Add the binding to wrangler.jsonc:

{
  "ai": { "binding": "AI" }
}

You can still use HTTP providers (Anthropic, OpenAI-compatible, Vercel AI Gateway) on the Cloudflare target — store the API key as a Workers Secret and reference it via env.

Account validation

Run the Cloudflare smoke in your account before rollout:

FABRIC_CLOUDFLARE_TEST=1 \
FABRIC_CLOUDFLARE_WORKER_URL=https://<worker>.<subdomain>.workers.dev \
FABRIC_CLOUDFLARE_ARTIFACT_DIR=/path/to/.fabricharness/build/cloudflare \
CLOUDFLARE_ACCOUNT_ID=<account-id> \
pnpm --filter @fabric-harness/cloudflare test -- live.test.ts

The repository test suite also runs local workerd tests without account credentials. It admits concurrent persistent requests, verifies FIFO and attachment materialization, aborts finite and persistent work, deletes a session, kills workerd during a tool call, restarts with the same Durable Object storage, and verifies conservative interrupted-tool settlement. The account smoke adds the provider-specific bindings, Sandbox container, and R2 checks.

Limits and considerations

  • Worker request/CPU limits apply; long-running workflows belong on the Temporal worker target.
  • Persistent turns use a Durable Object submission queue with leases, FIFO execution, restart reconciliation, attachment materialization, abort, deletion, and offset conversation streams.
  • Per-instance schedules use the instance Durable Object's single alarm with at-least-once delivery; schedule state lives in Durable Object SQLite and is lost if that storage is wiped.
  • The run registry is a discovery index, not a second source of truth. Monitor mirror errors; terminal writes self-heal missing starts, but an account-level outage can temporarily omit an active pointer while direct run-id inspection continues to work.
  • Use Temporal when workflows exceed Worker or Durable Object execution/storage limits, require long timers across many external activities, or need Temporal's workflow-history tooling.
  • Sandbox container start latency depends on the Cloudflare image; warm pools help.
  • Keep FABRIC_HARNESS_API_TOKEN, body limits, rate limits, and FABRIC_ENV=production trigger gating enabled for public deployments.
  • With FABRIC_ENV=production, an unset API token fails closed: only /health and /ready remain reachable without authentication.