Argus hub API conventions¶
Locked before the phase-3 app surfaces multiplied .json endpoints
(ARGUS-263). Every NEW endpoint follows these rules; existing endpoints that
predate them are grandfathered until touched, at which point they conform.
The app repo's PLAN.md mirrors the per-endpoint contracts; this file is the
cross-cutting rules.
Auth¶
- Mutations and non-public reads:
Authorization: Bearer <ARGUS_CAPTURE_TOKEN>, exact match, constant-time compare. Missing/wrong ->401with the error envelope. - Browser-fetched surfaces that cannot send headers (the OTA install page,
manifest, .ipa) use per-resource capability tokens (
?k=, rotated on every re-issue, constant-time compared). Existence checks come BEFORE token validation (404 first) so an unprovisioned resource is not a token oracle. - Human-only actions (task Approve/reject) require the SEPARATE
ARGUS_APPROVE_TOKENthat agents never hold — "an agent cannot approve" stays structural, not behavioral (decision on ARGUS-265).
Error envelope¶
Every non-2xx response is {"error": "<message>"} with a matching HTTP
status. No other error shape, including from routing defaults (404/405 are
JSON too). Validation failures are 400; auth 401; missing resources
404; forbidden capability tokens 403; not-configured subsystems 503.
Request bodies¶
- JSON with strict decoding: unknown fields are a
400, so a typo'd or tampering field never silently no-ops. - Body size is capped at 10MB globally; a route needing more gets an explicit
path-scoped cap (only
/app/upload, 200MB).
Timestamps¶
RFC3339 UTC (2026-07-03T16:19:30.174839Z). Timestamps that have not
happened yet (finished_at, done_at) are null or omitted — never zero
values, never sentinel dates.
List pagination¶
Two modes, mutually exclusive (400 if both given), items ALWAYS ascending
by id in the response:
?after_id=N— forward page: ids > N, oldest first.limitoptional (1-200, default 50). For incremental polling.?recent=N— the LAST N (1-200), still ascending. For cold loads that want the newest items.
Endpoints whose natural order is newest-first (feeds like /inbox.json) say
so explicitly in their contract; the default is the pagination above.
Action endpoints (state changes)¶
- Idempotent: repeating the same action returns
200with analready_*marker (e.g.{"status":"ok","already_resolved":true}) and no double effect — clients on flaky mobile networks retry safely. - Every action writes an audit/event row; a state change with no trace is a contract violation.
- Side effects that can fail (a push, a Telegram send) are logged AND recorded in the runs ledger — never silent.
Filters¶
Query-parameter filters are applied SERVER-side. A recognized-but-ignored filter param is a bug (a client cannot distinguish it from an applied filter): apply it or reject the request.
Pipeline reads (/tasks/{id}/pipeline, /pipelines) — ARGUS-512¶
The pipeline surface is read-only and derived at request time from existing
ledgers only: task_items / task_events, agent_runs, landing_queue,
deploys, and task_evidence. It does not persist a pipeline table or cache.
GET /tasks/{id}/pipelinereturns one task pipeline with ordered stepsBUILD,PR,LAND,DEPLOY,VERIFY. Each step hasstate: pending|active|ok|failed|skippedplus optionalat,detail,reason, andlink_key. A failedLANDstep also carries optionalfailing_check+excerpt(ARGUS-572): the failing check name + a bounded log excerpt the landing queue fetched via ForgeSource when the failure was a red CI check -- best-effort, so both are absent on non-CI failures (rebase conflict, closed PR, timeout) or when the fetch itself errored.BUILD'slink_keyisrun:<agent_runs.id>when a matching run row exists. A verified task always composesBUILDasok(ARGUS-543): if the run row that did it never got markeddone(crashed heartbeat, killed process), the verified outcome overrides the danglingactivestate rather than leaving it stuck. A task'srepofalls back to the first repo on its project registry entry when no PR/landing/deploy evidence has supplied one yet (a pre-PR task).GET /pipelinesreturns in-flight task pipelines (lifecycle=in_progressandverified=false) plus recently failed land/deploy rows, grouped by repo in landing-queue order, MINUS any composed pipeline whoseVERIFYstep isokwith no other stepfailed(ARGUS-543): the failed-land/deploy branches match on a row up to 72h old with noverifiedcheck, so a task fixed forward and reverified since would otherwise linger for the rest of that window. Task-less PRs in the landing queue are represented as pipelines that start atLAND.- The existing bearer-gated
GET /eventsstream is the invalidation contract:taskevents invalidate task state/evidence/verification,landingevents invalidate land status, anddeployevents invalidate deploy status. These reads need no new SSE kind; clients refetch the composed pipeline on any of those existing signals.
Model config (/models.json, PATCH /models) — ARGUS-376¶
The backend is the single source of truth for which model each ROLE runs.
Roles are chat (the chat worker) and builder (dispatch / board-watch
overnight builds). Env vars (ARGUS_CHAT_MODEL, ARGUS_BOARD_WATCH_MODEL)
are bootstrap-only defaults; the store wins when it has a value, so the app
retunes models without a hub redeploy.
GET /models.json(public read, like/projects.json) returns:{ "models": ["claude-sonnet-5", "claude-opus-4-8", "claude-haiku-4-5"], "roles": ["chat", "builder"], "config": { "global": {"chat": "claude-sonnet-5", "builder": "claude-opus-4-8"}, "projects": {"argus": {"builder": "claude-haiku-4-5"}} } }modelsis the RunModels allowlist (ARGUS-229) the pickers offer;configis the effective store state —globalis the per-role default,projectsholds only the roles a project overrides. Absent keys mean "inherit".PATCH /models(bearer) sets one role's model:{"role":"builder","project":"argus","model":"claude-opus-4-8"}.projectomitted or""targets the global default. A non-emptymodelmust be on the allowlist (400otherwise); an emptymodelCLEARS that override and falls back to the wider scope / env. Unknownroleor unknown field is a400. Returns the refreshed{"models","roles","config"}document.- Resolution precedence at every call site is
per-run > per-project > global > env. The per-run
RunModelsoverride (the agent-runmodelfield) is unchanged and still wins over the store.
The app repo's PLAN.md mirrors this per-endpoint contract; this section is the hub-side source it tracks.
Repo discovery (/projects/discover.json, POST /projects/discover/attach) — ARGUS-573¶
The hub half of Settings->Projects DISCOVER (ARGUS-560, US-33 Phase A):
onboarding by picking repos from the connected forge rather than typing
owner/name by hand. Built on the ForgeSource/CachingForgeSource
visibility layer (ARGUS-558) — repo listing is only as fresh as that layer's
TTL (DefaultForgeCacheTTL, 20s in production); the attached-project marker
below is always a live registry read, never cached.
GET /projects/discover.json(public read, like/projects.json) returns every repo the wired forge connection can see, each marked with the ACTIVE project it is already attached to:{ "repos": [ {"repo": "runonyourown/argus", "project": "argus"}, {"repo": "runonyourown/widgets", "project": null} ] }503when no forge source is configured (dev/smoke);502if the forge call itself fails.POST /projects/discover/attach(bearer) is per-repo only — there is no bulk variant. Body:{"repo":"owner/name","project":"slug"}.projectset to an EXISTING slug attaches the repo to it (repos are a replace-whole-set patch under the hood, so this is idempotent — repeating the same attach is a200no-op). Unknownprojectis404.projectomitted or""proposes a slug from the repo's name segment (lowercase, non-alnum runs collapsed to one hyphen) and creates a NEW project carrying just that repo (201). A proposed slug that collides with an existing project answers409— never a silent second project; the caller must retry with an explicitproject.- Either branch fires the same onboarding bootstrap PR (ARGUS-349) newly attached repos always get.
Lifecycle terminal state (done) — ARGUS-580¶
task_items.lifecycle (backlog|ready|in_progress|blocked|cancelled) was
originally agent-writable only, with NO terminal value — verified was the
only source of truth for "is this actually finished." That left every reader
that lists work by lifecycle alone (an app empty-state count, a board lane, a
pipeline view) needing to separately remember AND NOT verified, and one
that forgot showed phantom ready items that had, in fact, already shipped.
The checker now resolves this AT THE SOURCE. lifecycle gained a sixth,
checker-only value: done.
- When the verify sweep (or the rollup pass, for features/epics with no
direct evidence) flips an item's
verifiedtotruewhile its lifecycle is stillbacklog,ready, orin_progress, it stampslifecycle=donein the SAME write and records the prior value inlifecycle_before_done.blockedandcancelledare left alone — they are not pre-terminal, and are outside this rule. - If the checker later un-verifies that item (a reverted PR, CI turning red),
lifecycleis restored to the rememberedlifecycle_before_done(falling back toin_progressif none was recorded), andlifecycle_before_doneis cleared. doneis never agent-writable.PATCH /tasks/{id}/lifecycle(and the chatset_lifecycletool) validate against the same five agent-facing states as before; sending"to":"done"is a400, exactly like any other unrecognized value.
Practical effect: WHERE lifecycle = 'ready' (or IN ('backlog','ready',
'in_progress')) is now correct on its own — a verified item can never match
it, no per-consumer AND NOT verified required. Existing call sites that
already carried that belt-and-suspenders filter (e.g. flow.go's pipeline
query, next-task's NOT t.verified) keep it; it is now redundant, not
wrong, and cheap to leave in place as defense in depth.