Zapier Workflows Create
Create a complete durable workflow from natural language, test it when appropriate, and deploy it through the Zapier SDK experimental Code Workflows commands.
Use the public SDK CLI path. Do not use
zapier-sdk-code-substrate
.
Compatibility Gate
Before using this skill, run the
bundle compatibility check. If
is not installed or cannot be loaded, run
or install
from
before continuing. If
reports SDK/skill drift, follow its refresh instructions, stop this skill invocation, reload the agent workspace if needed, and ask the user to rerun the original request.
Prerequisites
Verify these at the start:
bash
zapier-sdk --version
zapier-sdk get-profile --json
zapier-sdk --experimental --help
zapier-sdk --experimental create-workflow --help
zapier-sdk --experimental publish-workflow-version --help
zapier-sdk --experimental run-durable --help
zapier-sdk --experimental list-triggers --help
zapier-sdk --experimental trigger-workflow --help
Pass
for versions — the platform resolves it. The sandbox installs with
pnpm install --config.minimumReleaseAge=1440
, so a dependency published less than 24h ago is rejected, and
publishes several times a day. You do not have to work out which release is old enough:
is resolved for you to the newest release that clears that 24h gate (COSUB-956).
That only applies to
and
. They are resolved
server-side, at publish and at run submit, and the exact version is stored on the workflow version or the run — so a workflow stays on the version it resolved to, however long it runs.
Give every other dependency an exact version:
bash
--zapier-durable-version latest
--dependencies '{"@zapier/zapier-sdk":"latest","zod":"4.3.6"}'
A spec that isn't resolved server-side is stored as written, and every tick regenerates
from it with no lockfile. So
would install whatever is newest at each tick, and a run that spans a
major would replay against a different library than it recorded. The install itself would succeed —
falls back from the
tag to the newest release outside the 24h gate — which is what makes this worth stating: the failure shows up later, as a replay that doesn't match.
When publishing a workflow version you can omit
entirely and the service injects a pinned version for you.
injects nothing, so declare there whatever the source imports.
Ranges (
,
) are rejected with a 400 for the durable runtime version — whether you pass it as
or as the
entry in
. So
is the only non-exact value it takes. Every other dependency does accept a range and stores it as written; pin those exactly anyway, for the reason above. An exact version is always fine; it must be at least 24h old.
Every package the generated imports must still appear in — the sandbox installs from
, not your local
, so a missing import (such as
) fails the run with
.
The user must also have app connections configured at
https://zapier.com/app/assets/connections for any app actions the workflow will run.
Phase 1: Understand The Intent
Read the user's natural language request and extract:
- Steps and ordering.
- Apps involved.
- Data passed between steps.
- Manual input fields or trigger input fields.
- Conditional logic.
- Waits, callbacks, or human approval gates.
- Start mode (required — see below).
Summarize the proposed workflow back to the user before discovery. Ask focused clarifying questions for missing details like target channels, folders, recipients, or whether to stop when a search returns no results.
Do not generate code until the user agrees on the workflow shape.
Classify The Start Mode (Required)
Every workflow has exactly one start mode, and you must decide it here — it is a required output of this phase, not something inferred later from whether a trigger happened to get configured. There are two:
- — the workflow starts on its own, either on a schedule (scheduled) or in response to an external event (event-driven). It is published with and runs without anyone invoking it.
- — the workflow has no trigger and runs only on-demand via . This is a deliberate choice, not the absence of a trigger.
Default to looking for a trigger in the build instruction. Infer from intent cues:
- Recurring or time words ("every morning", "daily", "each hour", "on a schedule") → , scheduled.
- "when X happens in " / "whenever a new is created" → , event-driven.
- Explicit "manually", "on demand", "when I run it", or a workflow clearly meant to be invoked by hand with input → .
If the start mode is ambiguous — no clear trigger cue and no explicit manual cue — ask the user: run it manually on-demand, or attach a trigger (and which)? Never silently assume just because no trigger was named. A triggerless workflow published on an unconfirmed assumption is the exact failure this classification exists to prevent.
This decision is
required even though the platform treats its start-mode input as optional — the write-only
flag sent at publish is not a required field, and enforcement is per-account and flag-gated, so the optionality is a staged-rollout artifact, not permission to skip the decision. Never publish without a start mode the user, or an unambiguous build instruction, chose.
Carry the chosen start mode forward: it is confirmed in the Phase 3 build plan, drives the publish in Phase 6, and is the gate for the Phase 7 verification.
Phase 2: Discover Apps, Connections, Actions, Triggers, And Fields
Use the standard Zapier SDK CLI for app/action discovery:
bash
zapier-sdk list-apps --search "<app name>" --json
zapier-sdk list-connections <appKey> --owner me --json
zapier-sdk list-actions <appKey> --action-type <write|search|read|read_bulk> --json
zapier-sdk list-action-input-fields <appKey> <actionType> <actionKey> --connection <connectionId> --json
zapier-sdk list-action-input-field-choices <appKey> <actionType> <actionKey> <fieldKey> --connection <connectionId> --json
For workflows that should subscribe to a Zapier app trigger, use the experimental trigger discovery commands:
bash
zapier-sdk --experimental list-triggers <appKey> --json
zapier-sdk --experimental list-trigger-input-fields <appKey> <triggerKey> --connection <connectionId> --json
zapier-sdk --experimental list-trigger-input-field-choices <appKey> <triggerKey> <fieldKey> --connection <connectionId> --json
If several apps, connections, actions, triggers, or field choices are plausible, show the candidates and ask the user to choose.
Use "AI by Zapier" For AI Steps
For any AI / "call an LLM" step — summarize, extract, classify, generate, or analyze text —
always use "AI by Zapier" (app key
) as the step and select the model
inside it: if the user names a provider or model, set that as the
(see below); otherwise use its default model. It runs on Zapier's built-in AI credentials (no third-party account required) and bills as normal Zapier tasks, so an agent-built workflow does not silently route to a separate raw-provider app the user must connect and pay for. Discover it with
list-apps --search "AI by Zapier"
; its generic completion action is
("Analyze and Return Data"), alongside
(from a URL) and
(confirm the current set with
list-actions AICLIAPI --action-type write --json
).
Configuring the step. Inspect its fields with
list-action-input-fields AICLIAPI write get_completion --json
. The ones that matter for a generated step:
- (required) — the prompt describing what the AI should do.
- (optional) — the AI provider, needed only when the user names one. Choices are , , , , (
list-action-input-field-choices AICLIAPI write get_completion provider_id --json
). Setting it is what makes 's choices resolve.
- (required, default ) — the model. For a generic step, pass the default — auto-pick a model in the Advanced tier (tiers: //) on built-in credentials. When the user names a provider or model, set first, then resolve the valid model for it with
list-action-input-field-choices AICLIAPI write get_completion model_id --inputs '{"provider_id":"<provider>"}' --json
(the list is empty until is set) and pass the matching value (for example anthropic/claude-sonnet-5
, ). Do not hardcode a model list — resolve it at build time.
- (required, default ) — is Zapier's built-in AI credentials (the models shown with a Zap icon). Keep for the default and any built-in model. A model the user names may not be available on built-in credentials — those require the user's own AI provider account (a custom ); if so, tell the user and use their authentication. depends on this field.
- (optional, OBJECT) — extra context fields mapped from earlier steps, merged into the prompt.
So a default AI step needs only a prompt.
and
are required but have working defaults; pass them explicitly with those defaults (
and
) so the
inputs are complete, and no connection alias is needed for the built-in path:
typescript
const summary = await ctx.step("summarize-with-ai", async () =>
sdk.runAction({
appKey: "AICLIAPI",
actionType: "write",
actionKey: "get_completion",
inputs: {
instructions: `Summarize this in one sentence: ${input.text}`,
model_id: "advanced/auto",
authentication_id: "0",
},
}),
);
Naming a provider or model is
not a reason to leave "AI by Zapier" — set it as the
above. Reach for a raw-provider AI app (Anthropic, OpenAI, Google AI, and so on) only when the user explicitly asks for that standalone app, or needs a capability "AI by Zapier" does not offer. When you do, tell the user the step uses their own provider connection and billing, not "AI by Zapier."
Assign a short snake_case connection alias for each chosen connection, such as
or
. Track alias to connection ID. The alias goes in workflow code; the connection ID is passed to test/deploy commands through the
JSON.
For output mapping between steps, run a safe action test only after user confirmation. Use the current SDK command shape:
bash
zapier-sdk run-action <appKey> <actionType> <actionKey> \
--connection <connectionId> \
--inputs '<{"key":"value"}>' \
--json
For trigger-backed workflows, capture the trigger configuration for publish:
json
{
"selected_api": "GoogleSheetsAPI@2.3.0",
"action": "new_row",
"authentication_id": "connection-id-or-null",
"params": {}
}
Use the version-pinned app/API identifier for
, the trigger action key for
, the trigger source connection ID for
when the trigger requires auth, and trigger input values for
. Omit optional fields only when the trigger does not need them.
For
, use the
version-pinned implementation identifier — the
returned by SDK discovery (
/
), such as
. Do not use the bare app key (
) and do not substitute a display name. A bare, unversioned
makes the trigger claim
fail silently at publish: the publish call returns success with no errors, but the workflow stays disabled and nothing surfaces the cause. If discovery only exposes a bare app slug and not a versioned
, treat that as a blocker and record it in the build plan before publishing — do not publish a trigger with an unversioned identifier.
For
, match each field's
from
list-trigger-input-fields <app> <action>
. ARRAY fields must be JSON arrays (for example
); STRING fields must be plain strings (for example
). Passing a scalar where an array is expected (or vice versa) fails the trigger claim the same silent way.
Capture app implementation/version information from SDK discovery output when available, such as
,
,
, or trigger/action result metadata. Do not invent app versions. If no implementation/version binding is exposed, omit
rather than guessing.
"Webhooks by Zapier" and other apps with a catch-hook trigger (PayPal, Salesforce, Twilio, WordPress, Wufoo, Zillow, and others) are discovered and configured exactly like any other trigger app — nothing about them is special-cased. Search
list-apps --search "webhook"
(or the specific app name) for its
(for example
— an illustrative example, not a version to hardcode; confirm the current version via discovery), then
for its catch-hook trigger action. "Webhooks by Zapier" itself is no-auth (
) with empty
, but confirm its action key via
list-triggers WebHookCLIAPI
rather than hardcoding one — as of this writing it exposes both
(parsed payload; the common default) and
(unparsed body and headers, max 2MB), and that pair of action keys is specific to
, not a pattern the other apps share. Other catch-hook apps (PayPal, Salesforce, Twilio, ...) commonly require a connection, because claiming their trigger means calling the provider's API to register a subscription. Do not assume no-auth or empty
for those — confirm each app's actual action key, auth, and param requirements via
/
list-trigger-input-fields
(see above) rather than generalizing from "Webhooks by Zapier." Configure them through
at publish time (Phase 6) like any other trigger — do not treat them as "no trigger" / manual-only workflows.
Phase 3: Confirm The Build Plan
Before writing code, present:
text
Workflow: <kebab-case-name>
Input: { field1, field2 }
Connections:
alias = connectionId (connection title)
Start mode: trigger (<selected_api.action with params, including "Webhooks by Zapier" or other catch-hook apps>)
— or —
Start mode: manual — on-demand only via `trigger-workflow`
Steps:
1. <step-name> - <AppName>.<actionType>.<actionKey>
2. <step-name> - <AppName>.<actionType>.<actionKey>
Return: <summary of output>
The
Start mode line is required and must state exactly one of the two modes classified in Phase 1.
is a deliberate, user-confirmed selection — never render it as "no trigger" or leave it implied by an absent trigger. If the mode is still ambiguous at this point, resolve it with the user before proceeding (Phase 1).
Ask the user to confirm before generating files, including explicit confirmation of the start mode.
Phase 4: Generate The Workflow Project
Create a workflow directory:
text
<working-directory>/
<kebab-case-workflow-name>/
package.json
workflow.ts
is for local type-checking only — the sandbox installs from
, not from this file — so
is fine here too:
json
{
"type": "module",
"dependencies": {
"@zapier/zapier-sdk": "latest",
"@zapier/zapier-durable": "latest",
"zod": "4.3.6"
},
"devDependencies": {
"typescript": "latest"
}
}
If you add a build script, use
for now to avoid type-check failures from SDK/durable transitive type declarations:
json
{
"scripts": {
"build": "tsc --target es2022 --module nodenext --moduleResolution nodenext --skipLibCheck --outDir dist workflow.ts"
}
}
- Import from .
- Import from .
- Create the SDK client once at module level:
const sdk = createZapierSdk()
above
- Use Zod for input validation when the workflow has input.
- Keep external side effects (app actions, fetches) inside calls.
- Make each app action exactly one whose body is a single
return sdk.runAction({...})
call — one per step.
- Group validation, input normalization, simple guards, data shaping into steps as needed.
- Use connection aliases, not raw connection IDs, inside workflow code.
- Reference a prior step's output with for the first result, or for the whole array.
- Normalize manual input before Zod validation. In the current path, input may arrive as a JSON string rather than an already-parsed object.
Use this helper pattern for workflows with input:
typescript
function normalizeInput(rawInput: unknown): unknown {
if (typeof rawInput === "string") {
return JSON.parse(rawInput);
}
return rawInput;
}
Then parse the normalized value:
typescript
const input = InputSchema.parse(normalizeInput(rawInput));
Visualizer-Friendly Structure
Generate durable source that can be turned into a meaningful step graph. Avoid overly dynamic construction.
call shape — every call must resolve to a function. Use either the bare form
defineDurable("workflow-name", async (ctx, input) => { ... })
or the object form
defineDurable({ name: "workflow-name", inputSchema, outputSchema, description, run: async (ctx, input) => { ... } })
.
is always the first parameter of
;
is the optional second parameter, so
is also valid. These shapes are invalid and make the workflow fail on its first run with
durable.run is not a function
:
defineDurable(async (ctx, input) => { ... })
— a bare function with no name. The function is treated as an options object, so is never set. This is the most common mistake.
defineDurable({ name: "workflow-name" })
— object missing .
defineDurable({ name: "workflow-name", run: someNonFunction })
— is not a function.
durable.run is not a function
is a code-shape defect in your
call, not a version mismatch. Do not change the pinned
or
versions to fix it — correct the call so it passes a
and a
function.
Default to this parser-friendly shape — module-level
, hoisted app-key/connection constants, and a bare
body for each app action:
typescript
import { defineDurable } from "@zapier/zapier-durable";
import { createZapierSdk } from "@zapier/zapier-sdk";
import { z } from "zod";
const sdk = createZapierSdk();
const InputSchema = z.object({ reaction: z.string() });
type Input = z.infer<typeof InputSchema>;
const TODOIST_APP_KEY = "TodoistV2CLIAPI";
const TODOIST_CONNECTION = "todoist_primary";
const workflow = defineDurable<Input, unknown>(
"example-workflow",
async (ctx, input) => {
// Plain code: guard outside any step.
if (input.reaction !== "todo") {
return { skipped: true };
}
// Plain code: shape the action input outside the step.
const taskInput = buildTaskInput(input);
// App action: one runAction, object literal, module-level sdk.
const createdTask = await ctx.step("create-todoist-task", async () =>
sdk.runAction({
appKey: TODOIST_APP_KEY,
actionType: "write",
actionKey: "new_task",
connection: TODOIST_CONNECTION,
inputs: taskInput,
}),
);
return { createdTask };
},
);
App-Action Step Shape (Editor Recognition)
The editor renders a
as an
app-action step (with the app icon) when its body is a single
call with
,
, and
(object literal, or a
that resolves to one; the
/
spellings also work). A string-literal step id (
ctx.step("create-todoist-task", ...)
) and an inline
callback are the recognized form; object form
works too.
Other steps render as plain
code steps — for example a step with no
, or with more than one, or one created in a loop with a dynamic id (
). That is expected, not a regression; loops and fan-out legitimately need dynamic ids.
Phase 5: Test The Workflow
bash
SOURCE_FILES="$(jq -n --rawfile workflow workflow.ts '{"workflow.ts": $workflow}')"
Build the
JSON from the selected aliases. It's a nested object — each alias maps to an object holding a
(never a bare string). The same shape is used for
in Phase 6:
json
{
"slack_work": { "connectionId": "12345678" },
"gmail_primary": { "connectionId": "87654321" }
}
Before running, tell the user what actions may happen in connected apps and wait for confirmation if there are side effects.
Run the durable:
bash
zapier-sdk --experimental run-durable "$SOURCE_FILES" \
--dependencies '{"@zapier/zapier-sdk":"latest","zod":"4.3.6"}' \
--zapier-durable-version latest \
--connections '<connections JSON>' \
--input '<JSON matching input schema>' \
--private
returns a run immediately, often before the workflow is complete. Capture the returned run ID, then poll until terminal status. Do not assume the first response contains final output.
bash
zapier-sdk --experimental get-durable-run <run-id> --json
Terminal success means the run has
, an expected
,
, and top-level
. Terminal failure means
or a non-null
. Continue polling while the run is initialized or started.
Fix code and retest until the behavior matches the confirmed plan.
Phase 6: Deploy The Workflow
Decide whether the workflow should be private before creating it. For EA users, default to private unless the user explicitly wants an account-visible workflow.
Create a private workflow container:
bash
zapier-sdk --experimental create-workflow "<workflow-name>" \
--description "<brief description>" \
--private \
--json
Omit
only if the user explicitly wants the workflow visible to the broader account.
Capture the returned workflow ID. Then decide how to ship the code:
-
Direct publish (the default below): publish the first version straight away with
. This is the legitimate no-open-draft case — the container was just created, so no draft exists to publish past.
-
Stage as a draft for review: if the user wants to look the workflow over in the Zapier editor before it goes live, put the generated code in a server draft instead of publishing:
bash
zapier-sdk --experimental create-workflow-draft <workflow-id> --json
zapier-sdk --experimental update-workflow-draft <workflow-id> <draft-id> "$SOURCE_FILES" \
--draft-revision <draft_revision from the create response> \
--json
Pass the same
,
,
,
, and — for a
workflow —
values Phase 6 would have passed to the publish. Then hand the user the draft's editor link —
https://zapier.com/durables-editor/<workflow-id>/draft/<draft-slug>/workflow.ts
, using the
from the draft response; the final segment is one of the draft's
keys (
in this skill's flow) — to review and publish, or publish on their go-ahead. Carry the start-mode decision to the draft publish exactly as a direct publish would: a
workflow publishes with
(
publish-workflow-draft <workflow-id> <draft-id> --manual --enabled --json
); a
workflow's draft already holds its
, so publish without
(
publish-workflow-draft <workflow-id> <draft-id> --enabled --json
). Never pass
and
together. Publishing consumes the draft. Skip Phase 7's version read-backs if nothing was published.
For a direct publish, the current SDK CLI expects
as a JSON object, not a path to
.
For publish, use the same nested
shape as
— each alias maps to an object holding a
:
json
{
"slack_work": { "connectionId": "123-or-uuid" },
"gmail_primary": { "connectionId": "456-or-uuid" }
}
If app implementation/version information is known, build the
payload:
json
{
"slack": { "implementation_name": "SlackCLIAPI", "version": "optional" }
}
Omit the entire
flag when no app implementation/version binding is needed. Likewise, omit
when the workflow has no connection bindings. Do not pass placeholder text like "if needed" to the CLI.
For trigger-backed workflows, build the
JSON from Phase 2. Keep
version-pinned to the
(for example
) and keep each
field shaped to its
(see Phase 2) — a bare app key or a wrong param shape makes the trigger claim fail silently at publish:
json
{
"selected_api": "GoogleSheetsAPI@2.3.0",
"action": "new_row",
"authentication_id": "connection-id-or-null",
"params": {}
}
A "Webhooks by Zapier" or other catch-hook trigger is a real trigger — publish it with
using the config captured in Phase 2, the same as any other app trigger.
How you publish follows directly from the start mode confirmed in Phase 3 — the two are not co-equal defaults; you commit to the one the user chose.
Before publishing, confirm the payload matches the declared start mode: → the publish passes
and
not ;
→ it passes
and
not . Pass
exactly one — the platform contract is a discriminated union (a version is either triggered or
, never both) and rejects passing both together as a contradiction; the CLI also guards the both-case client-side. The workflow you publish must carry the trigger you decided on, or be explicitly marked manual. Catch any disagreement here, before the publish call, so a dropped or missing trigger is not discovered only in Phase 7.
— publish with
, using the config built above. The trigger is the signal; do
not also pass
(that is the contradiction the gate rejects):
bash
SOURCE_FILES="$(jq -n --rawfile workflow workflow.ts '{"workflow.ts": $workflow}')"
zapier-sdk --experimental publish-workflow-version <workflow-id> "$SOURCE_FILES" \
--dependencies '{"@zapier/zapier-sdk":"latest","zod":"4.3.6"}' \
--zapier-durable-version latest \
--connections '<publish connection bindings JSON>' \
--app-versions '<app versions JSON if needed>' \
--trigger '<trigger config JSON>' \
--enabled \
--json
— and only when Phase 3 confirmed manual — omit
and pass
to declare the on-demand start mode explicitly. Marking manual is the deliberate branch, not a fallback for when a trigger was hard to configure: if the user asked for a trigger, a failure to build its config is a blocker to resolve, never a reason to drop to manual.
bash
zapier-sdk --experimental publish-workflow-version <workflow-id> "$SOURCE_FILES" \
--dependencies '{"@zapier/zapier-sdk":"latest","zod":"4.3.6"}' \
--zapier-durable-version latest \
--connections '<publish connection bindings JSON>' \
--app-versions '<app versions JSON if needed>' \
--manual \
--enabled \
--json
and
are mutually exclusive: pass
here
because there is no trigger. Never pass both in one publish.
Do not use the old
,
,
, or
flags. The current trigger publish path is the single JSON
object.
If the publish is rejected with a conflict about open drafts, someone (likely the user, in the Zapier editor) forked a draft on this workflow mid-flow. An open draft always holds unpublished work, so never publish past it silently. Tell the user and offer the same choices as
: fold your changes into that draft and publish it (
+
), or — with their explicit confirmation, since it drops the draft's unpublished work — discard the draft (
) and retry the direct publish.
Phase 7: Verify Deployment
Read back the workflow and versions:
bash
zapier-sdk --experimental get-workflow <workflow-id> --json
zapier-sdk --experimental list-workflow-versions <workflow-id> --json
zapier-sdk --experimental get-workflow-version <workflow-id> <version-id> --json
Gate On The Start Mode (Required)
Verification must confirm the deployed workflow matches the
start mode confirmed in Phase 3 — not merely that it is
. A triggerless workflow reads back as
, so an
check alone silently passes a workflow that was supposed to have a trigger but doesn't. Re-read the workflow (the trigger claim is asynchronous and can fail silently, so allow a few seconds and poll if needed) and gate on the declared mode:
bash
zapier-sdk --experimental get-workflow <workflow-id> --json
- → require both and a non-empty . An empty means the trigger was dropped or was omitted — the claim failed or was never attempted. Do not report the workflow as done. The most common cause is a that is not version-pinned to the , or a field with the wrong shape (see Phase 2); after publishing with is the same failure. Re-publish with a corrected and re-check.
- → require to be empty by design, and confirm this workflow was deliberately classified manual in Phase 3 (never triggered). A manual workflow with a non-empty is also a mismatch — stop and reconcile with the user. It is invoked on-demand via ; there is no trigger claim to verify.
is the authority for this gate. The platform's start-mode input (the write-only
flag) is never surfaced on any read-back, so do not look for it on
or a version — read the trigger's actual presence instead.
Regardless of trigger type, check the matching entry in
from the
read-back above for
(re-run the same command if enough time has passed since that read that the claim state could have changed). If present, it is the catch URL external services call — show it to the user plainly; unlike the workflow-level
, it is meant to be shared. Most triggers have no
, and that is normal — do not flag its absence.
If you configured a catch-hook trigger in Phase 2 (a "Webhooks by Zapier" or similar catch-hook app/action) and
is still absent once the trigger is active, the installed
may predate this field — run
to check for an update, and in the meantime tell the user to copy the URL from the trigger step in the Zapier editor (
https://zapier.com/durables-editor/<workflow-id>
).
If manual triggering is supported for the workflow, test it only after confirming side effects with the user:
bash
zapier-sdk --experimental trigger-workflow <workflow-id> --input '<JSON>' --json
If
returns a trigger ID before a workflow run ID is available, bridge from trigger to run:
bash
zapier-sdk --experimental get-trigger-run <trigger-id> --json
Then inspect run history and, if needed, a deployed workflow run:
bash
zapier-sdk --experimental list-workflow-runs <workflow-id> --json
zapier-sdk --experimental get-workflow-run <run-id> --json
Finish by reporting:
- Workflow name and ID.
- Where lives locally.
- Whether testing passed.
- Whether the deployed workflow is enabled.
- Whether the workflow is private or account-visible.
- The confirmed start mode: (a Zapier app trigger or a catch-hook trigger — report its if available) or (on-demand only via ), and that the Phase 7 gate confirmed the deployed workflow matches it.
- The Zapier editor link:
https://zapier.com/durables-editor/<workflow-id>
.
Durable Patterns
Waits
typescript
await ctx.wait("wait-before-followup", 3600);
Place waits at top-level workflow scope, not inside
.
Callbacks
typescript
const [approvalPromise, callbackUrl] = await ctx.createCallback({
name: "wait-for-approval",
payloadSchema: z.object({ approved: z.boolean() }),
timeoutSeconds: 86400,
});
await ctx.step("send-approval-request", async () =>
sdk.runAction({
appKey: "ExampleCLIAPI",
actionType: "write",
actionKey: "send_message",
connection: "example_connection",
inputs: { callbackUrl },
}),
);
const approval = await approvalPromise;
if (!approval.approved) {
throw new Error("Approval denied");
}
Parallel Or Repeated Work
Use
outside
; each iteration creates its own step:
typescript
const results = await Promise.all(
items.map((item, index) =>
ctx.step(`process-item-${index}`, async () =>
sdk.runAction({
appKey: "ExampleCLIAPI",
actionType: "write",
actionKey: "do_something",
connection: "example_connection",
inputs: { item },
}),
),
),
);
Loop/fan-out steps use a dynamic id (
), so the editor renders them as code steps — expected for this pattern (see
App-Action Step Shape (Editor Recognition)).
Error Handling
Use step-level retries for flaky external calls:
typescript
const result = await ctx.step({
name: "flaky-api-call",
maxAttempts: 3,
retryDelaySeconds: 5,
run: async () =>
sdk.runAction({
appKey: "ExampleCLIAPI",
actionType: "write",
actionKey: "do_something",
connection: "example_connection",
inputs: {},
}),
});
Prefer
when a Zapier action exists. Use
only when the app action cannot provide the needed behavior or data.