actuate
i’ve been building actuate. it takes one typescript file and turns it into a tool an ai agent can call: an mcp tool, a webhook and an openapi endpoint, with durable steps and its own sqlite database. it runs on cloudflare workers and durable objects, and the engine is open source.
this is the why and the how. the reference lives at actuate.run/docs.
the gap
writing a 150-line script is close to free now. running it properly isn’t. once a script does something real, like refunding an order or messaging a customer on whatsapp, it needs three things:
- arguments you can trust. sooner or later the model calls your tool with
"amount": "lots", or leaves a field out. - retries that don’t repeat side effects. a connection drops halfway through a call and the agent retries. you don’t want to refund twice.
- waiting. the payment webhook comes 40 minutes later. the manager approves tomorrow morning. keeping a process alive for that is silly, and polling for it is worse.
the usual answers were a vps per script, a no-code workflow tool, or letting the agent run the process itself and hoping. i wanted the file to be the whole thing.
one file
import { defineActuator, z } from "@actuate/sdk";
export default defineActuator({
name: "refund_order",
description: "Refund an order. Over RM 100, a manager approves.",
input: z.object({
orderId: z.string(),
billId: z.string(),
amount: z.number().positive().max(500),
}),
capabilities: ["payment", "messaging"],
async run(ctx, { orderId, billId, amount }) {
if (amount > 100) {
const decision = await ctx.step.waitForApproval("manager", {
message: `Refund RM ${amount} for order ${orderId}?`,
notify: "@shop_manager",
timeout: "24 hours",
});
if (!decision?.approved) return { status: "DECLINED" };
}
const refund = await ctx.step.do("refund", ({ idempotencyKey }) =>
ctx.payment.refund!({ billId, amount, idempotencyKey }),
);
return { status: "REFUNDED", refundId: refund.id };
},
});
the model only gets to pick the arguments. the ceiling, the approval and the refund itself are code it can’t talk its way around.
actuate deploy turns that into an mcp tool whose argument schema is the zod input, a webhook url, an openapi document, and a sqlite database that belongs to this one actuator.
replay
the core is small. run is replayed from the top every time a run wakes up. each ctx.step.do result is written to a journal table in the actuator’s sqlite. on replay, a step that already finished returns its stored value and its function doesn’t run again.
it’s the same idea as temporal and cloudflare workflows, with the same rule: anything outside a step has to give the same answer every time. Date.now(), Math.random() or a bare fetch will drift between replays. provider calls outside a step throw at runtime, and actuate check lints for the rest before you deploy.
once, mostly
it’s tempting to call this exactly-once. it isn’t, and nothing that talks to someone else’s api can be. if the worker dies after stripe accepted the refund but before the journal row is written, the step runs again on the next pass.
what makes that safe is the key. every step gets an idempotencyKey, which is runId:stepName: the same on every replay and retry. pass it to the provider and the second call gets the first refund back. at-least-once execution, effectively-once effects, as long as the api on the other end honours the key.
there’s a second kind of retry one level up: the agent’s mcp client retrying the whole tool call. that one is deduplicated per run, not per step. the same json-rpc id in the same mcp session, or the same _meta["actuate/idempotencyKey"], gets the first run back instead of starting a new one. a stateless client that sends neither gets no dedup, which is why the refund template also checks its own refunds table for the order before doing anything. agents retry, and agents rephrase.
waiting is free
ctx.step.sleep("2 hours"), waitForEvent and waitForApproval all work the same way. the step writes a waiting row with its wake time, the durable object sets one alarm for the earliest wake across its runs, and the pass ends by throwing a suspend signal. nothing stays in memory. when the alarm fires, a webhook lands or someone taps approve, the object wakes, replays run, and every finished step returns instantly until it reaches the one that was waiting.
so a three-day wait uses the same compute as no wait. that one property is most of why this is built on durable objects.
approvals go out as a pair of signed, single-use links through your own telegram, whatsapp, slack, discord or email. opening a link shows a confirm page and only the button decides, so a chat app’s link preview can’t approve a refund on its own. if nobody decides before the timeout you get null, and your code has to say what that means.
a database per tool
durable objects come with sqlite now, so every actuator gets its own database sitting next to its code:
await ctx.db.migrate([
`CREATE TABLE refunds (order_id TEXT, amount REAL, at INTEGER)`,
]);
await ctx.step.do("audit", ({ now }) => ctx.db.sql`
INSERT INTO refunds VALUES (${orderId}, ${amount}, ${now})
`);
interpolated values become bound parameters, and there’s deliberately no string-built exec. the journal lives in the same database, in tables prefixed _actuate_.
this is the part i like most. most durable execution engines keep your workflow’s state and send you somewhere else for your data. here the audit log, the once-per-order check and the journal are one sqlite database with no network hop between them. you can read tables back over http or as csv, or export the lot as a .sql dump. (not a .sqlite file. workers can’t hand you one.)
the agent side
two things here took longer than they should have.
bad arguments should cost nothing. on the hosted platform, zod is compiled to json schema at deploy, and the gateway checks every call against it before any durable object or isolate wakes. the agent gets back which field is wrong and why, in plain words, and tries again. the isolate re-parses with the real zod schema anyway, for defaults and for anything json schema can’t express. self-hosted there’s no separate gateway, so the same check runs inside the durable object, still before a run exists.
long tools are awkward over mcp. a call that waits two days for a manager can’t hold a request open. so a call that finishes returns its output, as structuredContent if you declare an output schema, and a call that’s waiting returns a receipt with a runId for the agent to poll with actuate_get_run. i’m not sure that’s the right shape yet. if you’ve solved this better, tell me.
why not workflows, or temporal
cloudflare workflows is a durable step engine for your own worker on your own account. if that’s all you need, use it. actuate sits a level up: it runs other people’s code, each actuator in its own isolate with no bindings and no network except https to the domains the file declares, and adds the approvals, the database per tool and the mcp endpoint. it doesn’t use workflows underneath. the journal is just a table.
temporal is the same replay idea in a different weight class: a cluster plus your own workers, in several languages, for teams with business-critical workflows. if you need that, use temporal. this is for the 150-line script that has nowhere to live.
what it isn’t
- heavy compute. on the hosted platform each pass gets 50 ms of cpu and 30 s of wall time, and time spent waiting on i/o doesn’t count. it’s glue: a few api calls, a query, a decision.
- one hot endpoint. every run of an actuator goes through one durable object. it suits many small tools, not one tool doing thousands of calls a second.
- someone else’s accounts. credentials are yours: your stripe keys, your telegram bot. refunds need a provider that does them through its api, which today means stripe or chip.
- mature. it’s young, and it’s one person. no sla.
try it
the sdk, the step engine, the provider adapters and the cli are apache-2.0 at github.com/actuate-run/actuate. actuate dev runs the same engine locally on workerd, no account needed:
npm create actuate my-bot
cd my-bot && npm install && npx actuate dev
you can also self-host an actuator on your own cloudflare account with a plain wrangler config. the guide is in the docs. the hosted mcp endpoint, the dashboard and the multi-tenant parts are the bit i charge for.