Apiary Guide
Apiary is a task-driven agent orchestration harness — it polls issue trackers (GitHub, Plane, etc.), routes work to LLM agents (Claude, OpenCode), and tracks every execution attempt in a local SQLite database.
Quick Start
1. Install
# From source
git clone https://github.com/orlandoburli/apiary
cd apiary/src && go build -o apiary ./cmd/apiary
2. Create a config file
Minimal apiary.yaml:
version: "1"
runners:
- id: claude
type: cli
provider: claude
config:
args: ["--output-format", "stream-json", "--verbose"]
sources:
- id: my-repo
type: github
config:
repo: my-org/my-repo
api_key: ${GITHUB_TOKEN}
poll_interval: 120s
filters:
states: [open]
labels: [ai-ready]
agents:
- id: engineer
description: "Implements tasks"
runner: claude
model: claude-sonnet-4-6
workflows:
- id: implement
trigger:
priority: 10
match:
source: my-repo
steps:
- id: run
agent: engineer
on_complete:
set_state: closed
settings:
concurrency: 2
log_level: info
3. Run
Architecture
┌──────────────┐ poll ┌──────────────────┐ dispatch ┌────────────┐
│ Issue Source │ ────────>│ │ ───────────> │ Agent │
│ (GitHub, │ │ Dispatcher │ │ (Claude, │
│ Plane, ...) │ <────────│ (apiary run) │ <─────────── │ OpenCode) │
│ │ write │ │ result │ │
└──────────────┘ │ ┌─────────────┐ │ └────────────┘
│ │ Router │ │
│ │ (triggers) │ │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ SQLite store │ │
│ └─────────────┘ │
└──────────────────┘
│
▼
┌──────────────┐
│ Dashboard │
│ (read-only) │
└──────────────┘
Configuration Reference
Config lookup order:
apiary.yamlin current directory.apiary/apiary.yamlin current directory
State lives alongside the config: .apiary/apiary.db (SQLite), .apiary/logs/, .apiary/apiary.sock (IPC).
version
Currently only "1".
runners
Define how agents execute code. Each runner has a type + provider pair.
runners:
# Claude CLI — requires `claude` binary on PATH
- id: claude
type: cli
provider: claude
config:
args: ["--output-format", "stream-json", "--verbose"]
# Anthropic API — requires ANTHROPIC_API_KEY
- id: claude-api
type: cli
provider: anthropic
config:
provider: anthropic
base_url: https://api.anthropic.com/v1
api_key: ${ANTHROPIC_API_KEY}
# OpenCode CLI — requires `opencode` binary on PATH
- id: opencode-go-cli
type: cli
provider: opencode
config:
mode: cli
subscription: go
binary: opencode
agent: backend-dev
args: ["--output-format", "stream-json", "--verbose"]
# OpenCode API
- id: opencode-go-api
type: opencode-api
config:
subscription: go
api_key: ${OPENCODE_GO_API_KEY}
models:
- opencode-go/deepseek-v4-pro
- opencode-go/minimax-m3
OpenCode-specific flags
| Flag | Usage |
|---|---|
model_flag |
--model (default) |
prompt_flag |
--prompt (default) |
turns_flag |
--max-turns (default) |
agent_flag |
--agent (default) |
OpenCode CLI requires run subcommand + positional prompt (prompt_positional: true is the default).
default_runner
Runner used by agents that don't specify one.
sources
Where Apiary gets its work items (Cells):
sources:
- id: my-repo
type: github
config:
repo: my-org/my-repo
api_key: ${GITHUB_TOKEN}
poll_interval: 120s
filters:
states: [open]
labels: [ai-ready]
GitHub source
| Config field | Required | Default | Description |
|---|---|---|---|
repo |
yes | — | owner/repo |
api_key |
no | — | PAT (repo scope classic, Issues: Read & Write fine-grained) |
base_url |
no | https://api.github.com |
GHES support |
Operations:
| Operation | API call |
|---|---|
| Poll | GET /repos/{owner}/{repo}/issues?state=open (all open, no since filter) |
| Acknowledge | POST /repos/{owner}/{repo}/issues/{n}/labels (adds in-progress) |
| WriteResult | POST /repos/{owner}/{repo}/issues/{n}/comments |
| SetState | PATCH /repos/{owner}/{repo}/issues/{n} |
| AddLabels | PATCH /repos/{owner}/{repo}/issues/{n} (replaces labels) |
Key behavior:
- Poll returns ALL matching issues every cycle.
inFlightmap prevents re-dispatch of already-running tasks. - Pull requests are filtered out during polling (GitHub's
/issuesendpoint returns PRs too, but they are not work items). GitHub Cells are alwaysType: "issue".
Plane source
- id: project-erp
type: plane
config:
workspace: project-erp
project: <uuid>
api_key: ${PLANE_API_KEY}
base_url: <instance-url>
poll_interval: 60s
filters:
states: [backlog, todo, in progress]
.env auto-load
Apiary loads .env from the config file directory at startup via loadDotEnv(). Already-set env vars take priority (they are not overwritten).
agents
Workers that execute tasks. Each agent links a runner to a model and optionally carries a soul file and skills.
agents:
- id: engineer
description: "Implements tasks following project conventions"
soul_file: .apiary/souls/engineer.md
runner: claude
model: claude-sonnet-4-6
skills: [git-workflow, gitnexus-codebase]
# Per-agent GitHub identity (overrides source-level api_key for write ops)
source_token: ${GITHUB_TOKEN_ENGINEER}
source_email: engineer@company.com
source_name: Engineer Bot
# Max concurrency for THIS agent (overrides global settings.concurrency)
max_workers: 2
# Rate-limit failover: if the primary runner is rejected by a provider
# usage limit (e.g. Claude's 5-hour session limit), retry on the next
# non-paused fallback runner/model instead of stalling. See Rate limits
# & resilience below.
fallbacks:
- {runner: opencode-go, model: opencode-go/deepseek-v4-pro}
- {runner: cursor, model: composer-2.5-fast}
Which tasks reach this agent is decided by a workflow trigger, not the
agent itself.
| Field | Required | Description |
|---|---|---|
id |
yes | Unique agent identifier |
description |
no | Human-readable description |
soul_file |
no | Path to agent system prompt / persona file |
runner |
no | Runner ID (uses default_runner if omitted) |
model |
no | Model name passed to the runner |
skills |
no | List of skill names for agent context |
source_token |
no | Override source API key for this agent's write operations |
source_email |
no | Git author email (set as GIT_AUTHOR_EMAIL, GIT_COMMITTER_EMAIL in runner env) |
source_name |
no | Git author name (set as GIT_AUTHOR_NAME, GIT_COMMITTER_NAME in runner env) |
max_workers |
no | Per-agent concurrency cap (default: global settings.concurrency) |
fallbacks |
no | Ordered list of {runner, model} to fail over to when the primary runner hits a provider rate limit. runner must be a defined runner id; model is optional (empty = that runner's default). See Rate limits & resilience |
env |
no | Agent-scope environment variables (map). Lowest-precedence explicit scope — see Environment variables |
workflows
A workflow fires when its trigger matches a task, then runs its steps in order.
Lower trigger priority is evaluated first. The simplest workflow has a single step —
that one-step form replaces what used to be a route.
workflows:
- id: complex-design
trigger:
priority: 10
match:
source: project-erp
labels: [agent:staff]
steps:
- id: run
agent: staff
on_complete:
set_state: in review
| Field | Description |
|---|---|
trigger.priority |
Lower number = evaluated first |
trigger.match.source |
Source ID to match |
trigger.match.labels |
Task must have ALL these labels |
trigger.match.types |
Task types to match (GitHub tasks are always issue) |
trigger.match.states |
Task states to match |
trigger.match.exclude_label_prefix |
Exclude tasks with labels starting with prefix (e.g. agent:) |
steps[].agent |
Agent that runs this step |
on_complete.set_state |
State to set on source when the workflow succeeds |
on_complete.add_labels |
Labels to add on source when the workflow succeeds |
on_fail |
Hook applied when the workflow fails (same shape as on_complete) |
settings
settings:
concurrency: 2 # Global worker pool size
log_level: info # debug | info | warn | error
state_lock: true # Add "in-progress" label on acknowledge
result_comment: true # Post agent output as comment
max_attempts: 3 # Re-dispatch failure cap per (task, workflow); <=0 disables
| Field | Default | Description |
|---|---|---|
concurrency |
2 | Global worker pool size (informational; real dispatch concurrency is the sum of per-agent max_workers) |
log_level |
info | debug | info | warn | error |
state_lock |
false | Add an "in-progress" label on acknowledge |
result_comment |
false | Post agent output back as a comment |
max_attempts |
3 | Stop re-dispatching a (task, workflow) after this many consecutive failed instances (rate-limited runs don't count). <=0 disables the cap. See Rate limits & resilience |
Rate limits & resilience
Apiary is built around a single dispatch path and a small set of safeguards that keep a saturated provider or a failing task from turning into a runaway, money-burning loop.
Provider rate limits → failover
When a runner is rejected by a provider usage limit — e.g. the Claude CLI emits a
rate_limit_event with status: rejected ("you've hit your session limit") —
Apiary does not treat the empty run as a success. Instead it:
- Pauses that runner type until the limit resets (
resetsAt). Because every Claude agent shares one account, pausing is keyed by runner type, so all Claude agents back off together. - Fails over to the agent's next
fallbacksentry whose runner isn't paused, retrying the same step on that runner/model. While the primary is paused, new steps go straight to the fallback (no wasted, pre-failed call).
agents:
- id: engineer
runner: claude
model: claude-sonnet-4-6
fallbacks:
- {runner: opencode-go, model: opencode-go/deepseek-v4-pro}
- {runner: cursor, model: composer-2.5-fast}
Each attempt is recorded as its own execution, so the dashboard shows the
failover (primary rate-limited → fallback ran). fallbacks load at startup —
changing the chain requires a restart.
Re-dispatch failure cap
A task whose workflow keeps failing would otherwise be re-dispatched on every poll
forever (especially workflows with no on_fail, like escape-hatch routes). The
settings.max_attempts cap is an internal backstop, independent of
source-side labels: after N consecutive failed instances for the same
(task, workflow), Apiary stops re-dispatching it and applies the workflow's
on_fail hook (if any). Rate-limited runs fail over and are not counted; a single
success resets the count. Default 3; set <=0 to disable.
Non-blocking dispatch
Each agent's max_workers slot is acquired inside the dispatch goroutine, not on
the poll-loop thread. A fully-busy agent therefore parks its own runs without
stalling polling or dispatch for any other source or agent.
Dashboard
Terminal UI for watching live state:
| Key | Action |
|---|---|
← / → |
Switch tabs |
Tab / Shift+Tab |
Next / previous tab |
↑ / ↓ |
Move selection / scroll |
Home / End |
Jump to top / bottom |
PgUp / PgDn / Space |
Page up / down |
r |
Refresh |
q / Ctrl+C |
Quit |
Tasks tab
| Key | Action |
|---|---|
s |
Toggle sort direction (asc/desc) |
S |
Cycle to next sort field |
/ |
Open filter bar (type query, Esc to exit, Backspace to delete) |
d |
Task detail view |
Enter / l |
Task logs view |
o |
Open task URL in browser |
R (Shift+R) |
Force restart (with confirmation) |
C |
Clear logs (with confirmation) |
Agents tab
| Key | Action |
|---|---|
d |
Agent detail view |
Enter / l |
Activity list → task logs drill-down |
o |
Open task URL in browser |
In detail view: m cycles model, r cycles runner, w cycles max_workers. Changes persist via IPC socket.
Logs tab
| Key | Action |
|---|---|
w |
Toggle word wrap |
← / → |
Horizontal scroll (when wrap is off) |
Key Concepts
| Term | Description |
|---|---|
| Task | Unit of work from a source (an issue or item) |
| Source | Adapter that polls an external issue tracker and writes results back |
| Agent | An LLM persona that processes tasks |
| Runner | How an agent executes (CLI subprocess or API call) |
| Route | Rule that matches Tasks to Agents |
| Dispatcher | Core loop that polls sources, routes Tasks, dispatches to agents |
| inFlight | Map of task IDs currently being processed — prevents double-dispatch |
| Soul file | System prompt / persona definition for an agent |
| IPC Socket | Unix socket (apiary.sock) for dashboard ↔ dispatcher communication |
Agent Identity
Each agent can have its own GitHub identity for write operations:
source_token— overrides sourceapi_keyfor Acknowledge/WriteResult/SetState/AddLabels (passed through Go context viacontext.WithValue(ctx, source.SourceTokenCtxKey, token)), and is also exported to the agent subprocess asGITHUB_TOKEN/GH_TOKENsoghcommands the agent runs itself authenticate as the agent's own accountsource_email+source_name— injected asGIT_AUTHOR_NAME,GIT_AUTHOR_EMAIL,GIT_COMMITTER_NAME,GIT_COMMITTER_EMAILin the runner's environment
Environment variables
Agent subprocesses inherit the daemon's environment plus an overlay. You can set
arbitrary environment variables at three scopes via an env: { KEY: VALUE } map:
| Scope | Field | Applies to |
|---|---|---|
| Agent | agents[].env |
every step that runs this agent, in any workflow |
| Workflow | workflows[].env |
every step of this workflow |
| Step | workflows[].steps[].env |
only that step |
Precedence (highest wins): STEP > WORKFLOW > AGENT. A key set at step scope overrides the same key at workflow scope, which overrides agent scope.
These three explicit scopes sit above the identity overlay (the git identity
and the source_token → GITHUB_TOKEN/GH_TOKEN mapping described under
Agent Identity), so by default the agent's own token wins — but
an explicit env value at any scope can deliberately override it (e.g. a single
step setting its own GITHUB_TOKEN).
Values pass through the config loader's ${VAR} expansion (resolved at load
time), so you can forward a daemon var explicitly:
env: { DEPLOY_URL: "${DEPLOY_URL}" }.
agents:
- id: reviewer
source_token: ${GITHUB_TOKEN_REVIEWER}
env:
REVIEW_PROFILE: strict # agent-scope default
workflows:
- id: code-review
env:
REVIEW_PROFILE: relaxed # overrides the agent value for this workflow
CI_TARGET: staging
steps:
- id: run
agent: reviewer
env:
CI_TARGET: production # overrides the workflow value for this step only
Effective environment for run: REVIEW_PROFILE=relaxed, CI_TARGET=production,
plus the identity overlay.
Troubleshooting
| Problem | Check |
|---|---|
| Dashboard shows no data | Ensure apiary run is running in another terminal |
| GitHub poll not picking up issues | Verify token has correct permissions, check filters.labels |
| Tasks stuck "running" | They may have crashed without cleanup — use R (Shift+R) to force restart |
| Socket connection refused | Dashboard and dispatcher must share the same config (same .apiary/ directory) |
| OpenCode runner fails | Ensure opencode binary is on PATH and prompt_positional: true is set |