Skip to content

CLI Reference

apiary <command> [flags]

Global flags, accepted by every command:

Flag Default Description
--config apiary.yaml Config file path
--env-file .env Path to a .env file (silently skipped if not found)
--verbose off Verbose (debug) output

Daily driving

apiary run

Start the daemon: poll sources, route tasks, dispatch agents. Runs until interrupted.

apiary run [--debug] [--once] [--dry-run] [--source id] [--worker id] [--profile name]
Flag Description
--debug Verbose DEBUG logging: per-task prompt, live agent conversation, and routing decisions (view in the dashboard)
--once Poll once, dispatch all matching tasks, then exit (exit code 4 if any run failed)
--dry-run Connect to sources and match tasks, but never invoke a runner
--source Restrict to a single source id
--worker Restrict to a single worker id
--profile Activate a named runner profile from profiles.<name>

apiary dashboard

Open the terminal dashboard — a live view of tasks, agents, and logs. Run it in a second terminal, from the same directory as apiary run (they share the .apiary/ state).

apiary dashboard [--view list|board]

--view board opens on the board — tasks grouped into columns by state — instead of the list. Press b to switch either way at any time, or set settings.dashboard.default_view to make the choice stick.

apiary status

Show daemon status and active runs.

apiary status [--watch]    # --watch refreshes every 2 seconds

The status payload includes durable job counts and each registered worker's readiness, drain state, capacity, active jobs, and last heartbeat.

apiary worker

Connect a separate worker process to a control plane. The worker loads the same runner and workflow configuration but does not poll sources.

apiary worker --control-plane https://apiary.example \
  --token "$APIARY_WORKER_TOKEN" --id build-01 --pool build --capacity 4

Use repeatable --label and --capability flags to advertise scheduling attributes. On SIGTERM/SIGINT the worker enters drain mode, stops claiming, lets active jobs finish while extending their leases, then becomes unready.

apiary validate

Validate apiary.yaml — schema, reference integrity (agents → runners, steps → agents, goto targets), workflow graph rules, and condition expression syntax (if:, reject_when:, split branches, ${{ }} joins). Local subworkflow uses references are resolved recursively relative to their declaring file; typed contracts, output mappings, and reference cycles are validated before connectivity checks run.

apiary validate [--connectivity]    # --connectivity also tests each source

Inspecting work

apiary cells

List the tasks currently visible to Apiary, before routing.

apiary cells [--source id] [--unmatched] [--limit n]

--unmatched shows only items matching no trigger — useful when an issue isn't being picked up.

apiary instances

List workflow instances, or show one in step-level detail.

apiary instances [--workflow id] [--state s] [--limit n] [--json]
apiary instances <instance-id>
apiary instances <instance-id> --cancel

States: queued, running, blocked, done, failed, canceled.

A blocked instance carries a reason saying what it is waiting on — approval, ci, dependency — or interrupted when a daemon died mid-run and left it orphaned. Only the last of those is resumable; the others are alive and will continue on their own.

--cancel stops one running instance: its in-flight step is cancelled and the instance is marked interrupted, along with any queued or leased dispatch job for the same task and workflow. The source item is left alone — no labels are stripped and no state is reset — so unlike apiary restart nothing is re-dispatched, and the run can be picked up later with apiary resume <instance-id>.

Use it when a task has more than one live instance and you want to keep one of them: restart acts on the whole cell, and stopping the run from the dashboard targets the cell too.

apiary profile

Show where a run's wall clock actually went — per step, plus the slowest individual calls across the whole run:

apiary profile <instance-id> [--json]
STEP               TOTAL     THINK     WRITE     TOOLS     OTHER
plan               3m0s      67%       33%       —         —
implement          1h22m42s  6%        24%       63%       7%
  63% with background work outstanding (overlaps the above)

Slowest calls
  9m54s      background  implement       workflow:verify  ·  run the full test suite
  8m0s       tool        implement       Bash  ·  ./gradlew test

Use it before tuning a slow step: the fix for a thinking-heavy step (model or effort), a writing-heavy one (a tighter prompt) and a wait-heavy one (fix the thing being waited on) have nothing in common, and guessing wrong costs another full-length run to disprove.

Steps recorded before this data existed, and runners that stream no events, report as not measured rather than as a breakdown of zeros. See Wall-clock attribution for what each bucket covers and why the background figure overlaps the others.

--json emits the full breakdown, for the analysis this table does not do.

apiary task

Show a task's full workflow history — all instances, steps, and scoped logs. Resolve by internal task id, or by source item:

apiary task <internal-task-id>
apiary task --source github --item 1948
apiary task <id> --json

apiary improve

Analyse the execution history and propose configuration changes. Standalone — it opens the database read-only, works with the daemon stopped, and takes no dispatch slot when it is running.

apiary improve                          # analyse; print findings and a diff
apiary improve --effort deep --since 30d
apiary improve --apply                  # write the accepted changes
apiary improve --dump-evidence          # just the metrics, as JSON, no model
apiary improve --dump-prompt            # the composed prompt, no model

The evidence is computed entirely in Go, so --dump-evidence needs no advisor and costs nothing. Everything else needs an agent to reason with: --advisor, an ad-hoc --runner/--model pair, settings.improve.agent, or an agent named improver.

Past runs are recorded so their effect can be measured later:

apiary improve history
apiary improve show <run-id>
apiary improve effect <run-id>

See Self-Improvement for the evidence pack, effort levels, the validation gate and what applying does.

apiary export usage

Export per-attempt usage and cost as a file a spreadsheet can open. One row per runner attempt from task_executions, joined to the workflow instance it ran in, so spend can be pivoted by workflow, step, model, agent or ticket without querying .apiary/apiary.db by hand. Read-only, no migration, safe with the daemon running.

apiary export usage -o usage.csv                       # all history, CSV
apiary export usage --since 30d --format json          # last 30 days to stdout
apiary export usage --since 2026-09-01 --until 2026-09-02 --status failed
apiary export usage --workflow implementation --model claude-fable-5-1
apiary export usage --include-transcripts -o full.csv  # prompts and outputs too
Flag Description
--format csv (default) or json
-o, --output Write to this file; default stdout. Written to a temp file and renamed on success, so an interrupted export never leaves a truncated file
--since Window start on started_at: a duration (7d, 24h), a date (2026-09-01, midnight UTC) or RFC3339. Default: all history
--until Window end, same forms. Default: now
--workflow, --agent, --model, --source, --status Filters, each repeatable; a repeated flag means "any of". --model is an exact match. Status values: success, failed, running, pending
--include-transcripts Add input_prompt and output_text. Off by default: they dominate the file size and are not needed for cost analysis
--include-slow-tools Add slow_tools, the per-attempt slowest tool calls as a JSON string

Rows are ordered oldest first. Attempts that never started (no started_at) are excluded unless --status pending is given, in which case they come last. A database written by an older Apiary exports the columns it has and leaves the rest empty.

Columns, in order. Timestamps are RFC3339 UTC; cost_usd has six decimals; credit_exhausted is true/false; an empty cell (CSV) or null (JSON) is a value the database does not hold.

Column Meaning
execution_id Row id in task_executions
task_id, task_number, title, task_url The task at dispatch time; task_number is the human reference (ERP-42, #1948)
source_id, workflow_id, workflow_instance_id, instance_state, step_id Where the attempt ran. Empty for attempts from before the workflow engine
agent_id, runner, model, attempt, status, failure_kind, credit_exhausted Who ran it and how it ended
started_at, completed_at, duration_ms Wall clock
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, total_tokens, num_turns, num_tool_calls, cost_usd Usage as reported by the runner
time_thinking_ms, time_writing_ms, time_model_ms, time_tool_wait_ms, time_other_ms, time_background_ms Wall-clock attribution (see apiary profile)
error_message Single line in CSV
slow_tools, input_prompt, output_text Opt-in, see the flags above

Three questions the export answers in a spreadsheet:

  • Spend by workflow. Pivot cost_usd by workflow_id, then by step_id within it. The step that dominates is the one to look at with apiary improve.
  • Duplicate dispatches. Group by task_number and step_id and count rows. A ticket with the same step run many times in a short window is a retry loop or a dedup fault, and the timestamps show which.
  • One ticket's full cost. Filter task_number, sum cost_usd, and read attempt and failure_kind down the rows to see where the money went.

The column list is a contract: columns are appended, never removed or reordered, so a saved spreadsheet keeps working across upgrades.

Intervening

apiary approvals / approve / reject

Answer an approval step that has parked its workflow. See Human-in-the-loop approvals.

apiary approvals                                   # what is waiting
apiary approvals --status all --json               # every request, machine-readable
apiary approvals <request-id>                      # one request, with the fields it expects

apiary approve <request-id>                        # prompts for any declared fields
apiary approve <request-id> --field strategy=canary --field ticket=OPS-482
apiary reject  <request-id> --comment "needs a design doc first"

Values submitted with --field reach the workflow as ${{ memory.<field> }}, which is how a choice field decides what runs next. On a terminal, fields left unsupplied are prompted for; off one, a missing required field is an error rather than a prompt, so a scripted approval never hangs. A rejection never collects fields.

Exit codes: 0 resolved · 3 recorded but the gate still waits (a quorum gate) · 4 unknown or already answered · 1 transport or validation.

apiary resume

Replay a failed or interrupted workflow instance as a new immutable descendant. Cached steps are copied into the new attempt (their outputs and memory restored), then execution continues. The source attempt is never modified.

apiary resume <instance-id> [--yes]
apiary resume --workflow <workflow-id> [--yes]   # most recent failed/interrupted instance
apiary resume <instance-id> --from implement     # rerun this step and later steps
apiary resume <instance-id> --definition original # use the snapshotted definition

--definition current is the default. --definition original requires an instance created after workflow snapshots were introduced.

Compare any two attempts step by step:

apiary instances compare <before-id> <after-id>
apiary instances compare <before-id> <after-id> --json

The comparison reports state, input/output changes, token and cost deltas, and model/runner changes. Use apiary run --dry-run to evaluate source matching and routing without starting an agent.

apiary dispatch

Start one named workflow right now, whether or not anything would have triggered it. Same action as W in the dashboard.

apiary dispatch triage --item PSP-199     # run `triage` on a source item
apiary dispatch nightly-audit             # run standalone, with no source item
apiary dispatch report --input scope=q3   # standalone, with structured input

A manual run skips every gate the poll loop applies:

Skipped Meaning
the trigger's match block states, labels, types, title_regex, source — the item does not have to look like something the trigger would select
exclusive-trigger suppression a higher-priority exclusive: true trigger does not claim the task away
the live-instance guard a workflow already running on the task starts a second concurrent instance
once: true a spent one-shot workflow runs again
the consecutive-failure cap settings.max_attempts does not block the run

Every bypass is printed, so none of them is silent:

✓ Started workflow triage on PSP-199 (10042) in jira
  ! this workflow was already running on the task — a second instance is now live
  ! bypassed guard: trigger match (state/labels/filters)
  ! bypassed guard: exclusive trigger suppression
  ! bypassed guard: active instance / in-flight
  ! bypassed guard: once
  ! bypassed guard: consecutive-failure cap
  → follow it with: apiary instances

With --item the run binds a source item and behaves exactly like an automatic dispatch of that workflow: it sees the item's live labels and state, and side effects (comments, state locks, sub-issues) write back to the source. The value is the item's human reference (PSP-199, #1953) or its cell id — the same vocabulary apiary restart accepts, matched case-insensitively with a leading # ignored.

The item does not have to be one apiary is already tracking. A reference it has never polled — a ticket outside the source's filters, in an excluded state, or created since the last tick — is fetched from the source and bound on the spot, exactly as a poll would have bound it:

apiary dispatch triage --item PSP-199                 # one source: inferred
apiary dispatch triage --item PSP-199 --source jira   # several: name it

--source is optional when exactly one configured source can fetch a single item. When several can, apiary asks rather than guesses — fetching a different project's PSP-199 and running a workflow over it is not a mistake anyone catches quickly:

Error: dispatch failed: source required: 2 sources could hold it (jira, my-repo)
  — pass --source to say which

--source also disambiguates a reference that exists in more than one source, which previously required looking up the cell id by hand.

A reference no source can produce an item for still fails and creates nothing. The source that answered is named in the output and on the binding — with Jira the key you typed (PSP-199) and the cell id it resolved to (the opaque numeric issue id) are both reported, since only one of them appears in Jira's UI.

Without --item the workflow runs standalone on a fresh internal task with no source binding. Nothing writes back to a source: comment and state-lock steps are no-ops and sub-issues cannot be materialized. Pass --input key=value (repeatable) for values the steps read as ${{ input.<key> }}, and --title to name the task.

This is what makes a trigger-less workflow useful: a workflow with no trigger: block never starts on its own — apiary validate warns about exactly that — but apiary dispatch <id> runs it on demand.

Because guards are skipped rather than overridden, nothing in the daemon prevents two runs of the same workflow on the same task from racing. Where that matters — steps that mutate the same branch, comment, or item state — start the second run after the first settles.

apiary restart

Force-restart a stale task: cancel its running dispatch, cancel its queued jobs, interrupt its non-terminal instances, strip its control labels — then re-route the item and dispatch it immediately, without waiting for the next poll. Same action as R in the dashboard.

apiary restart CDT-123     # Jira key
apiary restart '#1953'     # GitHub issue number (quote it — # starts a comment)
apiary restart 1953        # the cell id also works

The argument is the item's human reference (a Jira key, a GitHub issue number) or its cell id (the raw source item id). The reference is usually what you want: on Jira the cell id is the opaque numeric issue id, which appears in no interface — the key is the only thing you can see.

Matching ignores case and a leading #. An exact cell id always wins over a number. A reference that matches items in two different sources is rejected rather than guessed at, and names both candidates so you can restart the cell id directly. An argument that resolves to nothing fails and touches nothing; if it is an internal task id, the error names the item to use instead.

Restart overrides the once and failure-cap (settings.max_attempts) guards, since a task wedged behind either is what restart exists for; any override is printed. It does not override the in-flight guard, so a live workflow is never run twice. The command reports what it dispatched:

✓ Restarted CDT-123 (10042) (control labels cleared)
  ! overrode guard: implement (failure cap)
  → dispatched 1 workflow(s): implement

The item is echoed as reference (cell id) when the two differ, so it is always clear which item was acted on. dispatched 0 means the cleanup ran but no workflow matches the item in its current state — usually a label the triggers don't match.

apiary delete

Delete a task and all its workflow instances from the database.

apiary delete <task-id> [--yes]
apiary delete --source github --item 1953 [--yes]

apiary clear

Reset the project's SQLite database (asks for confirmation; --yes skips).

Setup & service

apiary init

Scaffold a starter apiary.yaml in the current directory.

apiary plugins

Find, install and inspect out-of-process plugins. The registry half (search, info, install, upgrade, uninstall) talks to a plugin index; the rest reports on what is already on disk.

apiary plugins search [query] [--capability source]
apiary plugins info <id>[@version]
apiary plugins install <id>[@version] [--dir path] [--yes] [--sha256 digest]
apiary plugins upgrade <id> [--rollback]
apiary plugins uninstall <id> [--force]
apiary plugins list
apiary plugins inspect <id>
apiary plugins validate
Command Description
search List registry entries matching a query, optionally filtered by capability
info One listing in full: capabilities, repository, releases, what CI's conformance run found, and whether it can be installed on this host
install Resolve, verify digests, validate the manifest, print what it will place and what access it declares, then commit it atomically. Prints the plugins: snippet — it never edits apiary.yaml, and never enables anything
upgrade The same checks, then swap; keeps one generation as <id>.bak, restores it if the new copy fails to validate. --rollback restores it on demand
uninstall Remove an installed plugin directory. Refuses while it is enabled in apiary.yaml unless --force
list What is installed, with each plugin's configured state
inspect Print one installed manifest as JSON
validate Re-check installed manifests, enabled instances' config, and pinned executables

Registry commands accept --registry <url> for a one-off index and --offline to use the cached one. A plugin runs with the daemon's OS permissions; a listing is reviewed, not endorsed. See Registries and mirrors for pinning a signing key or turning the registry off.

apiary service

Run the daemon for the current project as a per-user background service — a launchd LaunchAgent on macOS (~/Library/LaunchAgents/<name>.plist), a systemd user unit on Linux (~/.config/systemd/user/<name>.service) — so it starts at login and restarts after a crash:

cd /path/to/project        # the directory holding apiary.yaml and .env
apiary validate            # a config that cannot load would just respawn
apiary service install
apiary service start
apiary service status      # running | stopped | not installed
apiary service restart
apiary service stop
apiary service uninstall

The service manager starts the daemon with none of your shell's context, so install captures what apiary run needs and writes it into the definition:

Captured Default Override
Config file, as an absolute path (run --config <path>) apiary.yaml resolved from the current directory --config
Working directory — .env is loaded relative to it the current directory --workdir
PATH, which must resolve the agent CLIs (claude, gh, codex, …). The service manager's default PATH has no nvm, Homebrew or ~/.local/bin the current $PATH, de-duplicated --path
The apiary binary the running binary --executable — use a stable path (e.g. /opt/homebrew/bin/apiary) if the running one is version-specific
.env file the daemon's default, .env in the working directory --env-file
Runner profile none --profile
Drain window before the service manager kills the daemon 10m --stop-timeout

Re-run apiary service install --force after moving the project, changing your PATH, or to change any of the above; it stops the running service first.

The service runs as you, never as root: agent CLIs need your HOME, keychain and credentials, and install refuses to run under sudo (see refuse_root). It restarts only after an unsuccessful exit, throttled to once every 30 seconds — a clean stop stays stopped. On macOS the daemon's stdout/stderr go to .apiary/logs/<name>.{out,err}.log; on Linux, to the journal (journalctl --user -u <name>). On Linux, run loginctl enable-linger $USER once if the daemon should keep running while you are logged out.

stop, restart and uninstall wait for the graceful drain: the service manager sends SIGTERM, the daemon finishes its active runs before exiting — which can legitimately take minutes — and the command returns only once the process is gone (--timeout, default 11m). After --stop-timeout the service manager kills whatever is left.

To run several projects on one machine, give each its own name and pass it to every subcommand: apiary service --name apiary-erp install.

Windows is not supported: a Windows Service must speak the service-control protocol, which apiary run does not. Run apiary run under Task Scheduler or NSSM instead.

Migrating from the old apiary-dispatcher install

Releases up to v0.24.1 installed a system-level service named apiary-dispatcher (/Library/LaunchDaemons/apiary-dispatcher.plist on macOS, /etc/systemd/system/apiary-dispatcher.service on Linux). It never worked: the definition had no arguments, so it ran a bare apiary — which prints help and exits — as root, in a respawn loop, and apiary service start failed with Expecting a LaunchAgents path / Load failed: 5. apiary service install, start and status print a warning while that file exists. Removing it needs root, so apiary never does it for you:

# macOS
sudo launchctl bootout system/apiary-dispatcher
sudo rm /Library/LaunchDaemons/apiary-dispatcher.plist

# Linux
sudo systemctl disable --now apiary-dispatcher
sudo rm /etc/systemd/system/apiary-dispatcher.service
sudo systemctl daemon-reload

Then run apiary service install from the project directory, without sudo.

apiary update

Update apiary to the latest GitHub release in place:

apiary update          # download, verify the checksum, and replace the binary
apiary update --check  # only report whether a newer version exists

Downloads are validated against the release's checksums.txt before the swap. Installs managed by Homebrew or Scoop are detected and redirected to brew upgrade --cask apiary / scoop update apiary instead of self-updating. After an update, restart the daemon to pick up the new version: apiary service restart if it runs as a service; for a foreground apiary run, stop it with Ctrl+C (or SIGTERM), wait for it to drain, and start it again — apiary service commands do not affect a daemon you started by hand.

Interactive commands also check for a new release at most once every 24 hours and print a short notice when one is available. Set APIARY_NO_UPDATE_CHECK=1 to disable the check.

apiary migrate

Apply pending database data migrations.

apiary migrate

Schema creation — tables, indices, new columns — happens automatically whenever any command opens the database, so this command is not part of a normal upgrade. The daemon runs the data migrations itself at startup; apiary migrate exists for the operator who would rather apply them deliberately, with the daemon stopped and a copy of the database taken.

Stop the daemon first — the command refuses to run while one is answering on the control socket, and tells you so. The migrations rewrite rows, and one of them recreates a table, so a concurrent writer can lose a row that lands mid-rebuild. Read-only commands (apiary dashboard, apiary memory) never run them, precisely so that opening the dashboard cannot rewrite data underneath a running hive.

Every step is idempotent — running it on an already-migrated database does nothing.

apiary version

Print the version.

Environment variables

Variable Description
GITHUB_TOKEN GitHub source polling + write fallback (see GitHub source); also raises the API rate limit for apiary update
PLANE_API_KEY Plane source API key (see Plane source)
APIARY_NO_UPDATE_CHECK Disable the daily update-check notice

Any variable referenced as ${VAR} in the config must be set in the daemon's environment or in the auto-loaded .env file.