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 8787The 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 installOutput:
.fabricharness/build/cloudflare/
dist/worker.ts
wrangler.jsonc
Dockerfile # default Cloudflare Sandbox image
manifest.json
README.cloudflare.mdDevelop locally
fh dev --target cloudflareThis 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
workerdon 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 devDeploy
fh build --target cloudflare
cd .fabricharness/build/cloudflare
npx wrangler deployRoutes
The Cloudflare Worker exposes both execution models:
GET /health·GET /ready·GET /manifestPOST /jobs/:name— invoke a finite job and receive{ result, runId }POST /jobs/:name?wait=false— admit an asynchronous run; anIdempotency-Keyreuses its runGET /runs?status=&job=&limit=&cursor=— list tenant-visible finite runs in reverse chronological orderGET /runs/:runId·GET /runs/:runId/events?offset=0— inspect finite runs and eventsPOST /runs/:runId/abort— abort an active finite runPOST /agents/:name/:instanceId?wait=falsewith{ message, session? }— durably admit a persistent submissionGET /agents/:name/:instanceId/submissions/:submissionId?session=default— inspect settlementGET /agents/:name/:instanceId/conversation?session=default&offset=0— read the offset streamPOST /agents/:name/:instanceId/abort?session=default— abort queued or active submissionsGET /agents/:name/:instanceId/schedules·POST /agents/:name/:instanceId/schedules— list or create per-instance wake-up schedulesDELETE /agents/:name/:instanceId/schedules/:scheduleId— cancel a scheduleGET /agents/:name/:instanceId?session=default— inspect a named persistent sessionDELETE /agents/:name/:instanceId?session=default— delete that named sessionGET /sessions/:runId— inspect the stored runGET /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, andtriggers.scheduleoncreateAgent()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
agentsis provided, the default admission validator rejects unknown agents, validates initial data and instance-contact preconditions, ensures the instance identity, and stamps the agent's declareddurability: { maxAttempts, timeoutMs }bounds onto the admission — the same policy the generated Worker applies. PassvalidateInputto override; the override then owns durability stamping (seepersistentAgentSubmissionDurabilityin 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 --localIn 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 primitive | Cloudflare equivalent |
|---|---|
| Session store | Durable Object SQLite (one DO per persistent agent instance; finite runs route by run id) |
| Run discovery | Tenant-filtered registry DO containing pointers; per-run DOs remain authoritative |
SandboxEnv | Cloudflare Sandbox container binding via @cloudflare/sandbox, or a durable Computer workspace via @cloudflare/computer |
| Model provider | env.AI Workers AI binding via CloudflareWorkersAIModelProvider (optional; HTTP providers also work) |
| Webhook trigger | POST /jobs/:name |
| Schedule trigger | Worker scheduled() + triggers.schedule |
| Per-instance wake-up | Durable Object alarm + SQLite schedule rows (/agents/:name/:instanceId/schedules) |
| Persistent prompt | POST /agents/:name/:instanceId |
| Health/manifest | GET /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.tsThe 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, andFABRIC_ENV=productiontrigger gating enabled for public deployments. - With
FABRIC_ENV=production, an unset API token fails closed: only/healthand/readyremain reachable without authentication.