Skip to content

Self-hosting on workerd

The step engine is open source. @actuate/runtime runs one actuator as a Worker plus a SQLite-backed Durable Object, on Cloudflare or on plain workerd, with no Actuate account. It is the same engine actuate dev runs, and it is tested end to end on workerd in the Actuate repository.

What you get: durable steps, sleeps, events, approvals, the actuator’s SQLite database and the runtime HTTP API. What you don’t: the hosted MCP endpoint and OpenAPI document, the dashboard, managed provider setup and quotas. You bring your own provider adapters.

In your actuator project:

npm install @actuate/sdk @actuate/runtime
npm install -D wrangler

The runtime wraps your actuator in a Worker and a Durable Object class:

entry.ts
import { createRuntime } from "@actuate/runtime/worker";
import { consoleMessaging } from "@actuate/runtime";
import actuator from "./actuator.js";
const runtime = createRuntime(actuator, { providers: () => ({ messaging: consoleMessaging(() => {}) }) });
export default runtime.worker;
export const ActuatorObject = runtime.ActuatorObject;

runtime.worker routes every request and cron firing to one Durable Object instance, which owns the actuator’s SQLite database, journal and alarms. The exported class name must match the binding in the wrangler configuration.

consoleMessaging(() => {}) is a stand-in that discards messages; replace it with a real adapter (step 5).

wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "my-actuator",
"main": "entry.ts",
"compatibility_date": "2025-09-01",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [{ "name": "ACTUATOR", "class_name": "ActuatorObject" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["ActuatorObject"] }],
"triggers": { "crons": [] },
"vars": {
"ACTUATE_CONFIG": "{\"greeting\": \"Hi\"}",
"ACTUATE_PUBLIC_URL": "https://bot.example.com"
}
}
  • The binding must be named ACTUATOR.
  • new_sqlite_classes makes the Durable Object SQLite-backed, which the runtime requires. Keep the migration once you have deployed; add new tagged migrations after it if you ever rename the class.
  • Copy the actuator’s cron schedules into triggers.crons. The runtime starts a run with input {} for each firing.
  • ACTUATE_CONFIG is the actuator’s configuration as a JSON string.
npx wrangler secret put ACTUATE_TOKEN # required: your own token for the admin API
npx wrangler secret put ACTUATE_RUN_TOKEN # the token your agent uses: start and read runs only
npx wrangler secret put ACTUATE_WEBHOOK_SECRET # required to accept POST /webhook
npx wrangler secret put ACTUATE_APPROVAL_SECRET # optional: generated if unset
npx wrangler deploy

The runtime fails closed: without ACTUATE_TOKEN the admin API answers 503, and without ACTUATE_WEBHOOK_SECRET so does /webhook. Set ACTUATE_PUBLIC_URL (step 3) too, so approval links, payment callbacks and the self-call check use your real origin. Without it, the runtime waits for your first request made with ACTUATE_TOKEN to learn the origin, and approval links are relative until then. Don’t set ACTUATE_DEV in production: it opens routes whose secret is missing, and exists only for actuate dev. The full list of variables is in the runtime HTTP API reference.

npx wrangler dev runs the same thing locally.

Your agent starts runs with ACTUATE_RUN_TOKEN:

curl -X POST https://bot.example.com/run \
-H "authorization: Bearer $ACTUATE_RUN_TOKEN" \
-H 'content-type: application/json' \
-d '{"name":"Aina"}'

It can also describe the actuator (GET /) and read a run (GET /runs/<runId>), and nothing else. Keep ACTUATE_TOKEN for yourself: it can approve, answer, post events and read tables, so an agent holding it could let its own gated runs through. See Authentication.

The quickest path is the open-source adapters in @actuate/providers. They read the same variables as actuate dev (set them as Worker secrets), and they verify payment webhooks for you:

import { createRuntime } from "@actuate/runtime/worker";
import { parseProviderWebhookFromEnv, providersFromEnv } from "@actuate/providers";
import actuator from "./actuator";
const runtime = createRuntime(actuator, {
providers: (env, publicUrl) => providersFromEnv(env, publicUrl),
providerWebhook: (kind, request, env) => parseProviderWebhookFromEnv(env, kind, request),
});
export default runtime.worker;
export const ActuatorObject = runtime.ActuatorObject;

With ACTUATE_PAYMENT=billplz (for example), bills carry https://<your origin>/providers/billplz/webhook as their callback, and signed callbacks resume the waiting run with a PaymentEvent.

You can also write your own. createRuntime takes a providers function that receives the Worker’s environment and the public URL and returns a client for each capability. Anything you return must implement the SDK provider interfaces. Keep credentials in secrets and read them from env.

A minimal hand-written Telegram messaging adapter:

entry.ts
import { createRuntime, type RuntimeEnv } from "@actuate/runtime/worker";
import type { MessagingProvider } from "@actuate/sdk";
import actuator from "./actuator.js";
interface Env extends RuntimeEnv {
TELEGRAM_BOT_TOKEN: string;
}
function telegram(token: string): MessagingProvider {
return {
async send(to, text) {
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ chat_id: to, text }),
});
const body = (await res.json()) as { ok: boolean; description?: string; result?: { message_id: number } };
if (!body.ok) throw new Error(`Telegram: ${body.description}`);
return { id: String(body.result?.message_id), channel: "telegram" };
},
};
}
const runtime = createRuntime<Env>(actuator, {
providers: (env) => ({ messaging: telegram(env.TELEGRAM_BOT_TOKEN) }),
});
export default runtime.worker;
export const ActuatorObject = runtime.ActuatorObject;

How the runtime uses providers:

  • The actuator still sees only its declared capabilities, and provider calls must still happen inside ctx.step.do.
  • A declared capability with no provider throws on first use.
  • If the actuator declares http, the runtime builds ctx.http from http.allow for you (HTTPS only, no redirects, never its own public URL) unless you return your own.
  • Approval links are sent through messaging to the notify recipient, or printed to the console when either is missing.
  • With @actuate/providers, payment webhooks are verified for you; with your own adapters, wire them up as below.

Pass providerWebhook to serve POST /providers/:kind/webhook. Your function receives the kind from the URL, the request and the environment. Verify the request, then return the event to deliver, or null to ignore it. Throw an error named WebhookVerificationError to answer 401.

entry.ts
import { createRuntime, type RuntimeEnv } from "@actuate/runtime/worker";
import type { PaymentEvent } from "@actuate/sdk";
import actuator from "./actuator.js";
interface Env extends RuntimeEnv {
MYBANK_WEBHOOK_SECRET: string;
}
class WebhookVerificationError extends Error {
override name = "WebhookVerificationError";
}
const runtime = createRuntime<Env>(actuator, {
providerWebhook: async (kind, request, env) => {
if (kind !== "mybank") return null;
// Use your provider's real signature check here.
if (request.headers.get("x-signature") !== env.MYBANK_WEBHOOK_SECRET) {
throw new WebhookVerificationError("Bad signature");
}
const body = (await request.json()) as { reference: string; state: string; amount: number };
const payload: PaymentEvent = {
provider: "mybank",
status: body.state === "PAID" ? "paid" : "failed",
referenceId: body.reference,
amount: body.amount,
};
return { event: `payment.${body.reference}`, payload: { ...payload } };
},
});
export default runtime.worker;
export const ActuatorObject = runtime.ActuatorObject;

Point the provider at https://<your origin>/providers/mybank/webhook. Payment webhooks should deliver payment.<referenceId> with a PaymentEvent payload, so actuators written for the platform work unchanged.

createRuntime also takes config: (env) => unknown if you want to build the configuration from something other than ACTUATE_CONFIG.

The engine also runs in-process, for example on node:sqlite, which is handy for unit tests or for building your own host. RunManager drives runs, and executePass executes your actuator:

actuator.test.ts
import { DatabaseSync } from "node:sqlite";
import { RunManager, consoleMessaging, executePass, fakePayment, nodeSqlite } from "@actuate/runtime";
import actuator from "./actuator.js";
let now = Date.parse("2026-11-02T00:00:00Z");
const config = actuator.config!.parse({});
const manager = new RunManager({
sql: nodeSqlite(new DatabaseSync(":memory:")),
approvalSecret: "test-secret",
approvalUrl: "https://example.test/approve",
now: () => now,
invoke: (request, journal) =>
executePass(actuator, request, {
journal,
config,
providers: { messaging: consoleMessaging(), payment: fakePayment() },
}),
});
const { run } = await manager.start({
input: { customerName: "Aina", phone: "+60123456789", appointmentIso: "2026-11-05T09:00:00Z" },
trigger: { kind: "api" },
});
// Move the clock forward and fire the alarm.
now += 2 * 3_600_000;
await manager.alarm();
console.log(manager.getRun(run.runId)?.status, manager.getSteps(run.runId));

consoleMessaging() prints messages and fakePayment() creates bills that are paid by posting payment.<referenceId> (and refunds that always succeed), the same stand-ins actuate dev uses. deliverEvent(key, payload), decide(token), pendingApprovals(), listTables() and readTable(name) cover the rest of the lifecycle. manager.start does not validate input; parse it with actuator.input first if you want that check in tests.

RunManager takes options that bound what a run can cost. Pass them to createRuntime as guards: { ... }, or to RunManager when you build your own host.

Option Default Effect
passLimits.maxWaitMs none Longest sleep or wait. A sleep, or a wait with an explicit timeout or until, longer than this fails the run with an ActuatorError. A wait with no timeout gets this limit and then resolves to null
passLimits.maxRetries 10 Retries a step may ask for. Larger retries values are clamped to it
admitPass(runId) none Called before every pass. Return a time (epoch ms) to defer the pass until then; the run stays WAITING and nothing is lost. Return null to run now. Use it for budgets
maxBufferedEvents 1000 Events buffered while nobody waits for them. Beyond this, the oldest are dropped
maxEventBytes 65536 Largest event payload, in bytes of JSON. Larger events are refused
maxPasses 10000 Passes per run before the run fails
maxLogLinesPerPass 100 Log lines kept per pass
eventBufferTtlMs 7 days Buffered events older than this are deleted when the alarm runs

manager.pruneRuns(before, limit = 500) deletes settled runs (COMPLETED, FAILED, CANCELLED) last updated before before (epoch ms), with their steps and logs, and returns how many it removed. Your tables are never touched. Call it on a schedule to keep the journal small.

Your data is in the Durable Object’s SQLite storage on your account. Read it through GET /tables/:name?format=csv, or use your platform’s backup tooling for Durable Objects.