Skip to main content
Customize Behavior tells you what every field does. This page tells you which one to pick.

I want X, use Y

Duplicating a workflow

There is no duplicate endpoint. A definition supports five operations: create, update, delete, get, and list. Copying is not one of them, so you do it by hand. It is four steps.
1

Fetch the original

Call Get Definition. The response carries definitionId, name, description, scope, nodes, edges, groups, triggers, tags, and custom, plus the server-owned version, createdAt, updatedAt, status, and compiled. It does not carry webhookConfig.
2

Change the id and the name

definitionId is the workflow’s permanent handle, and two active definitions cannot share one. Turn marketing-page-approval into marketing-page-approval-eu. Give the copy a name a human can tell apart too.
3

Strip the server-owned fields and the explicit nulls

A read response carries bookkeeping that is not yours to set: version, createdAt, updatedAt, status, and compiled. Delete all five before sending.Then delete every key whose value is null. Every optional field you never set comes back that way: description, groups, triggers, tags, custom, and organizationId / documentId inside scope. Create takes each of those as absent or as a real value, never as null, so a leftover null is rejected.Create rejects unknown fields outright too, so leaving even one of either kind in fails the whole request with INVALID_ARGUMENT. That is the good outcome: you get a loud error, never a silently mangled copy.
4

Send it to create

POST the trimmed object to Create Definition. You now have a second workflow, starting at version: 1.
Triggers are what bite people. If the original runs every night at 2am, the copy now runs every night at 2am too, and you have two nightly runs.Before you create the copy, either drop triggers entirely and re-add them deliberately, or give the copy fresh triggerId values.
webhookConfig is write-only. Create Definition and Update Definition accept it, but Get Definition does not return it. The copy therefore starts with no per-definition webhook target. Re-send webhookConfig, with its own fresh secret, on the create call if the copy needs one. Dispatch-level webhookUrl + webhookSecret are a separate path, so a run dispatched with that pair still pushes every lifecycle event. Supplying both on dispatch replaces the definition’s webhookConfig for that run rather than adding a second target, and it clears any eventTypes filter.Updates hit the same gap. Update is a full replace, and the object you fetched carries no webhookConfig, so a get, edit, update round trip nulls the existing webhookConfig on the original definition. Re-send it on every update of a definition that has one.
Everything the read response carries copies safely. Your edges come back exactly as you authored them, byte for byte with no reformatting, so the round trip cannot quietly change routing. scope echoes the organization and document ids you originally sent, not internal ones, so it is safe to copy or edit as-is.

Versioning, and what it does not do

Every update bumps the version (v1 to v2 to v3) and stores a complete copy of the previous content. Nothing is overwritten in place. Three things follow, and they are the useful part:
  • You cannot clobber a coworker’s edit. Update requires ifVersion, your claim about which version you are editing. If someone pushed v5 while you were working from v4, your update is rejected with FAILED_PRECONDITION and a message like Version conflict: expected 4, current 5, instead of silently overwriting them.
  • Editing never disturbs runs already in progress. A run locks onto the version that was current when it started and finishes on that version. If a request is sitting in someone’s queue on v3 and you publish v4 adding a reviewer, that request does not change shape under the reviewer. It completes as v3. Only new runs pick up v4.
  • Delete and recreate resets the counter. Recreating a deleted definitionId purges the old snapshots and starts again at v1. The version line is not continuous across a delete.

What versioning does not give you

This is where expectations usually outrun reality. Old versions are stored, but you cannot read them. There is no endpoint to list history or fetch what v2 looked like. The snapshots exist for audit and recovery; they are not part of the public API. All you can see is the current version number. Which means there is no rollback. If v5 was a mistake, you cannot ask for v4 back. You need your own copy of v4’s content, which you resubmit as an update, producing a v6 that contains v4’s content. You move forward to go back. Without a copy, you are reconstructing it by hand. There is also no draft versus live distinction, no way to mark a version as the stable one, and no way to run a specific version. New runs always get the latest.
Keep your definitions in source control and treat the API as the deployment target. That single habit gives you the history, the diffs, and the rollback path the API does not.

Choosing a parallel-review policy

Pick by what should happen the moment quorum is met. Groups can branch collectively. Set from: { kind: "group", groupId } and the group fires one successor instead of one per member. waitAll waits for everyone and decides by unanimity, approving only if every member approved, so it can carry both an on: "approve" and an on: "reject" branch. joinOnQuorum and cancelOnQuorum fire on approval quorum only, so a forward on: "reject" from either is rejected as a dead edge.
A per-member on: "reject" edge on a joinOnQuorum member is unreachable at runtime. The policy suppresses per-member fan-out, so the rejecter’s edge never fires. It still satisfies the reject-path rule, but it is dead code. For per-rejecter routing, use waitAll or cancelOnQuorum, which keep per-member fan-out.

Choosing a rejection strategy

Every human node needs a reject route. There are three shapes. Send it back to me. One reviewer, one retry counter. The work returns to an earlier node, up to N times, then escalates.
Send it somewhere else. No retry, just routing. A different team picks it up.
Rewind the whole stage. Several reviewers work in parallel, all must approve, and any single rejection sends the entire stage back with one shared retry counter.
A group-source reject back-edge is joinOnQuorum-only, because that is the case where one shared counter makes sense. Forward collective fan-out works for any policy. If rejection should simply end the run, say so explicitly with a reject edge to a terminal node. The engine will not let you leave it implicit.

Choosing SLA and breach handling

Set slaMs when a step is time-critical. On breach the step becomes breached, which is terminal. The catch: a reject edge fires on decision == 'reject', never on a breach. So slaMs plus only a reject edge is rejected at write time with missing-breach-edge. Add one of:
  • An on: "always" edge, which fires on every terminal status including breached.
  • An on: "custom" edge whose when tests for the breached status.

Webhooks or polling

They are complementary, not alternatives. In production use both: webhooks for liveness, polling for recovery. seq is monotonic per execution, so catch-up is deterministic.

Anti-patterns

These look reasonable. They are not.

One reject back-edge per parallel reviewer

Each back-edge derives its own loop region, and the bodies overlap. Use one joinOnQuorum group with a single group-source reject back-edge, as in “Rewind the whole stage” above.

slaMs with only a reject edge

The reject edge never fires on a breach, so the breach would dead-end. Add a breach route.

A final human node with no reject edge

Even the last approver needs somewhere to send a rejection. If rejection should end the run, route it to a terminal node explicitly.

Assuming an agent in a quorum group never counts

An agent step ends completed with output.decision set to approve when its agent run passes or is skipped, and failed when it does not. Quorum counts every member that completes with a decision of approve, whatever the node type. So a passing agent-summarize plus an approving human-legal meets quorum: 2, and the group fires. Failure is the real constraint here, not node type. A failed agent step never counts toward the approval counter, whatever shape its output takes. If the agent run finishes unsuccessfully, the step carries output.decision set to reject. If the step fails before the run starts, its output is empty and carries no decision at all: a missing agentId, neither url nor urlPath, blocking: true, or a failed dispatch call. Other failure paths carry an output with neither a decision nor an approval. None of them advance the approval counter, only the completion counter. With quorum equal to expectedSteps, one agent failure keeps the group from firing, exactly as one human rejection would.
Marking the agent blocking: true is not available: blocking agent nodes are rejected at runtime with agent-blocking-not-supported. To get human review of an agent’s findings, put a human node downstream of the agent node.

when written as JavaScript

when is a JSON-AST string, not an expression language:
You only write this for on: "custom". The approve, reject, always, and exhausted roles compile their own predicates.

An on: "exhausted" target that is reachable another way

This is accepted, and it will surprise you. Dispatch never seeds an exhausted-route node as a root: it drops any node that has an incoming edge, and separately drops any node named by a loop’s onExhausted.routeToNodeId. The duplicate shows up later. If your escalation node is also the target of a normal edge, its predecessor’s fan-out spawns it when that predecessor completes, and the loop spawns a second copy when the cap is hit. The two steps get different ids, so neither one deduplicates the other. Both run. Keep on: "exhausted" targets as leaves with no other incoming edges. Chain anything else off them.

Next steps

Customize Behavior

The field reference behind every choice on this page.

Setup

Build and run a workflow end to end.