Controllers & Completions
Dagu gives a model two different jobs. A chat.completion step generates content inside a workflow you laid out. A type: controller workflow hands the model the layout itself: it decides which step runs next until stated goals are met. This page builds from one to the other, ending with both at once.
Every example runs as-is with an OPENROUTER_API_KEY exported; swap the llm block for any configured provider.
A model call in a fixed order
The starting point: the workflow decides everything, the model fills in content.
secrets:
- name: OPENROUTER_API_KEY
provider: env
key: OPENROUTER_API_KEY
llm:
provider: openrouter
model: deepseek/deepseek-v4-flash
steps:
- id: ask
action: chat.completion
with:
prompt: |
What is 2+2? Reply with just the number.
output: ANSWER
- id: use_answer
run: echo "The model said ${ANSWER}"
depends: askThe order is yours, the retries are yours, the model never chooses anything. For many workflows this is exactly right. The LLM overview covers this shape and its options.
Invert control
Add type: controller and the relationship flips: steps stop being a plan and become a catalog, and tasks state what must be true when the run is done.
type: controller
secrets:
- name: OPENROUTER_API_KEY
provider: env
key: OPENROUTER_API_KEY
llm:
provider: openrouter
model: deepseek/deepseek-v4-flash
steps:
- name: disk
description: Show filesystem usage.
run: df -h
- name: load
description: Show uptime and load average.
run: uptime
tasks:
- name: checked
description: Finished when both disk and load have been checked.No depends anywhere, and none allowed: ordering belongs to the model now. Each turn it picks one action, observes the output, and decides again; the run records the decision timeline and full transcript. The steps themselves stay deterministic commands, which is the point: the model chooses what runs, never what the commands are.
Controller Workflows is the full reference; the controller examples build every capability step by step, including failure recovery and asking a person.
Generate inside the loop
The two jobs compose directly: a chat.completion step can sit in the controller's catalog. The model schedules it like any other action, and because finished actions are upstream of the one starting now, the completion can read their output: variables.
type: controller
secrets:
- name: OPENROUTER_API_KEY
provider: env
key: OPENROUTER_API_KEY
llm:
provider: openrouter
model: deepseek/deepseek-v4-flash
steps:
- name: disk
description: Show filesystem usage.
run: df -h
output: DISK
- name: load
description: Show uptime and load average.
run: uptime
output: LOAD
- name: processes
description: List processes with CPU and memory usage.
run: ps aux | head -20
- name: summarize
description: Write the health summary. Run last, after the checks.
action: chat.completion
with:
prompt: |
Summarize this machine's health in three sentences:
${DISK}
${LOAD}
tasks:
- name: triage
description: >
Finished when the machine has been checked and a health summary has been
written. Inspect processes only if disk or load looks unhealthy.One model decides the order and depth of the checks; the same model, invoked as a step, writes the summary. The chat step inherits the workflow's llm block, so composing the two costs no configuration.
When a goal cannot be settled without a person, the controller's built-in ask_user tool opens a human task, the run releases its process while it waits, and the answer becomes the next observation. See asking a person.
LLM at both layers
The ceiling of the pattern: the controller dispatches a sub-workflow, filling its parameters from the goal, and the sub-workflow runs its own completion. The model above schedules; the model below writes.
type: controller
secrets:
- name: OPENROUTER_API_KEY
provider: env
key: OPENROUTER_API_KEY
llm:
provider: openrouter
model: deepseek/deepseek-v4-flash
steps:
- name: investigate
description: Investigate a reported symptom on this machine and write findings.
action: dag.run
with:
dag: investigate
tasks:
- name: reported
description: >
Finished when the symptom "disk filling up" has been investigated
and findings were written.
---
name: investigate
description: Probe the machine and write findings about a symptom.
secrets:
- name: OPENROUTER_API_KEY
provider: env
key: OPENROUTER_API_KEY
llm:
provider: openrouter
model: deepseek/deepseek-v4-flash
params:
- name: symptom
type: string
steps:
- id: probe
run: df -h
output: EVIDENCE
- id: findings
action: chat.completion
with:
prompt: |
Symptom reported: ${params.symptom}
Evidence:
${EVIDENCE}
Write findings in two sentences.
output: FINDINGS
depends: probeThe investigate step leaves symptom open, so it becomes an argument of the tool offered to the controller, which fills it from the goal text: the child run receives symptom="disk filling up". Inside the child, order is fixed again (probe then findings), and the child's FINDINGS output is reported back to the controller as the observation it settles the task against.
This is the shape that scales: a library of reviewed, parameterized sub-workflows as the vocabulary, goals as the interface, and every model decision and generation persisted per run, at both layers. See a catalog of workflows for the dispatch pattern on its own.
Cost and control
The controller resends the conversation each turn, so what a step prints is paid for once per remaining turn. Keep step output small, publish selectively from children with outputs.write, and bound the run with llm.max_tool_iterations (default 50). The controller reference covers the levers.
Related
- LLM Overview for
chat.completionitself - Controller Examples for the capability-by-capability tutorial
- When to reach for a controller for use cases and the inverse cases

