Write a workflow
A Whenever workflow is a TypeScript module: it declares when it runs, then does the work. A manifest of keyed triggers, named steps, one run function, and a typed context that carries everything the runtime supplies.
Let your coding agent write it
You do not have to write any of this by hand. Connect your coding agent to the Whenever MCP server at https://mcp.whenever.dev and describe the automation: the agent reads this contract, writes the module, checks it against the real build and saves it as a draft for you to publish. Everything below is what it writes, against the open-source SDK at github.com/wix-incubator/whenever.
claude.ai and the Claude desktop app
- Open Settings, then Connectors.
- Choose Add custom connector.
- Paste the server URL and save.
the terminal
- Run this once. Add --scope user to register it for every project instead of this one.
the Cursor editor
- Open Settings, then MCP, then Add new MCP server.
- Or write the file yourself: Cursor reads ~/.cursor/mcp.json on start.
the Codex CLI
- Add the server to your Codex config, then start a new session.
a VS Code workspace
- Create this file in the workspace you want the server available in.
- VS Code picks it up and offers to start the server.
Three declarations, on purpose
Everything comes from one package, @wix/whenever-workflow-sdk, and a workflow module declares three kinds of thing:
manifest- When the workflow runs. Its name, and at least one trigger, each under its own stable key.
export const manifest: WorkflowManifest = { name: 'daily-rate-report', triggers: [daily({ key: 'morning', at: '09:00', tz: 'Europe/Vilnius' })], };defineStep(name, operation)- One named unit of work, recorded on every call. Calls to other services live inside a step.
export const readRate = defineStep('read-rate', async (ctx) => { const response = await ctx.integrations.http.get({ url: ctx.config.RATE_URL }); return response.body; });defineWorkflow(run)- The default export: the run function that calls your steps in order, handed a typed context.
export default defineWorkflow<void, { rate: unknown }>(async (ctx) => { const rate = await readRate(ctx); ctx.log('read rate', { rate }); return { rate }; });
They are separate so the publish-time extractor can read the manifest without invoking your run function. Triggers are known before a single line of your code has executed, so keep them as literals in the manifest, not something the run function computes.
A daily rate, filed onward
Reads a rate every morning, annotates what it saw, and files a report — one step per externally visible action. For complete workflows with webhooks, several triggers and an AI agent, see Workflow examples.
import {
daily,
defineStep,
defineWorkflow,
manual,
NonRetryableError,
RetryableError,
type WorkflowContext,
type WorkflowManifest,
} from '@wix/whenever-workflow-sdk';
export const manifest: WorkflowManifest = {
name: 'daily-rate-report',
triggers: [
daily({ key: 'morning-report', at: '09:00', tz: 'Europe/Vilnius' }),
manual({ key: 'rerun' }),
],
};
// Every http.* result types body as unknown, so a guard is the only way in.
function isRate(body: unknown): body is { rate: number } {
return (
typeof body === 'object' && body !== null && 'rate' in body && typeof body.rate === 'number'
);
}
export const readRate = defineStep('read-rate', async (ctx: WorkflowContext) => {
const response = await ctx.integrations.http.get({ url: ctx.config.RATE_URL });
if (!isRate(response.body)) {
throw new NonRetryableError('the rate endpoint answered with no rate', {
operation: 'http.get',
});
}
ctx.log('read rate', { rate: response.body.rate });
return response.body.rate;
});
export const fileReport = defineStep('file-report', async (ctx: WorkflowContext, rate: number) => {
const receipt = await ctx.integrations.http.post({
url: ctx.config.REPORT_URL,
headers: { Authorization: `Bearer ${ctx.secrets.REPORT_API_KEY}` },
body: { rate, observedAt: ctx.now() },
});
if (receipt.status >= 500) {
throw new RetryableError('the report endpoint is not answering yet', {
retryAfterMs: 60_000,
});
}
return receipt;
});
export default defineWorkflow<void, { rate: number; status: number }>(async (ctx) => {
const rate = await readRate(ctx);
const receipt = await fileReport(ctx, rate);
return { rate, status: receipt.status };
});Steps
A step is an exported const built by defineStep. You call it from the run function, context first.
| Call | What happens |
|---|---|
defineStep(name, operation) | Names a unit of work. The operation receives the context, then the step's own arguments. An empty name throws. |
await readRate(ctx, …args) | Runs the operation through the runtime's step boundary, which records the invocation under that name. |
step.stepName | The name the step was declared with, readable on the returned function. |
Trigger builders
These go in the manifest, under triggers. Every one takes a required key: the trigger's stable identity, unique within the workflow, and what a run reads back as ctx.trigger.key.
| Builder | Shape and constraints |
|---|---|
manual({ key }) | Run on demand, with no automatic trigger. |
schedule(cron, { key, tz? }) | Five whitespace-separated cron fields, or @hourly / @daily / @weekly / @monthly. Six-field, seconds-granular cron is rejected. |
once(iso, { key }) | One absolute ISO 8601 instant, such as "2026-08-01T09:00:00Z". A bare time of day is rejected; the runtime enforces "still ahead". |
hourly({ key, minute?, tz? }) | minute is 0–59, default 0. |
daily({ key, at?, tz? }) | at is "HH:MM" on a 24-hour clock, default "00:00". Seconds are not accepted. |
weekly({ key, day?, at?, tz? }) | day is a lowercase weekday name, default "monday". |
monthly({ key, dayOfMonth?, at?, tz? }) | dayOfMonth is 1–31, default 1. A day past the end of a short month does not fire that month. |
every("<n>m" | "<n>h", { key, tz? }) | The interval has to divide its field evenly: minutes divide 60, hours divide 24. |
webhook({ key, name? }) | Unverified inbound HTTP: any request to the endpoint starts a run. The URL is bound to the key; name is display text only. |
event({ key, source, config? }) | A managed provider event, with source and config taken from Whenever's catalogue. |
A manual run carries the trigger the person chose, so a workflow with several triggers can tell every path apart by ctx.trigger.key. A managed event looks like event({ key: 'new-mail', source: 'gmail.new-gmail-message', config: { labels: ['INBOX'] } }), and its source and every config field come from the catalogue.
Signed webhooks
A sender that signs its deliveries gets its own helper, imported from @wix/whenever-workflow-sdk/webhooks. The platform verifies each delivery with that sender's connection in Setup before your code runs.
| Helper | Receives |
|---|---|
stripeWebhook({ key, name? }) | Stripe events, verified with the endpoint's signing secret. Deliveries older than five minutes are refused. |
githubWebhook({ key, name? }) | Repository, organization or GitHub App events. GitHub names the event in ctx.trigger.eventType, not in the body. |
shopifyWebhook({ key, name? }) | Store events from a webhook made in the store admin or subscribed by an app. |
slackAppEventsWebhook({ key, name?, event? }) | A Slack app's Event Subscriptions. event narrows the endpoint to one Slack event, such as app_mention. |
metaAppWhatsAppWebhook({ key, name? }) | WhatsApp Business messages from a Meta app. Status receipts and other objects never start a run. |
telegramBotWebhook({ key, name? }) | A Telegram bot's updates, verified with the secret token given to setWebhook. |
discordAppInteractionsWebhook({ key, name? }) | Slash commands, buttons and modal submissions. The platform defers each one; reply within fifteen minutes. |
discordAppEventsWebhook({ key, name? }) | App authorizations, deauthorizations and entitlements, named in ctx.trigger.eventType. |
standardWebhook({ key, name? }) | Any sender following the Standard Webhooks specification. |
Unless the helper narrows it, a signed endpoint receives every event kind the sender is configured to send. Branch on ctx.trigger.eventType before any provider write and return an ignored result for the kinds you do not handle — publish refuses a provider write that an unrelated event could reach.
WorkflowContext
The single argument your run function and every step receives.
| Member | Purpose |
|---|---|
ctx.input | The typed workflow input: the delivery's body on a webhook or event run. A scheduled run is handed none, so type it void. |
ctx.config.NAME | Non-sensitive workflow values the owner supplies. The names are fixed at build time from the ones your source spells out. |
ctx.secrets.NAME | The plaintext secrets the owner stored, named the same way. Never log one or return it in output. |
ctx.trigger | The trigger that started this run, on every run: its type and key, plus what that type delivers, in the table below. |
ctx.log(message, data?) | A human-readable annotation in the run timeline. Observational only — it never changes control flow. |
ctx.now() | Runtime-provided current time, in milliseconds. |
ctx.random() | Runtime-provided randomness. |
ctx.integrations | The integration ports. |
| ctx.trigger.type | Always carries | When known |
|---|---|---|
manual | key | nothing else |
schedule | key, cron, expectedAt | timeZone |
once | key, expectedAt | nothing else |
webhook | key, integration, deliveryId | eventType, signedAt |
event | key, integration, deliveryId, source | eventType, signedAt, acquisitionKind |
Integrations
ctx.integrations declares four ports. Everything else is added per workspace.
| Operation | Does |
|---|---|
ctx.integrations.ai.generateText({ prompt, system? }) | Returns { text }. No model or length settings: the platform pins the model. |
ctx.integrations.ai.generateText({ messages, system? }) | Continues a conversation without tools. Returns the text, and the message to append for the next turn. |
ctx.integrations.ai.generateText({ messages, tools, toolChoice? }) | One turn of an agent loop. Returns text, or the tool calls the model chose; the workflow runs them and loops. |
ctx.integrations.ai.generateImage({ prompt }) | Returns { imageUrl } for one generated image. |
ctx.integrations.http.get({ url, headers?, query? }) | Returns { status, headers, body }, with body typed unknown — narrow it with a guard. |
ctx.integrations.http.post({ url, body?, headers? }) | The write verbs, alongside put and patch. Uncertainty on a patch is never retried automatically. |
ctx.integrations.mcp.read({ url, toolName, toolProps?, headers? }) | Calls a read-only tool on an MCP server you name. Returns { structuredContent, content }, both unknown. |
ctx.integrations.mcp.write({ url, toolName, toolProps?, headers? }) | Calls a tool that may change remote state. Uncertainty here is never retried automatically. |
ctx.integrations.postgres.query({ sql, params?, maxRows? }) | One parameterized statement in a read-only transaction. Returns { rows, rowCount, truncated }; a write is refused. |
ctx.integrations.gmail.fetchEmails(…)Search the mailbox with a Gmail query.Those four are what the SDK itself declares. Every other provider operation arrives as a binding: binding an operation writes your workspace's integrations.generated.d.ts, which augments WorkflowIntegrations with that provider's member — so what a workflow can reach depends on what the workspace has bound and the owner has connected, not on what TypeScript would otherwise let you type.
The ai, http and mcp ports are credential-free, and postgres runs against the owner's stored connection, whose password your source never receives. Never read environment variables or credential values in workflow code: take settings from ctx.config, stored secrets from ctx.secrets, and everything else from a port.
Every http and mcp request names its destination in the source: an origin written as a literal, a whole URL from one ctx.config or ctx.secrets value, or — to answer whoever sent a delivery — the address that delivery carried in ctx.input, in which case the request may carry no workflow secret. An agent loop counts every model turn and every tool call against the run's call budget, so cap the turns and validate each call's arguments before you use them.
Errors
Throwing the right one is how you record whether the failure was worth waiting on. Nothing retries it for you.
| Class | Meaning |
|---|---|
RetryableError(message, { retryAfterMs?, code?, detail?, operation? }) | The failure is transient, and retryAfterMs records how long a caller should wait. It does not schedule a retry. |
NonRetryableError(message, { code?, detail?, operation? }) | The failure will not clear on its own. Use it for bad input, bad shapes, and permission failures. |
WorkflowError | The abstract base both extend, carrying retryable, code, detail and operation. |
Neither class causes a retry, and nothing replays a workflow: a failed step ends its run, and a step is a recorded boundary rather than a resume point. So make a step's work safe to repeat, express the waiting and the duplicate check you need in the workflow itself, and never report an outcome the provider did not confirm.
Stay deterministic
Take time and randomness from the context, using ctx.now() and ctx.random(), never from Date.now() or Math.random(). The runtime supplies both with the context, so a step that invents its own instead cannot be reproduced — the same input gives a different answer the next time you run it.