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.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.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 pushedv5while you were working fromv4, your update is rejected withFAILED_PRECONDITIONand a message likeVersion 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
v3and you publishv4adding a reviewer, that request does not change shape under the reviewer. It completes asv3. Only new runs pick upv4. - Delete and recreate resets the counter. Recreating a deleted
definitionIdpurges the old snapshots and starts again atv1. 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 whatv2 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.
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.
Choosing a rejection strategy
Everyhuman 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.
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
SetslaMs 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 includingbreached. - An
on: "custom"edge whosewhentests 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
joinOnQuorum group with a single group-source reject back-edge, as in “Rewind the whole stage” above.
slaMs with only a reject edge
A final human node with no reject edge
Assuming an agent in a quorum group never counts
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:
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.

