Workflow Iteration
A procedure for improving workflows through small tested revisions.
Template Content
Workflow Iteration
Use this skill when you need to change an existing workflow without breaking live runs. The goal is to make small, verified revisions: inspect the current workflow, diagnose the failing step, patch only the required node or edge, trigger a realistic run, and keep iterating until the run reaches the intended terminal state.
Core Loop
- Read the workflow before changing it. Capture the current version, node IDs, inputs, config, and downstream dependencies.
- Diagnose from a real run when possible. Inspect the failed step's recorded input and output; those fields show exactly what the executor saw.
- Patch one concern at a time. Prefer a node-level patch over replacing the whole workflow.
- Re-read after the patch. Confirm the version changed and the resulting config matches what you intended.
- Trigger with a realistic payload. Include the fields downstream nodes expect, not only the field you are testing.
- Watch the run to terminal state. If it fails, use that run as the next diagnostic input.
- Mirror the verified change into your workflows-as-code source, if your deployment uses one.
Authoring Rules
- Keep node IDs stable. Other nodes may reference them by exact string path.
- Treat
configas replacement-prone. When a patch touchesconfig, send the full config object for that node unless your workflow API explicitly deep-merges nested fields. - Make routing explicit. Branching nodes should have named pass/fail routes, and silent skip paths should still produce an observable outcome when operators need to know what happened.
- Wire inputs deliberately. Node executors receive the raw workflow context plus resolved
inputsaliases, so a condition can use either a local alias or a node-id-prefixed path. Confirm the chosen path against the recorded step input. - Keep schemas tight. If an agent-task has an
outputSchema, include the expected JSON shape in the task prompt and route it to a worker/provider that is known to return structured output correctly. - Prefer reusable script nodes for deterministic shared logic. Agent tasks are best for investigation or work that genuinely needs an LLM. For a bounded judgment, use
system-one-decision(see Choosing a decider). - Scope parallel branches so they do not overwrite one another. Fan-out tasks should have separate context keys or branch-specific output fields.
- Make retry paths idempotent. A rerun should detect existing artifacts, comments, PRs, or notifications and update or skip them rather than duplicating work.
Choosing a decider
When a node has to decide, pick the cheapest node that can decide. Use system-one-decision without being asked when the decision is a bounded judgment.
| The decision is | Use |
|---|---|
| A fact: a count, PR state, flag, date, lookup, regex, or schema check | script, property-match, or code-match |
| A bounded judgment: pass/fail, pick one of N, a score on a rubric, or the probability that a claim is true | system-one-decision |
| Free-form generation, or findings a later node reads | raw-llm or agent-task |
Jev is weak at counting. For a hybrid, count in a script and pass the number to system-one-decision as part of state.
swarm-script Timeout Limit
- Keep
config.timeoutMsat or below300000(5 minutes); the default remains30000(30 seconds). - Workflow create, update, bulk-patch, and single-node patch operations validate executor config and reject an oversized value before saving it.
- For orchestration that needs more than 5 minutes, use a durable one-off script workflow run via
launch-script-runand split the work into bounded, journaledctx.step.swarmScript(or other durable) steps. Do not stretch a singleswarm-scriptnode beyond the cap.
Node Contract Reference
Use these shapes as a starting point, then confirm them against the current executor schemas before patching a live workflow.
property-match
{
"type": "property-match",
"config": {
"conditions": [
{ "field": "verdict", "op": "eq", "value": "publish" }
],
"mode": "all"
},
"inputs": { "verdict": "review.taskOutput.verdict" },
"next": { "true": "publish", "false": "stop" }
}
- Operators are
eq,neq,contains,not_contains,gt,lt, andexists;modeisallby default orany. fieldresolves against the combined execution context. Both raw paths such asreview.taskOutput.verdictand resolved aliases such asverdictare available.- The executor returns
{ passed, results }and routes on ports named"true"and"false". The keys innextmust match those port names.
agent-task structured output
{
"type": "agent-task",
"config": {
"template": "Review the input and return the requested JSON object: {{draft}}",
"outputSchema": {
"type": "object",
"properties": { "verdict": { "type": "string" } },
"required": ["verdict"]
}
},
"inputs": { "draft": "prepare.result" }
}
- Put the task contract in
config.outputSchemaand repeat the exact required shape in the task template. The worker must complete withstore-progress.outputset to a stringified JSON object matching it. - The workflow step exposes
{ taskId, taskOutput }; downstream paths therefore use<node-id>.taskOutput.<field>. - Route only to an agent that exists and is eligible for the task's tools and output contract. Resolve the agent from the live agent registry instead of copying an ID from another workflow.
validate
{
"type": "validate",
"config": {
"targetNodeId": "review",
"schema": {
"type": "object",
"required": ["taskOutput"]
}
},
"next": { "pass": "continue", "fail": "repair" }
}
validate checks the named upstream node output. Supply either schema for a deterministic structural check or prompt for a judgment-based check. Its output is { pass, reasoning, confidence }, routed through pass or fail.
system-one-decision (SystemOne Decision)
Before you add the first system-one-decision node to a workflow, confirm its key works. A node with no working key fails its whole run, and a save only warns. Do these in order and stop at the first failure:
-
Pick the provider.
typesafe(the default) needs the global secretTYPESAFE_API_KEY.openrouterneedsOPENROUTER_API_KEY.layaneeds the global secretLAYA_API_KEYand the global configLAYA_URL, the base URL of its server.openaineedsOPENAI_DECISIONS_API_KEY(neverOPENAI_API_KEY).cloudflareneeds the global secretCLOUDFLARE_API_TOKENand the global configCLOUDFLARE_ACCOUNT_ID. Use the provider the requester named. If they named none, usetypesafe. If its key is missing but another provider's key exists, ask which to use rather than switching, because the accounts bill separately. -
Check the key is set:
get-configwithkeyset to that name and noincludeSecrets. A masked value (********) means a value exists. No entry means it is missing. Never passincludeSecrets, and never read the value. Forlaya, checkLAYA_URLthe same way, and forcloudflare,CLOUDFLARE_ACCOUNT_ID; neither is secret, so the value shows. -
Confirm it works with one cheap call. Create a throwaway one-node workflow, trigger it, read the run, then
delete-workflowit:{ "nodes": [ { "id": "ping", "type": "system-one-decision", "config": { "provider": "typesafe", "state": "ping", "questions": { "ping": { "type": "noul", "instructions": "Is the state the word ping?" } }, "returns": { "ping": { "type": "noul" } }, "timeoutMs": 15000, "maxRetries": 0 } } ] }A
completedrun means the key works. Afailedrun carries the reason:<KEY> is not configured(missing; forlayait can nameLAYA_URLorLAYA_API_KEY, or both), or<KEY> was rejected by <host> (HTTP 401)(present but refused). To probelaya, set"provider": "laya"in the ping node. Ifcreate-workflowsayssystem-one-decisionis an unregistered executor type, the node is not deployed on this swarm yet: stop and say so. -
If the key is missing or was rejected, do not build the node. Ask a human for it with a
request-human-inputquestion of typeconfig, one question per key, and share the returned URL in the thread:{ "id": "typesafe_key", "type": "config", "config": { "key": "TYPESAFE_API_KEY", "scope": "global" } }The person enters the value on the dashboard card, which already shows whether the key is set. A secret goes straight into encrypted config. You get back
{ key, scope, scopeId, isSecret, status, updatedAt }; non-secret results may also includevalue. A secret value is never returned. For a non-secret value such asLAYA_URL, set"isSecret": false; addoptionsordefaultValuewhen you know the valid values. Whenstatusisset, rerun step 3. Never ask them to paste a key into chat, a task, or a definition, and never write a key into a definition.
Afterwards, a warnings entry on create-workflow or a patch tool means the key went missing since you checked. Treat it as step 4.
{
"type": "system-one-decision",
"inputs": { "pr": "trigger.pullRequest" },
"config": {
"provider": "typesafe",
"model": "jev-1.13.0",
"state": "{{pr}}",
"questions": {
"ready": {
"type": "noul",
"instructions": "Is this pull request ready to merge?",
"criteria": { "true": "Tests pass and the change matches its description", "false": "Missing tests or scope creep" }
},
"risk": {
"type": "choice",
"instructions": "How risky is this change?",
"criteria": { "low": "Docs or tests only", "medium": "Contained behavior change", "high": "Touches auth, data, or billing" }
}
},
"returns": { "ready": { "type": "noul" }, "risk": { "type": "choice" } }
},
"next": "gate"
}
provideristypesafe(default),openrouter,laya,openai, orcloudflare, and must be a literal, not a{{token}}.modelis that provider's own id (jev-1.13.0ontypesafe,typesafe/jev-1.13onopenrouter, a checkpoint name such asmultilingualonlaya);gpt-6-lunaonopenai,clef(default) orclef-flashoncloudflare, which accepts no other id; leave it unset for the provider's default.layahas no default and sends nomodel, because it silently ignores one it does not know: leave it unset unless you mean a specific checkpoint. Every provider returns the same output;openaigets a translated request, and an OpenAI refusal of any question fails the step.cloudflaretakes at most 64 questions. There is no fallback between them. Never use OpenRouter'stypesafe/jev-router, which is a chat router that hands the request to another model.stateis required: text, a string array, or a JSON object. An exact{{token}}keeps the upstream JSON type. Every question in one call shares the state and cannot see the other answers, so a dependent judgment needs its own node.- Each question is
noul(probability that a claim is true; optionalcriteria: { true, false }),choice(2 to 255 options;criteria: { option: description }), orscore(2 to 10 ordered levels;criteria: [level, ...]). returnsrepeats every question id and its type. A mismatch is rejected when the workflow is saved.- The output is
{ model, answers, usage }, plusrouting: { model }onlaya(the checkpoint that answered). Read a field as<node-id>.answers.<question>.<field>, for examplequalification.answers.risk.confidence. Anoulanswer is only{ type, noul }and has no confidence. Achoiceanswer haschoice,probabilities, andconfidence. Ascoreanswer hasscore,legend,probabilities, andconfidence. - A valid low-confidence answer is a success. To have a person review the uncertain ones, set
humanReviewon the node (below). To route on a threshold yourself, keep it in the next node:property-match(gt,lt) orcode-match, with the uncertain band routed tohuman-in-the-loop. Do not invent a global threshold. humanReview: { band: { min, max }, approvers, title?, timeout?, notifications? }sends an answer to a person whenmin <= confidence <= max(0 to 1, both ends inside). It raises ahuman-in-the-loopapproval (same approvers, timeout, notifications,waitingrun state), so use the sameapproversshape.choiceandscoreare tested on theirconfidence(onlayathat is itsanswer_confidence, the top probability); anoulonmax(noul, 1 - noul). Any answer in the band makes the run wait.nextmust then be a port map:approved(required, every accepted answer), and optionallyrejectedandtimeout; a string or listnextis refused. The output stays{ model, answers, usage }on every path, plusreview(status, and per questionconfidence,inBand,decidedBy). A person's answer replaces the model's inanswers;probabilitiesandconfidencestay the model's.- On
laya, askchoicequestions, notnoul, until anoulquestion is checked against known inputs. On laya-server 0.1.0 anoulsentiment question returned close to 0 even for clearly positive text, while the same judgment as achoiceanswered correctly. This is how the model answered, not a node rule. After a run, read<node-id>.routing.modelto see which laya checkpoint answered. A complete laya workflow (manual trigger with{"text": "..."}, onechoicequestion, a review band, and aproperty-matchbranch on<node-id>.answers.<question>.choice) is indocs-site/content/docs/(documentation)/concepts/workflows.mdx, section "Example: a laya decision end to end". - Do not set
retryorvalidation.retryon asystem-one-decisionnode. It retries connection errors, 408, 429, and 5xx itself (config.maxRetries, 0 to 3, default 2) insideconfig.timeoutMs(default 30000). A 401, a 422, or an answer that fails validation ends after one attempt. - The node reads its provider's key (
TYPESAFE_API_KEY,OPENROUTER_API_KEY,LAYA_API_KEY,OPENAI_DECISIONS_API_KEY, orCLOUDFLARE_API_TOKEN, plusLAYA_URLforlayaandCLOUDFLARE_ACCOUNT_IDforcloudflare) server-side. The key never appears in the definition, the step output, or an error. A run whose key orLAYA_URLis missing fails before any node executes, and a 401 or 403 fails the step as<KEY> was rejected. An unresolved{{token}}fails the step before any request.
Jev images (direct TypeSafe API)
system-one-decision cannot send images today: its strict config has no assets field, its wire sends only state, model, and questions, and it does not send the image opt-in header. A URL or base64 string in state stays text. Use a swarm-script calling POST https://api.typesafe.ai/v1/systemone with the credential kept server-side until the node supports assets.
- Send
TypeSafe-Danger-Zone-Images: true. Bothjev-1.13.0andjev-1.14.0accept images; Box questions require 1.14. Pin the model you tested. - Upload PNG or JPEG bytes, not a URL. Prefer multipart: first part
requestis the JSON with content typeapplication/json; an asset such asassets[uuid] = { type: "image", part: "photo-file" }names its file part. For JSON-only calls, use the base64 shape below: standard padded base64, no whitespace ordata:prefix. - Each image must be still, at most 16 MiB decoded, 256–1568 pixels on each side, and 78,400–2,458,624 pixels total (256×256 is too small). Request body, all processed images together, and the expanded request each have a 32 MiB limit. Limits reject the whole request; the API does not resize. Rotate pixels upright before sending because EXIF orientation is not applied.
- Generate a fresh UUID reference per image. References are replaced wherever they occur in string values in
state,instructions, orcriteria; object keys are not replaced. Every asset must be referenced. Repeating the same reference inserts and bills another image copy.
{
"model": "jev-1.13.0",
"state": { "photo": "d1369c6a-538e-4ab3-91cd-f76f2f67b651" },
"assets": {
"d1369c6a-538e-4ab3-91cd-f76f2f67b651": {
"type": "image", "format": "png", "data_base64": "<encoded PNG bytes>"
}
},
"questions": {
"ready": { "type": "noul", "instructions": "Is the product in `photo` fully assembled?" }
}
}
Measured 2026-10-15 on jev-1.13.0, using an 850×850 RGB PNG with a red square on white. Cost is the complete request's usage.input_tokens × $0.042 / 1,000,000; output is free:
| Placement | Questions | Input tokens | Request cost (USD) |
|---|---|---|---|
Once in state | 1 | 2,273 | $0.000095466 |
Once in state, questions use the photo path | 2 | 2,289 | $0.000096138 |
Twice in state | 1 | 4,237 | $0.000177954 |
Once in each question's instructions | 2 | 4,203 | $0.000176526 |
Once in state, resized to 425×425 | 1 | 863 | $0.000036246 |
The roughly $0.0001 claim (about 10,000 one-image requests per dollar) holds for this example; the exact cost depends on image size and prompt. Question count alone does not multiply a shared state image's tokens. Put shared images in state once and have questions use its field path, not the asset UUID. Image duplication scales with repeated references, including one copy per question when embedded in each question's instructions. If several judgments form one classification, a single choice can avoid repeating question-local images. Downscale when the detail permits: halving both dimensions reduced this request's billed tokens from 2,273 to 863; recheck accuracy on your images.
Contract and current pricing: TypeSafe images, models. Re-measure before budgeting a different model, resolution, or prompt.
human-in-the-loop (how approvals render)
The same rules apply to a standalone request-human-input call.
- Dashboard card.
titleis plain text. Each question'slabelis its heading and renders as markdown.descriptionrenders as full markdown and is the only field that shows images, so put drafts, diffs, and logs there. - Code. Fence code, logs, and JSON with a language (
```json): the block keeps its lines and scrolls. Fence a prose draft with no language: it wraps. A single newline outside a fence becomes a paragraph break. - Config questions. A
configquestion (config: { key, scope }) renders the key, whether it is already set, and a password field for secrets or a select for non-secretoptions. The value is written only when the request resolves approved, and the response holds only the result (statusset,kept, orskipped), never a secret value. It needsapprovers.policy: "any". Use it whenever you need a person to supply a config value or secret. - Images. Put an
http(s)URL ending in.png,.jpg,.jpeg,.gif, or.webpalone on its own line indescription, or use. The link must outlive the request: an expired presigned URL shows an "image unavailable" note. - Slack. A node's
notificationspost the title, the question labels (each collapsed to one line, capped at 3000 characters in total), a Review button, and the timeout. Descriptions, options, code, and images are not shown, so the title and labels must make sense alone.request-human-inputposts nothing to Slack: share the returned URL yourself.
swarm-script
{
"type": "swarm-script",
"config": {
"scriptName": "<catalog-script-name>",
"args": { "repo": "{{trigger.repo}}" },
"fsMode": "none",
"timeoutMs": 30000
}
}
- Confirm
scriptNameand scope in the live script catalog. Omitscopeunless the workflow must forceagentorglobal; an incorrect explicit scope prevents resolution. - The script's return value is under
<node-id>.result, not.taskOutput. The full node output also includesstdout,stderr,truncated,durationMs,exitCode,scriptName,contentHash, andversion. - Catalog scripts are synchronous/instant nodes; they do not require an agent assignment. They are deterministic only when the script itself is deterministic. Keep
fsModeatnoneon runtimes that do not support workspace access.
Cancellation and Dependencies
- Before re-triggering, cancel any still-running attempt for the same work. Reuse the original trigger payload so downstream dependencies receive the same fields.
- Check referenced agents, catalog scripts, downstream node IDs, and external resources before triggering. A syntactically valid workflow can still fail later when a named dependency is missing.
- A patch does not resume a previously halted run. Trigger a fresh verification run after the fix, then watch that new run to a terminal state.
Common Failure Patterns
| Symptom | Likely Cause | Fix |
|---|---|---|
| A gate takes the wrong branch even though the upstream value looks correct | The condition path does not match the executor's context shape | Inspect the step input and use the exact upstream path the executor can resolve |
| Downstream prompt renders blank fields | Missing or wrong inputs mapping | Re-read the step input, then wire each template variable to a concrete source |
| A node loses its prompt, schema, or model after a small patch | Partial config patch replaced the full config | Restore from the previous version and resend the full node config |
| Structured-output task fails immediately | Worker did not return JSON matching outputSchema | Put the schema in the prompt and assign the task to a worker/provider validated for structured output |
| Parallel branches cancel, overwrite, or confuse each other | Shared context or shared output keys across sibling tasks | Give each branch its own context/output namespace and make writes branch-specific |
| A reusable script node completes but downstream fields are empty | Downstream node reads the wrong output shape | Inspect the script step output and reference the actual return path |
Preflight Checklist
- Current workflow version has been read in this session.
- Every changed node has a clear before/after purpose.
- Inputs and condition paths match a real recorded step shape.
- Output schemas include only fields used downstream.
- Agent-task routing matches the task shape and required tools.
- Trigger payload includes all required fields.
- The verification run reached the intended outcome.
- The source-of-truth definition was updated after live verification.