Capabilities and providers
An actuator has no ambient access to the outside world. It gets a client for each capability it declares, and nothing else:
export default defineActuator({ name: "deposit_booking", description: "Books a slot, sends a payment link, waits for the webhook or reminds.", input: InputSchema, capabilities: ["payment", "messaging"], // declared, not ambient async run(ctx, input) { ctx.payment; // PaymentProvider ctx.messaging; // MessagingProvider // ctx.llm and ctx.http do not exist here: a type error, and undefined at runtime },});There are four capabilities:
| Capability | Client | Launch providers | Credentials held by |
|---|---|---|---|
messaging |
ctx.messaging |
Telegram Bot API (default: free, live in two minutes), WhatsApp Cloud API (your own Meta app and WABA), Slack, Discord, email via Resend | You |
payment |
ctx.payment |
Stripe, Chip, ToyyibPay, Billplz | You |
llm |
ctx.llm |
Anthropic, OpenAI, Google, or any OpenAI-compatible API (OpenRouter, Groq, Together, DeepSeek, Mistral, Workers AI, a local Ollama), with your API key | You |
http |
ctx.http |
Outbound fetch to domains you allow-list |
You, per domain |
Bring your own credentials
Section titled “Bring your own credentials”A provider is a small adapter that you configure with your own credentials: your Telegram bot token, your Stripe account, your LLM API key. Actuate stores the secret and injects a typed client into ctx. It never holds a platform-wide WhatsApp, payment or LLM account, so the account, the customer relationship and the policy risk stay with you (or your client).
Telegram is the default messaging channel because you can have a working bot with no business verification. WhatsApp is supported from launch and suits a business that already has, or is setting up, its own WhatsApp Business account.
The adapters are open source in @actuate/providers, so the same clients run in actuate dev and on a self-hosted runtime with your keys from environment variables (see actuate providers). On the hosted platform you enter credentials in the dashboard; they are encrypted at rest.
The free Sandbox plan has no messaging, payment or LLM providers; see Limits.
- Declared, not ambient. Only declared capabilities appear on
ctx, in the types and at runtime. An undeclared name incapabilitiesis rejected bydefineActuator. - Configured before deploy. On the platform, an actuator whose declared capability has no provider configured is refused at deploy. In the open-source runtime, the first call to an unconfigured capability throws:
ctx.payment is declared but no payment provider is configured. - Optional when you can do without. List a capability in
optionalCapabilitiesinstead and it is onctxbutundefinedwhen no provider is configured, and never blocks a deploy:await ctx.step.do("alert", () => ctx.messaging?.send("ops", text)).httpcannot be optional. - Only inside steps. Every provider method call must happen inside
ctx.step.do. A call outside a step throws anActuatorError, because it would repeat on every replay. Pass the step’sidempotencyKeyto providers that deduplicate.
messaging
Section titled “messaging”await ctx.step.do("notify", () => ctx.messaging.send(input.chatId, `Release ${input.tag} is out`),);send(to, text, options?) returns { id, channel }. What to means depends on the provider:
| Provider | to |
|---|---|
| Telegram | A chat ID, e.g. 123456789 or -100123456789 for a group |
A phone number in international format, e.g. 60123456789 |
|
| Slack | A channel ID, e.g. C0123456789 (the bot must be in the channel). With an incoming webhook instead of a bot token, to is ignored |
| Discord | A channel ID. With a channel webhook instead of a bot token, to is ignored. Messages are at most 2,000 characters, and never ping @everyone or roles |
| Email (Resend) | An email address |
Slack and Discord suit messages to you or your team: approval requests, alerts, reports. Options:
| Option | Meaning |
|---|---|
channel |
Which channel sends, when several are configured: "telegram", "whatsapp", "slack", "discord" or "email". Without it, the default channel sends. See Several channels |
subject |
Email subject, when the channel is email |
template |
{ name, language, variables? }: a WhatsApp template, required for messages outside the 24-hour service window |
format |
"text" (default) or "html": a small HTML subset (<b>, <i>, <a href>, <code>, <pre>) where the channel supports it (Telegram, email); other channels get the text with tags removed |
idempotencyKey |
Passed to providers that deduplicate. Defaults to the current step’s key |
Several channels
Section titled “Several channels”You can connect more than one messaging provider, for example WhatsApp for customers and Slack for your team. The first one is the default; channel picks another:
await ctx.step.do("tell-customer", () => ctx.messaging.send(input.phone, "Your refund is on its way"));await ctx.step.do("tell-team", () => ctx.messaging.send("C0123456789", `Refunded ${input.orderId}`, { channel: "slack" }));On the platform, connect each in Settings → Providers and use Make default to choose the default; up to five channels. Locally, list them in .dev.vars, default first: ACTUATE_MESSAGING=whatsapp,slack. Sending on a channel that isn’t configured fails the step with No "slack" messaging channel is configured. Payment and LLM providers stay one each.
Receiving messages
Section titled “Receiving messages”With WhatsApp or Telegram, a run can wait for the customer’s reply. Each inbound message becomes the event message.<channel>.<from>, with an InboundMessage payload:
import type { InboundMessage } from "@actuate/sdk";
await ctx.step.do("ask", () => ctx.messaging.send(input.phone, "Reply YES to confirm tomorrow at 10:00"));const reply = await ctx.step.waitForEvent<InboundMessage>("reply", { event: `message.whatsapp.${input.phone}`, timeout: "1 day",});if (reply?.text.trim().toUpperCase() === "YES") { // confirmed}from is in the form send accepts, so ctx.messaging.send(reply.from, ...) answers the same person. A message nobody is waiting for is buffered like any event, so a reply that arrives before the run starts waiting is not lost.
To set it up, point the provider at the actuator’s provider webhook URL, which the dashboard shows under Connect → Receive messages (https://run.actuate.run/a/<id>/providers/<kind>/webhook):
| Provider | Setup | Needed so messages can’t be forged |
|---|---|---|
In your Meta app, WhatsApp → Configuration → Webhook: paste the URL, enter your Webhook verify token, subscribe to messages |
App secret: every delivery’s X-Hub-Signature-256 is checked. Only messages to your configured phone number ID count |
|
| Telegram | Call setWebhook with the URL and secret_token set to your Webhook secret token |
Webhook secret token: checked on every update |
Without the app secret or secret token, inbound messages are ignored rather than trusted. A WhatsApp number or Telegram bot has one webhook, so it delivers to one actuator; use a bot or number per actuator if several need replies. Locally, actuate dev serves the same route at http://localhost:8787/providers/<kind>/webhook; expose it with a tunnel.
payment
Section titled “payment”const bill = await ctx.step.do("create-bill", () => ctx.payment.createBill({ amount: 50, // major units: RM 50.00 currency: "MYR", // default "MYR" referenceId: bookingId, // comes back as the event payment.<referenceId> description: "Deposit", customer: { name: input.customerName, phone: input.phone }, }),);// bill: { id, url, status, amount, currency, referenceId }| Method | What it does |
|---|---|
createBill(request) |
Create a payment link. Returns a Bill with id, url, status, amount, currency and referenceId |
getBill(id) |
Look up a bill’s current status: pending, paid, failed, expired or refunded |
refund?(request) |
Refund a paid bill in full, or in part with amount. Optional: not every provider supports it, so call it as ctx.payment.refund?.(...) |
amount is always in major units. Other createBill fields are returnUrl (where the payer goes after paying) and idempotencyKey.
Payment webhooks become events. When the provider reports a payment, it is delivered to the actuator as the event payment.<referenceId>, so a run can wait for it:
import type { PaymentEvent } from "@actuate/sdk";
const paid = await ctx.step.waitForEvent<PaymentEvent>("payment", { event: `payment.${bookingId}`, timeout: "2 hours" });if (paid?.status === "paid") { // confirm}The payload is a PaymentEvent:
| Field | Meaning |
|---|---|
provider |
Which provider sent it |
status |
paid, failed, expired, refunded or pending |
referenceId |
The referenceId you passed to createBill |
billId, amount, currency |
Present when the provider reports them |
Check status: a provider can report a failed or expired bill under the same key. For anything that matters, also check that billId, amount and currency match the bill you created, and use a reference nobody else can guess (for example include ctx.runId). The event key is only your reference, so this is what ties the payment to this bill. (The local fake provider under actuate dev sends no billId or amount.)
Stripe events take their reference only from the actuate_reference_id metadata that createBill sets on the Checkout Session, never from client_reference_id: buyers can set that themselves on Payment Links and pricing tables, so it would let any payment on your account claim someone else’s reference. Sessions that createBill didn’t create are ignored.
ToyyibPay’s callbacks are not signed, so Actuate never trusts what the callback says. It re-queries the transaction with your ToyyibPay credentials and builds the event only from that answer: the event key uses the re-queried transaction’s reference, and the amount is the re-queried amount. A callback that doesn’t match a transaction on your account is rejected.
When you run locally with actuate dev, the payment provider is a fake: the bill’s url points at your actuator’s /events/payment.<referenceId>, so opening it in a browser “pays” the bill with the payload { provider: "dev", status: "paid", referenceId, via: "GET" }. Messages are printed in the terminal instead of being sent.
const summary = await ctx.step.do("summarise", async () => { const res = await ctx.llm.complete({ system: "Summarise the customer's message in one sentence.", prompt: input.message, maxTokens: 200, }); return res.text;});Any OpenAI-compatible API. Choose OpenAI-compatible API and give its base URL (the part before /chat/completions, e.g. https://openrouter.ai/api/v1, https://api.groq.com/openai/v1), an API key and a default model. That covers OpenRouter, Groq, Together, DeepSeek, Mistral, Fireworks, Cloudflare Workers AI, and a local Ollama or vLLM server under actuate dev (http://localhost:11434/v1). The base URL must be https:// (plain http:// only for localhost) and use a hostname, not an IP address.
complete takes model, system, prompt or messages ({ role: "user" | "assistant", content }[]), maxTokens and temperature, and returns { text, model, usage? }. Because the call is in a step, its answer is stored: replays reuse the same text instead of asking again.
http is outbound fetch, restricted to the domains you list:
export default defineActuator({ name: "release_notifier", description: "Posts new GitHub releases to Telegram.", input: z.object({ repo: z.string() }), capabilities: ["http", "messaging"], http: { allow: ["api.github.com"] }, async run(ctx, input) { const release = await ctx.step.do("latest-release", async () => { const res = await ctx.http.fetch(`https://api.github.com/repos/${input.repo}/releases/latest`); return (await res.json()) as { tag_name: string; html_url: string }; }); // ... },});- Declaring
"http"withouthttp.allowis an error. - Entries are exact hostnames (
api.github.com) or wildcards (*.example.com). A wildcard matches subdomains, not the apex:*.example.comallowsapi.example.combut notexample.com. - Only
https://URLs are allowed. - Redirects are not followed. A
3xxcomes back as a response for you to inspect. - An actuator cannot call its own URL, which stops webhook loops. Hostnames are compared case-insensitively and without a trailing dot, so
API.Example.com.is the same host asapi.example.com. - IP addresses (
https://1.2.3.4/,https://[::1]/) are refused when you call them. Use a hostname.
On the platform, http.allow is also checked when you deploy. The deploy is refused if the list:
- has more than 20 entries;
- contains an IP address or anything that isn’t a hostname;
- names the platform itself (
actuate.runor any subdomain); - uses a wildcard that is too broad: a bare or public-suffix wildcard such as
*.com,*.co.ukor*.com.my, or a shared-hosting zone where other people’s sites live, such as*.github.io,*.pages.dev,*.workers.dev,*.vercel.appor*.amazonaws.com.
A wildcard must sit under a domain you control: *.example.com, or list the exact hosts (myorg.github.io).
On the platform, a pass may make at most 50 ctx.http requests, and every request counts against the plan’s monthly HTTP budget (see Limits). Spread larger batches across steps that sleep, or page through them over several runs.
There is no raw fetch in a hosted actuator; ctx.http is the way out.