actuate

Your AI agent decides what to do. Actuate makes sure it happens.

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.

A tool your agent can't misuse.

  • Rejects bad input.

    Arguments must match your schema before anything runs.

  • Runs exactly once.

    A retried call gets the first receipt back, not a second charge.

  • Sleeps for free.

    A wait of hours or days costs nothing.

  • Asks a human.

    Above your limit, a person approves by signed link.

  • Keeps its own database.

    SQLite per tool. Browse it, export it anytime.

  • Uses your accounts.

    Your WhatsApp, Telegram, email and payment keys stay out of the prompt.

Instead of

  • Letting the LLM do it, which risks hallucinated parameters, double charges and leaked keys.
  • A server per script, which is billed and patched whether it runs or not.
  • A no-code JSON workflow, which is hard to review, diff or query.

Watch one run.

1Write one file

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 */ },});

2Deploy it

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.

3Rejects bad input

Nothing runs until the arguments fit the schema.

agent

Book Aisyah for Friday 3pm and take the RM 50 deposit.
Calling deposit_reminder
{ "customer": "Aisyah", "deposit": "lots" }
{ "customer": "Aisyah", "deposit": 50 }

actuator

Rejected by the input schema deposit: expected a number, got a string Nothing ran.
Accepted Run started for Aisyah

Try sending deposit as:

4Sleeps for free

Each step runs once. Nothing is billed while it waits for the payment.

agent

Aisyah paid. Friday 3pm is confirmed.
Connection dropped, retrying deposit_reminder
Same receipt as before. Nothing sent twice.

run journal

  1. ✓send-link payment link sent to Aisyah
  2. deposit waiting for payment, up to 2 hourscosts nothing while waiting
  3. ↘webhook payment received, 38 min later
  4. ✓confirm booking confirmed, message sent
  5. ↺retried call returned the first run's receipt

5Asks a human

Above your limit, the run pauses until a person clicks. You are the manager here.

agent

Order #1042 arrived broken. I want my money back.
Refund RM 480 for order #1042
A manager needs to approve this one.
Approved. RM 480 is on its way back to you.
The refund was declined. A person will follow up.

run journal

  1. ✓classify refund request, RM 480
  2. !policy above the RM 50 approval threshold, under the RM 500 cap
  3. …approval waiting for a manager
  4. ✓refund RM 480 issued, audit row written
  5. ✕refund not issued, audit row written

Signed link sent to the manager

Approve a refund of RM 480 for order #1042?

See it in a real flow.

A support agent refunds an order

  1. Customer → Your agent: #2291 arrived broken. The customer writes to your support agent.

  2. Your agent → Actuator: refund_order(120). The agent calls the tool. Arguments are checked against the schema first. MCP tool

  3. Actuator: RM 120 > RM 50. The cap and the threshold are code the model cannot change. code

  4. Actuator → Manager, Stripe: Approval link. The manager gets a signed link on Telegram. The run pauses. waitForApproval

  5. Manager, Stripe → Actuator: Manager decides. Approve or reject. You are the manager here. waitForApproval

  6. Actuator → Manager, Stripe: Stripe refund. Refunded once. The idempotency key stops a double refund. step.do

  7. Actuator: Audit row. The refund is recorded in the actuator's SQLite. db

  8. Actuator → Your agent: REFUNDED. The tool returns its result to the agent.

  9. Actuator → Your agent: DECLINED. No money moves. The agent gets a clear answer.

  10. Your agent → Customer: Refunded RM 120. The agent tells the customer.

  11. Your agent → Customer: A person will follow up. The agent tells the customer.

refund_order.ts
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 };
  },
});

An agent hands over a lead

  1. Lead → Your agent: Do you run group classes?. A visitor asks your website agent.

  2. Your agent → Actuator: lead_intake(...). The agent passes the lead on. The email must be valid. MCP tool

  3. Actuator: INSERT lead. Saved to the actuator's own SQLite. db

  4. Actuator → Email: Day 1 email. Sent once. step.do

  5. Day 3 Two days of waiting. It costs nothing while waiting.
  6. Actuator → Email: Day 3 follow-up. No reply yet, so the follow-up goes out. step.do

  7. Day 5
  8. Lead → Email: Re: Yes, Saturdays?. The lead replies.

  9. Email → Actuator: reply.<id>. The reply webhook resumes the run. Day 7 never happens. waitForEvent

  10. Actuator: status = REPLIED. The lead is marked as replied. db

lead_intake.ts
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 };
  },
});

A GitHub release goes out once

  1. GitHub → Actuator: release v2.4.0. GitHub sends the release webhook. The payload is checked against the schema. webhook

  2. Actuator: Claim v2.4.0. The release ID is claimed in SQLite. db

  3. Actuator → Telegram: v2.4.0 is out. Posted to the channel, with retries. step.do

  4. A minute later
  5. GitHub → Actuator: release v2.4.0 (again). GitHub redelivers. Already done, so nothing is posted twice. webhook

github_release_telegram.ts
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 };
  },
});

A report that writes itself

  1. Your agent → Actuator: event: order. Through the day, your agent records events. MCP tool

  2. Actuator: INSERT event. Each one lands in SQLite. db

  3. Next morning, 09:00
  4. Clock → Actuator: cron 0 1 * * *. The schedule starts a run. No server, no crontab. cron

  5. Actuator: SELECT by type. Yesterday's events, counted in SQL. db

  6. Actuator → Telegram: Daily report. The summary goes to the team chat. step.do

daily_report.ts
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.

Start from a template.

All templates on GitHub

For developers.

A hosted TypeScript script with a durable step engine, its own SQLite database and an MCP endpoint, deployed in one command.

actuator.ts
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" };
  },
});
ConfigSchema
Set once by the operator; rendered as a form in the dashboard.
InputSchema
One run's payload, validated with Zod at the gateway. It is also the MCP tool's argument schema.
run(ctx, input, config)
Standard TypeScript in a V8 isolate, replayed from the top after every pause.
  • 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.

Bring your own providers.

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

Above the step engine.

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.

Hard stops, no surprise bills.

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
Start free

Agency

Coming later

US$29 RM 89 / month

  • 50 actuators
  • 500,000 runs a month
  • 5 GB SQLite, pooled
  • Hosted MCP endpoint and OpenAPI document
  • Providers: messaging, payment, LLM, HTTP
  • Unlimited sleep and wait duration
  • Custom webhook domain
  • Client portals and approval queue
  • White-label domain

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.

Questions.

What is Actuate?

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.

Which agents can use it?

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.

What happens if my agent calls a tool twice?

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.

Can a tool wait for a payment, a reply or a person?

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.

How is it different from a workflow engine like Temporal or Cloudflare Workflows?

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.

Is it open source?

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.

Can I get a surprise bill?

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.

Deploy your first actuator.

npm create actuate
Read the docs