This document provides a comprehensive reference for the Conductor workflow YAML syntax.
- Workflow Configuration
- Agents
- Parallel Groups
- Routes
- Inputs and Outputs
- Limits and Safety
- Tools
- Skills
- External File References
The top-level workflow section defines metadata and behavior for the entire workflow.
workflow:
name: string # Required: Unique workflow identifier
description: string # Optional: Human-readable description
entry_point: string # Required: Name of first agent to execute
metadata: # Optional: free-form key/value metadata
tracker: ado # surfaced in the workflow_started event
project_url: https://... # CLI --metadata / -m can add or override
instructions: # Optional: extra instruction files (paths)
- ./docs/conventions.md # prepended to every agent prompt
- ./AGENTS.md # also auto-discoverable via
# --workspace-instructions (see CLI ref)
limits:
max_iterations: 10 # Default: 10, max: 500
timeout_seconds: 600 # Optional: Maximum wall-clock time (seconds)
budget_usd: 5.00 # Optional: Cost cap in USD (no tracking when unset)
budget_mode: audit # audit (default) | enforce
context_mode: accumulate # accumulate | snapshot | minimal (default: accumulate)
runtime:
provider: copilot # copilot | claude | hermes | claude-agent-sdk
# Structured object form (e.g. custom routing,
# or the experimental `aca` sandbox provider)
# is also accepted — see docs/configuration.md
# and docs/providers/aca.md.
default_model: gpt-5.2
temperature: 0.7
max_tokens: 4096
default_reasoning_effort: medium # Optional: low | medium | high | xhigh | max
# Workflow-wide default for reasoning /
# extended-thinking effort. Inherited by
# every provider-backed agent unless it
# declares its own `reasoning.effort`.
# See docs/configuration.md#reasoning-effort.
checkpoint: # Optional: periodic checkpoints (off by default)
every_agent: true # Save after each step boundary (governs alone when true)
every_seconds: 300 # Throttle: save at most this often (used only when every_agent is false)
keep_last: 5 # Retain this many periodic checkpoints per run
default_context_tier: default # Optional: default | long_context (Copilot only)
# Workflow-wide default for the model's
# context-window tier. Inherited by every
# provider-backed agent unless it declares
# its own `context_tier`.
# See docs/configuration.md#context-tier.
idle_timeout_seconds: 90 # Optional: seconds without SDK events before a
# Copilot session is treated as idle (Copilot only).
# Default: 90. Suppressed entirely while a tool
# call is in flight.
max_idle_recovery_attempts: 5 # Optional: "please continue" prompts sent before
# failing an idle Copilot session (Copilot only).
# Default: 5. 0 means fail on first genuine idle.
working_dir: "/path/to/cwd" # Optional: global default working directory for LLM agents
# and their MCP servers. Relative paths resolve against the
# parent directory of the workflow YAML file.Workflow metadata is included verbatim in the workflow_started event and lets downstream consumers (dashboards, queue runners, observability tools) adapt without parsing the YAML. CLI --metadata key=value flags merge on top of YAML metadata (CLI wins on conflicts).
Instructions files are loaded once and prepended to every agent's rendered prompt. They are inherited by sub-workflows and persisted in checkpoints so resume continues to use the same instructions. Use the YAML instructions: list for workflow-pinned context, or pass --workspace-instructions on the CLI to auto-discover AGENTS.md, CLAUDE.md, .github/copilot-instructions.md, and .github/instructions/**/*.instructions.md (recursive; only files marked applyTo: "**" in YAML frontmatter are loaded — see the Workspace Instructions section in the CLI reference for full details) by walking from CWD up to the git root.
accumulate(default): Agents see all previous agent outputssnapshot: Agents see only the context at workflow startminimal: Agents see only their direct dependencies
Agents are defined in the agents list. Each agent represents a unit of work.
agents:
- name: string # Required: Unique agent identifier
description: string # Optional: Purpose description
type: agent # agent | human_gate | questions | script | workflow | wait | terminate (default: agent)
model: string # Optional: Model identifier (e.g., 'claude-sonnet-4.5')
prompt: | # Required for type=agent: Agent instructions
Multi-line prompt with Jinja2 templates
{{ workflow.input.field }}
{{ previous_agent.output.field }}
input: # Optional: Explicit input declarations
field_name:
from: "{{ expression }}"
type: string # string | number | boolean | array | object
required: true
output: # Optional: Output schema for validation
field_name:
type: string # string | number | boolean | array | object
description: "Field purpose"
enum: ["a", "b"] # Optional: Allowed scalar values (string/number/boolean)
pattern: "^[a-z]+$" # Optional: Regex pattern (string type only)
minimum: 0 # Optional: Inclusive minimum (number type only)
maximum: 100 # Optional: Inclusive maximum (number type only)
minLength: 1 # Optional: Minimum string length (string type only)
maxLength: 50 # Optional: Maximum string length (string type only)
nullable: true # Optional: Allow null value (default: false)
required: false # Optional: Only inside object properties (default: true)
output_mode: raw # Optional: raw | envelope (default: inferred)
# raw: skip JSON extraction, wrap response
# as {"result": "<text>"}. Cannot be
# combined with output:.
# envelope: explicit opt-in to structured
# output pipeline (same as default when
# output: is declared).
tools: # Optional: Agent-specific tools
- tool_name
reasoning: # Optional: per-agent reasoning override
effort: high # low | medium | high | xhigh | max
# Overrides runtime.default_reasoning_effort.
# Only valid on type=agent (rejected on
# script, human_gate, questions, workflow).
# See docs/configuration.md#reasoning-effort.
retry: # Optional: per-agent retry policy
max_attempts: 3 # 1-10 (default 1 = no retry)
backoff: exponential # exponential | fixed
delay_seconds: 2 # base delay before first retry
retry_on: # error categories that trigger retry
- provider_error
- timeout
max_parse_recovery_attempts: 3 # 0-10; omit for provider default
context_tier: long_context # Optional: per-agent context-tier override
# default | long_context (Copilot only)
# Overrides runtime.default_context_tier.
# Composes with reasoning. Only valid on
# type=agent (rejected on script,
# human_gate, questions, workflow).
# See docs/configuration.md#context-tier.
routes: # Optional: Routing logic
- to: next_agent # Agent name or $end
when: "{{ condition }}" # Optional: Route conditionPer-agent retry controls how an agent retries on transient failures. The retry: block is optional; when omitted the agent makes a single attempt with no retries.
agents:
- name: analyzer
prompt: "Analyze the input"
output:
summary:
type: string
retry:
max_attempts: 3
backoff: exponential
delay_seconds: 2
retry_on:
- provider_error
- timeout
max_parse_recovery_attempts: 0 # disable parse recovery for this agent| Field | Type | Default | Description |
|---|---|---|---|
max_attempts |
1-10 |
1 |
Total attempts including the first. 1 = no retry. |
backoff |
exponential | fixed |
exponential |
Backoff strategy between retries. |
delay_seconds |
0.0-300.0 |
2.0 |
Base delay in seconds before the first retry. See below for how it interacts with the internal backoff cap. |
retry_on |
list | [provider_error, timeout] |
Error categories that trigger a retry. |
max_parse_recovery_attempts |
0-10 |
Provider default | In-session recovery attempts before giving up. See below. |
delay_seconds also raises the provider's internal 30s backoff cap when set above 30, rather than being clamped down to it: the effective cap is max(30, delay_seconds). It does not change the cap when set below 30 — with the default delay_seconds: 2.0 and backoff: exponential, the wait sequence is 2, 4, 8, 16, 30, 30 seconds, well beyond delay_seconds itself. Setting delay_seconds: 60 raises the cap to 60s, so waits grow up to ~60s before the default jitter of 0.25 (applied after the cap) adds up to another 25%. Note that once delay_seconds reaches or exceeds 30s, the base delay and the cap become equal, so backoff: exponential degenerates into a fixed wait on every attempt.
When an agent declares output: (structured JSON), the provider checks the model's response two ways: it parses the JSON, then validates the parsed fields against the declared schema. If either check fails, a correction prompt is sent in the same session asking the model to fix its response. This field controls how many correction prompts to send.
Both failure kinds are covered:
- Syntax failures — the response isn't valid JSON at all.
- Schema-shape failures — the response is valid JSON but a field has the wrong type, such as returning an object where
type: stringwas declared.
Before validating, providers apply one conservative normalization: if a scalar field (string, number, boolean) receives an object holding exactly one value of the expected type under either the field's own name or a generic value/result key, that value is unwrapped and a warning is logged. Two or more matching candidates count as ambiguous, and a wrapper with any other key shape is left alone — both are re-prompted rather than guessed at, so an object like {"error": "I could not complete the task"} never becomes the answer.
A response that parses as JSON but isn't an object at all (a bare 42 or an array) is re-prompted as a shape failure rather than failing the run. Providers word it differently: Hermes rewrites a non-object into {"result": ...} before validating, so it surfaces as a missing declared field instead.
- Omit (default): Use the provider default (Copilot=5, Claude=2, Hermes=3).
0: Disable recovery entirely — fail immediately.1-10: Custom limit.
This is useful when you know an agent's output is simple and a single attempt should suffice, or when you want to fail fast instead of burning tokens on recovery loops.
When the budget runs out, a schema-shape failure raises the specific validation error naming the offending field and its expected type, while a syntax failure raises a provider error. Each recovery attempt emits an agent_parse_recovery event, visible in the dashboard activity stream and the structured event log.
Output field definitions support optional validation constraints to enforce value boundaries and formatting rules.
| Field | Applicable Type | Description | Semantics |
|---|---|---|---|
enum |
string, number, boolean |
List of allowed scalar values | Uses exact value comparison. Cannot contain null (use nullable: true instead). |
pattern |
string |
Regular expression pattern | Python re.search matching (unanchored by default; use ^ and $ to anchor). Evaluated consistently on all providers. Matching is time-bounded (1 second); a pathological pattern fails validation instead of hanging the run. |
minimum |
number |
Inclusive minimum numeric bound | Value must be greater than or equal to minimum. |
maximum |
number |
Inclusive maximum numeric bound | Value must be less than or equal to maximum. |
minLength |
string |
Inclusive minimum string length | String length must be greater than or equal to minLength. |
maxLength |
string |
Inclusive maximum string length | String length must be less than or equal to maxLength. |
required |
Any (object property only) | Whether the object property must be present | Default: true. Must be true for root-level fields; setting required: false at the root level is rejected by conductor validate. |
nullable |
Any | Whether null is an acceptable value |
Default: false. When true, renders as type: [T, "null"] in JSON Schema. |
- Inclusive Bounds:
minimum,maximum,minLength, andmaxLengthrepresent inclusive bounds. - Regex Pattern Matching:
patternuses Pythonre.searchsemantics across all providers (including Claude). It matches anywhere in the target string unless explicitly anchored with^and$. Matching runs on are-compatible engine with a 1-second wall-clock deadline per check: model output is untrusted input, so a pattern with catastrophic backtracking raises a validation error (which drives the provider's output-recovery loop) instead of stalling the workflow. - Nullable Fields: Setting
nullable: truerenders the JSON Schema type astype: [T, "null"], allowing the field to holdnullor a value matchingtype. - Optional Object Properties: The
required: falseconstraint is permitted only inside nested object properties (e.g.properties.details.required: false). All root-level output fields must be required, so settingrequired: falseon a root-level agent output field will be rejected during workflow validation (conductor validate).
agents:
- name: evaluator
prompt: "Evaluate the artifact and return structured metrics."
output:
status:
type: string
enum: ["passed", "failed", "pending"]
description: "Execution status"
score:
type: number
minimum: 0
maximum: 100
description: "Evaluation score between 0 and 100"
code:
type: string
pattern: "^ERR-[0-9]{3}$"
minLength: 7
maxLength: 7
description: "Error code in format ERR-123"
notes:
type: string
nullable: true
description: "Optional notes or null when absent"
metadata:
type: object
description: "Additional execution metadata"
properties:
reviewer:
type: string
description: "Reviewer identifier"
comments:
type: string
required: false
description: "Optional comments property inside object"Declaring output: does two things at once: it asks the model to return JSON matching the schema, and it parses the response as structured JSON. For some agents that's what you want. For others it produces parse-recovery loops and burns tokens.
Declare output: when the agent emits small, strictly-structured JSON whose individual fields will be referenced downstream:
agents:
- name: classifier
prompt: "Classify the input. Return {category, confidence}."
output:
category:
type: string
confidence:
type: number
- name: router
prompt: |
Category was {{ classifier.output.category }}.
Confidence was {{ classifier.output.confidence }}.Omit output: when the agent emits prose, Markdown, or large/nested JSON. Without a schema, conductor stores the full raw response as a single string under .output.result, and downstream agents read it directly:
agents:
- name: synthesizer
prompt: |
Produce a comprehensive Markdown report of the findings.
The report may contain code blocks, tables, and quoted examples.
# No output: declared — response is captured verbatim.
- name: reviewer
prompt: |
Review the following report:
{{ synthesizer.output.result }}Why this matters: when an output: schema is declared, the model is asked to wrap its response in JSON. Large or prose-heavy responses tend to come back inside Markdown code fences, and any triple-backticks in the content can confuse the JSON-extraction step. Omitting output: for these agents avoids that whole class of failure and lets the model write naturally.
The output_mode field gives you explicit control over how the provider handles the agent's response. It accepts two values:
output_mode |
output: declared? |
Behavior |
|---|---|---|
| (not set) | yes | Default structured-output pipeline: schema injected, JSON parsed and validated |
| (not set) | no | Raw response captured as {"result": "<text>"} |
raw |
no | Same as above, but makes intent explicit — useful for agents that must never attempt JSON extraction |
raw |
yes | ValidationError — these options are incompatible |
envelope |
yes | Same as the default structured pipeline (explicit opt-in) |
envelope |
no | Raw response captured as {"result": "<text>"} |
Use output_mode: raw when an agent produces large Markdown reports, code, or free-form prose. This bypasses JSON extraction entirely — no schema instructions are injected, no parse-recovery loop runs, and the model's full response is available as {{ agent.output.result }}:
agents:
- name: report_writer
output_mode: raw
prompt: |
Write a detailed analysis report. Include code examples,
tables, and any formatting you need.
# No output: block — output_mode: raw is incompatible with output:
- name: reviewer
prompt: |
Review the following report:
{{ report_writer.output.result }}Use output_mode: envelope when you want to make the structured-output intent explicit (equivalent to the default when output: is declared):
agents:
- name: classifier
output_mode: envelope
prompt: "Classify the input."
output:
category:
type: string
confidence:
type: numberoutput_mode is only valid on provider-backed agents (the default type). It cannot be set on script, human_gate, questions, or workflow agents.
Regular LLM agents (provider-backed agents) and their MCP servers run in a specific working directory:
agents:
- name: repository_analyst
working_dir: "./my-project-repo" # Optional: working directory (Jinja2 template)
prompt: |
Examine the repository files and list any issues.The working_dir field can be defined globally in workflow.runtime.working_dir or overridden on individual agents.
- Precedence: The agent-level
working_diroverrides the globalworkflow.runtime.working_dir. If neither is configured, the current directory of the parent process (os.getcwd()) is used. - Jinja2 Rendering: Both agent-level and runtime-level configurations support Jinja2 template rendering. This allows dynamic paths, such as directories derived from previous steps:
working_dir: "{{ find_repo.output.path }}". - Relative Paths: Relative paths are resolved against the directory containing the workflow YAML file. When the workflow file location is unknown, relative paths resolve against the current process directory.
- Lexical Normalization: Paths are normalized lexically using
os.path.normpath. The engine does not resolve symlinks dynamically.
Because paths are normalized lexically instead of resolving to their real paths:
- Different symlink aliases pointing to the same folder are treated as distinct paths.
- For the Claude provider, distinct paths trigger separate MCP manager connections. This spawns separate MCP server subprocesses for each unique path alias.
- Rejected Step Types: The
working_dirfield is strictly rejected onwait,set,terminate,human_gate,questions, andworkflow(sub-workflow) step types. Definingworking_diron these steps raises aValidationErrorat load time. - Script Steps:
scriptsteps honor only their ownworking_dirfield, rendered as a Jinja2 template.workflow.runtime.working_diris not applied; relative paths are passed to the subprocess as-is and therefore resolve against the Conductor process cwd, not the workflow file directory; missing directories surface as subprocess startupExecutionErrors rather than the LLM-agent pre-provider working-dir check. - Dialog Turns: The working directory isn't applied to dialog turns in the current version. Multi-turn interactions run in the process default directory.
- Sub-Workflows: A sub-workflow doesn't inherit the parent's working directory configuration. Instead, any relative paths in the child workflow resolve against the child workflow's own file directory.
⚠️ Warning: Working directory is NOT a sandbox Settingworking_dirdoesn't restrict the model's filesystem access. The model can still read and write files outside this directory if it uses absolute paths or parent directory traversals (e.g.,../). Avoid relying on this configuration to sandbox untrusted model execution. On theclaude-agent-sdkprovider the directory is also a trust boundary in the other direction: theclaudeCLI loadsCLAUDE.mdand.claude/settings*.json(including hooks) from wherever it runs, so pointingworking_dirat an untrusted checkout means running that checkout's instructions.
settings_dir names a second directory whose .claude/skills the agent may
use, and whose tree the model's built-in file tools may read. It carries the
skills third of a Claude Code project settings tier and nothing else of it
— the table below is exact about which — and the filesystem half applies
whether or not any tier is enabled. It applies only to claude-agent-sdk agents.
Setting it against any other provider is an error, reported by conductor validate and again at run time — not a silently dropped field. The skills half
additionally requires runtime.provider.setting_sources to enable the project
tier; the filesystem grant below applies either way.
workflow:
runtime:
provider:
name: claude-agent-sdk
setting_sources: [project]
agents:
- name: judge
settings_dir: "{{ setup_worktree.output.worktree_path }}"
prompt: Review the change against this repository's conventions.An agent's cwd does two unrelated jobs, and on this provider they conflict.
The claude CLI supports the MCP Roots protocol and advertises exactly one
root — its cwd. A filesystem MCP server therefore discards the directories
in its own argv and permits cwd alone; --add-dir takes no part in that
negotiation, so it cannot widen what a server allows. cwd is simultaneously
the directory the project settings tier resolves against.
So pointing working_dir at a target repository to pick up that repository's
skills also narrows the agent's only MCP root onto it, and any sibling path
the step still has to read — an artifacts directory, a second checkout — is
denied. Widening cwd back loses the repository's conventions.
settings_dir splits the two. Skills are discovered from cwd and from
settings_dir, so cwd can stay wide enough to contain everything the agent
must read:
agents:
- name: judge
# No working_dir: cwd stays the launch directory, which contains both the
# worktree and the artifacts this judge reads through the filesystem MCP.
settings_dir: "{{ setup_worktree.output.worktree_path }}"Measured against the CLI:
Named via settings_dir |
Granted? |
|---|---|
Filesystem access for the model's built-in tools (Read, Edit, Bash, …) |
yes — unconditionally, see below |
.claude/skills |
yes — listed and invocable |
CLAUDE.md |
no |
.claude/rules/*.md |
no |
.claude/settings.json env |
no |
.claude/settings.json hooks |
no — measured, see below |
.claude/agents |
no |
⚠️ The filesystem grant does not depend onsetting_sources. This field maps to the SDK'sadd_dirs, whose own contract is "additional directories Claude can access beyond the current working directory" — so naming a directory here widens the model's built-in file tools to that tree whether or not any settings tier is enabled. Measured againstclaudeCLI 2.1.263 atpermission_mode: "default"withsetting_sourcesunset: a read outside cwd is refused withoutsettings_dirand succeeds with it. (Later CLI builds no longer accept that mode by name; Conductor never passes it explicitly, so the reproduction needs the version above.) Note an agent that omitstools:runs underbypassPermissions, where reads already succeed everywhere, so the grant only becomes observable once permissions are in play.Skill discovery is the reason to set this field; the filesystem grant is its unavoidable companion. Point it at a directory the agent is entitled to read.
conductor validatewarns whensettings_diris set without theprojecttier enabled, and so does the run itself — otherwise the only effect an author would get is the one they did not ask for.
Note this grant is for the model's built-in tools only. It does not widen what a filesystem MCP server permits — that stays cwd alone, which is the whole reason this field exists.
The hooks row is a measured negative. A PreToolUse hook that appends
to a file (an observable side effect, not a log line) runs when working_dir
is the repository and the project tier is enabled, and does not run when
the same repository is reached only through settings_dir — with or without a
tier enabled. The control firing is what makes the negative meaningful.
Setting aside the filesystem grant, this field is the skills portion of a
project tier, not a cwd-independent way to load one. It cuts favourably in one direction —
a target repository's skills arrive without its hooks also running — but it
does not compose with working_dir into "everything, anywhere":
An agent that needs a target repository's rules or instructions as well as a cwd wide enough for its MCP servers cannot get both from these fields. One directory cannot be narrow and wide at once.
settings_dirrecovers the skills; anything else is a caller-side trade — keepworking_diron the repository and arrange for every path the agent reads to sit beneath it.
- Resolved exactly like
working_dir— Jinja2-rendered,~-expanded, relative paths resolved against the workflow file's directory, normalized withos.path.normpath, and existence-checked before any provider call. - Per-agent only. There is no
runtime.settings_dir, because the repository whose conventions apply is what varies between steps. - Rejected on
wait,set,terminate,script,human_gate,questionsandworkflowstep types — none has an LLM session to apply a settings tier to, and accepting it silently would suggest conventions had been loaded when none had.
⚠️ A settings tier brings everything that tier defines. Enablesetting_sourcesand pointsettings_dironly at repositories trusted to the same degree as the workflow itself.
By default each agent execution starts a fresh provider session, so an agent
that runs twice re-reads everything it read the first time. The optional
per-agent session_key opts into continuity: every execution tagged with the
same key continues one underlying session. Only the claude-agent-sdk
provider supports it today.
agents:
- name: investigate
session_key: investigation # loop-backs continue this session
prompt: Investigate the failing build...
routes:
- to: verify
- name: verify
type: script
command: ./verify.sh
routes:
- to: investigate # second pass keeps the earlier context
when: "{{ verify.output.exit_code != 0 }}"
- to: summarize
- name: summarize
session_key: investigation # different agent, same session
prompt: Summarise what you found.- A static label, never rendered. Unlike
working_dirormodel,session_keyis not a Jinja2 template. A{{ ... }}value is rejected at validation, rather than becoming one literal key shared by every execution. Whitespace is stripped and the result must be non-empty. The provider maps the label to the real session id internally, so no session id passes through the workflow context. Two agents share a session by writing the same string. - The prompt is still rendered and sent every time. The session only means
the model additionally has the prior conversation, so it sees
[earlier turns] + [freshly rendered prompt]. Omitsession_keywhere an agent is meant to re-evaluate from scratch. - Scoped to one working directory. The
claudeCLI stores transcripts per directory, so Conductor tracks sessions by(session_key, working directory). Two agents sharing a key under differentworking_dirvalues get two independent sessions. Keepworking_dirstable across executions meant to continue each other. - Survives
conductor resume. The session map is written to the checkpoint and restored on resume. The engine merges every active provider's map, andclaude-agent-sdknamespaces its own entries (claude-agent-sdk:["<key>", "<cwd>"]) and ignores entries that are not its own. - Degrades, never fails the run.
--resumefor a session the CLI cannot find makes it abort before running the agent. The provider therefore resumes only after it confirms the transcript is on disk. If a recorded session has since disappeared — the CLI prunes transcripts on its own schedule — the provider logs a warning and starts fresh. Having nothing recorded for a key is normal and is not logged: that is the first execution under it, or one under a differentworking_dir. - Rejected step types:
script,human_gate,questions,workflow,wait,set, andterminate— none have a provider session to continue.session_keyis also rejected against a provider that does not declare thesession_continuitycapability, rather than being dropped at runtime. - Concurrent executions cannot share a key.
conductor validaterejects two members of one parallel group with the same key, and a for-each agent with asession_keyandmax_concurrent > 1. The second execution would resume a session the first still has open, leaving twoclaudeprocesses appending to one transcript. Give them distinct keys, drop the key, or setmax_concurrent: 1.
See examples/claude-agent-sdk-session-key.yaml.
The optional per-agent sandbox: block overrides settings for the
experimental aca (Azure Container Apps) sandbox provider — the one
provider that does isolate an agent's execution off the host, running it
inside an Azure Container Apps dynamic-sessions custom-container pool
instead. See docs/providers/aca.md for the full
provider documentation, architecture, and workflow-level
runtime.provider: {name: aca, ...} configuration.
workflow:
runtime:
provider:
name: aca
pool_endpoint: "https://my-agent-pool.<region>.azurecontainerapps.io"
api_version: "2025-07-01"
inner_provider: copilot
identifier_scope: agent # workflow | agent | item | none (default: agent)
egress: enabled # enabled | disabled (advisory; pool governs). The
# inner Copilot call always needs outbound network
# access, so this is effectively always `enabled`.
lifecycle: timed # timed | on_container_exit (advisory)
auth: azure_default # only supported strategy
agents:
- name: implement
sandbox: # Optional: aca-only per-agent overrides
identifier_scope: item # overrides runtime.provider.identifier_scope
working_dir: /workspace # container-relative — NOT a host path| Field | Type | Description |
|---|---|---|
identifier_scope |
workflow | agent | item | none |
Overrides the workflow-wide identifier_scope for this agent's session identifier. None (default) inherits the workflow setting. |
working_dir |
string |
Working directory inside the sandbox session filesystem. Unlike the top-level agent.working_dir above (a host path resolved against the workflow file's directory), this is interpreted container-relative — a path inside the remote ACA session, never resolved against the host. Defaults to the runner's own working directory when unset. The directory must already exist when the session starts (e.g. baked into the runner image, or a parent directory an earlier turn in the same reused session created) — a path that doesn't exist yet is a runtime error, not a silent fallback. See examples/aca-coding-agent.yaml for the pattern of pointing working_dir at an image-provisioned parent directory and having the agent itself create a subdirectory (e.g. git clone into it) on first run. |
sandbox: is only meaningful when the agent's effective provider is
aca — the fields validate structurally regardless of provider (so
conductor validate still checks types), but are otherwise ignored by
every other provider.
Human gates pause workflow execution for user input:
agents:
- name: approval_gate
type: human_gate
description: "Approve the proposed changes"
options: # Required: List of choices
- name: approve
description: "Approve and proceed"
- name: revise
description: "Request revisions"
- name: reject
description: "Reject the proposal"
routes:
- to: implementer
when: "{{ approval_gate.choice == 'approve' }}"
- to: reviser
when: "{{ approval_gate.choice == 'revise' }}"
- to: $end
when: "{{ approval_gate.choice == 'reject' }}"Gate prompts support full Markdown formatting. In the terminal, prompts are rendered with Rich Markdown (headings, bold, lists, code blocks). In the web dashboard, prompts render as styled HTML with interactive features:
- Headings, bold, lists, code blocks — all standard Markdown syntax is rendered
- Tables — GitHub Flavored Markdown (GFM) pipe tables are supported
- File links — relative file paths in the prompt (e.g.,
./src/plan.md) are auto-detected and rendered as clickable links that open in VS Code - URLs — bare
http://andhttps://URLs are auto-linked
agents:
- name: review_gate
type: human_gate
description: "Review the generated plan"
prompt: |
## Review Required
The planner produced the following artifacts:
| File | Purpose |
|------|---------|
| ./output/plan.md | Implementation plan |
| ./output/timeline.md | Delivery timeline |
Please review the files above and choose how to proceed.
See also: https://wiki.example.com/review-guidelines
options:
- name: approve
description: "Looks good — proceed"
- name: revise
description: "Needs changes"The auto-linkify processor is Markdown-aware: it skips fenced code blocks, inline code spans, and existing markdown links. File paths are validated against the workflow root directory (path traversal is blocked).
An option may collect free text after it is selected:
options:
- label: "Request revisions"
value: revise
route: reviser
prompt_for: feedback # field name; stored under additional_input
multiline: true # optional, default falseThe collected text is available as
{{ approval_gate.output.additional_input.feedback }}.
multiline is opt-in so existing gates keep single-line behavior. When
enabled, the terminal reads until a line containing only . (or EOF —
Ctrl-D, Ctrl-Z then Enter on Windows) and the dashboard renders a textarea
where Enter inserts a newline and Ctrl/Cmd+Enter submits. Without a TTY the
single-line path is used regardless, since multi-line editing is meaningless
on a pipe.
For asking a set of questions rather than collecting one blob of text, see Questions.
A questions step asks a human a set of questions inside one workflow step,
holding the cursor and the answers internally.
That "one step" property is the whole point. The obvious alternative — a
human_gate that loops back through a set step accumulating a transcript —
cannot support going back, because a workflow step cannot be un-executed and a
concatenated transcript has no addressable per-question answer to overwrite. It
also costs 2 iterations per question against limits.max_iterations, where a
questions node costs 1 for the whole set.
Use a gate when the choice changes where the workflow goes; use questions
when you just need the answers recorded. human_gate routes on the
selection, questions records it.
agents:
- name: ask_questions
type: questions
# Exactly one of `source:` or `questions:`.
source: architect.output.open_questions # dotted path, same as for_each
# questions:
# - text: "Server-side or client-side?"
# choices: ["Server-side", "Client-side"]
# - id: rollout # stable answer key
# text: "How should this roll out?"
# hint: "Think about the migration window."
# required: true
# default: "Behind a flag"
# multiline: true
# allow_free_text: true
prompt: | # Optional intro, shown once
Unanswered questions become silent assumptions.
allow_back: true # revise the previous answer (default: true)
allow_skip: true # skip one question (default: true)
allow_skip_all: true # skip everything remaining (default: true)
allow_abort: false # abandon the node (default: false)
# abort_route: rescue # where to go on abort; requires allow_abort: true
routes:
- to: finalize| Field | Default | Meaning |
|---|---|---|
id |
q1..qN |
Answer key. Set it explicitly so inserting a question upstream doesn't renumber the keys below it. |
text |
required | The question. Jinja2-rendered. |
hint |
— | Clarifying text shown beneath the question. |
choices |
— | Suggested answers, offered as selectable options. |
allow_free_text |
true |
Offer "write your own" alongside choices. |
default |
— | Recorded when the question is skipped. Counts as answered. |
required |
false |
Blocks submission, never navigation — a user is never trapped on one question. Skip and skip-all are both refused while a required question is unanswered. A question with a default is always skippable, since the default answers it. |
multiline |
true |
Whether the free-text path accepts multi-line input. Inert when allow_free_text: false. |
source: uses the same dotted-path convention as for_each. Entries may be
plain strings or objects:
open_questions: ["Why?", "When?"] # each becomes a question
open_questions: [{question: "Why?", choices: ["A", "B"]}] # with candidate answersQuestion text from source: is used verbatim, not rendered as a template —
Should this use {{ user.id }}? is an ordinary question for a developer tool,
and rendering it would abort the step. Inline questions: are author-written
and validated at conductor validate time, so they keep full Jinja2 support.
Because plain strings work, an agent already emitting open_questions as an
array of string needs no changes; adding choices later is a
backward-compatible upgrade that turns "answer this" into "pick one, or write
your own" — a far lower-effort interaction.
Stored under the node name:
| Field | Type | Meaning |
|---|---|---|
answers |
dict |
{question_id: answer}, skipped questions omitted. |
items |
list |
{id, question, answer, source, skipped} in presentation order. source is choice / free_text / default / skipped. |
transcript |
string |
Pre-formatted Q1. ...\nA: ... block for prompts. |
answered_count / skipped_count |
int |
Counts. |
answered_any |
bool |
Cheap check for "did the human engage at all". |
outcome |
string |
completed / skipped_remaining / aborted. A mid-node checkpoint additionally carries in_progress. |
answers being a keyed dict rather than an appended string is what makes going
back work: revisiting question 3 overwrites answers.q3.
routes:
- to: finalize
when: "{{ ask_questions.output.answered_any }}"
- to: $endQuestions are presented one at a time. After the last one, a closing review
lists every answer and offers Finish or Back — without it, answering
the final question would end the node instantly and Back would be unusable
exactly where it is most wanted. The review is skipped when allow_back: false.
Answers are committed to the workflow context after each response, so a
checkpoint taken mid-node already carries them and conductor resume continues
at the first unanswered question. Answers whose questions no longer exist (the
workflow was edited between the checkpoint and the resume) are dropped rather
than resurrected.
--skip-gates never selects a suggested answer. Those come from the
upstream agent, so recording one would feed invented input back as though a
human had provided it. Questions with a default take that default; the rest
are skipped, and outcome is skipped_remaining.
- Cannot be used inside parallel or for-each groups (concurrent prompts would
compete for one terminal and one dashboard prompt slot, the same reason gates
are refused). Route to a
questionsstep from the group'sroutes:instead. - Cannot set
options,model,provider,tools,output,dialog,validator,reasoning,skills,plugins,context_tier,input_mapping,sandbox,max_depth,timeout_seconds,session_key,output_mode, orworking_dir— no provider is invoked. Notetimeout_secondsin particular: there is no bound on how long a human may take. abort_routerequiresallow_abort: true.
Script steps run shell commands as workflow steps, capturing stdout, stderr, and exit code. Use them to integrate shell scripts, run tests, or invoke external tools without an AI agent.
agents:
- name: run_tests
type: script
description: "Run the test suite" # Optional
command: pytest # Required: command to execute (Jinja2 template)
args: # Optional: list of arguments (each Jinja2 template)
- "{{ workflow.input.test_path }}"
- "--verbose"
env: # Optional: environment variables for subprocess
CI: "true"
PYTHONPATH: "/app/src"
working_dir: "/app" # Optional: working directory (Jinja2 template)
timeout: 120 # Optional: per-step timeout in seconds
stdin: "{{ planner.output | tojson }}" # Optional: payload piped to the child's stdin (Jinja2 template)
routes:
- to: analyzer
when: "exit_code == 0"
- to: error_handlerOutput structure — script step output is always available in context as:
| Field | Type | Description |
|---|---|---|
stdout |
string | Captured standard output |
stderr |
string | Captured standard error |
exit_code |
integer | Process exit code (0 = success) |
JSON stdout auto-parsing — if stdout is valid JSON and the parsed value is an object, its fields are merged into the agent's output dict alongside stdout/stderr/exit_code. This lets you route on parsed fields directly instead of opaque exit codes:
# Script writes to stdout: {"route": "planning", "issue_count": 3}
agents:
- name: detector
type: script
command: pwsh
args: ["-File", "{{ workflow.dir }}/scripts/detect.ps1"]
routes:
- to: planner
when: "route == 'planning'" # parsed field
- to: scaler
when: "issue_count > 100" # parsed field
- to: $endJSON arrays and scalars are ignored (only objects merge). Non-JSON stdout is unchanged. Parsed fields shadow stdout/stderr/exit_code if a script outputs those as JSON keys.
Declared output schema (strict mode) — script steps can also declare an output: schema using the same syntax as LLM agents. When declared, conductor enforces a strict contract: stdout must be a single JSON object, the JSON gets merged onto the {stdout, stderr, exit_code} baseline, and the merged dict is validated against the schema. If any check fails the workflow aborts with a ValidationError:
agents:
- name: detector
type: script
command: pwsh
args: ["-File", "{{ workflow.dir }}/scripts/detect.ps1"]
output:
route:
type: string
description: Which phase to enter next
issue_count:
type: number
routes:
- to: planner
when: "route == 'planning'"
- to: scaler
when: "issue_count > 100"
- to: $endStrict-mode semantics:
- stdout must be a single JSON object. Non-JSON, empty stdout, JSON arrays, JSON scalars, and JSON followed by additional text (e.g. log lines) all fail validation with the underlying JSON parser error surfaced for diagnostics. Reserve stdout for the JSON payload and write logs to
stderr. - Missing or wrong-typed fields fail validation. Extra fields beyond the schema are kept in the output dict (the validator only enforces declared fields — the same loose-extras policy that LLM-agent structured outputs use).
- Validation runs on the merged dict, not the raw JSON. The
stdout/stderr/exit_codebuilt-ins are always present in the dict, with parsed JSON keys overlaid on top. Declaringexit_code: { type: number }asserts the built-in matches; if the script emits a shadowing JSON key (e.g.{"exit_code": "ok"}), the schema validates the shadowed value. - Failure semantics. On schema-validation failure, the engine emits
script_failed(notscript_completed) and aborts the workflow. The failure event carries the captured stdout, stderr, and exit_code so dashboards and logs can show what the script actually wrote. output: {}opts into strict mode with zero required fields — useful when you want the JSON-object enforcement without listing fields yet.
Note: this is structural parity with LLM agents — the script must emit clean JSON to stdout. The JSON-recovery heuristics LLM agents use (extracting JSON from code fences, wrapping non-object payloads) intentionally do not apply to scripts, which are deterministic.
Omit output: to keep the lenient auto-merge behavior described above.
Access in downstream agents:
prompt: |
The test run produced:
{{ run_tests.output.stdout }}
Exit code: {{ run_tests.output.exit_code }}Routing on exit code — use exit_code in route conditions to branch on success or failure:
routes:
- to: success_handler
when: "exit_code == 0" # simpleeval syntax
- to: failure_handler
when: "{{ output.exit_code != 0 }}" # Jinja2 syntax
- to: $endRestrictions — script steps cannot have prompt, model, provider, tools, system_prompt, options, or validator. Script steps also cannot be used inside parallel groups or for_each groups.
Environment variable note — values in env are passed as-is to the subprocess (they are not rendered as Jinja2 templates). Use ${VAR} syntax in the workflow YAML loader if you need environment variable substitution in env values.
Passing payloads via stdin — set stdin: to pipe a rendered payload to the script's standard input instead of (or in addition to) command-line args. This is the cross-platform way to hand large or structured data to a script: command-line arguments are subject to OS length limits (notably Windows, where the total command line is capped at ~32 KB), but stdin is not. Reach for stdin: whenever a script consumes an upstream agent's structured output.
agents:
- name: analyze
type: script
command: python3
args: ["scripts/analyze.py"]
stdin: "{{ evaluator.output.evaluations | tojson }}" # JSON payload via the tojson filter
routes:
- to: $endstdin:is a Jinja2 string template, rendered against the workflow context and written to the child as UTF-8.- For JSON, use the built-in
tojsonfilter:stdin: "{{ data | tojson }}". Plain{{ data }}renders a Pythonrepr(single-quoted), which is not valid JSON. - For arbitrary text — a diff, CSV, or a prompt — use it directly:
stdin: "{{ patch }}". - The script reads it like any stdin source:
data = json.load(sys.stdin)(Python), or pipe intojq/cat(shell).
- For JSON, use the built-in
- Omitting
stdinkeeps the legacy behavior — the child inherits the parent's stdin. - An explicit empty string (
stdin: "") still pipes, sending the child immediate EOF (distinct from omitting it). stdinandargsare orthogonal. When both are set,argsare passed on the command line andstdinis piped — there is no precedence conflict. Keep flags inargsand put the bulky/structured payload instdin.
This replaces the older pattern of writing large structured arguments to a temp file and passing --something-file <path>; the engine pipes the payload directly, so there is no temp file to manage or clean up.
Wait steps pause workflow execution for a parsed duration via in-process asyncio.sleep. Use them for rate-limit cooldowns, polling intervals, and external-system catch-up — cross-platform, no shell sleep dependency.
agents:
- name: cooldown
type: wait
description: "Cool down between API bursts" # Optional
duration: 60s # Required: see "Duration format" below
reason: "Avoiding rate limit" # Optional: shown in dashboard
routes:
- to: next_stepDuration format — duration accepts:
-
A plain
intorfloat(seconds):duration: 60,duration: 1.5. -
A string with a unit suffix:
ms(milliseconds),s(seconds),m(minutes),h(hours). Examples:"500ms","60s","2.5m","1h". -
A Jinja2 template that renders to one of the above. Templated durations defer literal validation to runtime:
duration: "{{ workflow.input.poll_interval_seconds }}s"
The resolved duration must be greater than 0 and no more than 24 hours (86400s). Longer pauses should reconsider workflow.limits.timeout_seconds first.
Output structure — wait step output is strict — only waited_seconds is exposed:
| Field | Type | Description |
|---|---|---|
waited_seconds |
number |
Wall-clock seconds actually slept (may be less than requested on interrupt) |
Access in templates: {{ cooldown.output.waited_seconds }}.
Polling pattern — wait composes with routing loop-backs to build polling workflows without writing any Python:
agents:
- name: check_status
type: script
command: ./poll-status.sh
routes:
- to: process_result
when: "status == 'ready'"
- to: wait_then_retry
- name: wait_then_retry
type: wait
duration: "{{ workflow.input.poll_interval_seconds }}s"
routes:
- to: check_status # loop back
- name: process_result
# ...Cancellation — Esc / Ctrl+G cancels an in-progress wait immediately (the engine races the sleep against the interrupt event). The workflow-level limits.timeout_seconds also cancels in-flight waits via the standard timeout path.
Iteration counting — wait steps count toward workflow.limits.max_iterations (each pause is one step). They are not subject to max_agent_iterations, which counts per-LLM-agent tool iterations.
Restrictions — wait steps cannot have prompt, model, provider, tools, system_prompt, options, command, args, env, working_dir, timeout, workflow, input_mapping, max_depth, max_session_seconds, max_agent_iterations, session_key, retry, dialog, reasoning, validator, timeout_seconds, or output. Wait steps also cannot be used inside parallel groups or for_each groups.
See examples/wait-step.yaml for a complete polling workflow.
Set steps evaluate one or more Jinja2 expressions and bind the typed results into the workflow context. No LLM call, no subprocess, no I/O — they're pure context transformations. Use them to combine inputs, derive flags from prior outputs, compute defaults, or normalise a value once for many downstream prompts to share.
agents:
# Single binding — output is the typed scalar / list / dict.
- name: compute_slug
type: set
value: "{{ workflow.input.org }}/{{ workflow.input.repo }}"
# accessible as: compute_slug.output (a string)
routes:
- to: derive_flags
# Multi-binding — output is a dict, accessible as step.output.<key>.
- name: derive_flags
type: set
values:
is_breaking: "{{ research.output.severity in ['high', 'critical'] }}"
target_branch: "{{ workflow.input.branch or 'main' }}"
effective_model: "{{ workflow.input.model or 'claude-sonnet-4-5' }}"
routes:
- to: breaking_path
when: "{{ output.is_breaking }}"
- to: safe_pathExactly one of value: or values: must be present.
Type detection — by default, the rendered string is parsed with safe YAML (equivalent to yaml.safe_load); booleans, numbers, lists, and dicts are returned as native types. Parse failures and pure-comment renders fall back to the raw string. Empty / whitespace-only renders become "", not None. yaml.safe_load produces datetime/date/time objects from strings like "2024-01-02"; these are converted to their ISO 8601 string form so checkpoint round-trips and dashboard payloads stay JSON-safe. Any other non-JSON-safe Python value raises ExecutionError.
Explicit output_type: (single value: only) forces a specific coercion:
| Value | Behaviour |
|---|---|
auto (default) |
YAML safe-load with the rules above |
string |
Keep the raw rendered string verbatim |
number |
Try int then float; raise on failure |
integer |
int; raise on failure |
boolean |
Case-insensitive true/false/1/0/yes/no/y/n/on/off |
list |
Parse via YAML; assert the result is a list |
dict |
Parse via YAML; assert the result is a dict |
Per-key typing on multi values: is not supported.
Multi-binding ordering — every binding in a single values: step renders against the original pre-step context. Later bindings cannot reference earlier ones in the same step. If you need ordered dependencies, chain multiple set steps:
- name: step_a
type: set
value: "{{ workflow.input.x | upper }}"
- name: step_b
type: set
value: "{{ step_a.output }}-suffix"Routing on set output — routes attached to a set step evaluate against the bound value directly. Dict outputs expose {{ output.<key> }} (Jinja2) and bare <key> (simpleeval); scalar / list outputs expose only {{ output }}:
# Multi-values step — route on a derived dict field.
- name: derive_flags
type: set
values:
is_breaking: "{{ severity == 'high' }}"
routes:
- to: breaking_path
when: "{{ output.is_breaking }}"
- to: safe_path
# Single-value step — route on the scalar itself.
- name: flag
type: set
value: "{{ workflow.input.severity == 'high' }}"
routes:
- to: hi
when: "{{ output }}"
- to: loOptional output schema — set steps support the same output: schema as LLM and script agents, but only when the rendered value is a dict (which is always the case for multi values:, and may be the case for single value:). If a single-value: step declares output: but produces a scalar / list, the engine raises a friendly ValidationError pointing to values: as the intended shape.
Composition — set steps are allowed inside parallel groups (each member publishes its bound value to context) and as the inline agent of a for_each group (one bound value per item). Inside a parallel group, set templates cannot reference sibling group members (the validator catches this at config time, since the engine renders against a pre-group snapshot).
Restrictions — set agents cannot have prompt, model, provider, tools, system_prompt, command, args, env, working_dir, timeout, workflow, options, input_mapping, max_depth, retry, dialog, reasoning, validator, timeout_seconds, max_session_seconds, max_agent_iterations, or session_key. They count toward limits.max_iterations like any other step.
Events — set steps emit set_started / set_completed / set_failed (mirroring the script-step lifecycle) in all three positions: linear main loop, parallel group member, and for-each iteration. The set_completed payload carries output_type, output_keys (sorted, empty for scalars), and value_repr (a JSON-safe preview, truncated at 512 chars).
MCP steps call a tool on a configured MCP server directly without invoking an LLM. There is no model call, no prompt tokens are spent, and execution is deterministic. Use them to fetch files, query databases, invoke APIs, or perform external tool operations where the exact tool and arguments are known in advance.
agents:
- name: read_spec
type: mcp
server: filesystem # Server name in runtime.mcp_servers (required, literal)
tool: read_file # Tool name on the MCP server (required, literal)
arguments: # Tool arguments (optional, Jinja2-rendered)
path: "docs/spec.md"
timeout: 30 # Per-call timeout in seconds (optional)
routes:
- to: handle_error
when: "{{ output.is_error }}"
- to: analyze_specFields:
| Field | Type | Description |
|---|---|---|
server |
string |
Required. MCP server name declared in workflow.runtime.mcp_servers. Literal string only; templates are rejected. |
tool |
string |
Required. Tool name as exposed by the server. Literal string only; templates are rejected. |
arguments |
mapping |
Optional tool arguments dict. Recursively Jinja2-rendered against workflow context. |
timeout |
integer |
Optional per-call timeout in seconds. Raises ExecutionError if exceeded. |
output |
mapping |
Optional output schema for validating the merged result envelope. |
routes |
list |
Optional route list evaluated against the merged result envelope. |
input |
list |
Optional input reference declarations used in explicit context mode. |
Argument rendering and type coercion:
Dicts and lists inside arguments are walked recursively. String leaves are Jinja2-rendered against the workflow context, and each fully rendered string is then parsed as YAML (the set step's auto rule) — whatever the rendered text parses as becomes the argument value:
- Whole-string scalars:
"105"->int,"true"->bool,"null"->None. - Collections:
"[1, 2]"->list,"key: value"->dict. - Embedded templates are parsed the same way —
"1{{ x }}"withx=2renders"12"and becomes the integer12, and"label: {{ x }}"becomes a mapping. Only renders whose text parses as a plain string (e.g."pre-{{ x }}"->"pre-2", multi-word prose) stay strings. - Empty or whitespace-only renders become
""; a render that parses asnullthrough anything but an explicit null marker (null,~) keeps its raw string form. - Native YAML scalars (integers, floats, booleans,
None) pass through without change.
If an argument's exact type matters, keep the rendered text unambiguous (e.g. quote it in a way that cannot parse as another type, or build the value in a set step where you can assert output_type).
Result envelope and merge rule:
An MCP tool execution produces a result envelope with three base keys:
{
"content": [
{"type": "text", "text": "..."}
],
"structured": {"record_id": 42, "status": "ok"},
"is_error": false
}When the tool returns structured content (a dictionary under structured), its top-level keys are merged directly into the agent's output dictionary alongside the envelope. Downstream templates and route conditions can access these fields directly:
{{ read_spec.output.content }} # Content blocks list
{{ read_spec.output.structured }} # Raw structured dict (or null)
{{ read_spec.output.is_error }} # Boolean error flag
{{ read_spec.output.record_id }} # Merged structured fieldThe base keys content, structured, and is_error are reserved by the envelope, and outputs / errors are additionally reserved because the workflow engine recognizes parallel/for-each group outputs by exactly those two top-level keys — a structured result flattening them would make the step's output indistinguishable from a group output. If the structured dictionary contains colliding keys, the envelope wins, the colliding keys are omitted from the merge with a debug-level log message, and they stay reachable under output.structured.<key>.
is_error semantics and routing:
When an MCP tool reports a logical tool failure (isError: true in the MCP protocol), the step sets output.is_error = True and completes normally. The workflow engine does not treat this as a workflow crash, allowing you to handle tool failures via routing:
routes:
- to: handle_tool_error
when: "{{ output.is_error }}"
- to: process_successIn contrast, transport failures, unknown server names, unlisted tools, server launch failures, call timeouts, and output schema validation mismatches raise exceptions and fail the step.
Server transport:
MCP steps currently support stdio servers only. Configuring an http or sse server for an MCP step is rejected during validation and runtime with an explicit error: type: mcp supports stdio servers only (http/sse support is not implemented yet).
Concurrency and slot serialization:
Calls to the same MCP server process are serialized via an internal per-server slot lock to protect the stdio stream. Calls to different MCP servers run in parallel when placed in parallel groups. The engine pools one server process per (server, working_dir) pair, bounded by _MCP_STEP_POOL_MAX (16) as a soft threshold: when the pool is at the cap, the oldest entry not currently serving a call is closed to make room, so a for_each rendering many unique working directories cannot spawn unbounded server processes within a single run. If every entry is busy serving a call, the overflow is allowed rather than blocking or failing the step — the cap bounds idle connections, not in-flight work.
Timeouts:
The step-level timeout field sets a per-call timeout in seconds for the MCP tool invocation. It is independent of the overall workflow.limits.timeout_seconds, which bounds the entire workflow execution.
Composition:
- Parallel groups: MCP steps can run inside
parallelgroups. Invocations targeting distinct servers execute concurrently; invocations targeting the same server serialize on the server slot lock. - For-each groups: MCP steps can serve as the inline agent of a
for_eachgroup. - Working directory: The server process inherits the workflow's
runtime.working_dir, rendered dynamically per execution. Step-levelworking_diris not allowed on MCP steps.
Validation rules:
- Static validation (
conductor validate): Validates workflows offline without connecting to servers. Checks that the referenced server is declared inruntime.mcp_servers, hastype: stdio, allows the tool in itstools:filter (a"*"member means unrestricted), that every Jinja template inargumentsparses, and that no template references a sibling member of the same parallel group. - Runtime validation (
conductor run): Repeats the target checks at execution time — the server is declared, the transport is stdio, the tool is allowlisted, and (only possible live) the tool actually exists on the connected server. Runtime does not repeat the offline-only diagnostics: template syntax checking and same-parallel-group reference analysis run exclusively underconductor validate, so skipping validation forfeits those two guarantees.
Limits and truncation policy:
runtime.tool_output bounds the total text length across all text blocks in content. When text output exceeds max_chars, blocks are truncated in order. Truncated blocks receive "truncated": true and a "spill_path" pointing to the full spilled output file when spilling is enabled. The structured dictionary represents structured application data and is never truncated.
Events, errors, and secrets policy:
MCP steps emit three lifecycle events:
mcp_started: containsagent_name,iteration,server,tool, andargument_keys(sorted list of key names only).mcp_completed: containsagent_name,elapsed,server,tool,is_error,result_bytes,truncated, and optionalspill_path(only ever a Conductor-generated spill file path — server-suppliedtruncated/spill_pathblock fields are stripped at ingestion and never forwarded; on a resumed run the synthetic replay does not republish markers stored in a checkpoint at all, since a checkpoint written before the stripping existed can carry server-supplied ones).mcp_failed: containsagent_name,elapsed,server,tool,error_type, and amessagethat is either authored and value-free (unknown server, non-stdio transport, disallowed/missing tool, a timeout with its duration) or a generic redacted pointer (see below).
Argument values and result payloads are never included in MCP step event payloads — this guarantee covers exactly the tool arguments and the tool result bodies, nothing else. Two things stay visible by design, so plan around them:
for_eachitem identifiers: thekey_byvalue of each item is copied onto that item'smcp_*events asitem_key. Do not use a sensitive value askey_by(e.g. a token that is also a tool argument) — it will appear in event streams and the dashboard.- Anything you explicitly surface: writing an MCP result into the workflow's final
output:publishes it inworkflow_completed, and referencing it in a later step'sprompt/argumentssends it onward. The redaction governs automatic event metadata, not data you route yourself.
When a call fails with anything but an authored value-free error, the step's raw exception (which can embed argument or result values) is written only to a private per-run diagnostic file — *.mcp-diagnostics.log next to the run's *.events.jsonl log — and the mcp_failed event plus the raised error point at that path. The redaction extends downstream: the step re-raises a generic error, so workflow_failed and group failure events (parallel_agent_failed, for_each_item_failed) also carry only the sanitized message. The diagnostic file may contain secrets; it is not deleted automatically and is covered by the same temp-directory hygiene as runtime.tool_output spill files.
Cancellation and interrupt semantics:
MCP steps do not support automatic retries (retry: is forbidden). A dashboard Stop (or Esc in the terminal) during a main-loop MCP step cancels the in-flight call — across the slot wait, the lazy connect, and the call itself — and enters the usual pause flow (agent_paused, then Resume/Kill). A cancelled call is never replayed transparently: its external side effects are unknown, so the step is re-entered from the top only on an explicit resume decision — a dashboard Resume/guidance, or the terminal interrupt menu. If the pause resolves without anyone making that decision (every browser client disconnects mid-pause, or the dashboard has no connected clients at all), the run stops as a resumable failure flagged stopped_by_user with a checkpoint, and conductor resume becomes the explicit re-execution boundary — unlike LLM agents, which auto-resume on disconnect because re-running one only costs tokens. Kill unwinds the workflow. Within parallel and for-each groups, MCP members behave like LLM members: a Stop reaches them through the group's cancellation/drain, not through a mid-call interrupt signal. When a run is cancelled or the workflow-level limits.timeout_seconds fires, the engine stops waiting for the call. Any external side effects already performed by the MCP server process are not rolled back, providing at-least-once execution semantics on workflow resume.
Restrictions:
MCP steps cannot have prompt, system_prompt, provider, model, tools, reasoning, context_tier, skills, plugins, validator, dialog, sandbox, session_key, max_agent_iterations, max_session_seconds, output_mode, retry, timeout_seconds (use timeout), command, args, env, working_dir, settings_dir, options, workflow, input_mapping, max_depth, value, values, or output_type.
Sub-workflow steps reference external workflow YAML files, enabling composable and reusable workflow building blocks. The sub-workflow runs as a black box — its internal agents are not visible to the parent.
agents:
- name: deep_research
type: workflow
workflow: ./research-pipeline.yaml # Required: path to sub-workflow YAML
input: # Optional: explicit input declarations
- workflow.input.topic
input_mapping: # Optional: per-call inputs to the sub-workflow
topic: "{{ workflow.input.topic }}"
depth: "{{ research_planner.output.depth }}"
max_depth: 3 # Optional: per-agent recursion cap
# (additionally bounded by global
# MAX_SUBWORKFLOW_DEPTH = 10)
output: # Optional: output schema for validation
findings:
type: string
routes:
- to: synthesizerKey semantics:
- The
workflowfield can be:- A local file path:
./research-pipeline.yaml(resolved relative to the parent) - A configured registry reference:
qa-bot@team#v1.2.3(see Workflow Registry) - An ad-hoc GitHub reference:
analysis@myorg/team-a#main(owner/repo fetched directly from GitHub)
- A local file path:
- Sub-workflow inherits the parent's provider configuration
- Sub-workflow output is stored in context and accessible via
{{ agent_name.output.field }} - Recursive composition is supported (sub-workflows can reference other sub-workflows) with a global depth limit of
MAX_SUBWORKFLOW_DEPTH = 10 - Self-referential sub-workflows (a workflow referencing itself) are allowed; depth is bounded by the global cap and the optional per-agent
max_depthfield input_mappingkeys are sub-workflow input names; each value is a Jinja2 expression evaluated against the parent's context. Wheninput_mappingis omitted, the parent'sworkflow.input.*is forwarded to the sub-workflow as before
Access sub-workflow output in downstream agents:
prompt: |
The research findings were:
{{ deep_research.output.findings }}Workflow reference types — the workflow field supports three forms:
agents:
# Local file path (relative to parent workflow)
- name: local_pipeline
type: workflow
workflow: ./shared/research-pipeline.yaml
# Configured registry reference
- name: registry_pipeline
type: workflow
workflow: qa-bot@team#v1.2.3
# Ad-hoc GitHub reference (no registry setup required)
- name: adhoc_pipeline
type: workflow
workflow: analysis@myorg/team-a#main
input_mapping:
data: "{{ workflow.input.raw_data }}"The ad-hoc form (workflow@owner/repo[#ref]) allows cross-team workflow
composition without pre-configuring registries. See
Ad-hoc References in the registry design
doc for details on caching, authentication, and ref resolution.
Sub-workflows in for_each groups — type: workflow agents can be used inside for_each groups to fan out one sub-workflow run per item in the source array. Each iteration receives its own input_mapping evaluated against the loop variable, and emits its own subworkflow_started / subworkflow_completed events:
parallel:
- name: plan_issues
for_each:
source: epic_planner.output.issues
as: issue
max_concurrent: 1
agent:
type: workflow
workflow: ./plan-and-review.yaml
input_mapping:
work_item_id: "{{ issue.id }}"
title: "{{ issue.title }}"Restrictions — workflow steps cannot have prompt, model, provider, tools, system_prompt, command, options, or validator.
Terminate steps end the workflow with an explicit status (success or failed) and a structured reason. Reaching a terminate step ends execution immediately — no routes are evaluated after — and produces a CLI exit code, dashboard state, and event payload that downstream tooling can distinguish from a generic crash.
agents:
- name: precheck
type: script
command: bash
args: ["-c", "echo '{\"action\":\"abort\",\"reason\":\"unsafe input\"}'"]
output:
action: { type: string }
reason: { type: string }
routes:
- when: "action == 'abort'"
to: abort_unsafe
- when: "action == 'noop'"
to: noop_exit
- to: main_pipeline
# Soft success — workflow ends cleanly, exit 0, dashboard ✅.
- name: noop_exit
type: terminate
status: success
reason: "Document already up to date; no edits needed."
# Hard failure with reason — workflow ends, exit 1, dashboard ❌.
- name: abort_unsafe
type: terminate
status: failed
reason: "{{ precheck.output.reason }}"
output_template: # optional; replaces workflow.output
aborted: "true" # rendered then JSON-coerced to True
stage: precheck
reason: "{{ precheck.output.reason }}"Behaviour
status |
CLI exit code | Dashboard | Event | Resumable? |
|---|---|---|---|---|
success |
0 |
✅ | workflow_completed { termination_reason, terminated_by, is_explicit: true, status: "success" } |
n/a (clean exit) |
failed |
1 |
❌ | workflow_failed { error_type: "WorkflowTerminated", termination_reason, terminated_by, is_explicit: true, status: "failed", output } |
No — explicit terminations skip the on-failure checkpoint |
Final output — when output_template: is set, it replaces the workflow-level output: mapping for this termination path. Each rendered value is passed through the same JSON-coercion helper used elsewhere in the engine, so "true" becomes True, "42" becomes 42, and JSON literals are parsed. When output_template: is omitted, the workflow-level output: is rendered as on any other terminal path.
Restrictions — terminate steps cannot have routes, tools, output, prompt, model, provider, system_prompt, command, args, env, working_dir, timeout, timeout_seconds, max_session_seconds, max_agent_iterations, session_key, max_depth, retry, dialog, reasoning, validator, workflow, input_mapping, or options. They cannot appear as members of a parallel group or as a for_each inline agent — route to them from those groups' routes: instead.
Sub-workflow boundary — a status: failed terminate inside a sub-workflow is downgraded to a SubworkflowTerminatedError (subclass of ExecutionError) at the parent boundary so the parent treats it as a normal sub-workflow failure (its own workflow_failed does NOT inherit is_explicit: true). The child's rendered output, reason, and terminate step name are preserved on the wrapper as terminated_output, terminated_reason, and terminated_by for debugging surfaces. A status: success terminate inside a sub-workflow returns its rendered output cleanly and the parent continues with its next routes.
See examples/terminate.yaml for a complete worked example with all three paths.
Dialog mode allows agents to conditionally pause after execution and enter a free-form conversation with the user. An LLM evaluator examines the agent's output against user-defined criteria and decides whether to initiate a dialog.
agents:
- name: researcher
prompt: "Research the given topic thoroughly"
dialog:
trigger_prompt: |
Enter dialog if the agent expresses uncertainty about
the user's intent, encounters ambiguous requirements,
or needs clarification before proceeding.
routes:
- to: writerWhen triggered, the user is presented with a choice:
- Discuss — engage in a multi-turn conversation with the agent
- Do your best and continue — skip the dialog and let the agent proceed
After the conversation, the agent re-executes with the dialog transcript as additional context, producing a refined output.
Configuration:
| Field | Type | Required | Description |
|---|---|---|---|
dialog.trigger_prompt |
string | Yes | Criteria for the LLM evaluator to decide when dialog is needed |
Behavior notes:
- Dialog is supported on regular
agenttype only (nothuman_gate,questions,script,workflow, orwait) - In an interactive terminal, a reply may span multiple lines — paste or type freely and submit the turn with
/sendon its own line. A dismiss keyword ends the dialog the same way, sodoneneeds/sendafter it there; off a tty every line is already a turn, so it does not. Ctrl-D at the start of a line (Ctrl-Z then Enter on Windows) also submits whatever lines have been entered so far, or dismisses the dialog when none have — so abandoning a part-written reply that way sends the lines already entered; press it on an empty prompt to leave without sending. An empty or whitespace-only submission is skipped rather than sent. Off a tty (a pipe or CI) replies are read one line at a time and/senddoes not apply, though a blank line is skipped rather than sent as an empty turn. The web dashboard is unaffected — its chat box takes a separate path that has always delivered each message whole, multi-line included - In web dashboard mode, the dialog temporarily replaces the graph area with a chat interface
- When
--skip-gatesis set (e.g., CI/automation), dialogs are automatically skipped - The evaluator prompt should describe when to trigger dialog, not what to ask — the evaluator generates the opening question from the agent's output context
- After dialog, the agent sees the full conversation transcript and produces updated output
A validator: block runs a second LLM call after a provider-backed agent completes, grading its output against a user-defined rubric. If validation fails, the agent is re-run once with the validator's feedback appended. This is distinct from retry: (transient failures, same prompt) and the output: schema (shape/type, not content quality) — it catches output that is structurally valid but semantically wrong, incomplete, or off-rubric.
agents:
- name: code_reviewer
model: claude-sonnet-4-5
prompt: "Review the diff for bugs.\n{{ workflow.input.diff }}"
output:
summary: { type: string }
issues: { type: array }
validator:
model: claude-sonnet-4-5 # optional; defaults to the agent's model
criteria: |
Verify the review identifies all null-safety issues, every suggestion
is actionable, and no function names are fabricated.
max_retries: 1Mechanics:
-
The primary agent runs and produces output.
-
The validator runs a second LLM call that receives the agent's rendered prompt, its output, and the
criteria, and must answer{ "passed": bool, "issues": [str, ...] }. -
If
passedis true, the output flows downstream unchanged. -
If
passedis false andmax_retries > 0, the agent re-runs once with a## Validation feedbacksection (the issues) appended to its prompt. The second output is taken as final — there is no second validation loop. On theclaude,openai, andhermesproviders this correction continues the completed agent conversation, preserving prior model and tool messages while sending only the validation feedback as the next user turn. Other providers keep their existing re-run behavior.The continuation trade-off: the re-run carries the entire first conversation, including every tool exchange, so an agent that already burned most of its context window on the first attempt can overflow on the retry where a rebuilt prompt would have fit. When the re-run itself fails, the original output is kept and the failure is reported on the
agent_validation_failedevent (with the error) and in the console log.
Configuration:
| Field | Type | Required | Description |
|---|---|---|---|
validator.criteria |
string | Yes | The rubric the output is graded against. Describe what a good output looks like (the checks to perform). |
validator.model |
string | No | Model for the validator call. Defaults to the primary agent's model. |
validator.max_retries |
int | No | Re-runs on failure. Default 1, hard-capped at 1. 0 = validate-and-report without re-running. |
Behavior notes:
- Supported on provider-backed
agentsteps only (nothuman_gate,questions,script,workflow,wait,set, orterminate). Works in the main loop, parallel groups, and for-each loops. - The validator uses the primary agent's provider; only
modelis overridable. - Fail-open: if the validator call errors or returns unparseable output, it is treated as a pass (with a logged warning) so a flaky grader never blocks the workflow.
- The validator sees only the agent's prompt + output + criteria, not other agents' outputs — keeping validation focused and cheap.
- Validator (and any discarded first attempt) token cost is reported as a separate
<agent> (validator)row in the usage summary. - Emits
agent_validator_start,agent_validator_complete, andagent_validation_failedevents, surfaced in the web dashboard and--verboseconsole output.
Parallel groups execute multiple agents concurrently for improved performance.
Execute a fixed list of agents in parallel:
parallel:
- name: string # Required: Group identifier
description: string # Optional: Purpose description
agents: # Required: Agents to run in parallel
- agent_name_1
- agent_name_2
- agent_name_3
failure_mode: fail_fast # Required: Error handling strategy
# Options: fail_fast | continue_on_error | all_or_nothing
routes: # Optional: Routes after parallel execution
- to: next_agent
when: "{{ condition }}"Execute an agent template for each item in an array determined at runtime:
for_each:
- name: string # Required: Group identifier
type: for_each # Required: Marks this as for-each group
description: string # Optional: Purpose description
source: string # Required: Reference to array in context
# Example: "finder.output.items"
as: string # Required: Loop variable name
# Available in templates as {{ <var> }}
# Reserved names: workflow, context, output, _index, _key
agent: # Required: Inline agent definition
model: string # Optional: Model override
prompt: | # Required: Template with {{ <var> }}
Process {{ item }}
Index: {{ _index }} # Zero-based item index
{% if _key is defined %}
Key: {{ _key }} # Extracted key (if key_by specified)
{% endif %}
output: # Optional: Output schema
result: { type: string }
max_concurrent: 10 # Optional: Concurrent execution limit
# Default: 10
failure_mode: fail_fast # Optional: Error handling strategy
# Default: fail_fast
key_by: string # Optional: Path for dict-based outputs
# Example: "item.id" → outputs["123"]
routes: # Optional: Routes after execution
- to: next_agentLoop Variables:
For-each agents have access to special loop variables in addition to the custom loop variable defined by as:
{{ <var_name> }}- Current item from array (e.g.,{{ kpi }},{{ item }}){{ _index }}- Zero-based index of current item (0, 1, 2, ...){{ _key }}- Extracted key value (only ifkey_byis specified)
Reserved Variable Names:
The following names cannot be used for the as parameter:
workflow- Reserved for workflow inputscontext- Reserved for execution metadataoutput- Reserved for agent outputs_index- Reserved for item index_key- Reserved for extracted key
fail_fast(recommended): Stop immediately on first agent failurecontinue_on_error: Run all agents; proceed if at least one succeedsall_or_nothing: Run all agents; fail if any agent fails
Downstream agents can access parallel group outputs using Jinja2 templates:
agents:
- name: summarizer
prompt: |
Summarize the research findings:
Web research: {{ parallel_researchers.outputs.web_researcher.summary }}
Academic research: {{ parallel_researchers.outputs.academic_researcher.summary }}
News research: {{ parallel_researchers.outputs.news_researcher.summary }}Structure:
{{ group_name.outputs.agent_name.field }}- Access successful agent output{{ group_name.errors.agent_name.message }}- Access error details (ifcontinue_on_errormode)
agents:
- name: aggregator
prompt: |
Process these results:
# Index-based access (when key_by not specified)
First result: {{ processors.outputs[0].result }}
Second result: {{ processors.outputs[1].result }}
# Key-based access (when key_by is specified)
KPI-123 result: {{ analyzers.outputs["KPI-123"].analysis }}
# Iterate over all outputs
{% for result in processors.outputs %}
- {{ result | json }}
{% endfor %}
# Access loop metadata
Total processed: {{ processors.outputs | length }}
# Check for errors
{% if processors.errors %}
Failed items: {{ processors.errors | length }}
{% endif %}Structure:
- Without
key_by:{{ group_name.outputs[index].field }}- Array access - With
key_by:{{ group_name.outputs["key"].field }}- Dict access {{ group_name.errors }}- Dict of failed items (ifcontinue_on_errororall_or_nothing)
Routes define workflow control flow. Routes are evaluated in order, and the first matching route is taken.
routes:
- to: next_agent # Agent name or $endroutes:
- to: approver
when: "{{ quality_score >= 8 }}"
- to: reviser
when: "{{ quality_score < 8 }}"
- to: $end # Default fallbackRoutes support Jinja2 templates and simpleeval expressions:
# Jinja2 syntax (recommended)
when: "{{ agent.output.status == 'success' }}"
when: "{{ agent.output.score > 5 and agent.output.valid }}"
# simpleeval syntax (legacy)
when: "status == 'success'"
when: "score > 5 and valid"$end- Terminate workflow successfully- Agent names must match an existing agent or parallel group name
Define expected inputs in the input section:
input:
question:
type: string
required: true
description: "The question to answer"
context:
type: string
required: false
default: "No additional context provided"Access in agents: {{ workflow.input.question }}
Optional inputs without an explicit default resolve to type-appropriate zero values rather than None, so templates render cleanly:
Input type |
Zero value |
|---|---|
string |
"" |
number |
0 |
boolean |
false |
array |
[] |
object |
{} |
This means {{ workflow.input.optional_msg | default("fallback") }} correctly renders "fallback" when optional_msg is omitted, instead of the literal string "None".
In addition to workflow.input.*, every agent has access to:
| Variable | Description |
|---|---|
workflow.name |
Workflow name from the YAML |
workflow.description |
Workflow description from the YAML |
workflow.dir |
Absolute path to the directory containing the workflow YAML |
workflow.file |
Absolute path to the workflow YAML file |
These are available in all context modes (they're metadata, not inputs). workflow.dir is particularly useful for registry-hosted workflows that need to reference co-located scripts or assets without depending on the caller's working directory:
agents:
- name: detector
type: script
command: pwsh
args:
- "-File"
- "{{ workflow.dir }}/scripts/detect-state.ps1"Define the final workflow output:
output:
answer: "{{ answerer.output.answer }}"
confidence: "{{ answerer.output.confidence }}"
sources: "{{ researcher.output.sources }}"Define expected output schema for validation:
agents:
- name: analyzer
output:
score:
type: number
description: "Quality score 1-10"
summary:
type: string
description: "Brief summary"
recommendations:
type: array
description: "List of recommendations"Configure safety limits to prevent runaway workflows:
workflow:
limits:
max_iterations: 50 # Maximum agent executions (1-500, default: 10)
timeout_seconds: 1800 # Maximum wall-clock time in seconds (optional)
budget_usd: 5.00 # Cumulative cost cap in USD (optional)
budget_mode: audit # audit | enforce (default: audit)- Each agent execution counts as 1 iteration
- Parallel agents count individually (3 parallel agents = 3 iterations)
- Loop-back patterns increment the counter on each iteration
- Script steps and wait steps each count as 1 iteration
- Workflow terminates when
timeout_secondsis exceeded - Includes all agent execution time and overhead
None(default) means no timeout
budget_usdcaps cumulative LLM cost across the run. When unset (default), no budget tracking occurs.budget_mode: audit(default) emits abudget_exceededevent and logs a warning on first overshoot, but the workflow continues — use this to discover cost profiles before enforcing.budget_mode: enforceemits abudget_exceededevent, saves a checkpoint, and stops the workflow withBudgetExceededError. Resuming withconductor resume <workflow.yaml>starts a fresh budget window (cumulative spend resets to $0); raisingbudget_usdfirst is optional.- Sub-workflow (
type: workflow) spend is merged into the parent's budget, so a parent-level budget accounts for cost incurred by delegated workflows. - Recommended graduation path:
- Run without
budget_usdto observe costs in the summary - Add
budget_usdinauditmode to track overshoots non-disruptively - Switch to
enforceonce the cost profile is understood
- Run without
See configuration.md for the budget configuration reference and notes on how budget tracking integrates with the provider usage callbacks.
The end-of-run summary reports per-agent and total cost. Pricing for a model is resolved in this order (first hit wins):
- Workflow
cost.pricingoverride — per-model rates you supply in the workflow file (highest precedence; treated as intent). - Provider hook — a provider that knows its own rates supplies them at runtime. The Copilot provider derives live pricing from the SDK's per-model billing metadata, so newly released models are priced without a table update. Providers whose SDK exposes no pricing (e.g. the Anthropic API) skip this step.
- Built-in table — a static
DEFAULT_PRICINGtable of common models. - Unavailable — if none of the above match, the agent is unpriced.
Unpriced agents are surfaced, not silently dropped. When a run mixes priced
and unpriced agents, the total is shown as a partial (e.g. Total: ~$0.4200 (2 agents unpriced: model-a, model-b)) rather than a clean-looking number that
hides missing spend. The web dashboard shows the same ~$X (N unpriced) marker.
When no model can be priced, the summary reads Cost data unavailable.
Run conductor doctor --models to see which models are priced and from
where — the Models detail table's Pricing column shows provider / table
/ none per model (see conductor doctor).
To price an unknown model yourself, add a cost.pricing override:
workflow:
cost:
pricing:
my-custom-model:
input_per_mtok: 3.00 # USD per million input tokens
output_per_mtok: 15.00 # USD per million output tokens
cache_read_per_mtok: 0.30 # optional
cache_write_per_mtok: 3.75 # optionalBy default Conductor writes a checkpoint only when a workflow fails with an
exception. A long run that stalls (a provider hang, an MCP deadlock, a network
blip, a sub-agent that never returns) produces no recoverable state, so
conductor resume has nothing to resume.
Enable periodic checkpoints to make stalled or hard-killed runs resumable:
workflow:
runtime:
checkpoint:
every_seconds: 300 # Save at most once every 5 minutes (throttle)
keep_last: 5 # Retain this many periodic checkpoints per run (1-100)
# every_agent: true # Alternative: save after EVERY step boundaryevery_agent(defaultfalse) — save at every step boundary (after each agent, parallel group, for-each group, gate, script, set, wait, or sub-workflow step). Whentrueit governs on its own andevery_secondsis ignored.every_seconds(defaultnull) — a throttle: save at the first step boundary reached after this many seconds have elapsed since the last checkpoint. The first periodic checkpoint of a run fires at the first boundary; the interval only throttles subsequent saves.- Set either trigger (or both — a save fires when either is met).
keep_last(default5) — older periodic checkpoints for the run are rotated away after each save; failure checkpoints are never rotated.
How it works:
- Checkpoints are evaluated at step boundaries, where all prior step outputs
are already committed. The checkpoint points at the step that was about to
run, so
conductor resumecontinues forward and re-runs only that step. - There is no background timer. If a single step runs longer than
every_seconds, the recovery point is the boundary checkpoint taken before that step started — which is exactly what you resume from after killing a stalled run. - Periodic checkpoints are written by the root workflow only (sub-workflow
state is re-run from scratch on resume) and are deleted automatically when
the run reaches a terminal, non-resumable outcome (clean completion or an
explicit
status: failedterminate). On an unexpected failure they are kept alongside the on-failure checkpoint. - If a periodic save itself fails (e.g. the disk fills), the run is not
interrupted; the failure is surfaced via a
checkpoint_save_failedevent and a console warning so you know recovery may be unavailable.
Recover a stalled run by killing the process (e.g. conductor stop for a
--web-bg run) and then:
conductor checkpoint list workflow.yaml # list checkpoints (Trigger column shows periodic/failure)
conductor resume workflow.yaml # resume from the latest checkpointSee examples/periodic-checkpoints.yaml for a complete example.
To prevent large tool results from overloading the context window, Conductor supports limiting the character size of individual MCP tool responses:
workflow:
runtime:
tool_output:
enabled: true # Default: true. Set false to disable output limiting.
max_chars: 50000 # Default: 50000. Retained character count (minimum: 1000).
spill_to_file: true # Default: true. Write full raw output to a temp file.
spill_dir: null # Default: null. Custom spill directory (defaults to OS temp dir).- Per-Result Cap: The
max_charslimit is a per-result cap applied to each tool result independently, not a cumulative context window budget. Multiple truncated tool results, combined with prompt and conversation history, can still exceed the model's context window. Users should tune this viamax_charsormax_agent_iterationsif needed. - Spill files: Spill files are written to the directory specified by
spill_dir(resolving to<tempfile.gettempdir()>/conductor/tool-outputifnull). These files contain raw tool output (which may include secrets) and are not deleted by Conductor. - Provider Support: The Copilot provider maps this limit directly to bytes in the native SDK's
large_outputconfiguration. For Claude, the provider handles truncation conductor-side. This option is ignored byclaude-agent-sdk(managed via native CLIMAX_MCP_OUTPUT_TOKENS) and is not applicable tohermes(no MCP tools).
See examples/tool-output-limits.yaml for a complete example.
OpenTelemetry tracing is configured exclusively via environment variables. For setup instructions, configuration options, and details on unified traces, see the OpenTelemetry Tracing guide.
Tools can be configured at workflow or agent level.
Available to all agents:
tools:
- web_search
- calculatorOverride or extend workflow tools:
agents:
- name: researcher
tools:
- web_search
- arxiv_searchNote: Tool implementation depends on your provider. See provider documentation for available tools.
Tools are typically provided by MCP servers configured in the workflow.runtime.mcp_servers section. MCP tools are automatically made available to agents and can be filtered using the tools field above.
workflow:
runtime:
mcp_servers:
web-search:
command: npx
args: ["-y", "open-websearch@latest"]
tools: ["*"]
agents:
- name: researcher
tools:
- web-search__search # Use specific MCP tool (server__tool format)
prompt: "Research the topic"For full MCP configuration details, see the MCP Tools guide.
The workflow.mcp: block is unrelated to the runtime.mcp_servers section
above. That section configures MCP servers this workflow calls as a
client; this block configures how conductor mcp serve exposes this
workflow itself as an MCP tool to a connected host.
Every field is optional and defaults to exposing the workflow:
workflow:
name: review-pr
mcp:
expose: true # default true — a candidate for MCP tool exposure
mode: async # async (default) | sync | auto
read_only: false # this workflow has no side effects
destructive: true # this workflow can destroy or irreversibly modify state
estimated_minutes: 8 # client-side hint for typical run duration| Field | Type | Default | Meaning |
|---|---|---|---|
expose |
boolean | true |
Whether this workflow is a candidate for MCP tool exposure. conductor mcp serve's --allow/--deny flags outrank this field, which in turn outranks the default. |
mode |
async | sync | auto |
async |
The invocation mode conductor mcp serve should use by default for this workflow. A hint, not a mandate — the caller's own _wait_seconds parameter can always override it per call. |
read_only |
boolean | false |
Whether this workflow only reads state, with no side effects. Surfaced to the MCP host as a tool annotation. |
destructive |
boolean | false |
Whether this workflow can destroy or irreversibly modify state. Surfaced to the MCP host as a tool annotation. |
estimated_minutes |
integer | null |
null |
Estimated wall-clock runtime in minutes, for client-side hints. Must be positive when present. |
Because workflow.mcp: (like every other WorkflowDef field) rejects
unknown keys, a typo such as expse: false is a conductor validate error
rather than a silently ignored no-op — the reason this is a real schema
block instead of riding the untyped workflow.metadata: bag.
An absent mcp: block behaves identically to an explicit default one, so no
existing workflow needs editing. conductor validate always reports the
effective block and the tool name the workflow would publish (the slugified
workflow.name — lowercased, with every character outside A-Za-z0-9_-.
folded to _, and then - itself additionally folded to _ to match
Conductor's snake_case convention, so review-pr publishes as review_pr),
so the generated name is inspectable without ever attaching
an MCP host. Two things fail validation regardless of whether mcp: is
declared, since every workflow is a candidate for exposure by default: a
workflow.input named _wait_seconds (it collides with the parameter the
tool generator reserves on every generated tool), and a workflow.name that
cannot slugify to a legal 1–128-character tool name.
See examples/mcp-serve.yaml for a complete example.
A skill is a directory of reusable knowledge an agent can opt into: a
SKILL.md describing what the skill covers, plus an optional references/
tree of supporting docs. Conductor consumes the same format the GitHub
Copilot CLI and Anthropic Claude Code use, so a skill written for either
generally works here unchanged. Conductor adds two constraints neither CLI
enforces: the frontmatter must actually parse (that is the point of this —
see below), and on claude-agent-sdk the frontmatter name must match the
directory basename, since the skill is enabled by name.
Set a workflow-wide default and override it per agent:
workflow:
runtime:
skills:
- conductor # built-in, ships in the wheel
- ./team-skills/acme-widgets # versioned alongside the workflow
- ~/scratch/skills # a skills root — every skill directly inside
agents:
- name: reviewer
prompt: "Review this workflow."
# inherits runtime.skills
- name: summarizer
prompt: "Summarize the review."
skills: [] # explicit opt-out — no skills
- name: widget_expert
prompt: "Check the widget conventions."
skills: [./team-skills/acme-widgets] # overrides the workflow defaultThe per-agent field is tri-state:
| Value | Meaning |
|---|---|
| omitted | inherit runtime.skills |
[] |
explicit opt-out — no skills, ignores the workflow default |
[...] |
explicit set — replaces the workflow default |
Skills apply only to provider-backed agents. They are rejected on script,
wait, set, terminate, workflow, human_gate, and questions steps.
Each entry is either a registered built-in name or a filesystem path.
The distinction is syntactic — an entry is a path when it starts with . or
~, or contains / or \. Everything else must be a
built-in name, so a bare conductor can never be shadowed by a directory
that happens to share its name.
Conductor ships one built-in skill:
| Name | Contents |
|---|---|
conductor |
Conductor's YAML schema, execution model, authoring patterns, and CLI commands |
A path may point at either granularity:
- a skill directory — one containing
SKILL.md - a skills root — a directory of skill directories, which expands to
every immediate child containing a
SKILL.md(not recursive)
Relative paths resolve against the workflow file's directory, the same
rule working_dir uses, so a workflow validates and runs identically from
any working directory. This is what lets a team version a skill next to the
workflow that uses it, with no per-developer install step.
Trust: a
SKILL.mdis injected into the agent's context, so treat skill paths as trusted input. Conductor applies no additional allowlist — the same workflow file can already declaretype: scriptsteps that run arbitrary shell, so a skill path grants strictly less.
Every resolved skill must declare a name and a description in valid YAML
frontmatter:
---
name: acme-widgets
description: |
Internal ACME widget conventions. Triggers: widget, acme widget.
---Use a block scalar (description: |) whenever the text contains a colon
followed by a space — without one the value is invalid YAML, and this is the
single most common mistake:
# Wrong — 'Triggers:' makes this unparseable
description: Internal ACME widget conventions. Triggers: widget, acme widget.Both the Copilot CLI and Claude Code silently skip a skill whose
frontmatter fails to parse — no warning, no error, the skill is simply
absent. Conductor parses it itself and fails loudly instead, both at
conductor validate and at run time.
The contract is the same everywhere — the agent has access to the named skill — but the mechanism and its cost differ:
| Provider | Mechanism | Cost |
|---|---|---|
copilot |
skill_directories on the SDK session |
progressive — frontmatter only, body loaded on demand |
claude-agent-sdk |
owning plugin registered, skill enabled by <plugin>:<skill> |
progressive |
claude |
eager injection into the rendered prompt | full body on every call |
hermes |
eager injection into the rendered prompt | full body on every call |
openai |
eager injection into the rendered prompt | full body on every call |
aca |
not supported (skills=False) — rejected by conductor validate and by the executor at run time |
n/a |
Two consequences worth knowing:
claude-agent-sdkrequires a plugin. The SDK has no bare skill-directory option, so a skill must live inside a Claude Code plugin (a.claude-plugin/plugin.jsonwith the skill under<plugin>/skills/).conductor validatereports a path skill that is not, rather than letting it fail mid-run. The same skill works oncopilotuntouched.- Eager injection is expensive. The bundled
conductorskill alone is ~132KB (~33K tokens), prepended to every call and every retry.
A repository that ships .claude/skills but no plugin manifest is unreachable
through skills: on this provider. runtime.provider.setting_sources lets the
workflow load the Claude Code settings tiers instead, which is how the claude
CLI finds those skills natively:
workflow:
runtime:
provider:
name: claude-agent-sdk
setting_sources: [project] # user | project | local; empty by default
agents:
- name: reviewer
working_dir: ./target-repo # the tier resolves against this directory
prompt: ...[project] reads <working_dir>/.claude/ — skills, CLAUDE.md, and
.claude/rules/*.md — with no plugin packaging. user also reads
~/.claude/, which makes the run depend on the operator's machine; prefer
[project].
A tier brings its hooks.
projectreads<working_dir>/.claude/settings.json, whosehooksrun shell commands on tool events. Enable this only for repositories trusted as much as the workflow itself. The field is workflow-global whileworking_diris per agent, so every agent on the provider loads the tiers against its own directory (agents without aworking_diragainst the directoryconductor runwas launched in). An agent with an explicitskills: []opts out of the tiers entirely.
When the workflow names no skills of its own, enabling a tier also widens the
session's skill filter to whatever the tier discovered — otherwise the tier
would load the repo's skills and then hide every one of them. A declared
skills: / plugins: list still wins: discovery never widens it. See
examples/claude-agent-sdk-setting-sources.yaml.
runtime.skill_injection bounds what eager-injection providers prepend. It
has no effect on providers with progressive disclosure.
workflow:
runtime:
skill_injection:
warn_bytes: 65536 # Default: 65536 (64KB). null disables the warning.
max_bytes: 163840 # Default: 163840 (160KB). null disables the limit.Exceeding warn_bytes logs a warning and reports it from conductor validate; exceeding max_bytes fails the agent. Both are measured against
the exact string being prepended and report a per-skill breakdown, so the
offender is named. The defaults sit either side of the bundled conductor
skill: enabling it on claude warns rather than breaking, while
accumulating several large skills errors.
runtime.skill_discovery picks up skills already installed on the machine,
so a workflow can use a personal or team skill library without listing each
one. It is off by default.
workflow:
runtime:
skill_discovery:
sources: [personal, project] # default: [] (disabled)
exclude: [scratch-notes] # optional, by skill nameEach source is a category of location. Conductor scans them itself and unions both CLIs' conventions, rather than asking each provider to discover its own:
| Source | Locations scanned |
|---|---|
personal |
~/.copilot/skills, ~/.claude/skills |
project |
.github/skills and .claude/skills, in the workflow file's directory and each ancestor up to the repository root — or that directory alone when it is not inside a repository |
Conductor doing the scanning is the point rather than an implementation
detail. Discovery locations are provider-specific, so a flag that asked each
provider to find its own would give a copilot agent and a claude-agent-sdk agent
different skill sets inside a single run. Scanning centrally means every
agent sees the same set whatever provider it resolves to. It also keeps the
providers' own discovery switched off, which matters because Copilot's would
additionally auto-load MCP servers from any .mcp.json in the working
directory.
Discovered skills join the workflow-level default set, so the per-agent
tri-state is unchanged — an agent that declares its own skills: (including
skills: []) overrides discovery along with runtime.skills.
Ordering is fixed: project, then personal, regardless of the order
written in sources. Reordering the list therefore cannot change which of
two same-named skills wins.
There is no plugins source. Scanning a plugin's skills/ took one of the
three things a plugin ships and left its subagents and MCP servers behind —
so a skill whose instructions dispatch to prs:code-reviewer loaded fine and
then could not. Name plugins in runtime.plugins instead, which
brings the whole unit and reproduces on another machine.
A skill named in skills: always beats a discovered one of the same name —
the discovered copy is skipped, with a warning unless both resolve to the same
directory, in which case there is nothing to report. This comes up immediately:
installing Conductor's own plugin puts a second conductor skill on the
machine, and skills: [conductor] must keep meaning the built-in one.
The author asked for the entries in skills:; they did not ask for whatever
happens to be installed. So the same problem is reported differently:
| Problem | Declared skill | Discovered skill |
|---|---|---|
Broken SKILL.md frontmatter |
error | warning, skipped |
| Name already taken | error | warning, skipped |
| Directory unreadable | error | warning, skipped |
claude-agent-sdk cannot load it |
error | warning, skipped |
The one exception is a provider with no native skill surface at all
(claude, hermes). Skipping there would drop the entire discovered set, so
that combination is an error either way — see below.
Discovery is realistically a copilot feature today:
copilot— fully supported.claude-agent-sdk— only loads a discovered skill that lives inside a Claude Code plugin, because the SDK has no bare skill-directory option. Most installed Copilot plugins are not Claude Code plugins, so expect a warning per skipped skill. Useexcludeto silence the ones you know about.claude/hermes— rejected byconductor validate. These inject every skill body into every prompt, and a discovered set is unbounded and varies by machine, so there is no limit to tune that makes it work. Name the skills you want inruntime.skillsinstead, or run those agents on a provider with progressive disclosure.
conductor validate lists every discovered skill, the location it came
from, and the total size if it were eagerly injected:
Skill discovery (personal, project): 3 skill(s)
• acme-widgets — /home/dev/.copilot/skills
• release-notes — /home/dev/.copilot/skills
• triage — /home/dev/work/repo/.github/skills
Total if eagerly injected: 41,204 bytes (~10,301 tokens)
Worth running before committing a workflow that uses discovery. An ambient set is the one part of a workflow that is not captured by the YAML, so the same file can behave differently on a teammate's machine or in CI — listing it is how that stays visible. If reproducibility matters more than convenience, leave discovery off and name the skills explicitly.
See examples/skills-self-improving-workflow.yaml and
examples/skills-discovery.yaml for complete examples.
A skill is one file of instructions. A plugin is the unit people actually install, and it ships up to three things Conductor can use:
<plugin>/
.claude-plugin/plugin.json or .github/plugin/plugin.json
skills/<skill>/SKILL.md → instructions
agents/<agent>.agent.md → subagents the model can dispatch to
(agents/<agent>.md for a Claude build — see Flavor below)
.mcp.json → MCP servers
Enabling a plugin brings all three. That matters because they are written
to work together: a plugin's SKILL.md routinely tells the agent to hand
work to prs:code-reviewer, or to call an ado MCP tool. Loading only
the instructions produces an agent that reads them correctly and then
reaches for something that was never registered — and says nothing.
workflow:
runtime:
plugins:
- prs # everything the plugin ships
- name: ado
mcp: false # skills and agents onlyPer-agent plugins: works the same way and follows the same tri-state as
skills: — omitted inherits runtime.plugins, [] opts out, a list
overrides.
| Form | Resolution |
|---|---|
prs |
An installed plugin, looked up under ~/.copilot/installed-plugins/*/ and ~/.claude/plugins/*/. An error if it is not installed, or if more than one marketplace ships that name |
prs@acme |
The prs plugin from marketplace acme — declared in plugin_sources, installed under that marketplace, or (for a copilot-flavored agent only) registered in ~/.copilot/settings.json's extraKnownMarketplaces |
./tools/my-plugin |
A path, resolved against the workflow file's directory |
Classification is syntactic — a path when the entry starts with ~ or .
or contains a separator, otherwise a name. So a same-named local directory
can never shadow an installed plugin, and resolution never depends on what
happens to exist. The path check runs first, so a directory called
my@plugin stays a path.
prs@acme is also the answer to the ambiguity error above: when two
marketplaces ship a git plugin, qualify it rather than falling back to a
path.
A plugin can be built for either CLI — Claude Code writes
.claude-plugin/plugin.json with agents/<agent>.md; the Copilot CLI
writes .github/plugin/plugin.json with agents/<agent>.agent.md. The
two agent-file conventions are read off whichever manifest actually
matched, never assumed from where the plugin happens to live — a
Copilot-built plugin can sit inside a ~/.claude/plugins/ tree (a real
configuration: a marketplace directory the Claude CLI manages, holding a
build meant for Copilot), and it still resolves every subagent it ships
correctly on a provider: copilot agent.
Flavor only ever breaks a tie, never gates whether a plugin can be read. Two situations actually have a tie to break:
- A marketplace that publishes both builds — a
.claude-plugin/marketplace.jsonand a.github/plugin/marketplace.jsonin one repository, each pointing at its own build directory.prs@acmeresolves to whichever build matches the requesting agent's provider. - A bare name installed once per CLI under the same marketplace
directory name —
prsunder both~/.copilot/installed-plugins/acme/and~/.claude/plugins/acme/. Two different marketplace names sharing a plugin name stay an ambiguity error regardless of flavor; that is a genuinely different plugin per marketplace, and picking one silently is exactly the per-machine drift this feature prevents.
Every other case is unaffected: a plugin that ships only one build resolves that build for every agent, whichever provider runs it.
conductor plugin list prints the flavor it resolved each group of
agents against, alongside the usual component counts.
An entry like prs or prs@acme still resolves against machine state, so
a workflow shared with a teammate needs "first install these plugins" in a
README. runtime.plugin_sources removes that step:
workflow:
runtime:
plugin_sources:
acme: acme/agent-plugins#v1.4.0 # string shorthand
beta: # object form
source: git@github.com:beta/plugins.git#3f2a1c9
path: packages/plugins # subdir, if not at the root
local-dev: ./vendor/plugins # a local path is a valid source
plugins:
- prs@acme
- name: ado@acme
mcp: falseTwo concerns, two keys: plugin_sources is acquisition, plugins is
activation. That split is not invented here — the Copilot CLI's own
settings separate extraKnownMarketplaces from enabledPlugins, for the
reason that eleven plugins commonly come from one repository. Inlining a
URL per entry would either clone it eleven times or silently pick one of
eleven refs.
The load-bearing property: prs@acme means the same thing whether
acme was declared here, installed via a CLI, or is a local directory. A
declared source registers its name into the same table the installed
marketplaces populate, and wins on a clash.
A copilot-flavored agent has one further fallback: a marketplace
registered in ~/.copilot/settings.json (the Copilot CLI's own
extraKnownMarketplaces, whatever the copilot CLI already has you
pointed at) resolves too, when nothing declared or installed already
matches. This is deliberately the last thing consulted — it can only turn
a hard "no such marketplace" error into a resolution, never change an
answer a declared source or an installed root already gave — and it comes
with a printed advisory naming the standalone remedy: declaring the same
marketplace under plugin_sources so the workflow resolves identically on
a machine that never ran copilot's own marketplace-add flow.
A source may be a marketplace catalog (a marketplace.json listing
many plugins) or a single plugin (a plugin.json at the root). Both
are detected automatically; a repository that is both needs a plugin:
key to say which, rather than having one picked for it. That key names
either the root plugin or any plugin the catalog lists — it also narrows
a pure catalog to a single entry.
The Copilot CLI's, so a source string you have already written works unchanged:
| Form | Example |
|---|---|
owner/repo |
acme/agent-plugins |
owner/repo#ref |
acme/agent-plugins#v1.4.0 |
| http/https/ssh URL | https://gitlab.com/acme/p.git#main |
| scp-style remote | git@github.com:acme/p.git#3f2a1c9 |
| local path | ./vendor/plugins, ~/src/plugins |
Cloning shells out to git, so existing SSH keys, credential helpers and
host configuration apply, and self-hosted forges work.
There is no lockfile. The YAML is the lock:
| Ref | Behaviour |
|---|---|
| A full 40-character SHA | Pinned — fetched once, never re-checked |
| A tag, a branch, or no ref | Floating — re-resolved on every run; a moved ref is fetched |
Worth knowing before leaving a source unpinned: tags move, so a floating
source can gain a subagent or an MCP server between two runs of the same
file. An MCP server is a subprocess launched with your credentials. Pin a
SHA when that matters — it is a one-character edit — and read conductor plugin list before committing.
| Command | Behaviour |
|---|---|
conductor run / resume |
Acquires sources up front, in parallel, before the first agent |
conductor plugin fetch <workflow> |
Acquires them explicitly — the CI step |
conductor plugin list <workflow> |
Reads the cache; reports what a run would load |
conductor validate |
Never touches the network |
Checkouts are cached under $CONDUCTOR_HOME/cache/plugins/ (default
~/.conductor/cache/plugins/), keyed by resolved commit, so different refs
coexist and a checkout is immutable once written.
When a floating ref cannot be re-checked — offline, VPN, expired
credentials — the cached checkout is used and you are told. A cold cache
with no network is an error naming conductor plugin fetch.
conductor validate reports an unfetched source as a warning, not an
error, and says which checks it had to skip. The workflow is not wrong;
the machine has simply not fetched yet, and conductor run heals it.
A source that is itself wrong — a path that does not exist, a path:
that escapes the checkout, a catalog that will not parse — is an error.
No amount of fetching fixes it. Sources are checked one at a time, so a
broken or unfetched source costs its own line rather than the report for
every healthy source beside it.
A source declared but never referenced is reported too — dead config that survives a refactor and then pins a repository nobody reads.
If a declared source shadows a marketplace of the same name installed on your machine, the declared one wins and you are told. The two can ship different subagents, or a different MCP server, so a silent substitution would change what your agents can do without saying so.
There is no conductor plugin update. A floating source updates itself and
a pinned one is meant not to.
Declaring a source is the consent. There is no prompt and no allowlist,
matching how a plugin path is already treated — the same YAML can run
arbitrary shell via type: script. But a git source goes a step further:
the code is not in your tree when you review the workflow, and enabling a
plugin can start an MCP server with your credentials. Pin a SHA, and use
conductor plugin list to see what a source actually brings.
| Key | Default | Effect when false |
|---|---|---|
skills |
true |
The plugin's skills/ is not loaded |
agents |
true |
Its subagents are not registered |
mcp |
true |
Its MCP servers are not started |
Every component defaults on, because defaulting one off would reproduce the partial load the feature exists to fix.
mcp: false is worth knowing about, though: an MCP server is a subprocess
launched with your credentials — the ado plugin authenticates through
az at process start, before any tool is called. Conductor starts one
only because a workflow named the plugin.
hooks/ and commands/ are never loaded. conductor validate warns when
a plugin ships them, rather than leaving the difference from the CLI
invisible.
Names must not collide. Two plugins shipping a same-named skill, or an MCP
server name claimed by two plugins or by runtime.mcp_servers, is an error
— one of the two would be unreachable, and dropping it quietly is the
failure this feature exists to remove. Disable the component on one of them
to resolve it. A plugin skill that collides with one you named in skills:
is the exception: yours wins, and the plugin's copy is reported as shadowed.
| Provider | Plugins |
|---|---|
copilot |
Yes — each component registered individually |
claude-agent-sdk |
Yes, with one carve-out below |
claude, hermes, aca |
No — conductor validate rejects plugins: |
Conductor deconstructs a plugin rather than handing its root to the
SDK. Both native SDKs have a whole-plugin option, and both are
all-or-nothing: on Copilot, hiding an MCP tool from the model does not
stop its server from launching, so mcp: false would be a guarantee that
isn't one. Registering the root also puts the two providers in opposition
— plugin MCP is unavoidable on one and suppressed on the other.
Deconstructed, a plugin's MCP servers also pick up the same
runtime.tool_output limits, dashboard tool events, and credential and
${VAR} resolution as a server the workflow declared itself. They are not
MCPServerDefs, though, so there is no per-server tools: filter to
author — mcp: false is the control you have.
The carve-out: on claude-agent-sdk, the only way to reach a plugin's
skills is to register the plugin root, which also contributes every
subagent it ships and exposes its hooks. So agents: false cannot be
honoured there alongside skills: true for the same plugin, and the
combination is refused rather than silently granting more than the YAML
declared. The identical config works on copilot.
There is no plugin equivalent of skill_discovery. A plugin loads because
a workflow named it, never because it happened to be installed — so a
missing plugin is a hard error at validate time rather than quietly less
capability.
Resolving plugins: [prs] does read the installed plugin roots, but that
is resolution, not discovery: the author wrote the name down, and
nothing enters the run unasked. Cloning a declared plugin_sources entry
is resolution too — a miss is a hard error, not silently less capability.
With sources declared, even the machine dependency of resolving an
installed name goes away.
A plugin name says nothing about how much it carries, so conductor validate prints it:
Plugin sources: 1 declared
• acme — acme/agent-plugins#v1.4.0 @ 9c4e1f2a8b3d
Plugins: 2 enabled
• prs — 3 skill(s), 7 agent(s), 0 MCP server(s) — /home/dev/.conductor/cache/plugins/github.com/acme/agent-plugins/9c4e1f2a8b3d/prs
agents: prs:code-reviewer, prs:code-simplifier, prs:comment-analyzer, ...
• ado — 0 skill(s), 1 agent(s), 0 MCP server(s) — /home/dev/.copilot/installed-plugins/team/ado
disabled by this workflow: mcp
Worth reading before committing. It is also how a change in what a plugin
ships becomes visible on the next validate rather than at run time.
conductor plugin list <workflow> prints the same thing on demand, per
agent, without validating anything else.
See examples/plugins.yaml and examples/plugin-sources.yaml for complete
examples.
When a conversation with an agent grows too large, it can exceed the model's context window and cause the provider to reject requests. To prevent this, Conductor features an automatic, always-on client-side context compaction mechanism for claude and openai providers. When the history size crosses a computed trigger threshold, Conductor automatically condenses the context.
Compaction does not trigger at a fixed percentage of the context window. Instead, it uses a reserve-based formula to guarantee the model has enough room to output a complete answer and receive tool results.
The trigger threshold is calculated using the following formula:
Here, the output limit (output_limit) is the minimum of:
- The effective max_tokens actually sent to the API. This has the source
settingsif explicitly configured underruntime.max_tokens, or the sourcedefault(the unified 16384 default, including any adjustment after Claude thinking coercion). - The model output cap reported by the provider (with the source
provider-cap).
The tool buffer is calculated using the configured tool output limit:
This buffer assumes a character-to-token ratio of 4 and reserves space for 2 worst-case tool results as a sizing heuristic. A workflow using more than two parallel calls per turn might exceed this budget, meaning this is a sizing heuristic, not a guarantee. The effective tool buffer is this value clamped to at most 25% of the resolved context window, so a pathological tool_output.max_chars cannot consume the entire window.
Compaction is disabled when the remaining trigger would fall below 4096 tokens (or the computed target would drop below 1 token) — arming a degenerate threshold would compact on every turn. A disabled plan is reported on the agent_compaction_config event via enabled: false and a disabled_reason; the remedy is lowering runtime.max_tokens or tool_output.max_chars.
The target ceiling to which compaction condenses the history is calculated as:
where the margin is a window-scaled 5% of the context window (minimum 1 token). This keeps the target strictly below the trigger, establishing a hysteresis gap so the agent does not trigger compaction again immediately on the next turn.
Below is how these values resolve in practice for different configurations using the default tool buffer of 40,000 tokens (50,000 character limit):
- 128k Window, default 16,384 Output Limit:
- Trigger: 128,000 minus 16,384 minus min(40,000, 32,000), which equals 79,616 tokens (about 62% of the window)
- Target: min(70,400, 79,616 minus 6,400), which equals 70,400 tokens
- 200k Window, default 16,384 Output Limit:
- Trigger: 200,000 minus 16,384 minus min(40,000, 50,000), which equals 143,616 tokens (about 72% of the window)
- Target: min(110,000, 143,616 minus 10,000), which equals 110,000 tokens
- 1M Window, default 16,384 Output Limit:
- Trigger: 1,000,000 minus 16,384 minus min(40,000, 250,000), which equals 943,616 tokens (about 94% of the window)
- Target: min(550,000, 943,616 minus 50,000), which equals 550,000 tokens
When the reserve (output limit plus effective tool buffer) leaves no viable headroom below the window, compaction is disabled for the agent execution rather than armed with a degenerate threshold. The agent_compaction_config event then carries enabled: false and a disabled_reason, so the condition is visible per run instead of surfacing as a one-shot log warning. To resolve this, lower runtime.max_tokens or tool_output.max_chars.
The trigger is measured by the primary estimator: the provider's reported token usage for the history up to the most recent response, plus a ~4-characters-per-token heuristic for everything after it. That heuristic undercounts token-dense content — CJK and other non-Latin scripts, base64, hex, or minified data — by 2-4x, so a dense suffix can grow the real request past the known context window while the trigger estimate stays below the threshold.
A second, density-calibrated estimate guards the hard window. It matches the primary heuristic on ordinary prose, counts text with a substantial non-ASCII share at ~1 token per character, and whitespace-poor ASCII blobs at ~2 characters per token. When that estimate reaches the known window, compaction runs even if the trigger never fired, and the tier chain is driven against the density-calibrated measurement until the history fits the target. Because the two estimates agree on ordinary text, the guard never compacts a history that is merely large.
The start event reports which gate fired via trigger_reason ("trigger" or "window_guard") and carries the density-calibrated value separately as density_tokens; tokens_before always stays the primary token estimate.
Conductor uses three sequential tiers to compress the history down to the target:
- Clear Tool Results: Keeps the most recent three tool call and return pairs, replacing older tool output payloads with simple truncation markers.
- Summarize History: Summarizes older messages using an internal model call. The model preserves the most recent 20 messages in their original form.
- Sliding Window: Discards the oldest messages. This is a deterministic final fallback that runs if summarization fails or doesn't reclaim enough tokens.
Conductor resolves the context window and output limit via these priority cascades:
- Authoritative provider metadata (e.g.,
models.list()on Claude or vendor-advertised limits on OpenAI-compatible endpoints). - The
genai-pricesregistry, active only when using first-party base URLs. - Conservative default fallback of 128,000 tokens.
- Effective
max_tokensactually sent to the API (source issettingsordefault). - The provider-reported per-model output cap (source is
provider-cap).
You don't configure compaction inside the workflow YAML files, and no user-facing context window override exists. Tune the trigger by adjusting runtime.max_tokens or runtime.tool_output.max_chars.
When a workflow runs the same agent multiple times using loop-back routing, the provider-level history does not accumulate across those iterations. Each agent execution starts a fresh model session. History only accumulates within a single execution step (for example, when an agent makes multiple tool calls in a single turn).
A summarizing compaction step runs a nested model call that inherits the parent agent's configuration. This summary call consumes one request slot from the agent's max_agent_iterations budget. If your agent uses low iteration limits, compaction can cause a UsageLimitExceeded error, which maps to a non-retryable provider error. We recommend setting max_agent_iterations to at least 5 when expecting heavy compaction.
All tokens consumed by summarizing compaction are added to the workflow's total usage and cost metrics.
Compaction operates in a fail-open manner. If an error occurs during compaction, Conductor logs a warning, disables compaction for the rest of that agent's execution, and continues with the uncompacted history. A failed context measurement never disables anything: the primary estimate falls back to an independent density-calibrated one, and only when both fail is compaction skipped for that request alone, reported as agent_compaction_skipped with reason: "estimate_unavailable".
Conductor emits four event types to track compaction:
agent_compaction_config: Emitted once at the start of agent execution to log resolved window and limit values.agent_compaction_start: Emitted when compaction begins.trigger_reasonnames the gate that fired ("trigger"or"window_guard"), anddensity_tokenscarries the density-calibrated estimate alongside the primary-scaletokens_before.agent_compaction_complete: Emitted when compaction completes, detailing token savings or errors. Degraded outcomes are named rather than hidden:degraded_tiersfor recovered tier failures,degraded_estimatorsfor lost measurements,still_over_triggerwhen the history remains above the trigger, andstill_over_windowwhen a window-guard compaction could not get back below the known window.agent_compaction_skipped: Emitted when compaction did not run because the context size could not be measured at all.
The web dashboard's context remaining bar estimates context size using only provider-supplied model limits. It might disagree with the actual compaction window, especially under proxy configurations. The bar is refreshed only when the agent step completes; a mid-execution compaction shows up in the activity log, not in the bar.
The !file YAML tag lets you reference external files from any YAML field value. The file content is transparently inlined during loading, keeping workflow files concise and enabling reuse of prompts, schemas, and configuration across workflows.
Use the !file tag followed by a file path:
field_name: !file path/to/fileThe tag can be used on any scalar YAML value — string fields, output schemas, tool lists, or any other field.
The content of the referenced file is handled based on its structure:
- YAML dict or list — If the file content parses as a YAML mapping or sequence, it is returned as structured data (dict or list). This is useful for output schemas, tool lists, or any structured configuration.
- Scalar or non-YAML — If the file contains a YAML scalar (e.g., a plain string), is not valid YAML, or is a non-YAML format like Markdown, the raw file content is returned as a string.
File paths are resolved relative to the directory containing the YAML file that uses the !file tag, not relative to the current working directory.
project/
├── workflows/
│ └── review.yaml # prompt: !file ../prompts/review.md
├── prompts/
│ └── review.md # ← resolved relative to workflows/
└── schemas/
└── output.yaml
When using load_string() programmatically:
- If
source_pathis provided, paths resolve relative tosource_path.parent - If
source_pathis not provided, paths resolve relative to the current working directory
Keep long prompts in separate Markdown files for easier editing:
# workflow.yaml
agents:
- name: reviewer
model: gpt-4
prompt: !file prompts/review-prompt.md
routes:
- to: $end# prompts/review-prompt.md
You are a code review expert.
Please analyze the following code and provide:
- A summary of what the code does
- Any bugs or issues found
- Suggestions for improvementExtract output schemas into reusable files:
# workflow.yaml
agents:
- name: analyzer
model: gpt-4
prompt: "Analyze the input data"
output: !file schemas/analysis-output.yaml
routes:
- to: $end# schemas/analysis-output.yaml
summary:
type: string
description: A brief summary of the analysis
score:
type: number
description: A confidence score from 1 to 10Share tool configurations across agents:
# workflow.yaml
agents:
- name: researcher
model: gpt-4
prompt: "Research the topic"
tools: !file tools/research-tools.yaml
routes:
- to: $end# tools/research-tools.yaml
- web_search
- arxiv_search
- calculatorIncluded YAML files can themselves contain !file tags. Each nested reference resolves relative to its own file's directory:
# workflow.yaml
agents:
- name: agent1
model: gpt-4
prompt: "Hello"
output: !file schemas/nested.yaml
routes:
- to: $end# schemas/nested.yaml
summary:
type: string
description: !file ../descriptions/summary-desc.md# descriptions/summary-desc.md
A comprehensive summary of the analysis results.When a prompt or system_prompt is loaded via !file, the directory of that file becomes the search root for Jinja template loading. This allows statements like {% include "_shared.md" %}, {% import "_macros.md" as m %}, and {% extends "_base.md" %} to resolve relative to the prompt file's directory rather than the workflow's directory or the current working directory.
Only prompt: !file and system_prompt: !file support this behavior. Other fields that use !file (such as command, stdin, value, schemas, or tool lists) don't have include loader support. Inline prompts defined as plain strings don't support loader-dependent Jinja tags. If you attempt to use them inline, the system raises a template rendering error suggesting you switch to a file-backed prompt:
Template rendering failed: loader-dependent Jinja constructs ({% include %}, {% import %}, {% extends %}) require a file-backed prompt via prompt: !file ...
If an include file is missing, the error identifies the missing template name and the searched directory:
Template not found: '<name>'. Searched in: <dir>
Note: Prompts for human_gate steps loaded via !file also support these include features because they use the same shared renderer.
Included, imported, and base template files go through the same environment variable resolution (${VAR} / ${VAR:-default}) as the root prompt file, applied when Jinja loads each partial at render time. An unset required variable (no default) inside a partial fails the run with the standard configuration error:
ConfigurationError: Required environment variable 'X' is not set
💡 Suggestion: Set the environment variable 'X' or provide a default value using the syntax: ${X:-default_value}
The prompt file must still exist when the workflow runs — relative includes resolve against its directory. If the file was deleted or became inaccessible after the workflow was loaded, rendering fails immediately with an explicit error instead of silently treating the prompt as inline:
TemplateError: File-backed prompt source is no longer available: '<path>' (loaded via !file).
💡 Suggestion: Restore the prompt file or fix the !file reference — relative Jinja includes/imports/extends resolve against that file's directory.
A workflow using a file-backed prompt:
# workflow.yaml
workflow:
name: review-workflow
description: A workflow that uses Jinja includes in its prompt
entry_point: reviewer
agents:
- name: reviewer
model: gpt-4
prompt: !file prompts/review.md.jinja
routes:
- to: $endThe prompt file referencing a partial file:
# prompts/review.md.jinja
You are a code review assistant.
{% include "_checklist.md.jinja" %}
Please review the provided code according to the checklist.The partial file:
# prompts/_checklist.md.jinja
Check for:
1. Proper error handling
2. Clear variable namesDuring validation, conductor validate doesn't scan inside included files to check template references. It only verifies that the direct !file targets exist.
Environment variable references (${VAR} or ${VAR:-default}) inside included files are resolved after inclusion, during the standard environment variable resolution pass. This means you can use env vars in external files just as you would inline:
# prompts/greeting.md
Hello ${USER_NAME:-User}, welcome to the system.If a referenced file does not exist, a ConfigurationError is raised with the file path and a suggestion:
ConfigurationError: File not found: 'prompts/missing.md' (resolved to '/absolute/path/prompts/missing.md')
💡 Suggestion: Check the file path is correct relative to the workflow file directory.
If !file tags form a cycle (e.g., file A includes file B which includes file A), a ConfigurationError is raised:
ConfigurationError: Circular file reference detected: 'a.yaml'
File inclusion chain: /path/main.yaml → /path/a.yaml → /path/b.yaml → /path/a.yaml
💡 Suggestion: Remove the circular !file reference.
Only UTF-8 text files are supported. Non-UTF-8 files produce a ConfigurationError with encoding guidance.
- UTF-8 only — Only UTF-8 encoded text files are supported
- No glob patterns — Wildcards like
!file prompts/*.mdare not supported - No URLs — Remote references like
!file https://...are not supported - No conditional includes — File references cannot be parameterized or conditional
- No caching — Each
!filereference reads the file independently - Jinja includes search root: Relative template includes (
{% include %}, etc.) resolve only against the prompt file's own directory, with no fallback to the workflow directory or current working directory.
workflow:
name: code-review
description: Multi-stage code review with parallel validation
entry_point: analyzer
limits:
max_iterations: 20
timeout_seconds: 600
context_mode: accumulate
input:
code:
type: string
required: true
language:
type: string
required: true
tools:
- static_analyzer
agents:
- name: analyzer
model: claude-sonnet-4.5
prompt: |
Analyze this {{ workflow.input.language }} code for issues:
{{ workflow.input.code }}
output:
issues:
type: array
routes:
- to: parallel_validators
parallel:
- name: parallel_validators
agents:
- security_check
- performance_check
- style_check
failure_mode: continue_on_error
routes:
- to: summarizer
agents:
- name: security_check
prompt: "Check for security vulnerabilities: {{ analyzer.output.issues }}"
output:
security_issues:
type: array
- name: performance_check
prompt: "Check for performance issues: {{ analyzer.output.issues }}"
output:
performance_issues:
type: array
- name: style_check
prompt: "Check for style violations: {{ analyzer.output.issues }}"
output:
style_issues:
type: array
- name: summarizer
prompt: |
Summarize findings:
Security: {{ parallel_validators.outputs.security_check.security_issues }}
Performance: {{ parallel_validators.outputs.performance_check.performance_issues }}
Style: {{ parallel_validators.outputs.style_check.style_issues }}
output:
summary:
type: string
routes:
- to: $end
output:
summary: "{{ summarizer.output.summary }}"
all_issues: "{{ analyzer.output.issues }}"- Parallel Execution Guide - Detailed parallel execution patterns
- ACA Provider - Experimental Azure Container Apps sandbox provider (
sandbox:block,runtime.provider: {name: aca}) - Examples - Complete workflow examples
- README - Getting started and CLI reference