Limits
What this plugin cannot do, stated plainly, because each of these is easier to learn here than from a workflow that silently does nothing.
Routine items are read-only
Apiary plugin sources implement none of the optional write capabilities. A routine's work item cannot be transitioned, labelled, commented on, or closed — there is nothing behind it to write to. An occurrence is an instant, not a record in a backing system.
Concretely, Apiary's config validation (SourceCapabilities) rejects a
workflow pinned to this source that uses any of these — each verified against a
live daemon:
| Feature | Rejected |
|---|---|
on_complete / on_fail set_state |
yes |
add_labels / assign_from_output |
yes |
remove_labels |
yes |
approval step with approvers / resume_on |
yes |
wait_for with kind: ci |
yes (including nested in a parallel: group) |
materialize: sub_issue |
yes |
An operator-gate approval is allowed — one with a message and no
approvers or resume_on. It is answered locally (apiary approvals, or the
dashboard) and never reads a source signal, so it needs no write capability.
That makes "run nightly, then wait for a human to sign off" a perfectly good
routine.
A routine that needs to produce a ticket does it from inside the workflow.
Have the agent emit APIARY_SPAWN and add a materialize: sub_issue step, so
the ticket is created in a real source (GitHub, Jira) that does support
write-back. The routine starts the work; a real source records it.
Always pin source: in a routine trigger
The capability check has two rules. A trigger that pins
match.source: routines is checked against that source and fails
validation — which is what you want. A trigger with no source pin is
only rejected when no configured source supports the capability.
So in a hive that also runs GitHub or Jira, an unpinned routine workflow
using set_state passes apiary validate and then silently does
nothing at runtime, because the item it actually received came from this
plugin. Verified against a live daemon:
| Trigger | on_complete.set_state |
Result |
|---|---|---|
source: routines |
yes | rejected at validate |
| unpinned, plugin is the only source | yes | rejected at validate |
| unpinned, GitHub also configured | yes | accepted, no-op at runtime |
Pinning costs one line and turns a silent runtime no-op into a startup error.
The agent starts in /, so name your paths
Apiary dispatches every agent step with the process working directory hardcoded
to / (internal/daemon/workflow.go, WorkingDir: "/"), and there is no config
knob for it — see apiary#436.
An issue-driven agent rarely notices, because its soul file or prompt names the
repository. A routine has nothing to name it. Observed on a real run: the
agent found no git repository in /, searched $HOME for one, listed the
operator's Documents folder along the way, and spent most of nine turns and
$0.50 orienting before it found the right checkout.
So put the path in the prompt:
steps:
- id: report
agent: reporter
prompt: |
The repository is at /srv/projects/acme — cd there first.
Report on its current health, READ ONLY …
Without it the agent will hunt, and it may find the wrong checkout and report on that instead — silently, because the output still looks right.
No overlap control
Each occurrence becomes a new internal task. Apiary's live-instance guard is per (task, workflow), so it does not apply across occurrences: a routine whose run takes longer than its interval will overlap itself.
The plugin cannot prevent this — it has no visibility into Apiary's state and, being a fresh process per poll, no memory of what it started.
Mitigations, in order of bluntness:
- Give the routine an interval comfortably longer than its worst-case run.
- Cap the agent's
max_workersso overlapping runs queue rather than pile up. - Have the workflow's first step check for its own prior run and exit early.
If you need real overlap: skip semantics, that has to live in the daemon,
which knows what is running. It is out of scope for a source plugin.
No operator surface
There is no apiary routines command, no next-run column, and no dashboard
tab. What you get:
- What ran — the Tasks list, filtered by the
routine:<id>label - What is scheduled — your config, and the
state_file - What the plugin decided — its stderr, captured by the daemon per invocation (64 KiB) and logged against the plugin
Enabling or disabling a routine means editing config and restarting the daemon
(enabled: false parks one without losing its cursor).
No auto-disable on repeated failure
The plugin emits occurrences; it never learns whether the resulting workflow succeeded. A routine whose workflow fails every night keeps firing every night.
Apiary's own settings.max_attempts caps consecutive failed instances per
(task, workflow) — but every occurrence is a new task, so that cap resets each
time and will not stop a chronically failing routine.
Watch for this with Apiary's notification channels on the workflow's on_fail.
Why once is not optional
Apiary's pre-dispatch guards drop a match when there is a live instance, when a
once: true trigger has already completed, or when the failure cap is spent.
None of them drop a match because the workflow completed earlier.
So if an occurrence item ever reappears — a restored state file, a hand-edited
cursor, a state_file pointed at a fresh path — Apiary will dispatch the
workflow again. The plugin's cursor is the first lock; trigger.once: true is
the second, and the cheap one.
Not a general job runner
This schedules Apiary workflows. If what you want is "run this shell command every night", cron already does that better. The value here is that a routine enters the same pipeline as every other task: the same routing, the same agent dispatch, the same transcripts, instances, and history.