DSL Reference
Workflow files use the .cloche extension and live in .cloche/. The first step declared is the entry point. Graphs are validated at parse time: all results must be wired, no orphaned steps, and an entry point must exist.
Basic Structure
workflow "develop" {
container {
image = "my-project:latest"
agent_command = "claude"
}
step implement {
prompt = file(".cloche/prompts/implement.md")
results = [success, fail]
}
step test {
run = "make test && make lint"
results = [success, fail]
}
step fix {
prompt = file(".cloche/prompts/fix.md")
max_attempts = 3
results = [success, fail, give-up]
}
implement:success -> test
implement:fail -> abort
test:success -> done
test:fail -> fix
fix:success -> test
fix:fail -> abort
fix:give-up -> abort
}
Step Configuration
A step must have exactly one of prompt, run, workflow_name, or poll.
| Key | Type | Description |
|---|---|---|
type |
identifier | Explicit step type declaration. Rarely needed — step type is normally inferred from prompt, run, workflow_name, or poll. |
prompt |
string or file("path") |
Prompt template. Makes this an agent step. |
run |
string | Shell command. Makes this a script step. |
workflow_name |
string | Workflow to dispatch by name. Makes this a workflow step. |
poll |
string | Shell command polled at a fixed interval. Makes this a poll step. |
interval |
string | Poll frequency as a Go duration, e.g. "5m", "1h". Required for poll steps. |
results |
ident list | Declared result names, e.g. [success, fail, give-up]. |
max_attempts |
integer | Max retries before automatic give-up result. |
timeout |
string | Step timeout as Go duration, e.g. "30m", "2h". Default: 30m for agent and script steps, 72h for poll steps; for workflow steps the default is derived from the target workflow. See Timeouts. |
token-limit |
integer | Maximum output tokens for this step. Produces a "token-limit" result (implicitly wired to abort) when exceeded. Default: 500 000. -1 disables enforcement; 0 aborts immediately without running the step. See Token Limits. |
repository |
string | Repository name (from [[repositories]] in config.toml) to pin this step to a specific repository’s workspace. When omitted, the runtime uses the project default. |
agent_command |
string | Agent binary name(s), comma-separated for fallback chains, e.g. "claude,gemini". |
agent_args |
string | Override default agent arguments. |
agent |
identifier | Reference a named agent declared in the workflow’s agent block. |
usage_command |
string | Shell command to capture token usage after agent step. Output must be JSON: {"input_tokens": N, "output_tokens": N}. |
skip |
string | Shell command to run before the step. Exit 0 = skip (follow the step’s first declared result wire); non-zero = run normally. 90s timeout. |
prompt_step |
string | For workflow steps: which preceding step’s output to use as the prompt. |
intent_tracking |
boolean | false opts this step out of standing-requirement injection and excludes its logs from intent source mining. Also valid at workflow level. See Intent Injection. |
domains |
ident list | Explicit domain context for intent scope matching, e.g. [versioning, api]. Also valid at workflow level. See Intent Injection. |
Timeouts
Every step has a timeout; when it fires the step produces a "timeout" result, implicitly wired to abort unless you wire <step>:timeout -> <target> yourself. Defaults depend on the step type:
| Step type | Default timeout |
|---|---|
agent (prompt) |
30m |
script (run) |
30m |
poll (poll) |
72h |
workflow (workflow_name) |
Derived from the target workflow |
A workflow step’s timeout applies to the entire dispatched sub-workflow run, not a single step. When no explicit timeout is set, the default is the sum of the target workflow’s own step timeouts plus a fixed dispatch overhead (container/worktree setup), so a child workflow with genuinely long steps (say a 45m step followed by a 1h step) is not silently capped by an unrelated 30m outer limit. cloche validate warns when an explicit timeout you set is shorter than that sum. If the applied timeout does fire, the daemon logs which step was killed and what timeout applied, so it isn’t mistaken for a hung agent.
The file() Function
file("path") reads the file at the given path relative to the working directory (/workspace/ in containers) at execution time, not parse time.
prompt = file(".cloche/prompts/implement.md")
Prompt Templates
Prompt files referenced by file("...") are evaluated as templates before the agent runs. The {{ }} syntax injects runtime values.
| Form | Meaning |
|---|---|
{{ $name }} |
Variable lookup: built-in first, then KV store |
{{! cmd }} |
Run cmd via sh -c; substitute stdout (30s timeout) |
{{@ path }} |
Read file at path; substitute contents verbatim |
$$ |
Literal $ — only inside {{! ... }}; left untouched elsewhere |
Inside {{! }} and {{@ }} bodies, bare $name references resolve against the same built-in / KV tiers as {{ $name }} — e.g. {{@ $temp_file_dir/data.csv }} expands $temp_file_dir first, then reads the resulting path. Brace pairs inside a directive body are literal (not nested directives), and file contents / shell stdout are not re-templated.
Built-in variables:
| Variable | Value |
|---|---|
$task_id |
Task identifier |
$run_id |
Run identifier |
$step_name |
Name of the current step |
$workdir |
Working directory for the step |
$prev_output |
Preceding step’s captured stdout |
$task_description |
Content of the user prompt (--prompt flag) |
$result_nonce |
Per-step random nonce that frames this step’s result marker (CLOCHE_RESULT:{{ $result_nonce }}:<name>). See Result Protocol. |
Built-ins shadow KV keys with the same name. An unresolvable variable fails the step before the agent runs.
Result Protocol
Steps report their outcome by printing a CLOCHE_RESULT marker line to stdout. The last marker wins if several are printed, marker lines are stripped from the captured output, and the name must be one of the step’s declared results. The form of the marker depends on the step type:
| Step type | Marker |
|---|---|
agent (prompt) |
CLOCHE_RESULT:{{ $result_nonce }}:<name> |
script (run), poll, skip |
CLOCHE_RESULT:<name> |
Agent steps use a nonced marker. A free-form coding agent’s transcript can reproduce the literal string CLOCHE_RESULT:success while grepping code, editing test fixtures, or discussing this very protocol. Framing the marker with a per-step random nonce means only a line the agent deliberately copied from its own instructions counts; incidental mentions are left alone as ordinary text. The nonce is generated once per step, persisted under .cloche/runs/<task-id>/result_nonce/, and reused across retries and resumes of that step, so a resumed session — which only receives a short retry prompt — still submits a marker the classifier recognizes. It is available in prompt templates as {{ $result_nonce }} and is exported to the agent process as CLOCHE_RESULT_NONCE. Custom prompt templates or agent_command wrapper scripts that hardcode CLOCHE_RESULT:<name> must switch to the nonced form.
You do not normally write the marker instructions yourself. When a step declares results, Cloche appends a result-selection section to the assembled prompt listing the declared results and the exact nonced marker to print. If nothing in the assembled prompt mentions CLOCHE_RESULT: (no declared results and no mention in the template), a generic reminder to print CLOCHE_RESULT:{{ $result_nonce }}:success or ...:fail is appended instead.
No marker means fail for agent steps. Script steps without a marker fall back to the exit code: 0 is success, non-zero is fail. Agent steps get no such fallback — a marker is required regardless of exit code, because an agent that exits 0 without one cannot be trusted as a success. If the agent exited 0 with substantive output but no marker, the adapter first issues one recovery turn, resuming the same session and asking it to print only the marker. If it still emits none, the adapter falls back to the next agent in the agent_command fallback chain, or returns fail if it was the last.
Host script steps also receive a per-invocation nonce as CLOCHE_RESULT_NONCE and may optionally frame their marker as CLOCHE_RESULT:$CLOCHE_RESULT_NONCE:<name> to stay immune to unrelated CLOCHE_RESULT: text elsewhere in their own output (collected docs, commit messages, echoed source). The executor prefers a nonce-framed marker when present and falls back to the bare form, so existing unnonced scripts keep working unchanged. Poll and skip steps are author-controlled and always use the bare form.
Agent Declarations
Declare reusable named agents at the workflow level and reference them from steps.
workflow "develop" {
agent claude {
command = "claude"
args = "-p --output-format stream-json"
}
agent codex {
command = "codex"
args = "--full-auto"
}
step implement {
prompt = file(".cloche/prompts/implement.md")
agent = claude
results = [success, fail]
}
step review {
prompt = file(".cloche/prompts/review.md")
agent = codex
results = [success, fail]
}
implement:success -> review
implement:fail -> abort
review:success -> done
review:fail -> implement
}
Agent block fields:
| Field | Required | Description |
|---|---|---|
command |
yes | The agent binary to run. |
args |
no | Arguments passed to the agent command. |
Resolution order (highest to lowest priority):
- Step-level
agent_command/agent_args agent = <identifier>declaration- Workflow-level config block
CLOCHE_AGENT_COMMANDenvironment variable- Default:
claude
Duplicate agent names within a workflow are a parse error. An agent declaration without a command field is a parse error.
Configuration Blocks
container {} (container workflows)
workflow "develop" {
container {
id = "dev-env"
image = "my-project:latest"
agent_command = "claude"
agent_args = "-p --dangerously-skip-permissions"
memory = "4g"
network_allow = ["docs.python.org", "api.example.com"]
}
...
}
Recognized keys: id, image, agent_command, agent_args, memory, network_allow. Unknown keys are silently ignored.
network_allow is parsed but not yet enforced — containers currently run with unrestricted network access.
Step-level container {} — a container { … } block may also appear inside an individual step body. Keys set there override the workflow-level container {} defaults for that step only — useful for giving a single step a different image or network policy.
workflow "develop" {
container {
image = "my-project:latest"
agent_command = "claude"
}
step research {
prompt = file(".cloche/prompts/research.md")
container {
network_allow = "api.example.com"
}
results = [success, fail]
}
...
}
host {} (host workflows)
workflow "main" {
host {
agent_command = "claude"
}
...
}
Recognized keys: agent_command, agent_args. Unknown keys are silently ignored. An empty host {} block is valid.
Repository Declarations
Repositories are configured in .cloche/config.toml as [[repositories]] entries, not in .cloche workflow files — there is no repository DSL block. Workflows declare which repositories they use with the repos field:
workflow "develop-backend" {
repos = ["backend"]
...
}
repos is a list of repository names matching entries in config.toml. The runtime enforces it in two ways:
- Container seeding — only the declared repositories are materialized into the container’s workspace copy, so workflows that need one repo of many don’t pay to copy the rest. Workflows sharing a
container.idshare one container (and one copy), so the copy contains the union of theirreposdeclarations; if any sharing workflow declares norepos, all configured repositories are included. - Extraction — result branches and worktrees are created only for the declared repositories.
A workflow with no repos field gets every configured repository (the backward-compatible default). Declaring a name not present in config.toml is an error.
Step Environment Variables
Cloche injects environment variables into every step invocation. The available variables differ between host and container contexts.
Host steps
| Variable | Description |
|---|---|
CLOCHE_PROJECT_DIR |
Absolute path to the project directory on the host. |
CLOCHE_PREV_OUTPUT |
Path to the output file from the immediately preceding step. |
CLOCHE_RUN_ID |
Workflow ID for this workflow execution. |
CLOCHE_TASK_ID |
Task ID assigned by the daemon (set for the main phase). |
CLOCHE_ATTEMPT_ID |
Attempt identifier for this run. |
CLOCHE_GIT_AUTHOR_NAME |
Git author name from config. Only set when configured. |
CLOCHE_GIT_AUTHOR_EMAIL |
Git author email from config. Only set when configured. |
CLOCHE_GIT_SSH_COMMAND |
Pre-composed SSH command from config. Only set when configured. |
CLOCHE_RESULT_NONCE |
Per-step result nonce. Script steps may frame their marker as CLOCHE_RESULT:$CLOCHE_RESULT_NONCE:<name>; agent steps must. See Result Protocol. |
Container steps
| Variable | Description |
|---|---|
CLOCHE_RUN_ID |
Run ID for this workflow execution (e.g. a133:develop). |
CLOCHE_TASK_ID |
Task ID assigned by the daemon. Set when the run is associated with a task. |
CLOCHE_ATTEMPT_ID |
Attempt identifier for this container run. Used for unique container naming. |
CLOCHE_PROJECT_DIR |
Working directory inside the container (/workspace). |
CLOCHE_AGENT_COMMAND |
Overrides the default agent command inside the container. |
CLOCHE_ADDR |
Daemon gRPC address (e.g. host.docker.internal:50051). Used by clo get/clo set. |
ANTHROPIC_API_KEY |
Passed through from the host environment if set. |
CLOCHE_RESULT_NONCE |
Set for agent steps: the per-step result nonce, for custom agent_command scripts that construct their own marker. See Result Protocol. |
See Communicating Between Steps for how to pass data between steps using these variables, the KV store, and shared files.
Container IDs
Every container workflow has a container id used to identify which shared container to use per run attempt. Set via the id key in container {}. Default id is _default.
Workflows sharing the same id share a container per attempt:
workflow "develop" {
container {
id = "dev-env"
image = "my-project:latest"
}
...
}
workflow "review" {
container {
id = "dev-env" // shares the same container as "develop"
}
...
}
cloche validate enforces consistent config for workflows sharing a container id.
Wiring
Connect steps with step:result -> next_step:
implement:success -> test
implement:fail -> abort
test:success -> done
test:fail -> fix
Reserved result names. done and abort are terminal targets, not steps. parked is also reserved: steps cannot declare or wire it. It is produced by the help-channel park mechanism when a step’s clo ask / ask_user call goes unanswered past park_after — the run suspends (state parked) instead of completing or failing, and resumes from that step when the pending help thread receives a reply. See cloche threads.
Token Limits
Cloche can automatically abort a run when an agent exhausts a token budget. Token limits are injected implicitly — every step automatically gains a token-limit result and wire that routes to abort unless overridden. Only output tokens count against the limit; input tokens do not.
Workflow-level token limit — set a per-run budget that caps cumulative output tokens across all steps:
workflow "develop" {
token-limit = 100000
...
}
Step-level token limit — override the budget for a specific step:
step implement {
prompt = file(".cloche/prompts/implement.md")
token-limit = 50000
results = [success, fail]
}
Default values:
- Per-step: 500 000 output tokens
- Workflow-level: 2 000 000 output tokens
Sentinels: token-limit = -1 disables enforcement (unlimited output tokens); token-limit = 0 aborts immediately without running the step/workflow.
Overriding the abort target — by default a token-limit event routes to abort. A global wire directive, placed at the workflow level alongside other wiring lines (not inside a step block), redirects it — e.g. to a cleanup or summary step:
token-limit -> release-task
Every step whose token-limit result has not been wired explicitly will route there instead of abort.
Only token-limit -> is supported as a global wire shorthand. A timeout -> <target> line at the workflow level is parsed without error but has no effect. To redirect timeout events for a specific step, use a step-level wire: <stepname>:timeout -> <target>.
Intent Injection
Agent steps in a project that has a .cloche/intent/ directory automatically get a ## Standing project requirements block prepended to their prompt, built from that project’s requirements. A prompt template that places {{ $intent }} explicitly gets the block there instead of at the top. Projects with no .cloche/intent/ directory are unaffected — nothing is injected and no run KV is written. See the Intent guide for concepts, the file format, the intent-scan extraction workflow, and the cloche intent CLI.
Opt out per workflow or per step with intent_tracking = false. This also excludes the step’s logs from intent source mining.
workflow "develop" {
intent_tracking = false
...
}
step implement {
prompt = file(".cloche/prompts/implement.md")
intent_tracking = false
results = [success, fail]
}
Domain-scoped requirements are selected when their domain matches the step’s domain context: domains whose paths overlap the workflow’s declared repos, or an explicit domains = [...] step or workflow config key.
The [intent] table in config.toml also supports a project-wide inject = "off" to disable injection everywhere, and token_budget to cap the injected block’s size (defaults to ~2000 tokens).
Retry Loops
Wire failures back to earlier steps and use max_attempts to cap retries. When attempts are exhausted, the step returns give-up:
step fix {
prompt = file(".cloche/prompts/fix.md")
max_attempts = 2
results = [success, fail, give-up]
}
test:fail -> fix
fix:success -> test // retry the test
fix:fail -> abort
fix:give-up -> abort
Poll Steps
A poll step pauses a workflow until an external decision is available — a code review, an approval gate, a CI run, or any event the pipeline needs to wait for. The orchestrator polls a shell command at a fixed interval until the script reports a decision, the step times out, or the script fails.
Poll steps work in both host and container workflows. In a host workflow the daemon’s orchestration loop drives the polling. In a container workflow the in-container agent runs a standalone polling loop.
step code-review {
poll = "scripts/check-pr-review.sh"
interval = "5m"
timeout = "48h"
results = [approved, fix]
}
code-review:approved -> merge
code-review:fix -> address-feedback
code-review:timeout -> escalate // or omit; default routes to abort
The polling script reports a decision by printing CLOCHE_RESULT:<wire-name> to stdout, the same mechanism as script steps. The key difference is what happens when no marker is printed:
| Exit code | CLOCHE_RESULT marker |
Outcome |
|---|---|---|
| 0 | none | Pending — poll again after interval. |
| 0 | <name> |
Decision — follow the named wire. |
| non-zero | none | Failure — follow the fail wire. |
| non-zero | <name> |
Decision — follow the named wire. Non-zero exit is ignored when a marker is present. |
Poll steps are author-controlled and always use the bare CLOCHE_RESULT:<name> marker — the per-step nonce that frames agent-step markers (see Result Protocol) does not apply here.
Polling cadence
The default timeout for poll steps is 72h (vs. 30m for agent and script steps, or the target-workflow-derived default for workflow steps — see Timeouts). The first poll fires immediately; subsequent polls fire no sooner than interval after the previous poll completes. interval is a no-sooner-than constraint, not a strict schedule — actual poll times depend on the loop tick rate and on how long the previous invocation took, so expect polls to land within ~30 seconds of the ideal time.
Overlapping invocations. If a poll is still running when the next interval comes due, that interval is skipped. If a single invocation runs longer than 4x interval (three consecutive skips), the step produces a fail result and follows the fail wire.
Concurrency slot. In a host workflow, a run parked at a poll step gives up its concurrency slot (see [orchestration] concurrency in .cloche/config.toml) for the duration of the poll, so the orchestration loop can use it to launch other work. When the poll resolves (decision or timeout), the run reacquires a slot — ahead of any brand-new task launches — before the workflow continues, and its state moves back from waiting to running.
Script execution environment
Poll scripts run under sh -c. In a host workflow the working directory defaults to the project’s main git worktree (the main branch checkout, even if the project directory is a linked worktree on another branch). This is a default, not an invariant: it falls back to the project directory if git is unavailable or the directory is not a git repository — the same rule as host script steps. In a container workflow the working directory is /workspace/. The following variables are injected on every invocation:
| Variable | Host | Container | Description |
|---|---|---|---|
CLOCHE_PROJECT_DIR |
yes | yes | Absolute path to the project directory. |
CLOCHE_PREV_OUTPUT |
yes | no | Path to the output file from the immediately preceding step. |
CLOCHE_RUN_ID |
yes | yes | Run ID for the current workflow run. |
CLOCHE_TASK_ID |
yes | no | Task ID being processed (if launched from a task). |
CLOCHE_ATTEMPT_ID |
yes | no | Attempt ID for the current run attempt. |
Reading run context
Poll scripts can read values written to the run’s KV store by earlier steps via cloche get <key> (host-side) or clo get <key> (container-side). The KV store persists for the lifetime of the run.
# In an earlier container step (e.g. create-pr):
clo set pr_id 1234
# In the polling script:
pr_id=$(cloche get pr_id)
state=$(gh pr view "$pr_id" --json state -q .state)
case "$state" in
MERGED) echo "CLOCHE_RESULT:approved" ;;
CLOSED) echo "CLOCHE_RESULT:rejected" ;;
*) ;; # still open → exit 0 with no marker → pending
esac
Idempotency
Poll scripts are invoked once per interval for the lifetime of the step — which can easily be dozens or hundreds of invocations over a 72-hour window. Any side effect (posting a comment, sending a notification, creating a ticket) must be guarded so it runs at most once. Use the KV store to record that the side effect has occurred:
if [ "$(cloche get notified_reviewer)" != "yes" ]; then
slack-notify "@reviewer PR ready"
cloche set notified_reviewer yes
fi
Visibility in cloche list / cloche status
While a poll step is active, its run’s state is set to waiting, which cloche list and cloche status surface distinctly from running. The daemon also records last_poll_at and the step name so you can see how long the step has been waiting and when it last polled.
Parallel Branches (Fanout)
Wire one result to multiple targets for concurrent execution:
test:success -> lint
test:success -> quality
Collect (Join)
Synchronize parallel branches with collect:
collect all(lint:success, quality:success) -> done
collect any(lint:success, quality:success) -> done
all fires when every condition is met. any fires when at least one is.
Comments
Line comments use //:
// This is a comment
implement:success -> test // inline comment
Built-in Workflows
Some workflows are constructed in Go and compiled into the cloched/cloche binaries rather than parsed from a project’s .cloche files, so they work in any project with no setup. intent-scan (see the Intent guide) is the first built-in.
Built-in workflows are resolved after project workflow discovery: a project that defines its own workflow with the same name overrides the built-in entirely. cloche workflow (list view) marks unoverridden built-ins with a (built-in) label.
Examples
Host Workflow
workflow "list-tasks" {
host {}
step get-tasks {
run = "python3 .cloche/scripts/get-tasks.py"
results = [success, fail]
}
get-tasks:success -> done
get-tasks:fail -> abort
}
workflow "main" {
host {}
step claim-task {
run = "python3 .cloche/scripts/claim-task.py"
results = [success, fail]
}
step develop {
workflow_name = "develop"
results = [success, fail]
}
step finalize {
run = "python3 .cloche/scripts/finalize.py"
results = [success, fail]
}
claim-task:success -> develop
claim-task:fail -> abort
develop:success -> finalize
develop:fail -> finalize
finalize:success -> done
finalize:fail -> abort
}
Container Workflow with Parallel Validation
workflow "develop" {
step implement {
prompt = file("prompts/implement.md")
results = [success, fail]
}
step test {
run = "bundle exec rake test 2>&1"
results = [success, fail]
}
step lint {
run = "bundle exec rubocop 2>&1"
results = [success, fail]
}
step quality {
run = "python3 scripts/quality-check.py 2>&1"
results = [success, fail]
}
step fix {
prompt = file("prompts/fix.md")
max_attempts = 2
results = [success, fail, give-up]
}
implement:success -> test
implement:fail -> abort
test:success -> lint
test:success -> quality
test:fail -> fix
lint:fail -> fix
quality:fail -> fix
collect all(lint:success, quality:success) -> done
fix:success -> test
fix:fail -> abort
fix:give-up -> abort
}