Skip to content

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 -> 401 with 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_TOKEN that 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. limit optional (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 200 with an already_* 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}/pipeline returns one task pipeline with ordered steps BUILD, PR, LAND, DEPLOY, VERIFY. Each step has state: pending|active|ok|failed|skipped plus optional at, detail, reason, and link_key. A failed LAND step also carries optional failing_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's link_key is run:<agent_runs.id> when a matching run row exists. A verified task always composes BUILD as ok (ARGUS-543): if the run row that did it never got marked done (crashed heartbeat, killed process), the verified outcome overrides the dangling active state rather than leaving it stuck. A task's repo falls 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 /pipelines returns in-flight task pipelines (lifecycle=in_progress and verified=false) plus recently failed land/deploy rows, grouped by repo in landing-queue order, MINUS any composed pipeline whose VERIFY step is ok with no other step failed (ARGUS-543): the failed-land/deploy branches match on a row up to 72h old with no verified check, 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 at LAND.
  • The existing bearer-gated GET /events stream is the invalidation contract: task events invalidate task state/evidence/verification, landing events invalidate land status, and deploy events 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"}}
      }
    }
    
    models is the RunModels allowlist (ARGUS-229) the pickers offer; config is the effective store state — global is the per-role default, projects holds 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"}. project omitted or "" targets the global default. A non-empty model must be on the allowlist (400 otherwise); an empty model CLEARS that override and falls back to the wider scope / env. Unknown role or unknown field is a 400. Returns the refreshed {"models","roles","config"} document.
  • Resolution precedence at every call site is per-run > per-project > global > env. The per-run RunModels override (the agent-run model field) 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}
      ]
    }
    
    503 when no forge source is configured (dev/smoke); 502 if 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"}.
  • project set 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 a 200 no-op). Unknown project is 404.
  • project omitted 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 answers 409 — never a silent second project; the caller must retry with an explicit project.
  • 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 verified to true while its lifecycle is still backlog, ready, or in_progress, it stamps lifecycle=done in the SAME write and records the prior value in lifecycle_before_done. blocked and cancelled are 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), lifecycle is restored to the remembered lifecycle_before_done (falling back to in_progress if none was recorded), and lifecycle_before_done is cleared.
  • done is never agent-writable. PATCH /tasks/{id}/lifecycle (and the chat set_lifecycle tool) validate against the same five agent-facing states as before; sending "to":"done" is a 400, 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.