Sandbox
US$0 RM 0 / month
- 2 actuators
- 1,000 runs a month
- 25 MB SQLite per actuator, 50 MB total
- Hosted MCP endpoint and OpenAPI document
- No messaging or payment providers; HTTP to domains you list
- Sleeps and waits up to 7 days
One TypeScript file becomes a tool your agent can call over MCP.
npm create actuate For developers: a hosted TypeScript script with a durable step engine, its own SQLite database and an MCP endpoint, deployed in one command.
Arguments must match your schema before anything runs.
A retried call gets the first receipt back, not a second charge.
A wait of hours or days costs nothing.
Above your limit, a person approves by signed link.
SQLite per tool. Browse it, export it anytime.
Your WhatsApp, Telegram, email and payment keys stay out of the prompt.
You or your AI assistant can write it.
actuator.ts
export default defineActuator({ name: "deposit_reminder",what the tool is called input: z.object({ customer: z.string(), deposit: z.number().min(1) }),what it accepts, strictly capabilities: ["payment", "messaging"],what it is allowed to touch async run(ctx, input) { /* send link, wait, confirm */ },}); One command gives it an MCP endpoint. It costs nothing while idle.
terminal
$ actuate deploy
✓ input schema checked
✓ payment and messaging providers configured
✓ deposit_reminder deployed
MCP tool deposit_reminder ready Without a configured provider, the deploy stops here.
Nothing runs until the arguments fit the schema.
agent
deposit_reminder{ "customer": "Aisyah", "deposit": "lots" } { "customer": "Aisyah", "deposit": 50 } actuator
Try sending deposit as:
Each step runs once. Nothing is billed while it waits for the payment.
agent
deposit_reminderrun journal
Above your limit, the run pauses until a person clicks. You are the manager here.
agent
run journal
Signed link sent to the manager
Approve a refund of RM 480 for order #1042?
Customer → Your agent: #2291 arrived broken. The customer writes to your support agent.
Your agent → Actuator: refund_order(120). The agent calls the tool. Arguments are checked against the schema first. MCP tool
Actuator: RM 120 > RM 50. The cap and the threshold are code the model cannot change. code
Actuator → Manager, Stripe: Approval link. The manager gets a signed link on Telegram. The run pauses. waitForApproval
Manager, Stripe → Actuator: Manager decides. Approve or reject. You are the manager here. waitForApproval
Actuator → Manager, Stripe: Stripe refund. Refunded once. The idempotency key stops a double refund. step.do
Actuator: Audit row. The refund is recorded in the actuator's SQLite. db
Actuator → Your agent: REFUNDED. The tool returns its result to the agent.
Actuator → Your agent: DECLINED. No money moves. The agent gets a clear answer.
Your agent → Customer: Refunded RM 120. The agent tells the customer.
Your agent → Customer: A person will follow up. The agent tells the customer.
import { defineActuator, z } from "@actuate/sdk";
export default defineActuator({
name: "refund_order",
description: "Refunds an order, with approval over RM 50.",
input: z.object({
orderId: z.string(),
billId: z.string(),
amount: z.number().positive(),
}),
capabilities: ["payment", "messaging"],
async run(ctx, input) {
const { orderId } = input;
const amount = Math.min(input.amount, 500); // hard cap
if (amount > 50) {
const ok = await ctx.step.waitForApproval("manager", {
message: `Refund RM ${amount}, order ${orderId}?`,
notify: "@shop_manager",
});
if (!ok?.approved) return { status: "DECLINED", orderId };
}
const refund = await ctx.step.do("refund", async (step) => {
if (!ctx.payment.refund) throw new Error("No refunds.");
return ctx.payment.refund({
billId: input.billId,
amount,
idempotencyKey: step.idempotencyKey,
});
});
await ctx.step.do("audit", ({ now }) => ctx.db.sql`
INSERT INTO refunds VALUES (${orderId}, ${amount}, ${now})`);
return { status: "REFUNDED", orderId, refundId: refund.id };
},
}); Lead → Your agent: Do you run group classes?. A visitor asks your website agent.
Your agent → Actuator: lead_intake(...). The agent passes the lead on. The email must be valid. MCP tool
Actuator: INSERT lead. Saved to the actuator's own SQLite. db
Actuator → Email: Day 1 email. Sent once. step.do
Actuator → Email: Day 3 follow-up. No reply yet, so the follow-up goes out. step.do
Lead → Email: Re: Yes, Saturdays?. The lead replies.
Email → Actuator: reply.<id>. The reply webhook resumes the run. Day 7 never happens. waitForEvent
Actuator: status = REPLIED. The lead is marked as replied. db
import { defineActuator, z } from "@actuate/sdk";
export default defineActuator({
name: "lead_intake",
description: "Saves a lead, then follows up until they reply.",
input: z.object({
name: z.string(),
email: z.email(),
}),
capabilities: ["messaging"],
async run(ctx, { name, email }) {
const id = ctx.runId;
// An inbound-email webhook posts reply.<id>, ending a wait.
const wait = (step: string, timeout: "2 days" | "4 days") =>
ctx.step.waitForEvent(step, { event: `reply.${id}`, timeout });
await ctx.step.do("save", ({ now }) => ctx.db.sql`
INSERT INTO leads VALUES (${id}, ${email}, ${now})`);
await ctx.step.do("day-1", () =>
ctx.messaging.send(email, `Hi ${name}, thanks!`));
let reply = await wait("day-3", "2 days");
if (!reply) {
await ctx.step.do("follow-up", () =>
ctx.messaging.send(email, "Any questions?"));
reply = await wait("day-7", "4 days");
}
const status = reply ? "REPLIED" : "COLD";
await ctx.step.do("close", () => ctx.db.sql`
UPDATE leads SET status = ${status} WHERE id = ${id}`);
return { leadId: id, status };
},
}); GitHub → Actuator: release v2.4.0. GitHub sends the release webhook. The payload is checked against the schema. webhook
Actuator: Claim v2.4.0. The release ID is claimed in SQLite. db
Actuator → Telegram: v2.4.0 is out. Posted to the channel, with retries. step.do
GitHub → Actuator: release v2.4.0 (again). GitHub redelivers. Already done, so nothing is posted twice. webhook
import { defineActuator, z } from "@actuate/sdk";
export default defineActuator({
name: "github_release_telegram",
description: "Posts each GitHub release to Telegram once.",
input: z.object({
action: z.string(),
release: z.object({
id: z.number(),
html_url: z.url(),
}),
}),
capabilities: ["messaging"],
async run(ctx, { action, release }) {
if (action !== "published") return { posted: false };
// GitHub redelivers webhooks; the primary key stops repeats.
const firstTime = await ctx.step.do("claim", async () => {
const rows = await ctx.db.sql`INSERT INTO announced
VALUES (${release.id}) ON CONFLICT DO NOTHING
RETURNING release_id`;
return rows.length === 1;
});
if (!firstTime) return { posted: false, reason: "repeat" };
await ctx.step.do("post", { retries: 3 }, () =>
ctx.messaging.send("@releases", release.html_url));
return { posted: true };
},
}); Your agent → Actuator: event: order. Through the day, your agent records events. MCP tool
Actuator: INSERT event. Each one lands in SQLite. db
Clock → Actuator: cron 0 1 * * *. The schedule starts a run. No server, no crontab. cron
Actuator: SELECT by type. Yesterday's events, counted in SQL. db
Actuator → Telegram: Daily report. The summary goes to the team chat. step.do
import { defineActuator, z } from "@actuate/sdk";
export default defineActuator({
name: "daily_report",
description: "Records events; sends a summary every morning.",
input: z.object({
event: z.string().optional(),
}),
capabilities: ["messaging"],
cron: ["0 1 * * *"], // 09:00 in Kuala Lumpur
async run(ctx, { event }) {
if (event) {
await ctx.step.do("record", ({ now }) => ctx.db.sql`
INSERT INTO events VALUES (${event}, ${now})`);
return { recorded: event };
}
const rows = await ctx.step.do("summarise", ({ now }) =>
ctx.db.query<{ type: string; n: number }>(
`SELECT type, COUNT(*) AS n FROM events
WHERE at >= ? GROUP BY type`,
[now - 86_400_000],
));
const text = rows.map((r) => `${r.type}: ${r.n}`).join("\n");
await ctx.step.do("send", () =>
ctx.messaging.send("@team", text));
return { types: rows.length };
},
}); If a TypeScript file can do it, Actuate can run it.
refund-order Refunds with a human approval above a limit. lead-intake Save a lead, email, follow up until they reply. escalate-to-human Ask a person, wait for their answer. schedule-follow-up Message someone at a set time later. deposit-reminder Booking deposits with reminders. payment-reconciler Payment webhooks into a SQLite table. github-release-telegram Release notes to Telegram. daily-report A morning summary on a cron. A hosted TypeScript script with a durable step engine, its own SQLite database and an MCP endpoint, deployed in one command.
import { defineActuator, z } from "@actuate/sdk";
export const ConfigSchema = z.object({
depositAmount: z.number().min(1).default(50),
reminderDelay: z.enum(["30 minutes", "2 hours", "24 hours"]).default("2 hours"),
});
export const InputSchema = z.object({
customerName: z.string().min(1),
contact: z.string().describe("WhatsApp number or Telegram chat ID"),
});
export default defineActuator({
name: "deposit_reminder",
description: "Sends a deposit link, waits for payment, reminds once if unpaid.",
config: ConfigSchema,
input: InputSchema,
capabilities: ["payment", "messaging"], // declared, not ambient
async run(ctx, input, config) {
const payUrl = await ctx.step.do("send-link", { retries: 3 }, async ({ idempotencyKey }) => {
const bill = await ctx.payment.createBill({
amount: config.depositAmount, referenceId: ctx.runId, idempotencyKey,
});
await ctx.messaging.send(input.contact, `Hi ${input.customerName}, pay to confirm: ${bill.url}`);
return bill.url;
});
// Nothing runs, and nothing is billed, while the run waits.
const paid = await ctx.step.waitForEvent("deposit", {
event: `payment.${ctx.runId}`, timeout: config.reminderDelay,
});
if (paid === null) {
await ctx.step.do("remind", () => ctx.messaging.send(input.contact, `Reminder: ${payUrl}`));
return { status: "REMINDED" };
}
return { status: "CONFIRMED" };
},
}); ConfigSchemaInputSchemarun(ctx, input, config)ctx.step.do Memoized; replays return the stored result. ctx.step.sleep Hibernates the run; nothing is billed. ctx.step.waitForEvent Resumes on a webhook or event, or times out. ctx.step.waitForApproval Resumes when a person clicks a signed link. ctx.step.waitForInput Resumes when a person answers a question. ctx.db Per-actuator SQLite; parameterised queries only. Declared per actuator, configured with your credentials, injected as a typed client.
messaging Telegram (default), WhatsApp Cloud API, Slack, Discord, email via Resend payment Stripe, Chip, ToyyibPay, Billplz llm Anthropic, OpenAI, Google, or any OpenAI-compatible API http fetch to domains you allow-list Durable steps are not new. Actuate adds per-tenant isolation, a SQLite database per actuator, the MCP layer and one-file deploys. For a full Linux machine, use a VM sandbox instead.
US$0 RM 0 / month
US$9 RM 29 / month
US$29 RM 89 / month
Quotas are hard stops, with no overage billing. A runaway loop gets refused at the limit, never billed.
USD by card. MYR by FPX or DuitNow.
Actuate runs a TypeScript file as a tool your AI agent calls over MCP or HTTP. The file gets a durable step engine, its own SQLite database and clients for your own messaging, payment and LLM accounts. You deploy it with one command.
Any MCP client: Claude Code, Claude Desktop, Cursor, VS Code, or your own agent through an MCP SDK. Each tool also has an HTTP endpoint and an OpenAPI document, so anything that speaks HTTP can call it.
A retried call gets the first run's receipt back instead of starting another. Each step runs once and its result is stored, so a payment link, message or refund is not made twice.
Yes. A run can sleep for days, wait for a payment webhook or a WhatsApp or Telegram reply, or pause until someone approves or answers through a signed link. A waiting run costs nothing.
A workflow engine is a library you wire into your own app and infrastructure. An actuator is the whole tool, hosted: the step engine, database, provider clients, approval links and MCP endpoint come with it.
The runtime, SDK, CLI and provider adapters are open source under Apache-2.0 and can be self-hosted on Cloudflare Workers. The hosted platform adds multi-tenant hosting, the dashboard and billing.
No. Every plan has hard monthly limits. At a limit, work pauses until the month resets instead of costing more. Fees from your own providers, such as payment fees or LLM tokens, are billed to you by them.
npm create actuate