Your first actuator
This page walks through a real actuator: a clinic takes a booking, sends the customer a payment link for a deposit, and waits. If the payment webhook arrives, the booking is confirmed. If it does not arrive in time, the customer gets one reminder.
It uses every core primitive: configuration, validated input, the database, two capabilities, memoized steps and an event wait with a timeout.
The whole file
Section titled “The whole file”import { defineActuator, z } from "@actuate/sdk";
export const ConfigSchema = z.object({ businessName: z.string().default("Klinik Pergigian Senyum"), depositAmountRM: z.number().min(10).max(500).default(50), reminderDelay: z.enum(["30 minutes", "2 hours", "24 hours"]).default("2 hours"),});
export const InputSchema = z.object({ customerName: z.string(), phone: z.string().regex(/^\+60\d{9,10}$/), appointmentIso: z.string().datetime(),});
export default defineActuator({ name: "deposit_booking", description: "Books a slot, sends a payment link, waits for the webhook or reminds.", config: ConfigSchema, input: InputSchema, capabilities: ["payment", "messaging"], // declared, not ambient async run(ctx, input, config) { await ctx.db.migrate([ "CREATE TABLE bookings (id TEXT PRIMARY KEY, name TEXT, phone TEXT, status TEXT)", ]);
const bookingId = await ctx.step.do("save-booking", async () => { await ctx.db.query( "INSERT INTO bookings (id, name, phone, status) VALUES (?, ?, ?, 'PENDING')", [ctx.runId, input.customerName, input.phone], ); return ctx.runId; });
const payUrl = await ctx.step.do("send-link", async () => { const bill = await ctx.payment.createBill({ amount: config.depositAmountRM, referenceId: bookingId }); await ctx.messaging.send( input.phone, `Hi ${input.customerName}, confirm your ${config.businessName} booking with a RM${config.depositAmountRM} deposit: ${bill.url}`, ); return bill.url; });
const paid = await ctx.step.waitForEvent("payment", { event: `payment.${bookingId}`, timeout: config.reminderDelay, }); if (!paid) { await ctx.step.do("remind", () => ctx.messaging.send(input.phone, `Reminder: ${payUrl}`)); return { status: "REMINDER_SENT", bookingId }; }
await ctx.step.do("confirm", () => ctx.db.query("UPDATE bookings SET status = 'CONFIRMED' WHERE id = ?", [bookingId]), ); return { status: "CONFIRMED", bookingId }; },});Walkthrough
Section titled “Walkthrough”Configuration and input
Section titled “Configuration and input”ConfigSchema is what the operator sets once: the business name, the deposit and the reminder delay. Every field has a default, so the actuator runs with an empty configuration. Locally, configuration comes from actuate.config.json; on the platform the dashboard renders this schema as a form.
InputSchema is the payload of one run. It arrives by API, webhook, cron or MCP tool call and is validated before any of your code runs, so run never sees a malformed phone number.
Capabilities
Section titled “Capabilities”capabilities: ["payment", "messaging"] is what puts ctx.payment and ctx.messaging on the context. Leave one out and TypeScript will not let you use it, and it is absent at runtime too. The credentials behind each capability belong to you; see Capabilities and providers.
The database
Section titled “The database”ctx.db.migrate creates the bookings table the first time any run reaches it and does nothing afterwards, so it is safe at the top of run. The insert is a parameterised query inside a step, so it happens once even though run is replayed. See Database.
run is replayed from the top every time the run wakes. Each ctx.step.do writes its result to the run’s journal, and on replay it returns that result without calling the function again. That is why the booking is inserted once and the payment link is created and sent once, even if the run wakes many times.
Two details matter here:
createBilltakesamountin major units (50 means RM 50.00) and returns aBillwithid,urlandstatus. The step returnsbill.url, so the reminder can reuse the same link without creating a second bill.- Provider calls must happen inside
ctx.step.do. Callingctx.messaging.senddirectly inrunthrows, because it would send again on every replay.
Waiting for the payment
Section titled “Waiting for the payment”waitForEvent suspends the run until an event with the key payment.<bookingId> arrives. The payment provider’s webhook delivers exactly that: createBill was given referenceId: bookingId, and the payment webhook surfaces as the event payment.<referenceId>.
While it waits nothing runs and nothing is billed. If the event does not arrive within reminderDelay, waitForEvent resolves to null and the run sends a reminder instead.
The return value is the run’s output, stored with the run record and returned to API and MCP callers.
Try it locally
Section titled “Try it locally”Start from a fresh project (npm create actuate deposit-bot), save the file as its actuator.ts, and run npx actuate dev. Locally, messages are printed in the terminal and the payment provider is a fake whose bill URL points back at your actuator.
curl -X POST localhost:8787/run -H 'content-type: application/json' \ -d '{"customerName":"Aina","phone":"+60123456789","appointmentIso":"2026-11-05T09:00:00Z"}'The terminal prints the message with a link like http://localhost:8787/events/payment.run_.... Open it in a browser, or post the event yourself:
curl -X POST localhost:8787/events/payment.<runId> -H 'content-type: application/json' -d '{"status":"paid"}'curl localhost:8787/runs/<runId>The run is COMPLETED with output { "status": "CONFIRMED", ... }, and curl localhost:8787/tables/bookings shows the row. To test the reminder path, set {"reminderDelay": "30 minutes"} in actuate.config.json, restart actuate dev (it reads the configuration at startup), start a run and don’t pay.
Deploying it
Section titled “Deploying it”actuate deploy works as in the Quickstart, with two differences. This actuator declares payment and messaging, so it needs the Maker plan: the free Sandbox refuses it with 402. And it needs providers: set your payment and messaging credentials in the dashboard under Settings → Providers before you deploy, or the deploy is refused.
- Steps and replay: retries, idempotency keys and the determinism rules.
- Sleeping and waiting: timeouts and events that arrive early.
- Human approvals: add a sign-off before money moves.