CLI Reference
Synopsis
sortie [flags] [workflow-path]
sortie <command> [flags]
sortie --dry-run [--log-level level] [workflow-path]
sortie --log-format json [flags] [workflow-path]
sortie --env-file path [flags] [workflow-path]
sortie validate [--format text|json] [workflow-path]
sortie stats [--format text|json] [--since value] [--until value] [workflow-path]
sortie mcp-server --workflow <path>
sortie -h | --help
sortie -V | --versionWithout a subcommand, Sortie runs as a long-lived process. It loads the workflow file, opens the SQLite database, validates configuration, and enters the poll-dispatch-reconcile loop. The process blocks until terminated by a signal.
The validate subcommand checks the workflow file without starting the orchestrator. The stats subcommand summarizes past runs from the local database and exits, reading that database read-only rather than starting the orchestrator. The mcp-server subcommand starts an MCP stdio server for agent tool execution. See Subcommands.
Arguments
| Argument | Required | Default | Description |
|---|---|---|---|
workflow-path | No | ./WORKFLOW.md | Path to the workflow file. Relative paths resolve to absolute against the working directory at startup. |
One positional argument is accepted. Providing two or more produces an error:
sortie: too many argumentsFlags
| Flag | Type | Default | Description |
|---|---|---|---|
-h, --help | boolean | false | Print the help message and exit. |
-V, --version | boolean | false | Print the version banner, then exit. |
-dumpversion | boolean | false | Print the bare version string, then exit. |
--dry-run | boolean | false | Run one poll cycle without spawning agents or writing to the database, then exit. |
--env-file | string | (empty) | Path to a .env file containing SORTIE_* overrides. See environment variables reference. |
--log-format | string | text | Log output format. Accepted values: text, json. |
--log-level | string | info | Log verbosity. Accepted values: debug, info, warn, error. |
--port | integer | 7678 | HTTP server listen port. 0 disables the server. |
--host | string | 127.0.0.1 | HTTP server bind address. Must be a parseable IP address. |
--dry-run
Runs a single poll cycle in read-only mode, then exits. Sortie connects to the tracker, fetches candidate issues, computes dispatch eligibility for each candidate, and logs the results. No agents are spawned, no SQLite database is opened, and no state is written.
This fills the gap between sortie validate (offline config checks) and a full sortie run (live operation). Use it to verify tracker connectivity, query results, and concurrency slot math before going live.
The --dry-run flag suppresses server startup regardless of port or host settings.
--version (or -V) and -dumpversion take precedence over --dry-run when both are provided.
The startup sequence through preflight validation is identical to a normal run. The dry-run branch diverges after the tracker adapter is built (see startup sequence step 8).
Dry-run output
Each candidate issue produces an INFO-level log line:
level=INFO msg="dry-run: candidate" issue_id=abc123 issue_identifier=MT-649 title="Fix pagination bug" state="To Do" would_dispatch=true global_slots_available=4 state_slots_available=2 priority=1Key fields:
| Field | Description |
|---|---|
would_dispatch | true if the issue would be dispatched under current config. false with a skip_reason when ineligible. |
global_slots_available | Remaining global agent slots at this point in the simulation. |
state_slots_available | Remaining per-state slots for this issue’s tracker state. |
priority | Issue priority (present only when the tracker provides it). |
ssh_host | Assigned SSH host (present only when SSH worker mode is configured). |
skip_reason | Present only when a blocker or an SSH host limit is why would_dispatch is false. Absent for any other ineligibility, such as a full concurrency slot or a basic eligibility check. The candidate log line still reports would_dispatch=false in those cases, with no skip_reason. |
skip_reason takes one of these values:
| Value | Meaning |
|---|---|
blocked_by | At least one blocker has a non-terminal or unknown state. |
blockers_unresolved | The blocker read for this candidate failed, or this simulated poll already gave up on further reads after an earlier failure. |
blockers_not_read | This simulated poll’s read budget was already spent on other candidates before reaching this one. |
blockers_incomplete | The candidate’s blocker list was not authoritative and nothing was available to complete it. |
ssh_hosts_at_capacity | Every configured SSH host is at its concurrency limit. |
See candidate eligibility for how the first four are decided, and the Prometheus metrics reference for the sortie_candidate_holds_total counter a live run increments for the same four reasons.
A summary line follows all candidates:
level=INFO msg="dry-run: complete" candidates_fetched=5 would_dispatch=3 ineligible=2 max_concurrent_agents=4Exit codes
| Code | Meaning |
|---|---|
0 | Dry-run completed. Candidates fetched and evaluated. |
1 | Startup failure (same as normal run) or tracker fetch failure. |
--log-level
Sets the minimum log severity emitted to stderr. Accepted values (case-insensitive): debug, info, warn, error.
Takes precedence over logging.level from the workflow file. When neither the flag nor the workflow field is set, the process logs at info.
An unknown value (e.g., --log-level trace) prints an error to stderr and exits with code 1:
sortie: unknown log level "trace": accepted values are debug, info, warn, errorApplied before the workflow file is loaded, so all startup output, including workflow loading errors, respects the requested level.
--log-format
Sets the log output format. Accepted values (case-insensitive): text, json. Default: text.
When text is active (the default), Sortie emits structured key=value lines:
time=2026-04-07T14:30:00.000+00:00 level=INFO msg="sortie starting" version=<version> workflow_path=/opt/sortie/WORKFLOW.mdWhen json is active, each log line is a single JSON object:
{"time":"2026-04-07T14:30:00.000Z","level":"INFO","msg":"sortie starting","version":"<version>","workflow_path":"/opt/sortie/WORKFLOW.md"}JSON format is intended for containerized and cloud-native deployments where log aggregation systems (Loki, Datadog, CloudWatch, ELK) expect newline-delimited JSON on stdout/stderr.
Takes precedence over logging.format from the workflow file. When neither the flag nor the workflow field is set, the process uses text.
An unknown value (e.g., --log-format yaml) prints an error to stderr and exits with code 1:
sortie: unknown log format "yaml": accepted values are text, jsonApplied before the workflow file is loaded, so all startup output uses the requested format immediately. Both --log-format and --log-level can be combined freely. Any combination works.
--env-file
Loads SORTIE_* variables from a file as configuration overrides.
sortie --env-file /etc/sortie/prod.env WORKFLOW.mdTakes a file path argument. Only keys prefixed with SORTIE_ are read from the file; all others are ignored. The file format is KEY=VALUE with # comments, optional quotes, and no variable interpolation.
Real environment variables take precedence over .env values. When both --env-file and the SORTIE_ENV_FILE environment variable are set, the flag wins.
When --env-file is provided, the CLI resolves the path to absolute and exports it as SORTIE_ENV_FILE in the process environment. This makes the value available to the MCP server through the config env block, so the MCP server can locate and load the .env file to resolve credential $VAR indirection. The absolute resolution is necessary because the MCP server’s working directory (the per-issue workspace) differs from the orchestrator’s.
The file is re-read on every WORKFLOW.md reload (file change detection). If the file does not exist at load time, a warning is logged and loading continues without it.
--port
Sets the listening port for the embedded HTTP server. The server starts by default on port 7678. All observability surfaces share this port:
/: HTML dashboard (dashboard reference)/api/v1/state: JSON API (HTTP API reference)/api/v1/<identifier>: per-issue detail/api/v1/refresh: trigger an immediate poll cycle/livez: liveness probe/readyz: readiness probe/metrics: Prometheus metrics (Prometheus metrics reference)
Valid range: 1–65535, or 0 to disable. Port 0 disables the server entirely: no TCP listener, no Prometheus metrics. The orchestrator runs with a no-op metrics implementation.
Overrides server.port from the WORKFLOW.md server extension. When the default port (7678) is already occupied and the operator did not explicitly request a port, Sortie logs a warning and starts without the HTTP server. When the operator explicitly requested a port (via --port or server.port) and it is already in use, Sortie exits with code 1.
Invalid values (negative, above 65535) produce an error and exit 1.
--host
Sets the bind address for the embedded HTTP server. Default: 127.0.0.1 (loopback only).
Must be a parseable IP address. DNS hostnames are not accepted. Container deployments that need inbound connections from the container network use 0.0.0.0.
Overrides server.host from the WORKFLOW.md server extension. Requires a restart to take effect.
-h, --help
Prints the help message to stdout and exits with code 0. -h and -help are aliases for --help, recognized by the same interception pass.
Turn issue tracker tickets into autonomous coding agent sessions.
Usage:
sortie [flags] [workflow-path]
sortie <command> [flags]
Commands:
validate Validate a workflow file without running it
stats Summarize past runs: outcomes, duration, and cost
mcp-server Start the MCP stdio server for agent-to-orchestrator communication
Flags:
-h, --help Print this help message and quit
-V, --version Print program's version information and quit
-dumpversion Print the version of the program and don't do anything else
Run options:
--dry-run Run one poll cycle without spawning agents, then exit
--env-file PATH Path to .env file for config overrides
--log-level LEVEL Log verbosity: debug, info, warn, error (default: info)
--log-format FORMAT Log output format: text, json (default: text)
--host ADDRESS HTTP server bind address (default: 127.0.0.1)
--port PORT HTTP server port, 0 to disable (default: 7678)
Examples:
sortie WORKFLOW.md Run orchestrator with a workflow
sortie --dry-run WORKFLOW.md Validate config and poll once without writing state
sortie validate --format json w.md Check workflow syntax, output as JSON
sortie stats --since 24h Summarize the last 24 hours of runs
Learn more:
https://docs.sortie-ai.comEach subcommand carries its own help text, printed by sortie validate -h, sortie stats -h, and sortie mcp-server -h.
-V, --version
Prints the full version banner to stdout and exits with code 0. The short form -V is an alias for --version.
sortie <version> (commit: <short-sha>, built: <date>, <go-toolchain>, <goos>/<goarch>)The banner includes the Git commit SHA (first 7 characters), build date, Go toolchain version, and target platform. Every field is filled at build time; a build from source with no injected values reports version dev, commit unknown, and date unknown.
Skips workflow loading, configuration validation, and database initialization. Ignores the workflow-path argument when present.
-dumpversion
Prints the version string alone to stdout and exits with code 0:
<version>The flag is registered as dumpversion on the flag set, so --dumpversion parses identically.
Takes precedence over --version when both are provided. -V is intercepted before flag parsing, so if both -V and -dumpversion appear, -V wins.
Subcommands
validate
Checks that a workflow file is loadable, its configuration parses without type errors, required adapter fields are present, and the workspace root is writable. Does not start the orchestrator, open the database, or spawn a filesystem watcher.
sortie validate [--format text|json] [workflow-path]The validation pipeline runs the same checks as the main startup path through preflight validation (steps 1–5 of the startup sequence), then exits. No .sortie.db file is created.
Validation scope
The pipeline checks:
- Workflow file existence, readability, and YAML syntax.
- Front matter is a YAML map (not a scalar, list, or null).
- Integer-typed fields validated under the
config.<field>check accept a whole-number float or a numeric string in addition to a literal integer (type coercion). A value outside the range an integer setting accepts (-9223372036854775808to9223372036854775807) is rejected as a configuration error under that check. Other coercion failures are too, except forhooks.timeout_msandagent.max_concurrent_agents_by_state. A field inside a block the workflow leaves disabled or unconfigured is not checked this way at all. - A reaction’s own
poll_interval_ms,debounce_ms,watch_window_ms, ormax_continuation_turnsis checked for that same range too, under thereactions.<kind>check instead, with a differently worded message; see the errors reference for both message forms. server.portis never checked byvalidate.tracker.handoff_stateis a string, is non-empty when present, and does not collide withactive_statesorterminal_states.tracker.no_change_state, when present, requirestracker.handoff_stateto be set, and must equalhandoff_stateor name a member ofterminal_statesas written.tracker.handoff_evidenceis one ofobserved,strict, oroff. The check is a closed-set comparison and runs offline with no network access.tracker.in_progress_stateis a member ofactive_stateswhen present, and does not collide withterminal_statesorhandoff_state.db_pathis a string when present.agent.max_sessionsis non-negative.agent.turn_timeout_msis positive.- Go
text/templatesyntax in the prompt body (strict mode: unknown variables and functions are errors). - Template static analysis: dot-context misuse inside
{{ range }}/{{ with }}, unknown top-level variables, and unknown sub-fields of known variables (advisory warnings). tracker.kindis present and maps to a registered adapter.agent.kindmaps to a registered adapter. Defaults toclaude-codewhen absent.- Fields required by the selected adapter:
tracker.api_key,tracker.project,agent.command. - At least one of
tracker.active_statesortracker.terminal_statesis non-empty. - Adapter-specific config validation. When the registered tracker adapter declares its own config validation, the pipeline invokes it with the extracted tracker config fields. Adapter validation runs after the generic preflight checks and can produce both errors (block validity) and warnings (advisory). The Jira, GitHub, GitLab, Gitea, and Linear adapters each declare one; the
fileadapter does not. Each adapter reference page lists that adapter’s checks, for example GitHub adapter validation. - Settings block presence (
dispatch.agent.missing_block), for every agent kind adispatch.default.agentor adispatch.rules[i].agentnames, when that kind is registered and differs from the top-levelagent.kind. The kind must carry its own top-level block in the front matter, or the workflow is refused, naming the selector that introduced the kind and the block it expects. An empty block (codex: {}or a barecodex:key) is enough. Skipped for a kind Sortie does not recognize as a registered adapter, since that is already reported separately asagent_adapter. - Session-resume refusal (
agent.kind.session_resume), for every agent kind the configuration can reach. An adapter declares which of its own pass-through keys stops it resuming a session across separate agent launches; when the configuration sets that key to the blocking value, the workflow is refused. Sortie re-dispatches an issue carrying its earlier session after a retry, a continuation, a stall, or a restart, so every resumed turn would fail. The check reads the adapter’s declaration and that adapter’s own pass-through block, and no core setting; it runs offline with no network access and no subprocess launch.claude-code.session_persistenceset tofalseis the only key any built-in adapter declares. - Agent-adapter config validation, for every agent kind the configuration can reach: the default
agent.kind, the kind a dispatch default names, and the kind each dispatch rule selects. A registered kind the configuration never names is skipped, because reporting a fault in a block no run reads would be noise. These checks cover the pass-through values that would let the agent stop and wait for a person, and they run offline with no network access and no subprocess launch. The Codex, Claude Code, Copilot CLI, OpenCode, and Kiro adapters each declare them: see Codex, Claude Code, Copilot CLI, OpenCode, and Kiro. - Workspace root directory exists (or can be created) and is writable.
The pipeline does not check:
- Value ranges, for most fields.
agent.max_sessions,agent.max_tokens,agent.max_consecutive_absences,agent.turn_timeout_ms,agent.stop_grace_ms,workspace.retention_days,ci_feedback.max_retries,ci_feedback.max_log_lines, theself_reviewinteger fields,reactions.*.max_retries, and thereactions.ci_failureinteger fields are checked and reject an out-of-range value as a configuration error. Negative values forpolling.interval_msor other timeout fields are accepted. Zero replaces with a built-in default forpolling.interval_msandagent.read_timeout_ms; foragent.stall_timeout_mszero is kept and disables stall detection.agent.turn_timeout_msandagent.stop_grace_msmust be positive; any other value is rejected rather than replaced. - Format constraints.
tracker.endpointis not checked for valid URL syntax. Path fields are not checked for existence (exceptworkspace.root).
Advisory warnings
Beyond the error-level checks above, validate runs static analysis on the front matter and the prompt template, plus four checks on the resolved configuration, emitting warnings for likely-wrong patterns. Warnings do not block validity: valid remains true and the exit code is 0 when only warnings are present. Runtime behavior is unchanged; warnings surface patterns that the orchestrator would silently accept or that would produce unexpected output.
Six warning classes across two analysis passes, four configuration checks, plus adapter-specific warnings when the tracker adapter declares config validation (see adapter-specific warning check values):
Front matter analysis:
- Unknown top-level keys (
unknown_key). A top-level YAML key that is not a core section (tracker,polling,workspace,hooks,agent,db_path,ci_feedback,self_review,reactions,dispatch,notifications), not a recognized extension (server,logging,worker), and not the adapter pass-through block matching the configuredtracker.kindoragent.kind. Catches typos liketrackers:instead oftracker:. - Unknown sub-keys (
unknown_sub_key). A key inside a known section that does not match any defined field. For example,tracker.typo_endpointorhooks.before_launch. Sub-objects named after the section’s adapter kind are exempt (e.g.,tracker.jirawhentracker.kindisjira). - Type mismatches (
type_mismatch). A value whose YAML type does not match the expected type for a field. For example,hooks.timeout_ms: "not-a-number"ortracker.kind: 123. Also covers semantic issues: a non-positivehooks.timeout_msthat falls back to the default, and non-numeric or non-positive entries inagent.max_concurrent_agents_by_statethat are silently ignored at runtime.
Template static analysis:
- Dot-context misuse (
dot_context). A reference to a top-level data key (.issue,.attempt,.run) inside a{{ range }}or{{ with }}block where the dot has been redefined. Almost always a bug. Use the$prefix ($.issue.title) to reach root data from inside these blocks. - Unknown template variable (
unknown_var). A top-level variable reference not in the template data contract. For example,{{ .config }}or{{ $.settings }}. Valid top-level variables are.issue,.attempt, and.run. - Unknown sub-field (
unknown_field). A sub-field of a known top-level variable that does not exist in the domain schema. For example,{{ .run.foo }}or{{ .issue.nonexistent }}. Also flags sub-field access on scalar variables like{{ .attempt.something }}.
Configuration checks:
- Unreachable
mcp_config(agent.mcp_config). An agent kind’s pass-through block setsmcp_config(kiro.mcp_config, for example; theagent:section carries no such key) for a kind whose adapter delivers the generated MCP configuration to the agent process in no form at all, so the value cannot reach the agent.kirois one such built-in kind, and any custom adapter declaring the same disposition draws the warning too.claude-codeandcopilot-clideliver the generated file itself, so the check never fires for them.codex,opencode, andagent-client-protocoldeliver that file’s servers re-expressed rather than the file, and only on a local launch; the check does not fire for them either, because validation reads the workflow file offline and cannot know which sessions will be dispatched to an SSH host. Setmcp_configin one of those three blocks and dispatch the session over SSH, and you get neither the warning nor the effect. - No tool execution channel (
agent.kind.no_tool_channel). The agent kind delivers no channel for Sortie’s tools even on a local launch, so the session can neither call them nor be told about them. It fires for every kind whose adapter declares that it never delivers the generated MCP configuration,kirobeing one such built-in kind, and for any adapter that declares no MCP disposition at all. It does not fire forcodex,opencode, oragent-client-protocol, whose channel exists locally; validation reads the workflow file offline and cannot know which sessions will be dispatched to an SSH host. - Token ceiling on a kind that reports no usage (
agent.kind.no_usage_reporting).agent.max_tokensis set against an agent kind whose declared usage reporting yields no figure for the sessions this configuration produces, so the per-issue token ceiling has nothing to count against. Budget those sessions by time instead, throughagent.turn_timeout_ms. - Rates priced for a kind that reports no usage (
agent.kind.no_cost_estimate). Atoken_ratesentry prices an agent kind that reports no token usage for the sessions this configuration produces, so no cost can be estimated for it and the dashboard’s Est. Cost field stays blank. Remove the entry or move the workload to a kind that reports usage.
Unlike the two checks above them, the two usage checks read worker.ssh_hosts and resolve the disposition for a remote launch when the pool is non-empty. copilot-cli reports usage on a local launch and none over SSH, so a workflow that adds a host pool draws both warnings where the same file without one drew neither.
All four configuration checks run for every agent kind the configuration can reach, including one named only by a dispatch rule, and each kind reports its own warning.
Arguments
| Argument | Required | Default | Description |
|---|---|---|---|
workflow-path | No | ./WORKFLOW.md | Path to the workflow file. Resolved identically to the main command. |
One positional argument is accepted. Two or more produce an error.
Flags
| Flag | Type | Default | Description |
|---|---|---|---|
--format | string | text | Output format: text or json. |
-h, --help | boolean | false | Print the validate help message and exit. |
Invalid --format values produce an error and exit 1.
Output formats
Text (default): each diagnostic is written to stderr, one per line, prefixed with its severity:
error: tracker.kind: tracker.kind is required
error: agent_adapter: unknown agent kind "nonexistent"Format: {severity}: {check}: {message}
Warning-only output (exit 0):
warning: unknown_key: unknown top-level key "trackers"
warning: dot_context: did you mean "$.issue.title" instead of ".issue.title"? Inside a {{ range }}/{{ with }} block (including arguments to nested range/with), dot refers to the current element, not root data
warning: unknown_var: unknown template variable ".config"; valid top-level variables are: .issue, .attempt, .run
warning: unknown_field: unknown field ".run.foo"; known fields: is_continuation, max_turns, turn_numberWhen no errors and no warnings are present, nothing is written.
When the workflow file itself cannot be loaded, a single error line is emitted:
error: workflow_load: workflow file not found: /path/to/WORKFLOW.md: ...JSON (--format json): a single JSON object is written to stdout on both success and failure:
{"valid":true,"errors":[],"warnings":[]}{"valid":false,"errors":[{"severity":"error","check":"tracker.kind","message":"tracker.kind is required"}],"warnings":[]}With warnings only:
{"valid":true,"errors":[],"warnings":[{"severity":"warning","check":"unknown_key","message":"unknown top-level key \"trackers\""}]}The errors and warnings arrays are always present (never null). valid is true when errors is empty, regardless of warnings. Each diagnostic element has three fields:
| Field | Type | Description |
|---|---|---|
severity | string | "error" or "warning". Redundant with array membership but useful when consumers flatten the arrays. |
check | string | Diagnostic category. Error checks match the startup and configuration errors table. Warning checks are listed under advisory warning check values. |
message | string | Human-readable description. |
Exit codes
| Code | Meaning |
|---|---|
0 | Workflow is valid (warnings may be present), or -h/--help was requested. |
1 | One or more errors, invalid flag, or too many arguments. |
Diagnostic check values
The check field in JSON output and the prefix in text output use these values:
| Check | Source |
|---|---|
workflow_load | Workflow file missing, unreadable, or unparseable YAML. |
workflow_front_matter | Front matter is not a YAML map. |
config.<field> | Configuration field type or value error (e.g., config.polling.interval_ms, config.tracker.handoff_state, config.tracker.handoff_evidence). |
config.workspace.retention_days | Workspace retention window is not an integer, is negative, or is non-zero but below the accepted minimum. |
config.agent.turn_timeout_ms | The per-turn timeout is not a positive integer. |
reactions.review_comments | Invalid reactions.review_comments block. |
reactions.bot_review | Invalid reactions.bot_review block. |
reactions.auto_merge | Invalid reactions.auto_merge block. |
reactions.merge_conflicts | Invalid reactions.merge_conflicts block. |
reactions.merge_completion | Invalid reactions.merge_completion block: a missing or colliding target_state, a required tracker field left unset, or a poll_interval_ms below the floor. |
reactions.scm_provider_conflict | Two active SCM reactions name different providers. |
scm_adapter | The single provider named by the active SCM reactions has no registered SCM adapter. |
ci_provider | The resolved CI feedback kind has no registered CI provider. |
template_parse | Go template syntax error in the prompt body. |
tracker.kind | Missing tracker.kind field. |
tracker.api_key | Missing or empty API key after environment variable expansion. |
tracker.project | Missing tracker.project when required by the adapter. |
tracker_adapter | Unknown tracker adapter kind. |
agent.kind | Missing agent.kind field. |
agent.command | Missing agent.command when required by the adapter. |
agent_adapter | Unknown agent adapter kind. |
tracker.project.format | tracker.project is non-empty but not in owner/repo format (GitHub adapter). |
dispatch.agent.missing_block | A dispatch.default.agent or dispatch.rules[i].agent names a registered kind, other than agent.kind, with no top-level settings block in the front matter. |
agent.kind.session_resume | An agent kind’s pass-through block sets a key the adapter declares as blocking session resume across separate agent launches. |
workspace.root_writable | Workspace root directory does not exist and cannot be created, or is not writable. |
args | Invalid command-line arguments (too many positional args). |
Check values from preflight validation match the startup and configuration errors table. Adapter-specific error checks (e.g., tracker.project.format) are produced by the registered adapter’s own validation.
Advisory warning check values
Warning diagnostics use a separate set of check values. They appear only in the warnings array (JSON) or with the warning: prefix (text). They do not affect valid or the exit code.
| Check | Meaning |
|---|---|
unknown_key | Unrecognized top-level YAML key. Likely a typo (e.g., trackers instead of tracker). |
unknown_sub_key | Unrecognized key inside a known section (e.g., tracker.typo_endpoint). Adapter pass-through sub-objects matching the configured kind are exempt. |
type_mismatch | Value type does not match the expected type for the field (e.g., string where integer is expected). Also covers semantic issues: non-positive hooks.timeout_ms, non-numeric or non-positive values in agent.max_concurrent_agents_by_state. An out-of-range integer never produces this warning; see Validation scope for what checks it instead. |
dot_context | Reference to a top-level data key (.issue, .attempt, .run) inside a {{ range }} or {{ with }} block where dot is the current element, not root data. Use $ prefix to fix. |
unknown_var | Top-level template variable not in the data contract. Valid variables: .issue, .attempt, .run. |
unknown_field | Sub-field of a known top-level variable that does not exist in the domain schema (e.g., .issue.nonexistent, .run.foo). |
agent.mcp_config | An agent kind’s pass-through block sets mcp_config for a kind whose adapter delivers the generated MCP configuration to the agent process in no form at all, so the value cannot reach the agent. |
agent.kind.no_tool_channel | The agent kind has no tool execution channel, so Sortie’s tools are neither advertised in the first-turn prompt nor callable during the session. |
agent.kind.no_usage_reporting | agent.max_tokens is set against an agent kind that reports no token usage for the sessions this configuration produces, so the per-issue token ceiling has nothing to count against. |
agent.kind.no_cost_estimate | token_rates prices an agent kind that reports no token usage for the sessions this configuration produces, so no cost can be estimated for it. |
Adapter-specific warning check values
When the tracker adapter declares its own config validation, it can produce additional warnings. These appear alongside the advisory warnings above and follow the same rules: they do not affect valid or the exit code.
The GitHub adapter (tracker.kind: github) produces these warning checks:
| Check | Meaning |
|---|---|
tracker.api_key.github_token_hint | tracker.api_key is empty but the GITHUB_TOKEN environment variable is set. Consider using api_key: $GITHUB_TOKEN. |
tracker.api_key.github_token_missing | tracker.api_key is empty and GITHUB_TOKEN is not set. |
tracker.active_states.empty_element | An element in active_states is empty or whitespace-only. |
tracker.terminal_states.empty_element | An element in terminal_states is empty or whitespace-only. |
tracker.states.overlap | A label appears in both active_states and terminal_states (case-insensitive). |
State collisions involving handoff_state or in_progress_state are not warnings. The generic configuration layer rejects them for every tracker.kind before adapter validation runs, and they are reported under the config.tracker.handoff_state and config.tracker.in_progress_state check values with exit code 1.
For details on each check, see GitHub adapter validate-time checks. The Jira, GitLab, Gitea, and Linear adapter references list the checks those adapters declare.
stats
Reports how past runs went and what they cost. Sortie appends one row to run_history each time an agent session finishes; stats reads that history back over a time range and aggregates it into run counts, success rate, duration percentiles, turns, token sums, and derived cost, broken down by outcome, by coding agent, by dispatch rule, and by prompt template. The database is opened read-only, so the command is safe to run while the orchestrator is working, and it makes no network call.
sortie stats [--format text|json] [--since value] [--until value] [workflow-path]The workflow file supplies two things: db_path, which locates the database, and token_rates, which prices the recorded token counts. The command reads those, opens the database read-only, writes the report, and exits. It does not start the orchestrator, apply migrations, spawn agents, or write anything.
Arguments
| Argument | Required | Default | Description |
|---|---|---|---|
workflow-path | No | ./WORKFLOW.md | Path to the workflow file. Resolved identically to the main command. |
One positional argument is accepted. Two or more produce an error: sortie stats: too many arguments.
Flags
| Flag | Type | Default | Description |
|---|---|---|---|
--format | string | text | Output format: text or json. |
--since | string | (no limit) | Count only runs that finished at or after this point. |
--until | string | (no limit) | Count only runs that finished before this point. |
-h, --help | boolean | false | Print the stats help message and exit. |
An invalid --format value produces an error and exit 1:
sortie stats: invalid --format value "xml": must be "text" or "json"Range bounds
--since and --until each accept one of three forms:
| Form | Example | Meaning |
|---|---|---|
| RFC3339 timestamp | 2026-07-01T00:00:00Z | That exact instant. |
| Calendar date | 2026-07-01 | 00:00:00Z on that date. |
| Positive Go duration | 24h, 90m, 45s | That much time before now. |
Both bounds normalize to UTC. The filter is on completion time, not start time, and the range is half-open: --since is inclusive, --until is exclusive. A run that finished at exactly the --since instant is counted; a run that finished at exactly the --until instant is not. Omitting both covers every run on record.
--since must be strictly before --until:
sortie stats: --since must be strictly before --untilA zero or negative duration is a usage error, as is any value that matches none of the three forms:
sortie stats: --since: invalid range bound "nonsense": accepts an RFC3339 timestamp (2026-07-01T00:00:00Z), a date (2026-07-01), or a positive duration (24h)Both messages exit 1.
Schema tiers
Which figures a report can carry depends on the database it reads, not on the version of the binary reading it. The command inspects the live run_history column set and reports the result as schema_tier. There are exactly two values.
full: the table carries all five optional column groups:
| Group | Columns | Figures it supplies |
|---|---|---|
| Turns | turns_completed | Mean turns, in the summary and in every breakdown row |
| Self-review | review_metadata | The self-review section |
| Dispatch-rule routing | rule_name, template_id | The dispatch-rule and prompt-template breakdowns |
| Tokens | input_tokens, output_tokens, total_tokens, cache_read_tokens | Token sums and every derived cost figure |
| Token measurement | tokens_measured | Which runs the coding agent could measure, and so which ones the token and cost figures cover |
base: at least one group is missing. The report falls back to run counts, the outcome breakdown, the coding-agent breakdown, and durations. Turns, tokens, cost, the dispatch-rule breakdown, the prompt-template breakdown, and the self-review section are all left out.
The tier is all-or-nothing by design. A database carrying four of the five groups still reports base, and the report then drops the groups it does carry along with the ones it never recorded. The warning names both lists so the two are not confused:
warning: this database was written before sortie recorded dispatch-rule routing, tokens and cost, which runs the coding agent could measure. The report falls back to run counts and durations, so it also leaves out turns, self-review results, which this database does carry. Run sortie once with this workflow to get the full report.A degraded report is still a report: the warning goes to stderr in text mode and into warnings in JSON, and the exit code is 0. In JSON, by_rule and by_template are empty arrays, self_review is null, and every figure the tier cannot supply is null rather than 0. A null means the database never recorded that figure, not that the figure measured zero.
The remedy is to run the orchestrator once with this workflow. Startup applies the pending migrations, and runs recorded from then on carry the full set. stats cannot do this itself; its read-only connection cannot apply a migration.
When run_history is absent altogether, or missing any of status, agent_adapter, started_at, or completed_at, the file is not a Sortie database and the command exits 1:
sortie stats: run_history table not found or missing base columnsOutput formats
Both formats carry the same figures. Breakdown rows are sorted by descending run count, with ties broken by ascending name. A run that recorded no dispatch rule or prompt template appears under the sentinel name <none>, which also covers an empty agent adapter. Rounding is fixed so repeated runs over the same data produce identical output: rates and shares to four decimals, cost and mean turns to two, mean duration to one.
The summary’s duration and mean-turn figures cover succeeded runs only, so they describe the work that landed rather than the volume attempted. Token sums, and every cost figure derived from them, cover measured runs only: a run whose coding agent reported no token usage records that fact and is left out of those figures instead of counting as a run that spent nothing. A run counts as succeeded when its status is exactly succeeded. Cost is never stored; it is derived at report time from the token counts and the configured rates. See control agent costs for where this fits among the other cost surfaces.
Text (default): the report is written to stdout. Warnings are written to stderr, after a blank line, one per line, each prefixed warning: . The two streams can be redirected independently.
The report opens with four header lines (workflow:, database:, covering:, generated:), then the summary block, then one table per breakdown, then any footnotes. Column headers are upper-cased, rows are indented two spaces, and a nullable figure with no value renders as -.
workflow: /srv/sortie/WORKFLOW.md
database: /srv/sortie/.sortie.db
covering: 2026-07-01T00:00:00Z until 2026-08-01T00:00:00Z
generated: 2026-08-09T07:51:43Z
runs 9 succeeded 7 (77.8%)
duration (succeeded) p50 2m 49s p95 7m 12s mean 3m 34s samples 7
turns (succeeded) 3.3
tokens (measured runs) input 502,900 output 96,000 total 598,900 cache read 12,638,000
cost (measured runs) $6.49 per succeeded run $0.93
by outcome
OUTCOME RUNS SHARE P50 P95 MEAN TURNS TOTAL TOKENS COST
succeeded 7 77.8% 2m 49s 7m 12s 3m 34s 3.3 408,900 $4.59
failed 2 22.2% 6m 0s 9m 40s 7m 50s 5.5 190,000 $1.90
by coding agent
AGENT RUNS SUCCEEDED SUCCESS RATE P50 P95 MEAN TURNS TOTAL TOKENS COST
claude-code 6 5 83.3% 2m 41s 9m 40s 4m 32s 3.3 388,400 $5.72
codex 3 2 66.7% 4m 10s 6m 0s 4m 30s 4.7 210,500 $0.77
by dispatch rule
RULE RUNS SUCCEEDED SUCCESS RATE P50 P95 MEAN TURNS TOTAL TOKENS COST
bugfix 4 4 100.0% 2m 30s 2m 49s 2m 35s 2.5 172,800 $2.54
<none> 3 2 66.7% 4m 10s 6m 0s 4m 30s 4.7 210,500 $0.77
feature 2 1 50.0% 7m 12s 9m 40s 8m 26s 5.0 215,600 $3.18
by prompt template
TEMPLATE RUNS SUCCEEDED SUCCESS RATE P50 P95 MEAN TURNS TOTAL TOKENS COST
<none> 5 3 60.0% 6m 0s 9m 40s 6m 4s 4.8 426,100 $3.95
prompts/bugfix.md 4 4 100.0% 2m 30s 2m 49s 2m 35s 2.5 172,800 $2.54
self review
runs reviewed 6 iterate 1 pass 5 hit iteration cap 1 mean iterations 1.50The outcome table shows SHARE, a group’s runs over total runs. Every other breakdown shows SUCCEEDED and SUCCESS RATE, a group’s succeeded runs over its own. On the base tier the TURNS, TOTAL TOKENS, and COST columns are absent entirely, and the dispatch-rule, prompt-template, and self-review sections do not appear.
The covering: line is prose rather than a sentinel. It reads every run on record when neither bound was given, <since> onward with only --since, the start of the record until <until> with only --until, and <since> until <until> with both.
When the workflow sets no token_rates, token counts are still reported and the cost line is replaced:
cost not estimated; set token_rates in the workflow to price these runsFour disclosure counters each add a footnote below the tables when they are not zero:
note: 2 of these runs took no measurable time. A run that a CI result closed out carries the same start and finish time.
note: the duration figures skip 1 of these runs, whose recorded start and finish times were unusable.
note: the token and cost figures skip 4 of these runs, because the coding agent behind them reported no token usage.
note: the cost figures skip 3 of these runs, because token_rates has no price for the coding agent behind them.A range that matches no runs is a success, not a failure. The header still prints, one of two lines follows, and the exit code is 0:
No runs on record yet. sortie adds one each time an agent session finishes.No runs finished in this range.The first appears when neither bound was given, the second when at least one was.
JSON (--format json): a single document is written to stdout, compact and on one line, terminated by a newline. Every array field is always present and never null. Every scalar figure the report cannot supply is null. For piping the document into your own metrics store, see aggregate metrics across instances.
Envelope:
| Field | Type | Description |
|---|---|---|
generated_at | string | RFC3339 UTC timestamp of when the report was produced. |
workflow_path | string | Absolute path to the workflow file that was read. |
db_path | string | Absolute path to the database the figures came from. |
since | string or null | The --since bound, RFC3339 UTC. null when the flag was omitted, meaning the range is open at the start. |
until | string or null | The --until bound, RFC3339 UTC. null when the flag was omitted, meaning the range is open at the end. |
schema_tier | string | "full" or "base". See schema tiers. |
warnings | array of string | Advisory messages: a degraded schema, or a malformed token_rates block. Empty when there is nothing to report, never null. The warning: prefix belongs to text rendering and is not part of these strings. |
summary | object | Report-wide figures. Always present. |
by_status | array of object | Breakdown by outcome. |
by_adapter | array of object | Breakdown by coding agent. |
by_rule | array of object | Breakdown by dispatch rule. Empty array on the base tier. |
by_template | array of object | Breakdown by prompt template. Empty array on the base tier. |
self_review | object or null | Self-review aggregation. null on the base tier, meaning the results were not read, not that no review ran. |
summary:
| Field | Type | Description |
|---|---|---|
runs | integer | Runs that finished in the range. |
succeeded | integer | Runs whose status is exactly succeeded. |
success_rate | number | succeeded over runs, between 0 and 1. 0 when runs is 0. |
duration_seconds | object | Duration figures over succeeded runs only. |
mean_turns_succeeded | number or null | Mean turns completed over succeeded runs. null on the base tier and when no run succeeded, never 0 in either case. |
tokens | object or null | Token sums over the measured runs in the range. null on the base tier and when no run in the range was measured. |
cost_usd | number or null | Estimated cost over every priced run in the range. null on the base tier and when no run could be priced, never 0 in either case. |
cost_per_succeeded_run_usd | number or null | cost_usd divided by the succeeded runs that were measured. null whenever cost_usd is null, and when no measured run succeeded. |
zero_duration_runs | integer | Runs that took no measurable time. A run that a CI result closed out carries the same start and finish time. |
duration_excluded_runs | integer | Runs left out of every duration figure because their stored timestamps could not be parsed or ran backwards. |
cost_unpriced_runs | integer | Runs left out of the cost figures because token_rates has no entry for their coding agent. |
tokens_unmeasured_runs | integer | Runs left out of the token and cost figures because the coding agent behind them reported no token usage. 0 on the base tier, where the distinction was never recorded. |
Each element of by_status, by_adapter, by_rule, and by_template:
| Field | Type | Description |
|---|---|---|
name | string | The group: an outcome, an agent adapter kind, a dispatch rule name, or a template identifier. A run that recorded none carries the sentinel <none>. |
runs | integer | Runs in this group. |
succeeded | integer | Succeeded runs in this group. In by_status this is structural rather than informative: the succeeded row necessarily reports it equal to runs. |
success_rate | number | succeeded over this group’s runs. |
share | number | This group’s runs over the report’s total runs. |
duration_seconds | object | Duration figures over all of this group’s runs, not only the succeeded ones. |
mean_turns | number or null | Mean turns completed over this group’s runs. null on the base tier. |
tokens | object or null | Token sums over this group’s measured runs. null on the base tier and when the group holds no measured run. |
cost_usd | number or null | Estimated cost over this group’s priced runs. null on the base tier and when the group holds no priced run. |
cost_per_succeeded_run_usd | number or null | cost_usd divided by this group’s succeeded runs that were measured. null whenever cost_usd is null, and when the group has no measured succeeded run. |
tokens_unmeasured_runs | integer | This group’s runs left out of the token and cost figures because the coding agent behind them reported no token usage. 0 on the base tier. |
duration_seconds, in both summary and every breakdown element:
| Field | Type | Description |
|---|---|---|
p50 | number or null | Median duration in seconds, nearest rank with no interpolation. null when samples is 0. |
p95 | number or null | 95th percentile in seconds, nearest rank with no interpolation. null when samples is 0. |
mean | number or null | Arithmetic mean duration in seconds. null when samples is 0. |
samples | integer | Runs contributing to the three figures above. |
Durations are seconds here. Text mode renders the same values as 2m 49s.
tokens, in both summary and every breakdown element:
| Field | Type | Description |
|---|---|---|
input | integer | Sum of recorded input tokens over the measured runs. |
output | integer | Sum of recorded output tokens over the measured runs. |
total | integer | Sum of the recorded totals, taken as stored rather than recomputed from input and output. |
cache_read | integer | Sum of recorded cache-read tokens over the measured runs. |
self_review:
| Field | Type | Description |
|---|---|---|
runs_with_metadata | integer | Runs whose recorded review metadata was present and parsed. |
by_final_verdict | array of object | One entry per distinct final verdict, sorted by verdict name. |
cap_reached_runs | integer | Runs that reached the review iteration cap. |
mean_iterations | number or null | Mean review iterations over runs_with_metadata. null when runs_with_metadata is 0. |
unparsed_runs | integer | Runs whose recorded review metadata failed to parse. They count here and contribute to nothing else in this object. |
Each element of by_final_verdict:
| Field | Type | Description |
|---|---|---|
verdict | string | The final verdict. A run whose recorded verdict is empty is grouped under the literal none, which is distinct from the <none> group sentinel. |
runs | integer | Runs carrying this verdict. |
A worked example, expanded for readability and trimmed to one row per breakdown. The command emits it as a single compact line:
{
"generated_at": "2026-08-09T07:51:57Z",
"workflow_path": "/srv/sortie/WORKFLOW.md",
"db_path": "/srv/sortie/.sortie.db",
"since": "2026-07-01T00:00:00Z",
"until": "2026-08-01T00:00:00Z",
"schema_tier": "full",
"warnings": [],
"summary": {
"runs": 9,
"succeeded": 7,
"success_rate": 0.7778,
"duration_seconds": {"p50": 169, "p95": 432, "mean": 214.6, "samples": 7},
"mean_turns_succeeded": 3.29,
"tokens": {"input": 502900, "output": 96000, "total": 598900, "cache_read": 12638000},
"cost_usd": 6.49,
"cost_per_succeeded_run_usd": 0.93,
"zero_duration_runs": 0,
"duration_excluded_runs": 0,
"cost_unpriced_runs": 0,
"tokens_unmeasured_runs": 0
},
"by_status": [
{
"name": "succeeded",
"runs": 7,
"succeeded": 7,
"success_rate": 1,
"share": 0.7778,
"duration_seconds": {"p50": 169, "p95": 432, "mean": 214.6, "samples": 7},
"mean_turns": 3.29,
"tokens": {"input": 341900, "output": 67000, "total": 408900, "cache_read": 9038000},
"cost_usd": 4.59,
"cost_per_succeeded_run_usd": 0.66,
"tokens_unmeasured_runs": 0
}
],
"by_adapter": [
{
"name": "claude-code",
"runs": 6,
"succeeded": 5,
"success_rate": 0.8333,
"share": 0.6667,
"duration_seconds": {"p50": 161, "p95": 580, "mean": 272, "samples": 6},
"mean_turns": 3.33,
"tokens": {"input": 324500, "output": 63900, "total": 388400, "cache_read": 12638000},
"cost_usd": 5.72,
"cost_per_succeeded_run_usd": 1.14,
"tokens_unmeasured_runs": 0
}
],
"by_rule": [
{
"name": "bugfix",
"runs": 4,
"succeeded": 4,
"success_rate": 1,
"share": 0.4444,
"duration_seconds": {"p50": 150, "p95": 169, "mean": 155, "samples": 4},
"mean_turns": 2.5,
"tokens": {"input": 145100, "output": 27700, "total": 172800, "cache_read": 5636000},
"cost_usd": 2.54,
"cost_per_succeeded_run_usd": 0.64,
"tokens_unmeasured_runs": 0
}
],
"by_template": [
{
"name": "prompts/bugfix.md",
"runs": 4,
"succeeded": 4,
"success_rate": 1,
"share": 0.4444,
"duration_seconds": {"p50": 150, "p95": 169, "mean": 155, "samples": 4},
"mean_turns": 2.5,
"tokens": {"input": 145100, "output": 27700, "total": 172800, "cache_read": 5636000},
"cost_usd": 2.54,
"cost_per_succeeded_run_usd": 0.64,
"tokens_unmeasured_runs": 0
}
],
"self_review": {
"runs_with_metadata": 6,
"by_final_verdict": [{"verdict": "iterate", "runs": 1}, {"verdict": "pass", "runs": 5}],
"cap_reached_runs": 1,
"mean_iterations": 1.5,
"unparsed_runs": 0
}
}Exit codes
| Code | Meaning |
|---|---|
0 | A report was produced. Includes a range that matched no runs and a reduced report on the base tier, both of which are successes, and -h/--help. |
1 | Usage error (invalid --format, an unparseable or inverted range bound, too many arguments) or load error (workflow file missing or invalid, database unopenable, run_history unreadable). |
mcp-server
Starts an MCP stdio server that exposes registered agent tools over JSON-RPC on stdin/stdout. Intended to be launched by an MCP-compatible agent runtime via .sortie/mcp.json, not run manually.
sortie mcp-server --workflow <path>The subcommand loads the workflow file, builds the tracker adapter from its configuration, assembles the tools available to the session, and serves MCP requests until stdin closes or the process receives a signal. No agents are spawned and no HTTP server starts. The database is opened read-only, and only when both SORTIE_DB_PATH and SORTIE_ISSUE_ID are present in the environment; no migration is applied and nothing is written.
Flags
| Flag | Type | Default | Description |
|---|---|---|---|
--workflow | string | (none) | Path to the WORKFLOW.md file. Required, and must be absolute; a relative path is rejected. |
-h, --help | boolean | false | Print the mcp-server help message and exit. |
No other flags beyond --workflow and -h/--help. All behavior derives from the workflow file and environment variables.
Startup sequence
- Parse the
--workflowflag. Exit1when it is missing or not an absolute path. - Log to stderr,
textformat atinfolevel. Neither--log-levelnor--log-formatexists on this subcommand. - Load and parse the workflow file.
- Build the resolved configuration from the parsed workflow file.
- Resolve the tracker adapter for
tracker.kind(when non-empty). Gather the tracker configuration, setuser_agenttosortie-mcp/<version>, merge extensions, and build the adapter. - Assemble the session’s tools. Each tool is included only when its inputs are present:
tracker_apiwhen the tracker adapter was built andtracker.projectis non-empty;sortie_statuswhenSORTIE_WORKSPACEis set;workspace_historyandcost_budgettogether when bothSORTIE_DB_PATHandSORTIE_ISSUE_IDare set and the read-only database opens;notify_operatorwhen the workflow configures at least one notification backend. A read-only open that fails logs a warning and skips the two database-backed tools; an unresolvable notification backend is fatal. With none of the inputs present, the session has no tools. - Build the MCP server with the assembled tools, reading from stdin and writing to stdout.
- Serve requests until stdin closes or a shutdown signal arrives.
Failures at steps 1–6 write to stderr and return exit code 1.
Environment variables
The MCP server receives its environment exclusively from the env field in .sortie/mcp.json. The worker writes all SORTIE_*-prefixed variables from the orchestrator’s process environment into this block, plus the per-session variables below, which override any same-named process variable. See MCP server environment for the full composition model.
Per-session variables written by the worker. Every row but SORTIE_ATTEMPT is written on every dispatch; SORTIE_ATTEMPT is written only when the orchestrator has an attempt number:
| Variable | Purpose |
|---|---|
SORTIE_ISSUE_ID | Scopes tool calls to the current issue. |
SORTIE_ISSUE_IDENTIFIER | Human-readable issue key. |
SORTIE_WORKSPACE | Workspace root path. |
SORTIE_DB_PATH | SQLite database path. Gates the workspace_history and cost_budget tools. |
SORTIE_DISPATCH_ID | Identifies the current dispatch to the cost_budget tool, and fences the session id notify_operator reads from .sortie/dispatch.json. |
SORTIE_SESSION_AGENT_KIND | Dispatch-frozen agent kind for the session. May be empty. |
SORTIE_ATTEMPT | Attempt number as a decimal integer. Absent on the first dispatch. |
There is no SORTIE_SESSION_ID variable: the agent’s session id is not known when the worker writes this environment. notify_operator reads it separately, live, from a workspace-held record; see notify_operator.
Tracker credentials (e.g., SORTIE_TRACKER_API_KEY) reach the server through the same env block via the SORTIE_* prefix scan. The MCP server resolves $VAR indirection in the workflow file against these variables.
The MCP server does not validate environment variable presence at startup. Validation failures surface at tool execution time when a tool requires a variable that is absent.
Graceful shutdown
The MCP server exits cleanly when either:
- stdin closes: the agent runtime terminates the stdio pipe, and the server detects EOF.
- A shutdown signal arrives:
SIGINTorSIGTERMstops the server.
No explicit shutdown handshake. The server’s lifetime is bound to the agent runtime’s stdio pipe.
Exit codes
| Code | Meaning |
|---|---|
0 | Clean shutdown (stdin closed or signal received), or -h/--help requested. |
1 | Startup failure: missing or relative --workflow, an unparseable flag, unreadable workflow file, invalid config, failure to build the tracker adapter, a notification backend that cannot be resolved, or a server error during operation. |
Startup sequence
When no version or help flag is present, Sortie executes these steps in order:
- Intercept short flags and parse. Short aliases (
-h,-V) are intercepted before subcommand dispatch and before flag parsing. If-h(or-help) is found, help is printed to stdout and the process exits0. If-Vis found, the version banner is printed to stdout and the process exits0. Subcommand tokens (validate,stats,mcp-server) and the POSIX--terminator stop the scan:-hafter a subcommand is handled by the subcommand itself. After interception, remaining flags are parsed normally. Unknown flags exit with code1and print a one-line error to stderr (the full help text is not printed on errors).--env-filepath (when provided) is resolved to absolute and exported asSORTIE_ENV_FILE. - Resolve workflow path. Relative paths resolve to absolute against the working directory.
- Initialize logging. Structured output to stderr. Uses
--log-leveland--log-formatflags when set; otherwise defaults toINFOlevel withtextformat for the duration of startup. - Load and watch workflow file. Start a filesystem watcher for dynamic config reload. During config parsing,
SORTIE_*overrides are applied, including.envfile loading when enabled. - Preflight validation. Verify
tracker.kindis registered,agent.kindis registered, required API keys are present, active/terminal state lists are non-empty, adapter-specific config validation passes (when declared), and the workspace root is writable. Failure exits with code1. No database file is created on disk. - Resolve log level and format. When
--log-levelwas not set, checklogging.levelfrom the workflow config. When--log-formatwas not set, checklogging.formatfrom the workflow config. If either differs from the startup default, re-initialize the logger before emitting the startup message. - Resolve server port and host. The
--portand--hostflags overrideserver.portandserver.hostfrom config. An invalid value exits1. No socket is bound yet. - Build tracker adapter. Resolve and build the tracker adapter for
tracker.kind, gathering its configuration, withuser_agentset tosortie/<version>. - Open SQLite database. Path from
db_pathconfig field, or.sortie.dbadjacent to the workflow file. Relative paths resolve against the workflow file’s directory, not the working directory. - Run schema migrations. Applied automatically on every startup.
- Restore persisted state. Load pending retry entries and rebuild their timers from the stored
due_at, load the cumulative token and runtime totals, and load the park records that hold issues out of dispatch. A failure to read the totals or the park records is logged as a warning and startup continues with none. - Build agent adapters. Build the adapter for the default
agent.kind, then eagerly build every other registered kind so dispatch-rule routing resolves without building one per issue. A non-default kind that fails to build is logged at warn level and skipped. - Clean terminal workspaces. Query tracker for states of existing workspace directories; remove those in terminal states. Only directories whose state comes back known and terminal are removed, and if the directory listing or the tracker read fails, Sortie logs a warning and cleans nothing on this pass. No age-based removal runs here: the
workspace.retention_daysbound belongs to the periodic sweep, whose first pass falls 60 poll ticks after step 16. - Recover pending reactions. Rebuild the pending reaction set from recent run history so a restart does not lose a watch that was in flight. A failure here is logged as a warning and startup continues.
- Bind the HTTP listener. Binds to the host and port resolved in step 7 when the server is enabled. A conflict on an implicitly defaulted port degrades to running without the server; a conflict on an explicitly requested port exits
1. - Enter poll-dispatch-reconcile loop. First poll tick fires immediately. Blocks until signal.
When --dry-run is set, execution diverges after step 8. Steps 9–16 are skipped entirely. Instead, Sortie fetches candidate issues from the tracker, evaluates dispatch eligibility, logs the results, and exits. No database file is created, no agent adapter is built, and no HTTP server starts.
Any step that fails prints a diagnostic to stderr and exits with code 1.
Exit codes
| Code | Meaning |
|---|---|
0 | Clean shutdown (signal received), help output (-h, --help), version output (-V, --version, -dumpversion), successful validate, successful --dry-run, a stats report (including one whose range matched no runs), or clean mcp-server shutdown. |
1 | Startup failure: unknown flag, too many arguments, missing or unreadable workflow file, invalid configuration, preflight validation failure, or database open/migration error. Also used by validate for any validation failure, by --dry-run when the tracker fetch fails, by stats for usage and load errors, and by mcp-server for startup or runtime errors. |
Sortie does not define exit codes above 1. Agent subprocess failures, tracker errors, and runtime exceptions are handled internally through the retry and reconciliation mechanisms. They do not affect the process exit code.
Signals
| Signal | Behavior |
|---|---|
SIGINT | Initiates graceful shutdown. |
SIGTERM | Initiates graceful shutdown. |
Both signals trigger the same sequence:
- Stop accepting new dispatches.
- Cancel all running worker contexts.
- Wait for workers to exit. The ceiling derives from
agent.stop_grace_ms: 50 seconds at the default5000, and one second longer for each extra second of stop grace. Worker results are processed the same way as a normal exit during drain: run history is persisted and retry entries are recorded. Refresh signals arriving during this window are discarded. - Wait up to 35 seconds for the reaction triage runs still in flight. Cancellation has already terminated their process groups, so this wait returns promptly in practice.
- Wait up to 35 seconds for the detached tracker calls (comments, labels) still in flight.
- Cancel pending retry timers.
- Shut down the HTTP server with a 5-second timeout for in-flight responses.
- Close the SQLite database.
- Exit with code
0.
During drain, /livez and /readyz return 503, and POST /api/v1/refresh returns 409 Conflict with queued: false instead of 202 Accepted.
A second SIGINT or SIGTERM during shutdown ends every drain still waiting at once, and shutdown continues from the step after it. Each abandoned drain logs a warning naming what was given up. Later signals do nothing.
Logging
All log output goes to stderr. The default format is structured key=value text:
time=2026-03-26T14:30:01.271+00:00 level=INFO msg="sortie starting" version=<version> workflow_path=/opt/sortie/WORKFLOW.md server_addr=127.0.0.1:7678
time=2026-03-26T14:30:01.298+00:00 level=INFO msg="database path resolved" db_path=/opt/sortie/.sortie.db
time=2026-03-26T14:30:01.304+00:00 level=INFO msg="sortie started"
time=2026-03-26T14:30:01.305+00:00 level=INFO msg="pending reaction recovery completed" enabled=false candidates=0 skipped=0 success=true
time=2026-03-26T14:30:01.307+00:00 level=INFO msg="http server listening" addr=127.0.0.1:7678When --log-format json is active (or logging.format: json in the workflow file), each line is a JSON object:
{"time":"2026-03-26T14:30:01.271843915+00:00","level":"INFO","msg":"sortie starting","version":"<version>","workflow_path":"/opt/sortie/WORKFLOW.md","server_addr":"127.0.0.1:7678","log_format":"json"}
{"time":"2026-03-26T14:30:01.298104220+00:00","level":"INFO","msg":"database path resolved","db_path":"/opt/sortie/.sortie.db"}
{"time":"2026-03-26T14:30:01.304552031+00:00","level":"INFO","msg":"sortie started"}
{"time":"2026-03-26T14:30:01.307918664+00:00","level":"INFO","msg":"http server listening","addr":"127.0.0.1:7678"}JSON output uses RFC 3339 timestamps with nanosecond precision, uppercase level strings, and emits all structured attributes as top-level keys. Each record is a single line terminated by \n.
Context fields
Different log lines carry different context fields depending on scope:
| Field | Present on |
|---|---|
version | Startup |
workflow_path | Startup |
server_addr | Startup (only when the HTTP server is enabled and this is not a dry run). Carries host:port, not the port alone. |
log_level | Startup (only when the effective level is not INFO) |
log_format | Startup (only when the effective format is not text) |
db_path | Database initialization |
issue_id | Dispatch, worker lifecycle, retry, reconciliation |
issue_identifier | Dispatch, worker lifecycle, retry, reconciliation |
session_id | Agent events, worker lifecycle |
error | Error and warning lines |
next_attempt, delay_ms | Retryable worker failures (WARN level) |
tool, duration_ms, outcome | Tool call completions. A failed call adds tool_error. |
addr | HTTP server start |
Stdout is used for help output (-h, --help), version output (-V, --version, -dumpversion), validate --format json diagnostics, the stats report in both formats, and mcp-server JSON-RPC responses. All other output goes to stderr, including the validate text diagnostics and the stats warnings.
Version injection
Three variables carry build identity: Version, Commit, and Date. They default to dev, unknown, and unknown when the binary is built without linker flags. Builds inject them at compile time:
go build -ldflags "-s -w -X main.Version=<version> -X main.Commit=<sha> -X main.Date=<date>" -o sortie ./cmd/sortieThe Makefile sets all three: Version from git describe --tags --always --dirty with any leading v stripped, Commit from git rev-parse HEAD, and Date from the current UTC date.
make buildThe injected version appears in:
--versionand-dumpversionoutput- The
versionfield in startup log lines - The
sortie_build_info{version="..."}Prometheus metric - The HTTP dashboard and
/readyzresponse - The
User-Agentthe tracker adapter sends, assortie/<version>from the orchestrator andsortie-mcp/<version>from the MCP server
Files
| File | Location | Purpose |
|---|---|---|
| Workflow file | workflow-path argument or ./WORKFLOW.md | Configuration and prompt template. Watched for changes after startup. |
| SQLite database | db_path or .sortie.db next to the workflow file | Run history, retry entries, aggregate metrics, session metadata. Created automatically if absent. |
The database path resolves against the workflow file’s directory, not the process working directory. A workflow file at /opt/sortie/WORKFLOW.md with no db_path configured creates /opt/sortie/.sortie.db regardless of where sortie was launched.
Usage
# Default workflow file in working directory
sortie
# Explicit workflow path
sortie /opt/sortie/WORKFLOW.md
# Enable HTTP server on port 8080
sortie --port 8080
# Run with verbose debug output
sortie --log-level debug
# Emit JSON-formatted logs (for log aggregation systems)
sortie --log-format json
# Combine path, port, log level, and log format
sortie --log-level debug --log-format json --port 8080 /opt/sortie/WORKFLOW.md
# Print full version banner
sortie --version
# Print bare version string (for scripts)
sortie -dumpversion
# Validate the default workflow file
sortie validate
# Validate a specific file
sortie validate /opt/sortie/WORKFLOW.md
# Validate with JSON output (for CI pipelines)
sortie validate --format json ./WORKFLOW.md
# Summarize every recorded run
sortie stats
# Summarize a bounded range (inclusive start, exclusive end)
sortie stats --since 2026-07-01 --until 2026-08-01 /opt/sortie/WORKFLOW.md
# Summarize the last 24 hours as JSON (for your own metrics store)
sortie stats --since 24h --format json ./WORKFLOW.md
# Dry-run: verify tracker connectivity and dispatch math without starting agents
sortie --dry-run
# Dry-run with explicit workflow path
sortie --dry-run /opt/sortie/WORKFLOW.md
# Dry-run with debug output for full candidate detail
sortie --dry-run --log-level debug
# Load config overrides from a .env file
sortie --env-file /etc/sortie/prod.env
# Combine .env file with explicit workflow path and port
sortie --env-file /etc/sortie/prod.env --port 8080 /opt/sortie/WORKFLOW.md
# Help text
sortie --help
sortie -h
# Short version alias
sortie -V
# Subcommand help
sortie validate -h
sortie mcp-server --help
# Start MCP stdio server (launched by agent runtime, not run manually)
sortie mcp-server --workflow /opt/sortie/WORKFLOW.mdSee also
- WORKFLOW.md configuration reference: all config fields
- Environment variables reference:
SORTIE_*config overrides, agent runtime vars,$VARindirection, hook env - HTTP API reference: JSON API endpoints and response shapes
- Dashboard reference: built-in HTML monitoring dashboard
- Prometheus metrics reference: metric names, types, labels, and PromQL examples
Was this page helpful?