Animus workflow YAML is authored in .animus/workflows.yaml and .animus/workflows/*.yaml.
Those files are merged with installed pack overlays to produce the
effective workflow configuration that workflow-runner executes. This document
describes the authored YAML surface.
For the target direction of phase output contracts, universal verdicts, and YAML-defined phase-local fields, see Phase Contracts.
YAML is the default config source. As of v0.6 the daemon can read workflow and agent config from an installed
config_sourceplugin instead of these YAML files (e.g.animus-config-postgresfor the LaunchApp portal). This document describes the YAML surface, which remains the default for all projects that do not have aconfig_sourceplugin installed. See Config sources (v0.6) for details.
A workflow YAML file can contain any combination of these top-level sections:
mcp_servers: # MCP server definitions
agents: # Agent profile definitions
agent_channels: # Agent communication channel definitions
models: # Named model+tool registry entries (agents reference by name)
phase_catalog: # Phase UI metadata (label, description, category, tags)
phases: # Reusable phase execution definitions
workflows: # Named workflow pipelines
schedules: # Cron-driven workflow dispatch
triggers: # Event-driven workflow dispatch
daemon: # Daemon-wide runtime tuning
secrets: # Declarative secret references (v0.5.5+)All sections are optional. Multiple YAML files in .animus/workflows/ are merged,
and project YAML can override installed pack workflows.
Declares logical secret names backed by env-var names. Reference them in
any YAML scalar with ${secret.<name>} — resolution happens at
config-compile time, with the file path and line number included in any
error. The lookup chain is explicit process env first, then the
project-scoped keychain entry for the same env-var key if one exists.
secrets:
linear_token:
env: LINEAR_API_TOKEN
required: true # default; compile-fails if unset
description: Linear GraphQL token
optional_pat:
env: OPTIONAL_GITHUB_PAT
required: false # resolves to empty string when unset
mcp_servers:
linear:
command: linear-mcp
env:
LINEAR_API_TOKEN: "${secret.linear_token}"- The mapped env var key is resolved at compile time. Explicit process env
wins; if it is unset, Animus falls back to the project-scoped keychain
store populated by
animus secret. - Required-but-unset keys fail the compile.
- Referencing an undeclared key (
${secret.unknown}) fails the compile. - The compiled
workflow-config.v2.jsoncontains the resolved string — plugins consume the same scalar shape they always did. There is no runtime secret-store indirection. - Parse diagnostics redact resolved values: when a substituted
${secret.<name>}value (or a plain${VAR}value that came from the keychain fallback) would be echoed in a YAML error message, it is replaced with[redacted:<name>]. Plain${VAR}values resolved from the explicit process environment are not redacted. animus workflow phases upsert/definitions upsertnever serialize resolved secret values back into the project tree: the generated overlay at.animus/workflows/generated-workflow.yamlcarries only the upserted phase/pipeline entries (with any${...}references preserved unresolved), not a dump of the compiled config.
When a workflow YAML contains ${VAR} whose name matches
TOKEN|KEY|SECRET|PASSWORD (case-insensitive) and the reference is
not inside the secrets: block or a *_env: declaration field
(which name env vars rather than interpolate their values), the
compiler emits a warning to stderr. This is a hint to move the value
under secrets:; it does not fail the compile, since trusted
workflows may have legitimate uses for direct env-var references.
Controls whether the workflow runner creates a fresh git worktree for the phase. Available at the workflow level (under a workflow definition) and at the phase level (where it overrides the workflow default).
phases:
doc-update:
mode: agent
agent: writer
directive: "Update docs."
worktree: skip # short-form scalar
workflows:
- id: standard
phases: [requirements, implementation, code-review]
worktree:
mode: auto # auto | required | skip
cleanup: true # remove worktree on success (default true)
base_ref: main # branch to fork from (default: project default)auto(default) — create a worktree when the subject implies write work. Matches the historical, always-on behavior.required— always create a worktree; fail-fast if creation fails. Use this when the phase must be isolated from the project root.skip— never create a worktree; the phase runs in the project root. Use this for read-only / report-only phases.
A phase-level worktree: always overrides the workflow-level value.
The short-form worktree: skip expands to { mode: skip, cleanup: true, base_ref: null }.
The kernel parses, validates, and surfaces the worktree: block on
the compiled workflow config. Actual worktree creation is owned by the
installed workflow runner plugin (launchapp-dev/animus-workflow-runner-default
v0.4.0+). Older runners that don't yet understand the field treat it
as auto; upgrade the runner plugin via animus plugin install-defaults
to pick up required and skip enforcement.
Declares external MCP servers that agents can connect to during execution.
mcp_servers:
<server_name>:
command: <string> # Required. Binary to execute.
args: [<string>, ...] # Optional. Command arguments.
transport: <string> # Optional. Transport type (default: stdio).
env: # Optional. Environment variables.
KEY: "value"
tools: # Optional. Allowed tool name prefixes.
- "tool.prefix"
config: # Optional. Arbitrary key-value config.
key: value| Field | Type | Required | Description |
|---|---|---|---|
command |
string | yes | Executable command (e.g., npx, animus, python) |
args |
string[] | no | Arguments passed to the command |
transport |
string | no | MCP transport protocol (default: stdio) |
env |
map<string, string> | no | Environment variables for the server process |
tools |
string[] | no | Tool name prefixes to allow from this server |
config |
map<string, any> | no | Arbitrary configuration passed to the server |
Every string scalar in .animus/workflows.yaml, .animus/workflows/*.yaml, and
pack-shipped workflow overlays supports shell-style ${VAR} interpolation. Substitution
runs before YAML parsing, so subject backend configs, provider tokens, MCP env
blocks, phase env overrides, and any other string field all use the same syntax:
| Form | Meaning |
|---|---|
${VAR} |
Required. Errors with file path + line number if VAR is unset. |
${VAR:-default} |
Optional. Falls back to literal default. |
${VAR:?message} |
Required with custom error message. |
$$ |
Literal $. |
References inside YAML comments are not interpolated: a # that begins a
comment (preceded by start-of-line or whitespace, outside quoted scalars and
block scalar content) suppresses substitution through end of line, so a
comment like # export ${LINEAR_TOKEN} never fails the compile when the
variable is unset. # inside quoted strings (key: "#tag ${X}") and inside
block scalar bodies (| / > prompts) still interpolates normally.
mcp_servers:
hubspot:
command: npx
args: ["-y", "@hubspot/mcp-server"]
env:
HUBSPOT_ACCESS_TOKEN: "${HUBSPOT_ACCESS_TOKEN}"
HUBSPOT_BASE_URL: "${HUBSPOT_BASE_URL:-https://api.hubapi.com}"For subject backend and provider config patterns (and guidance on keeping secrets out of YAML), see Workflow YAML environment variable interpolation.
For HTTP-transport MCP servers (transport: http + url:), Animus rewrites
any server with an oauth: block to the local animus-mcp-proxy stdio
bridge. The agent never receives a resolved bearer token directly; the proxy
resolves the live credential itself at connect time, injects
Authorization: Bearer <token> upstream, and retries once after an upstream
auth failure.
mcp_servers:
robinhood-trading:
transport: http
url: https://agent.robinhood.com/mcp/trading
oauth:
flow: client_credentials # client_credentials | refresh_token | manual_bearer
token_url: https://auth.robinhood.com/token
client_id_env: ROBINHOOD_CLIENT_ID
client_secret_env: ROBINHOOD_CLIENT_SECRET
scopes: [trade.read, trade.write]
audience: https://api.robinhood.com # optional
cache: true # optional, default true| Field | Required for | Description |
|---|---|---|
flow |
always | client_credentials, refresh_token, or manual_bearer. |
token_url |
client_credentials, refresh_token |
OAuth token endpoint. Must be http:// or https://. |
client_id_env |
client_credentials |
Env var name holding the client id. |
client_secret_env |
client_credentials |
Env var name holding the client secret. |
refresh_token_env |
refresh_token |
Env var name holding the initial refresh token. |
bearer_env |
manual_bearer |
Env var name holding a pre-baked bearer token. |
scopes |
optional | OAuth scopes, joined with spaces and sent as scope=. |
audience |
optional | Auth0-style audience= parameter. |
cache |
optional | When false, the on-disk token cache is bypassed. Default true. |
For authorization_code, the proxy uses the OS keychain entry created by
animus mcp auth. For broker-backed M2M flows, bearer resolution and cache
behavior stay inside the OAuth broker; the resolved token never rides the
runtime contract, .mcp.json, or provider argv.
Flows:
client_credentials— POSTsgrant_type=client_credentialsplus the resolvedclient_id/client_secret(and optionalscope/audience) totoken_url. The returnedaccess_tokenis cached until 60 seconds before itsexpires_in.refresh_token— POSTsgrant_type=refresh_tokenplus the cached or env-var refresh token (and any optionalclient_id/client_secret) totoken_url. If the response returns a rotatedrefresh_token, the cache file is updated so the next phase uses the new token; the original env var stays unchanged (so the env-var seed only kicks in on a cold cache).manual_bearer— Readsbearer_envdirectly. No network call, no refresh, no expiry. The escape hatch for tokens minted by an external system.
Failure modes: A failed token resolution leaves the proxy entry intact and the downstream MCP call surfaces the auth error from the proxy at runtime. The agent contract is not downgraded to an unauthenticated HTTP entry. Token text never appears in logs.
Validation: oauth is only valid with transport: http. Missing
required env-var pointers or token_url fail the configuration compile
with a path-qualified error (e.g., mcp_servers['svc'].oauth.client_id_env is required for flow="client_credentials").
Declares agent profiles that phases can reference. Each profile specifies the model, tool, and behavioral configuration for an agent.
agents:
<profile_name>:
name: <string> # Optional. Display name used in prompts/UI.
description: <string> # Optional. Human-readable description.
system_prompt: | # Optional. System prompt for the agent.
You are a code reviewer...
system_prompt_file: <path> # Optional. Load system prompt from a UTF-8 file.
role: <string> # Optional. Role identifier.
persona: # Optional. Personality/style configuration.
style: <string>
traits: [<string>, ...]
instructions: <string>
customizations: {}
memory: # Optional. Project-scoped memory behavior.
enabled: true
scope: project
max_context_chars: 6000
max_entries: 200 # Optional. FIFO cap; oldest entries trim on
# append. Omitted ⇒ default 200. Must be > 0.
write_policy: explicit
communication: # Optional. Project-scoped channel access.
enabled: true
channels: [engineering]
can_message: [reviewer]
max_context_chars: 8000
model: <string> # Optional. Model to use (e.g., claude-sonnet-4-6).
tool: <string> # Optional. CLI tool to use (e.g., claude, codex, gemini).
tool_profile: <string> # Optional. Named global Claude profile; only valid with tool=claude.
mcp_servers: # Optional. MCP server names this agent can access.
- "animus"
- "hubspot"
skills: # Optional. Skill identifiers.
- "skill-name"
capabilities: # Optional. Boolean capability flags.
can_write: true
tool_policy: # Optional. Tool access control.
mode: "allowlist"
allowed: ["tool.name"]
approval_policy: # Optional. animus.agent.request_approval routing.
auto_allow: ["cargo *", "git.commit"]
auto_deny: ["git.push*"]
default: ask # ask (manual) | allow (approve everything) | deny | llm (auto-approve)
evaluator_model: anthropic/claude-haiku-4-5 # for default: llm — judge model (falls back to the agent's model)
evaluator_instructions: "Deny anything touching billing or prod." # optional extra rubric for the judge
hooks: # Optional. Author-controlled harness-hook config.
policy_rules: # Guardrail rules merged into the compiled hook policy.
- id: no-prod-deploy
tools: ["Bash"]
input_matchers:
- field: command
regex: "deploy .*--env(=| )prod"
decision: deny # deny | ask | allow | defer (deny always wins)
reason: "Production deploys require a human."
observers: # Extra events routed to animus-hook (record-only).
- events: ["UserPromptSubmit"]
action: record # only `record` this wave (no arbitrary shell)| Field | Type | Required | Description |
|---|---|---|---|
name |
string | no | Human-readable display name for this agent |
description |
string | no | Human-readable description of the agent |
system_prompt |
string | no | System prompt injected into the agent's context |
system_prompt_file |
string | no | Path to a UTF-8 file whose contents are inlined into system_prompt at compile time; mutually exclusive with system_prompt |
role |
string | no | Role identifier for the agent |
persona |
object | no | Personality/style config injected into the agent's system context |
memory |
object | no | Project-scoped memory settings. When enabled, bounded memory entries are injected into phase prompts |
communication |
object | no | Channel and direct-message permissions. When enabled, bounded recent channel messages are injected into phase prompts |
model |
string | no | LLM model identifier |
tool |
string | no | CLI tool to invoke (claude, codex, gemini, etc.) |
tool_profile |
string | no | Named global Claude profile to resolve into launch env; only valid for claude |
reasoning_effort |
string | no | Provider reasoning/thinking effort: low, medium, or high. Mapped per provider (codex -c model_reasoning_effort="<level>", claude --effort <level>); other providers ignore it. Validated at compile time |
permission_mode |
string | no | Provider permission/approval mode, forwarded verbatim (claude: default/acceptEdits/bypassPermissions/plan; codex: untrusted/on-failure/on-request/never; gemini: default/auto_edit/yolo). Unknown values warn but pass through. The animus agent run / animus chat send --permission-mode flag overrides it |
mcp_servers |
string[] | no | Names of mcp_servers entries this agent can use |
skills |
string[] | no | Skill identifiers to attach. Skills resolve from built-ins, .animus/config/skill_definitions/*.yml, and Markdown skills such as .animus/skills/<name>/SKILL.md or .animus/skills/<name>.md |
capabilities |
map<string, bool> | no | Capability flags |
tool_policy |
object | no | Tool access control policy |
approval_policy |
object | no | Routing for animus.agent.request_approval MCP calls. auto_allow / auto_deny are *-glob pattern lists matched against the request's tool_name when present, otherwise its action; auto_deny wins on overlap (fail closed). default selects the mode when no list matches: ask (manual — escalate to a pending human interaction, the default), allow (approve everything / "dangerous" mode), deny, or llm (auto-approve: a judge model reads the tool call and returns allow/deny). For llm mode, evaluator_model picks the judge model (defaults to the agent's own model) and evaluator_instructions appends an operator rubric to the built-in judge prompt; the judge runs with no MCP tools (cannot recurse), and any evaluator failure falls back to manual ask so an LLM outage never silently auto-approves. Allow/deny decisions are recorded with source: "llm" and the judge's reason |
hooks |
object | no | Author-controlled harness-hook config. policy_rules are guardrail rules (protocol::HookPolicyRule shape) merged into the compiled hook policy; observers route extra harness events to animus-hook (constrained to a named built-in action — only record this wave — never an arbitrary command). An author rule can only add restriction: deny always wins over allow regardless of source. claude-only this wave; gated behind ANIMUS_DISABLE_HARNESS_HOOKS. See harness-hooks.md |
Agent profiles defined in YAML are merged into the agent runtime config during compilation. Phase definitions reference agents by profile name.
The merge is presence-aware per field: a field you write in YAML always wins
over the base profile (builtin defaults or pack overlays), even when you set
it back to its default value — memory: { enabled: false }, mcp_servers: [],
or skills: [] explicitly disable what a pack enabled. A field you omit
inherits the base profile's value.
When prompts get large, prefer system_prompt_file over embedding long prose
directly in YAML. Relative paths resolve from the source YAML file's parent
directory, absolute paths are allowed for project YAML, and the file contents
are copied verbatim into the compiled runtime config.
Claude profile references resolve against the user's global Animus config, not the
repository. This keeps account-specific paths such as CLAUDE_CONFIG_DIR out
of project files.
Declares project-scoped communication channels for YAML-defined agents.
agent_channels:
engineering:
description: Implementation coordination
participants: [architect, implementer, reviewer]
max_context_chars: 8000Messages are stored under the scoped runtime state directory and can be written
through animus agent message send or the MCP tool animus.agent.message.send. Agents
only receive channel context when their profile has communication.enabled: true and lists that channel.
The participant roster is advisory, not an access-control boundary.
participantsdocuments the intended membership and drives which agents the channel context is injected for; it does not hard-deny a write. The load-bearing gate is the per-profilecommunicationblock (enabled: true+ the channel listed underchannels). Treat the roster as a coordination hint, not a security control — do not rely on it to keep an agent out of a channel.
| Field | Type | Required | Description |
|---|---|---|---|
description |
string | no | Human-readable channel description |
participants |
string[] | yes | Agent profile IDs allowed in the channel |
max_context_chars |
number | no | Maximum recent channel context injected into prompts |
Declares reusable phase execution definitions. Workflow phase entries reference these definitions by ID.
phases:
implementation:
mode: agent
agent: default
directive: "Implement the change."
skills:
- implementation
- code-review
runtime:
tool: claude
model: claude-sonnet-4-6
tool_profile: overflow
code-review:
mode: agent
agent: po-reviewer
skills:
- code-review| Field | Type | Required | Description |
|---|---|---|---|
mode |
string | yes | Execution mode: agent, command, or manual |
agent |
string | no | Agent profile name to use for the phase |
directive |
string | no | Phase-specific instruction appended to the prompt contract |
system_prompt |
string | no | Phase-specific system prompt |
skills |
string[] | no | Skill identifiers to resolve, validate, and apply at phase runtime. Markdown skills in .animus/skills are loaded as prompt-only skills |
runtime |
object | no | Tool/model/runtime overrides for the phase |
capabilities |
object | no | Structured phase capability flags |
output_contract |
object | no | Structured result contract for the phase |
output_json_schema |
object | no | Additional JSON schema constraints for the result |
decision_contract |
object | no | Structured phase decision contract |
retry |
object | no | Retry policy for the phase |
command |
object | no | Command execution definition when mode: command |
manual |
object | no | Manual gate definition when mode: manual |
default_tool |
string | no | Default tool hint for the phase |
evals |
object | no | Eval gate definition (see evals). Parsed and validated in v0.5.5; runtime enforcement lands when the out-of-tree workflow-runner plugin pin bumps |
Phase skills are validated during config load and applied at phase runtime. The phase's effective skill set is the union of the phase-level skills: list and the executing agent profile's skills: (phase entries first, deduplicated; a workflow-YAML agents.<id>.skills declaration — even an explicit empty list — wins over the agent runtime config profile). Skill names resolve at workflow dispatch time, daemon-side, against the same scoped sources and trust rules as the ad-hoc --skill path: project > user > installed > agent-host prompt-only (agent-host SKILL.md skills have structural fields like tool_policy, env, and mcp_servers stripped at load). The resolved definitions ride the dispatch payload to the workflow runner (ANIMUS_PHASE_SKILLS_JSON on the spawn environment); a runner paired with an older daemon resolves locally with the same rules.
A skill name that does not resolve is a loud warning in the daemon dispatch log plus a missing record in the phase execution metadata — not a hard failure (the autonomous dispatch path never wedges on a stale skill reference, unlike the interactive --skill flag, which hard-errors). Misses of skills declared only by builtin agent-profile defaults (e.g. the animus.core-skills pack references) log at debug level instead. Explicit skills: declarations in workflow YAML (phases.<id>.skills and agents.<id>.skills) are additionally checked at compile/validate time against the project's skill sources: an unresolvable name surfaces as a warning (never an error) on the compile stderr and in the warnings array of animus workflow config validate / compile, suggesting animus skill list. Implicit references from builtin agent-profile defaults are exempt — they are legitimately absent until the matching pack is installed. After a run, animus output phase-outputs --workflow-id <id> shows per-phase requested/applied/missing skills in its human view.
Activation gating (activation.tools / activation.models) is evaluated at phase execution, against the tool/model the runner actually selects. Applied skills can inject prompt fragments (system/prefix/suffix/directives), tool policy, MCP server attachments (resolved by name against workflow-YAML mcp_servers: or project config, joining the phase contract the same way phase_mcp_bindings do; unknown names warn and are skipped), launch args/env, and capability overrides. The phase execution metadata records requested_skills (declared names), resolved_skills (names that resolved to a definition), and applied_skills (skills whose activation matched and whose contributions were injected). animus workflow prompt render previews the skill-injected prompt. Installed registry skills work the same as local skills when a definition snapshot is present in Animus state.
When runtime.tool_profile is set, the effective tool must resolve to
claude. Animus looks up the named profile in the user's global config and injects
its environment into the Claude launch contract.
The phase runtime block accepts the same provider knobs as an agent
profile — including reasoning_effort (low/medium/high) and
permission_mode. Resolution cascades phase runtime → agent profile: a
non-empty runtime.reasoning_effort or runtime.permission_mode on the
phase wins over the agent profile's value, mirroring how model and
tool cascade. The resolved effort level maps per provider (codex
-c model_reasoning_effort, claude --effort) and is validated at compile
time; permission_mode is forwarded verbatim (claude --permission-mode,
codex -c approval_policy, gemini approval mode) and unknown values only
warn. The animus agent run / animus chat send --reasoning-effort and
--permission-mode flags override both. Note: the ad-hoc surfaces
(animus agent run / animus chat send with --agent) honour
permission_mode today; workflow phase execution enforces it once the
out-of-tree workflow-runner plugin pin consumes the new field.
Status (v0.5.5): the YAML surface, config types, validators, and runner library (
animus_runtime_shared::evals) ship in v0.5.5. The workflow-runner pin that callsrun_evalsbetween phase output and phase advance lives inlaunchapp-dev/animus-workflow-runner-defaultand is pending its next release. Until that lands, a phase advances regardless of anevals:block — author/test the gate now, but do not yet rely on it for production trust. Declaring anevals:block therefore emits a declared-but-unenforced warning at compile time and inanimus workflow config validateso you are never silently unprotected.
evals declares a quality gate that runs after the phase emits an
advance decision and before the workflow advances. Each check returns
pass/fail; the gate advances when pass_rate >= pass_threshold. Failures
route to either rework (re-execute the phase, up to max_reworks) or
block (pause the workflow for manual approval).
phases:
implementation:
mode: agent
agent: implementer
evals:
pass_threshold: 0.8 # 80% of checks must pass; default 1.0
on_fail: rework # rework | block; default block
max_reworks: 2 # default 0; required > 0 when on_fail=rework
checks:
- id: unit-tests
kind: command
command: cargo
args: [test, --workspace]
working_dir: $REPO_ROOT # falls back to the phase working dir
timeout_secs: 300
expected_exit: 0
- id: code-quality
kind: llm_judge
agent: po-reviewer
prompt: "Does the implementation address the spec? Reply PASS or FAIL."| Field | Type | Required | Description |
|---|---|---|---|
pass_threshold |
float | no | Minimum pass rate (0.0–1.0) required to advance. Default 1.0 |
on_fail |
enum | no | rework or block. Default block |
max_reworks |
int | no | Rework attempts available when on_fail=rework. Default 0. Must be > 0 if on_fail=rework |
checks |
list | yes | At least one eval check |
kind: command — spawns the program in the phase's working directory
(or working_dir when set; $REPO_ROOT resolves to the default. Do NOT
use ${REPO_ROOT} — that form is consumed by the workflow YAML env-var
interpolation layer at load time and never reaches the runner. Relative
paths anchor on the default working directory). Waits up to timeout_secs
(default 300) and passes when the process exit code matches
expected_exit (default 0). On timeout the entire process group is
killed (Unix; Windows kills the direct child only). command is required
and is validated against tools_allowlist when that is non-empty.
kind: llm_judge — dispatches a one-shot agent call. Requires agent
(must resolve through agent_profiles) and prompt. The judge sees the
just-produced phase output via phase_output_summary in the request
context. Pass: the response's first whitespace-delimited token is PASS
(case-insensitive, optional trailing punctuation; words that merely START
with PASS such as PASSIVE or PASSAGE do NOT count). Anything else
is a fail.
Each check appends one animus.eval.v1 record to the workflow decision
log:
{
"schema": "animus.eval.v1",
"phase_id": "implementation",
"check_id": "unit-tests",
"kind": "command",
"passed": true,
"duration_ms": 12345,
"exit_code": 0,
"output_excerpt": "<capped at ~2 KiB; head+tail elision for long runs>"
}exit_code is omitted on kind: llm_judge records. output_excerpt is
empty when the runner could not capture output (e.g. process spawn
failure). An error field is populated when the check failed for a reason
other than a clean exit-code mismatch (spawn error, timeout, judge
backend missing, etc.).
Declares variables that can be used throughout the workflow. Variables support defaults and can be overridden at runtime via --input-json.
variables:
- name: target_branch
description: "Branch to merge into"
required: false
default: "main"
- name: reviewer
description: "Assigned reviewer"
required: true| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Variable name |
description |
string | no | Human-readable description |
required |
boolean | no | Whether the variable must be provided (default: false) |
default |
string | no | Default value if not provided |
Pipelines define named workflow sequences. Each pipeline is a WorkflowDefinition with an ordered list of phases and optional post-success hooks.
A pipeline is defined as a top-level key under pipelines (or directly as a workflow definition with id, name, description, phases, etc.):
workflows:
- id: my-workflow
name: My Workflow
description: A workflow that does things
phases:
- research
- implementation
- code-review:
max_rework_attempts: 3
skip_if:
- "task.type == 'hotfix'"
on_verdict:
rework:
target: implementation
advance:
target: testing
fail:
target: ""
- testing
- create-pr # command phase running `gh pr create`
variables:
- name: target_branch
default: main| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique workflow identifier |
name |
string | yes | Human-readable workflow name |
description |
string | no | Workflow description |
phases |
PhaseEntry[] | yes | Ordered list of phase entries |
variables |
Variable[] | no | Variables used by this workflow |
budget |
BudgetConfig | no | Cost ceiling for the whole workflow run (v0.5.5+) |
Enforcement status (v0.5.x): the daemon enforces budget caps on its housekeeping cadence — once per heartbeat interval (
interval_secs, default 30s), not mid-phase token-by-token. Each sweep rescans run spend, evaluates declared caps, and acts on any NEWLY crossed cap: a breach decision is written to the breaching run'sruns/<run_id>/decisions.jsonl(visible viaanimus output decisions) and to the scoped fleet log (visible viaanimus cost decisions), the declared action is applied —on_exceed: pausepauses the workflow through the standard pause path,on_exceed: failfails the current phase terminally,warnrecords + notifies only — and aworkflow-budget-breachevent reaches notifier plugins — exactly once per breach. Two honest caveats: a phase in flight can overshoot the cap by up to one sweep before the pause lands, and enforcement requires a running daemon — with no daemon, caps are only recorded (never paused) when ananimus costcommand runs. The declared-but-unenforced warning thatbudget:blocks used to emit at compile/validate time was removed when this enforcement landed.
The budget: block declares cost ceilings. It can live at three places:
- Top-level on a workflow — cap that applies across all phases of a single workflow run. The ceiling is authoritative; if a phase has a higher cap, the workflow-level cap still wins.
- Inline on a rich phase entry — cap that applies for one phase inside one workflow run. Resets per rework attempt.
- Anywhere either of the two above applies, the workflow runner
pauses, fails, or warns according to
on_exceed.
workflows:
- id: expensive-flow
name: Expensive Flow
phases:
- exploration:
budget:
max_tokens: 100_000
max_cost_usd: 1.00
on_exceed: fail
- implementation
budget:
max_tokens: 1_000_000
max_cost_usd: 5.00
on_exceed: pause| Field | Type | Required | Description |
|---|---|---|---|
max_tokens |
integer | one of two | Cap on combined input + output + reasoning tokens. Cache reads/writes are tracked but excluded from the cap. |
max_cost_usd |
number | one of two | USD cost ceiling. Cents precision. |
on_exceed |
pause | fail | warn |
no (default pause) |
What to do when a cap is crossed. |
Validation rules (from validate_workflow_config):
- at least one of
max_tokensormax_cost_usdmust be set; max_tokensmust be greater than 0;max_cost_usdmust be a finite number greater than 0;on_exceedmust be one ofpause,fail, orwarn.
The animus cost CLI surface (summary, workflow, top, trends)
reports against the same per-run rollup the budget enforcer reads. See
docs/reference/cli/index.md and
docs/reference/configuration.md for runtime
configuration including the per-model USD rate table.
Today, workflow YAML supports execution configuration such as decision_contract,
output_contract, and output_json_schema. The intended long-term direction is
to keep YAML as the authored surface while moving toward a simpler phase contract
model:
- every phase emits the same universal verdict-driven envelope
- YAML defines extra phase-local fields and their descriptions
- the runtime composes and validates an effective contract in memory
- users do not manage standalone JSON schema files
See Phase Contracts for the target model.
Each entry in the phases array can be one of three types:
A bare string referencing a phase definition by ID:
phases:
- research
- implementation
- testingAn inline phase override with routing, rework limits, budget caps, and conditional
skipping. The YAML form is a single-key map whose key is the phase ID and
whose value is the configuration block. An id: sibling key is not used and
will cause a parse error — the phase id IS the map key.
phases:
- code-review:
max_rework_attempts: 3
skip_if:
- "task.type == 'hotfix'"
on_verdict:
rework:
target: implementation
advance:
target: testing
fail:
target: ""
budget:
max_cost_usd: 2.00
on_exceed: pause| Field | Type | Required | Description |
|---|---|---|---|
<phase-id> (map key) |
string | yes | Phase definition ID to execute — this IS the map key, not a sibling id: field |
max_rework_attempts |
integer | no | Maximum rework loops before failing (default: 3) |
skip_if |
string[] | no | Conditions under which to skip this phase |
on_verdict |
map<string, TransitionConfig> | no | Routing rules keyed by verdict name |
budget |
BudgetConfig | no | Phase-level cost ceiling (v0.5.5+) |
Embeds another workflow definition as a nested sub-workflow:
phases:
- workflow_ref: hotfix-pipeline| Field | Type | Required | Description |
|---|---|---|---|
workflow_ref |
string | yes | ID of the workflow definition to embed |
The on_verdict map controls what happens after a phase produces a decision. Keys are verdict names, values are transition configs:
on_verdict:
rework:
target: implementation # Go back to implementation phase
advance:
target: testing # Proceed to testing phase
fail:
target: "" # Terminate the workflow
skip:
target: deployment # Jump to deployment| Verdict | Description |
|---|---|
rework |
Phase needs rework; route to the specified target phase |
advance |
Phase succeeded; proceed to the specified target phase |
fail |
Phase failed fatally; terminate or route to error handling |
skip |
Phase should be skipped; jump to the specified target |
Each transition has:
| Field | Type | Required | Description |
|---|---|---|---|
target |
string | yes | Phase ID to transition to (empty string = terminate) |
guard |
string | no | Optional guard condition for the transition |
post_success.mergewas removed in v0.5.x. Animus no longer performs git operations (commit, push, PR, merge) as runner automation. A workflow YAML that still sets apost_success.mergeblock — orintegrations.git.auto_merge— is rejected at parse time with an actionable error.
Express commit, push, PR creation, and merge as ordinary command phases: a
phase with a command: block that runs git or gh. They sequence in phases:
like any other phase, run in the task worktree, and surface their exit code
through the standard output_contract. Because they are real phases, they also
get verdict routing, retries, and approval gates for free.
phases:
create-pr:
mode: command
directive: Open a GitHub PR from the current branch
command:
program: gh
args: ["pr", "create", "--fill", "--base", "main"]
cwd_mode: task_root
timeout_secs: 60
success_exit_codes: [0]
workflows:
- id: ship
name: Ship
phases:
- implementation
- testing
- create-pr # command phase: `gh pr create`A human still performs the final merge of the PR (or you add an explicit
gh pr merge command phase, gated by an approval if desired). See the
phases reference for the full command: schema.
When a phase completes, the agent (or automated system) produces a PhaseDecision:
| Field | Type | Description |
|---|---|---|
kind |
string | Decision type identifier |
phase_id |
string | The phase that produced this decision |
verdict |
string | One of: advance, rework, fail, skip |
confidence |
float | Confidence score (0.0 to 1.0) |
risk |
string | Risk level of the decision |
reason |
string | Human-readable explanation |
evidence |
string[] | Supporting evidence for the decision |
target_phase |
string? | Explicit target phase (overrides on_verdict routing) |
# .animus/workflows/custom.yaml
# Agent profiles
agents:
default:
model: claude-sonnet-4-6
tool: claude
po-reviewer:
system_prompt: |
You are a Product Owner reviewing completed development work.
Verify that ALL acceptance criteria are fully met.
model: claude-sonnet-4-6
tool: claude
requirements-refiner:
system_prompt: |
You are a requirements analyst. Take vague task descriptions
and refine them into well-specified, testable acceptance criteria.
model: claude-sonnet-4-6
tool: claude
# MCP server integrations
mcp_servers:
animus:
command: animus
args: ["mcp", "serve"]
# Workflow: standard development pipeline
id: default
name: Default Pipeline
description: Standard development workflow with research, implementation, and review
phases:
# Phase 1: Research the codebase
- research
# Phase 2: Implement the solution
- implementation
# Phase 3: Review with rework routing (rich entry: single-key map)
- code-review:
max_rework_attempts: 3
on_verdict:
rework:
target: implementation
advance:
target: testing
# Phase 4: Run tests
- testing
# Phase 5: Open a PR (command phase running `gh pr create`)
- create-pr
variables:
- name: target_branch
default: mainDeclares cron-driven dispatchers that enqueue a workflow on a recurring
schedule. Each entry compiles into a WorkflowSchedule and is evaluated once
per daemon scheduler tick.
schedules:
- id: nightly-housekeeping
cron: "0 2 * * *"
workflow_ref: housekeeping
input: { include_archived: true }
- id: hourly-dispatch
cron: "0 * * * *"
workflow_ref: dispatch-batch
- id: weekday-report
cron: "0 9 * * 1-5"
workflow_ref: send-report
enabled: true| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique identifier within the project; used for state tracking |
cron |
string | yes | Standard 5-field cron expression evaluated in UTC |
workflow_ref |
string | yes | ID of the workflow to enqueue when the schedule fires |
input |
object | no | Structured JSON forwarded as the spawned workflow's input |
enabled |
boolean | no | Whether the schedule is active (default: true) |
The command: field still exists on the underlying WorkflowSchedule struct
for back-compat, but the current config validator rejects schedules that use it
(schedules['<id>'].command is no longer supported; use workflow_ref). Wrap any
shell work you need in a workflow whose phase runs mode: command and point
the schedule at that workflow instead.
- Schedules are evaluated each daemon scheduler tick (default 5s, configurable
via the persisted daemon project config; see
daemonbelow). - An occurrence that falls between ticks (long tick,
interval_secsabove 60) is caught up on the next tick. Only the most recent missed occurrence fires — older occurrences inside the catch-up window are skipped, so a schedule resuming after a gap never replays a backlog. The catch-up scan only looks back 10 minutes: runs missed for longer (daemon down at fire time,active_hourswindow closed) are not replayed — schedules fire forward-only from the next occurrence. - Per-schedule activity is tracked under the scoped runtime state in
ScheduleRunStatewith three counters:last_run— UTC timestamp of the cron occurrence covered by the most recent dispatch attempt. Updated when the schedule fires AND the daemon got far enough to invoke the spawn — including spawn failures other than tick-budget exhaustion and the workflow-concurrency cap (this prevents the same occurrence from retrying every tick).run_count— total dispatch attempts since project init (successes and non-budget spawn failures both increment it).missed_count— increments only when the per-tick budget or the workflow-concurrency cap rejected the spawn slot;last_runis not updated in that case so the schedule gets another shot at the same occurrence on the next tick. Ticks skipped outsideactive_hoursdo not touch either counter — the whole schedule branch is bypassed.
See crates/orchestrator-core/src/services/schedule_state.rs for the on-disk
schema.
Declares event-driven dispatchers that enqueue a workflow when an external event
fires. Each entry compiles into a WorkflowTrigger and is processed each daemon
tick after the cron schedule block.
triggers:
- id: docs-rebuild
type: file_watcher
workflow_ref: docs-build
config:
paths: ["docs/**/*.md"]
debounce_secs: 10
ignore: ["docs/_drafts/**"]
- id: github-push
type: github_webhook
workflow_ref: ci-pipeline
config:
secret_env: GITHUB_WEBHOOK_SECRET
max_triggers_per_minute: 30
- id: slack-inbound
type: plugin
workflow_ref: triage
# `config:` is accepted but not currently forwarded to plugins; see below.| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique trigger identifier within the project |
type |
string | yes | Event source: file_watcher, webhook, github_webhook, or plugin |
workflow_ref |
string | yes | Workflow to enqueue when the trigger fires. Optional in the struct but validation rejects triggers that omit it (triggers['<id>'] must define workflow_ref) |
enabled |
boolean | no | Whether the trigger is active (default: true) |
config |
object | depends on type |
Type-specific configuration. Required for file_watcher (must supply paths) and webhook/github_webhook (must supply max_triggers_per_minute > 0 — see note below). Optional for plugin |
input |
object | no | Structured JSON forwarded as the spawned workflow's input |
Webhook config caveat: for webhook/github_webhook triggers you must supply
an explicit config: block — even an empty config: {} is fine, because
serde then applies the per-field default of max_triggers_per_minute = 10.
Omitting config: entirely makes the field default to Value::Null, which
the parser falls back through WebhookTriggerConfig::default() to
max_triggers_per_minute = 0, and the config validator rejects that
(triggers['<id>'].config.max_triggers_per_minute must be greater than zero).
A config: block whose fields have the wrong type (e.g. secret_env: 123) is
rejected at validation time with the underlying deserialization error
(triggers['<id>'].config is not a valid webhook config: ...) instead of being
silently replaced with defaults — a malformed secret_env can no longer
silently disable signature verification.
Watches local filesystem paths and fires when they change.
| Field | Type | Default | Description |
|---|---|---|---|
paths |
string[] | — | Required. Glob patterns to watch, relative to the project root. Validation rejects an empty list (triggers['<id>'].config.paths must not be empty for file_watcher triggers) |
debounce_secs |
integer | 5 |
Debounce window in seconds before re-dispatching after a burst of changes |
ignore |
string[] | [] |
Glob patterns to exclude from watching, relative to the project root |
triggers:
- id: schema-rebuild
type: file_watcher
workflow_ref: regenerate-schema
config:
paths:
- "crates/orchestrator-config/src/**/*.rs"
- "schema/**/*.json"
debounce_secs: 15
ignore:
- "**/target/**"
- "**/*.tmp"Both kinds drain inbound webhook events from a daemon-managed queue. The
in-tree daemon does not itself register POST /triggers/{id} HTTP routes —
the public ingress is provided by an installed transport plugin (e.g.
launchapp-dev/animus-transport-http), which is expected to honour
secret_env and max_triggers_per_minute when it forwards events into the
queue. (Note: the WebhookTriggerConfig doc comment in
crates/orchestrator-config/src/workflow_config/types.rs still describes the
in-tree HTTP path that existed before transports were extracted out-of-tree
— treat the description in this section as authoritative until that comment
is refreshed.)
The in-tree path that every deployment can rely on is
animus trigger fire <trigger_id> --payload <json>, which
appends a synthetic event into the same pending-events queue that the daemon's
trigger dispatcher drains each tick. Use it for local testing or for piping
events from custom upstreams.
github_webhook behaves the same as webhook but is intended for GitHub-style
event payloads; filtering on the GitHub event type is left to the receiving
workflow today.
| Field | Type | Default | Description |
|---|---|---|---|
secret_env |
string | null |
Environment variable name. Transport plugins that implement HTTP ingress read this env var to validate the sha256=<hex> signature header on incoming requests. animus trigger fire ignores it (events are already trusted) |
max_triggers_per_minute |
integer | 10 |
Soft rate limit that transport plugins enforce by returning HTTP 429 on excess. animus trigger fire ignores it. Must be > 0 |
triggers:
- id: deploy-hook
type: webhook
workflow_ref: deploy
config:
secret_env: DEPLOY_WEBHOOK_SECRET
max_triggers_per_minute: 5The signing secret is read from the transport plugin's process environment at
request time — it is not stored in YAML. Use shell export, your service
manager's env file, or direnv to populate it.
The daemon discovers every installed trigger_backend plugin via the stdio
plugin host and supervises one session per plugin. Each plugin emits
trigger/event notifications, which the daemon routes into the same
pending_events queue used by webhook triggers and drains via
TriggerDispatch::process_due_triggers on each tick.
Important caveat (current behaviour): the supervisor sends
TriggerWatchParams::default() to each plugin and does not forward the
per-trigger config: map from this YAML block to the plugin. Trigger plugins
that need configuration (Slack tokens, watch paths, etc.) currently source it
from their own environment or sidecar config files, not from this YAML. Putting
keys under config: for a type: plugin trigger is accepted by the config
parser but has no runtime effect today.
A dedicated trigger-plugin authoring guide is planned for docs/guides/; until
it lands, the plugin host contract lives in crates/animus-plugin-protocol
(see TriggerWatchParams, TriggerEvent, TriggerAckParams) and the
supervisor in crates/orchestrator-daemon-runtime/src/schedule/trigger_supervisor.rs.
Setting ANIMUS_DAEMON_DISABLE_TRIGGERS=1 skips the trigger plugin supervisor on
daemon start and interrupts any in-progress restart backoff. Schedules, webhooks,
and file watchers configured via this YAML block continue to run; only
type: plugin triggers are suppressed.
Top-level block that tunes parts of the daemon at workflow-config compile time.
This block lives at the top level of the workflow YAML — not under a
workflow — and compiles into DaemonConfig.
daemon:
active_hours: "09:00-17:00"
phase_routing:
implementation:
tool: claude
model: claude-sonnet-4-6
mcp:
# daemon-side MCP runtime configThe daemon currently honours these fields from the YAML daemon: block:
| Field | Type | Default | Description |
|---|---|---|---|
active_hours |
string | unset (24/7) | Local-time window during which the daemon's project tick will dispatch new schedule- AND trigger-driven work, e.g. "09:00-17:00". Outside this window the tick skips both process_due_schedules and process_due_triggers, so cron schedules, webhook events, file-watcher events, and plugin events are all suppressed. Missed cron fires are not replayed when the window reopens — the next tick re-evaluates the cron expression against the new current minute, so an 08:00 cron does not get a delayed run when a 09:00 window opens. Webhook and plugin events stay queued in pending_events until the window opens and drain then. In-flight phases are not interrupted. Schedules suppressed this way do not bump missed_count. Read on every tick from workflow YAML (the persisted daemon config has no active_hours field) |
phase_routing |
object | unset | Per-phase model/tool routing overrides applied at daemon spawn time. See Model Routing |
mcp |
object | unset | Daemon-side MCP runtime config (forwarded to ProcessManager). See MCP Tools |
budget |
object | unset | Fleet-level daily spend cap. See daemon.budget below |
The daemon.budget block is a fleet-level spend cap, distinct from the
per-workflow / per-phase budget: caps. Per-workflow caps stop a
single runaway workflow; the fleet cap bounds the daemon's total rolling
spend so that many individually in-budget workflows cannot collectively blow
through a daily wallet.
daemon:
budget:
max_cost_usd_per_day: 50.0 # pause new dispatch past $50 in any rolling 24h
on_exceed: pause # only `pause` is honored today| Field | Type | Default | Description |
|---|---|---|---|
max_cost_usd_per_day |
float | unset (uncapped) | USD ceiling on the daemon's total spend over a rolling 24-hour window. A non-positive value means uncapped |
on_exceed |
pause | fail | warn |
pause |
Action when the cap is crossed. Only pause is honored today; the field is carried for forward compatibility |
Window semantics. The cap is measured over a rolling 24-hour window, not a calendar day — no timezone applies. When the rolling spend crosses the cap, the daemon stops picking up all new work (cron schedules, triggers, ready tasks, and explicit queue-drain entries) until either spend ages out of the window or the cap is raised/cleared, at which point dispatch resumes automatically on the next sweep. In-flight phases are never interrupted.
Precedence. The scoped daemon runtime config takes precedence over this
YAML block: set it with animus daemon config --max-daily-usd <N> (persisted
to ~/.animus/<repo-scope>/daemon/pm-config.json, hot-reloaded). Pass
--max-daily-usd 0 to explicitly clear the cap — an explicit pm-config value
(positive or zero) always overrides the YAML cap. The cap, today's rolling
spend, remaining headroom, and the pause state are surfaced under
budget_enforcement.daily_cap in animus daemon health --json and as the
daily_* fields in animus cost summary --json.
The fleet cap honors the ANIMUS_DAEMON_DISABLE_BUDGET_ENFORCEMENT kill-switch:
when budget enforcement is disabled, the daily-cap latch is neither reconciled
nor enforced.
The following keys were removed from the workflow DaemonConfig struct.
They no longer parse into a field; declaring them under daemon: in workflow
YAML still compiles but emits a removed-key warning.
| Key | Where to set it today |
|---|---|
interval_secs |
animus daemon config --interval-secs <n> (persisted to ~/.animus/<repo-scope>/daemon/pm-config.json, hot-reloaded) or animus daemon run --interval-secs <n> |
pool_size (alias max_agents) |
animus daemon config --pool-size <n> (persisted, hot-reloaded) or animus daemon run --pool-size <n> |
max_task_retries |
No runtime sink — the daemon never read it; drop it from the daemon block |
retry_cooldown_secs |
No runtime sink — the daemon never read it; drop it from the daemon block |
auto_run_ready |
Removed — the daemon is queue-only and never auto-dispatches Ready subjects; enqueue work with animus queue enqueue or drive it from a schedules: cron entry |
The persisted daemon config lives at
~/.animus/<repo-scope>/daemon/pm-config.json (not the project-local
.animus/ tree). Set persisted fields via
animus daemon config --<flag> <value> (a leaf command — flags directly, no
set subcommand) or pass equivalent flags to animus daemon run /
animus daemon start.
The daemon git/merge policy keys (auto_merge, auto_pr,
auto_commit_before_merge, auto_prune_worktrees) were removed in v0.5.x
along with their animus daemon start / animus daemon run /
animus daemon config flags. Declaring them in YAML still compiles but emits a
removed-key warning. Animus no longer performs git operations as runner
automation — express commit/push/PR/merge as
command phases instead.
Declaring any of these keys in workflow YAML emits a compile-time warning
(stderr, on every path that compiles YAML — daemon start, workflow run,
config reload) and a structured warnings entry in
animus workflow config validate / animus workflow config compile
(including --json). The warnings are advisory only: the config still
compiles and validates. The warning registry lives in
crates/orchestrator-config/src/workflow_config/validation.rs
(UNENFORCED_RULES); entries are removed as enforcement lands.
schedules: and triggers: entries merge by id across all
.animus/workflows/*.yaml files (later overlays override earlier entries with
the same id). The daemon: block field-merges: as each overlay is applied,
only the fields the overlay explicitly sets override the previously-accumulated
block — fields defined only in earlier overlays survive a later partial
daemon: block.
See also: Configuration, Status Values.