When the user asks to review, optimize, simplify, or audit a workflow, walk this checklist and produce a structured report. Findings are graded:
Treat the checklist as guidance — not every item applies to every workflow. A 3-task batch job doesn’t need a failureWorkflow. Use judgment.
conductor workflow get {name} --version {v} (omit --version for the latest).SIMPLE task, load its task definition: conductor taskDef get {name}. Timeout/retry config lives there, not on the workflow task.conductor workflow search -w {name} -s FAILED -c 20 and inspect a few with get-execution.description should explain what the workflow does and why. Empty descriptions force readers to reverse-engineer intent.
SUB_WORKFLOWs, just like refactoring oversized functions.
len(tasks) > 100.taskReferenceName. Each ref name is unique workflow-wide and shows up in the UI/logs. Prefer validate_order over task1.
pollTimeoutSeconds — task sits in the queue this long without a worker picking it up → abandoned. Catches “no worker is polling for this type.”responseTimeoutSeconds — once a worker checks out the task, how long without a heartbeat before redelivery. Catches “worker crashed mid-execution.”timeoutSeconds — total wall clock from pickup to terminal status. Catches “worker is alive but the task takes too long.”The severity ladder for missing/zero timeouts is B1 below.
version, deploy callers pointing at the new version, deprecate the old when no executions remain. In-place updates can affect running executions in ways that vary by task type (especially around input expressions). New versions are free; the registry holds many.
responseTimeoutSeconds, pollTimeoutSeconds, and timeoutSeconds. See A6 for what each catches. Single severity ladder:
0 or unset on a task def for a SIMPLE task in production use.responseTimeoutSeconds: 1).timeoutSeconds + timeoutPolicy (TIME_OUT_WF or ALERT_ONLY). Without one, a stuck workflow can run forever.
retryCount, retryLogic (FIXED or EXPONENTIAL_BACKOFF), retryDelaySeconds. Transient errors are common — retryCount: 0 exposes every blip.
retryCount == 0 and the task isn’t intrinsically non-retryable.failureWorkflow for cleanup/alerting. Runs when the parent fails. Common pattern: send an alert, mark the entity failed in your DB, release reserved resources. Often missing.
loopCondition should always include a max-iteration guard ($.loop_ref['iteration'] < N) in addition to any result-driven exit. Without it, an unexpected output spins forever.
optional: true on non-critical branches. A best-effort notification, audit log, or analytics push shouldn’t fail the workflow. Mark them optional.
rateLimitPerFrequency + rateLimitFrequencyInSeconds — token-bucket rate limit. Use for tasks calling external APIs with quotas (Stripe, Slack, third-party LLMs). Without this, a spike in workflow starts blows your quota.concurrentExecLimit — caps simultaneous executions of this task across all workflows. Use for resource-bound tasks: heavy DB writes, GPU-bound model calls, memory-hungry transforms.rateLimitPerFrequency. WARN on resource-bound tasks without concurrentExecLimit.jsonOutput: true without “JSON” in the prompt. Conductor’s @Documented on jsonOutput notes: “Depending on the model you MUST include JSON word as part of the prompt.” Anthropic Claude in particular silently degrades to prose when this cue is missing.
LLM_CHAT_COMPLETE sets jsonOutput: true and neither the system nor user messages contain the substring JSON (case-insensitive). Also recommend pairing with outputSchema for stricter contracts (Conductor retries on schema-validation failure).previousResponseId provider lock-in / chain breakage. The OpenAI Responses-API chaining field is silently ignored on other providers, and a mid-chain provider switch breaks the chain.
previousResponseId and either (a) llmProvider is not openai/azureopenai, or (b) a chained task’s provider differs from the task whose responseId it references.responseId retention, recommend the accumulated-messages fallback (../examples/ai-agent-loop.md) and downgrade to INFO when an explicit fallback path is present.LLM_CHAT_COMPLETE, LLM_GENERATE_EMBEDDINGS, LLM_GENERATE_IMAGE, LLM_GENERATE_TTS, LLM_GENERATE_VIDEO, LLM_SEARCH_INDEX). Hand-rolling the same call as an HTTP task to api.openai.com / api.anthropic.com / generativelanguage.googleapis.com / Vertex / Bedrock / Azure-OpenAI / Cohere / Mistral / Grok / Perplexity / HuggingFace / Ollama loses everything the built-in tasks give you: auth wiring, retries, token accounting, the {role, message} schema, webSearch/codeInterpreter built-in tools, previousResponseId chaining, tools[] function-calling, structured-output parsing (jsonOutput + outputSchema-driven retry), and a uniform output.result shape that downstream tasks can consume.
HTTP task’s http_request.uri matches a known LLM-provider host (*.openai.com, *.anthropic.com, generativelanguage.googleapis.com, *-aiplatform.googleapis.com, bedrock-runtime.*.amazonaws.com, *.openai.azure.com, api.cohere.ai, api.mistral.ai, api.x.ai, api.perplexity.ai, api-inference.huggingface.co, *.ollama.ai, or any /v1/chat/completions, /v1/messages, /v1/embeddings, /v1/responses path on a non-Conductor host).LLM_* task. If the user says “the server doesn’t have an Anthropic integration configured,” the answer is to set ANTHROPIC_API_KEY (or the provider-equivalent env var) on the Conductor server, not to keep the HTTP task. Conductor auto-enables providers when the key is present.llmProvider on the built-in task solves.JSON_JQ_TRANSFORM for data shaping. JQ is purpose-built and faster than INLINE for filter/map/aggregate. INLINE makes sense for control flow or arithmetic; JQ for shape transforms.
FORK_JOIN with > ~20 branches is a smell — switch to FORK_JOIN_DYNAMIC. Dynamic fork with thousands of branches needs batching (chunk inputs, run sub-workflows of size ~50).
asyncComplete: true for long-running operations. Worker initiates external work, returns immediately, then signals completion later. Avoids holding worker threads for hours.
Don’t extract a sub-workflow just to “organize” a long workflow into chapters — that’s what naming and the description field are for. The cost is real: debugging a single failure now spans two execution views.
${workflow.secrets.X} on Orkes) or worker environment variables — never ${workflow.input.token}. Workflow inputs are visible in the execution view.
${workflow.input.x} or ${workflow.variables.x} — environment-specific URLs hardcoded into a definition mean a separate definition per environment.
outputParameters is a public API. Other workflows, services, and dashboards depend on the workflow’s output shape. Treat changes the way you’d treat function-signature changes: additions are usually safe, removals and renames are breaking. Bump version on breaking output changes; never reshape outputs in place.
${chat.output.result.action} (or any LLM-emitted field), an unparseable or unexpected emission can silently flow into the wrong branch.
expression reads output.result.<x> from an LLM_CHAT_COMPLETE task has a non-empty defaultCase that performs business logic (writes, finalize, etc.). Recommend either an empty defaultCase: [] or a sentinel/no-op handler. See template-resolution.md Pitfall 1.Sometimes the right answer is not a workflow. Smell tests:
{ "bucket": "...", "key": "..." }).
Common patterns to flag:
| Smell | Use built-in |
|---|---|
HTTP POST to a Kafka REST proxy, or worker that calls a Kafka producer |
KAFKA_PUBLISH |
| Worker that renders HTML/markdown to PDF | GENERATE_PDF |
HTTP POST to pinecone.io, *.pinecone.io, api.pinecone.io, *.weaviate.network, MongoDB Atlas Search, or worker that wraps a vector-DB client |
LLM_INDEX_TEXT / LLM_STORE_EMBEDDINGS / LLM_SEARCH_INDEX / LLM_SEARCH_EMBEDDINGS / LLM_GET_EMBEDDINGS |
| Worker that just sleeps / polls a deadline | WAIT (duration or until) |
| Worker that waits on a human approval queue | HUMAN |
| Worker that triggers another workflow via the REST API | SUB_WORKFLOW (synchronous) or START_WORKFLOW (fire-and-forget) |
INLINE script that just reshapes / filters / aggregates / stringifies JSON |
JSON_JQ_TRANSFORM (also covered by C2 — INFO) |
| Worker that publishes to SQS / internal Conductor event sink | EVENT |
| Worker that resolves “which task to run” at runtime from input | DYNAMIC / FORK_JOIN_DYNAMIC |
HTTP POST to OpenAI Images / Vertex Imagen, OpenAI TTS, OpenAI Sora / Vertex Veo |
GENERATE_IMAGE / GENERATE_AUDIO / GENERATE_VIDEO (B10 CRITICAL — these are LLM-provider hosts) |
HTTP GET/POST to an MCP server |
LIST_MCP_TOOLS / CALL_MCP_TOOL |
llmProvider on LLM_*, vectorDB on LLM_INDEX_TEXT/LLM_SEARCH_INDEX, and subWorkflowParam on SUB_WORKFLOW already give you.Render findings like this:
Workflow: order_processing v3 (47 tasks)
CRITICAL (4)
✗ B1 SIMPLE task `charge_card`: responseTimeoutSeconds=0
→ Set responseTimeoutSeconds >= 30, pollTimeoutSeconds >= 60, timeoutSeconds = 300
✗ B5 DO_WHILE `retry_loop`: condition has no iteration cap
→ Add `$.retry_loop['iteration'] < 10 &&` to loopCondition
✗ B10 HTTP task `call_claude` posts to https://api.anthropic.com/v1/messages
→ Replace with an LLM_CHAT_COMPLETE task (llmProvider: anthropic). Set
ANTHROPIC_API_KEY on the server if the integration isn't configured yet.
✗ D1 Workflow input `stripeKey` looks like a secret
→ Move to ${workflow.secrets.STRIPE_KEY} or worker env
WARN (4)
⚠ A1 Description is empty
⚠ B2 No workflow timeout. Add timeoutSeconds + timeoutPolicy.
⚠ B3 SIMPLE task `send_email` has retryCount=0 (transient SMTP errors will fail the workflow)
⚠ C1 INLINE task `compute_pricing` has 60 lines of JS — extract to a worker
INFO (2)
• A4 47 tasks — well within the 100-task soft limit
• A5 Task names are descriptive
Recommended Changes (priority order)
[ ] task_def_charge_card.json set responseTimeoutSeconds=30, pollTimeoutSeconds=60, timeoutSeconds=300
[ ] order_processing.json:7 add `$.retry_loop['iteration'] < 10` clause to loopCondition
[ ] order_processing.json:2 move stripeKey to ${workflow.secrets.STRIPE_KEY}
[ ] order_processing.json:1 add description, timeoutSeconds, timeoutPolicy
[ ] task_def_send_email.json set retryCount=3, retryLogic=EXPONENTIAL_BACKOFF
[ ] compute_pricing INLINE extract to a Python worker
Then offer: “Want me to apply any of these? I can update the task definitions and re-register the workflow.”
Always end with a Recommended Changes checklist even if the findings are split by severity above. The checklist is the actionable artifact the user takes away — one bullet per fix, file/path pointer first, then the change to make. Skip findings that are INFO-only.
A simpler workflow is one a new engineer can read in five minutes. The biggest levers:
Don’t over-refactor. If the workflow is already small and readable, “simpler” might be a no-op — say so.