API Reference¶
Base URL: http://rig-conductor-api:8080 (internal ClusterIP)
Operator-token gate (rc#2369). Nine destructive write routes (POST /api/pods/{name}/start|stop, force-done, POST/DELETE /api/admin/tenants, POST /api/admin/overrides, DELETE /api/agents/{id}, /api/agents/offline, /api/repo-learnings) check X-Rig-Operator-Token. The default is report mode: logged, never refused. With OPERATOR_ROUTE_GATE=enforce, a missing or wrong token gets 403. Production has enforced since 2026-09-25 (rc#2435). Decision record: 2026-09-24-operator-route-gate.md.
Endpoints¶
Health¶
Deep Health (rc#1188)¶
Returns the status of every dependent system the conductor needs to function. Critical deps (Valkey, Marten, GitHub API) drive the readiness verdict; non-critical deps (Discord) can soft-degrade overall but cannot trip the 503.
Status-code mapping:
| Overall | HTTP | Meaning |
|---|---|---|
Ok |
200 | all critical deps reachable; non-critical deps Ok |
Degraded |
200 | reachable but slow / rate-limited / a non-critical dep is non-Ok — pod stays in the load balancer |
Unreachable |
503 | at least one critical dep is unreachable — readiness probe should pull the pod |
Post-merge baseline (PR-B1 only):
Populated response (after PR-B2 wires production checkers):
{
"overall": "Degraded",
"dependencies": [
{ "name": "valkey", "status": "Ok", "critical": true, "reason": null, "latencyMs": 3, "lastCheckAt": "..." },
{ "name": "marten", "status": "Ok", "critical": true, "reason": null, "latencyMs": 12, "lastCheckAt": "..." },
{ "name": "github", "status": "Degraded", "critical": true, "reason": "http 429", "latencyMs": 88, "lastCheckAt": "..." },
{ "name": "discord", "status": "Ok", "critical": false, "reason": null, "latencyMs": 41, "lastCheckAt": "..." }
],
"checkedAt": "2026-05-19T13:42:00Z"
}
reason is null on Ok and short on non-Ok ("high ping latency", "http 429", "ping failed: RedisConnectionException", etc.). latencyMs is the per-checker round-trip; the orchestrator runs checkers in parallel with a 2-second per-checker hard timeout that maps to Status=Degraded, reason="timeout".
Rollout slices:
- PR-B1 (this slice): port + orchestrator + /healthz/deep endpoint; empty deps array
- PR-B2 (rc#1204): four production checkers (Valkey / Marten / GitHub API / Discord) + DI
- PR-D: Kubernetes readiness + liveness probes switched from /health to /healthz/deep + decision doc
See rc#1188 for the overall design and rc#1173 for the Valkey silent-degrade incident that motivated it.
Submit Event¶
Submit any rig event. The stream ID is derived from repo#issueNumber for issue events, or agentId for heartbeats.
Request:
{
"type": "ISSUE_APPROVED",
"repo": "dashecorp/rig-conductor",
"issueNumber": 42,
"title": "feat: Add health check endpoint",
"priority": "normal",
"dependsOn": []
}
Response (200):
{
"streamId": "dashecorp/rig-conductor#42",
"type": "ISSUE_APPROVED",
"timestamp": "2026-04-02T06:56:22Z"
}
Error (400):
A body streamId or agentId that starts with cockpit-task: or release: (ignoring case) is also a 400. Those
namespaces belong to the operator task store (rc#2104) and the
release store (rc#2733).
An event without an int field that its type's mapping needs is a 400 too, and nothing is appended. For example,
REVIEW_PASSED needs both issueNumber and prNumber (rc#2342):
missing lists every absent field and uses the C# property names, so they are PascalCase. The list of types and
the fields each one needs is under Required fields. Before rc#2342 these
requests threw inside SubmitEvent and came back as an unhandled 500.
See Event Store for all event types and their fields.
Get Issues¶
GET /api/issues
GET /api/issues?state=queued
GET /api/issues?view=full
GET /api/issues?view=full&include=body
GET /api/issues?limit=50&cursor=100
Returns tracked issues, optionally filtered by state. The response body is always a bare JSON array.
States: queued, assigned, in_progress, in_review, deploying, done, failed
Default: narrow, bounded summary (rc#2542). Agents (Rig-E, Dev-E, Planner) call this endpoint bare, and its output goes straight into a 16,384-token model context. By default each element is
{"id": "dashecorp/rig-conductor#2542", "title": "…", "state": "in_progress", "agentId": "dev-e-dotnet", "stuck": true}
agentId appears only when assigned; stuck only when the stuck flag is live
(stuckSince >= lastUpdated). At most 100 rows come back, non-terminal issues first, then most
recently updated. Measured against the live backlog on 2026-10-02: 145 issues, wide response 206,274 B →
default response 16,957 B (~4,845 tokens).
| Query | Effect |
|---|---|
view=summary (default) |
Narrow row above; default limit 100 |
view=full |
Full IssueStatus row (repo, issueNumber, prNumber, releaseSummary, …) minus body; no default limit (the operator dashboard uses this) |
include=body |
Adds the markdown body (rc#2080). Without view, implies view=full; ignored by view=summary |
limit=N |
Page size, 1–1000, either view |
cursor=C |
Continue from a previous page; pass back X-Next-Cursor unchanged |
Response headers: X-Total-Count (rows matching state before paging) and X-Next-Cursor
(present only when more rows remain). The cursor is an offset into the ordered list — treat it as opaque.
An unknown view, a limit outside 1–1000 or a malformed cursor returns 400.
History: rc#2080 dropped body (316 KB → 199 KB); rc#2542 narrowed the row and bounded the count.
For a single issue (with body) use GET /api/issues/item?repo={repo}&issueNumber={n}.
Get Issue Trace (rc#951)¶
Returns the ordered Marten event history for one issue stream, plus the current IssueStatus.State from the projection. Events are sorted by Marten's global Sequence (authoritative — not by timestamp, which can collide or be reported out of clock-skew order).
404 Not Found when the stream has zero events (issue never landed in conductor).
{
"issueId": "dashecorp/rig-conductor#244",
"currentState": "in_progress",
"eventCount": 28,
"events": [
{"at": "2026-05-14T07:26:17Z", "type": "work_started", "data": {...}, "sequence": 364467},
{"at": "2026-05-14T07:26:46Z", "type": "merged", "data": {...}, "sequence": 364470}
]
}
Replaces the prior psql-into-Marten + log-tail workflow (~10 min per triage cycle). Also feeds the rc#947 SelfImprovementService a stable per-issue event-sequence API.
Get Priority Queue¶
Returns unassigned issues sorted by priority (critical > high > normal > oldest).
Get Next Assignment¶
Returns the top-priority unassigned issue, or 204 No Content if nothing available.
With LOCATION set, it also answers 204 when this location does not own invotek (the tenant whose rows this query
reads) or the tenant map is missing or stale. See multi-location fencing
(rig-conductor#2561).
Response (200):
{
"streamId": "dashecorp/rig-conductor#42",
"issue": {
"number": 42,
"repo": "dashecorp/rig-conductor",
"title": "feat: Add health check endpoint",
"milestone": null
},
"priority": "normal",
"attempt": 1
}
Get Agent Status¶
Returns status of all known agents. The status and currentIssue/currentRepo fields are
updated by both heartbeat events and assignment events (ISSUE_ASSIGNED, WORK_STARTED)
so the dashboard reflects live work immediately without waiting for a heartbeat cycle.
Each item includes:
isOnline— computed from recent heartbeat freshness (last heartbeat within 2 minutes)status—idle,working, orstuck. Set toworkingwhenISSUE_ASSIGNEDorWORK_STARTEDis dispatched; cleared toidleonISSUE_DONE,ISSUE_CANCELLED, or next idle heartbeat.currentIssue— issue number the agent is currently working on (null if idle)currentRepo— repo of the current issue (null if idle)activeProvideravailableProvidersproviders[]— provider health snapshotintegrations[]— integration health snapshotlastModel— last LLM model the agent reported viaTOKEN_USAGE(e.g.claude-opus-4-7).nulluntil the agent emits its firstTOKEN_USAGEevent after deploy. Added in #530 (rc#529) so the dashboard can show which agents have switched models after a config change.
Response:
[
{
"id": "dev-e-1",
"status": "working",
"isOnline": true,
"currentIssue": 136,
"currentRepo": "dashecorp/rig-conductor",
"activeProvider": "claude",
"availableProviders": ["claude", "codex"],
"providers": [
{ "name": "claude", "status": "ready", "details": "Claude Code auth configured", "active": true },
{ "name": "codex", "status": "authenticated", "details": "logged in using ChatGPT", "active": false }
],
"integrations": [
{ "name": "github", "status": "ready", "details": "GitHub App configured" },
{ "name": "discord-webhook", "status": "ready", "details": "operator webhook configured" }
],
"lastModel": "claude-opus-4-7",
"lastHeartbeat": "2026-04-02T09:14:22Z",
"issuesCompleted": 12,
"issuesFailed": 1
}
]
lastModelisnullfor agents that have not yet emitted aTOKEN_USAGEevent after deploy.
Get Execution Logs¶
Returns recent execution log summaries (without full step/log payload). Used by the dashboard overview.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
int | 50 |
Max results to return |
status |
string | (all) | Filter by status. Must be one of: running, completed, failed, stuck. Case-insensitive — RUNNING, Running, and running are all accepted and treated identically. |
Status filter validation: If status is provided and is not one of the allowed values, the endpoint returns 400 Bad Request:
Note:
cancelledis not a valid status — the domain usesstuckto represent issues that have stalled. Sending?status=cancelledreturns 400.
Response (200 OK):
[
{
"id": "uuid",
"repo": "dashecorp/rig-conductor",
"issueNumber": 42,
"prNumber": 99,
"agentId": "dev-e-1",
"status": "completed",
"startedAt": "2026-04-23T10:00:00Z",
"completedAt": "2026-04-23T10:12:00Z",
"durationSeconds": 720,
"totalCostUsd": 0.18,
"totalTurns": 24,
"model": "claude-sonnet-4-5",
"stepCount": 7,
"logCount": 130
}
]
Get Execution Log Stats¶
Aggregated turn / cost / token distribution for execution logs in a window — the instrument that sizes cost levers, e.g. choosing a safe --max-turns set above the p95 of real runs. Pure aggregation lives in ConductorE.Core.UseCases.ExecutionStatsPolicy; see the Review-E cost-reduction decision and Cost Attribution.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
agentId |
string | (all agents) | Filter to a single agent. Omit to aggregate across all agents; echoed back as null when omitted. |
days |
int | 14 |
Look-back window from now. Must be 1–365; out-of-range returns 400 (a non-positive window would push the cutoff into the future and report zero rows, indistinguishable from "no runs"). |
status |
string | completed |
Status to aggregate. One of running, completed, failed, stuck (case-insensitive). An unknown value returns 400 — it is not silently treated as "no data", since an empty result would size --max-turns from a zero p95. |
Response (200 OK):
{
"agentId": "review-e",
"windowDays": 14,
"status": "completed",
"stats": {
"runCount": 42,
"turns": { "count": 42, "min": 3, "p50": 18, "p90": 47, "p95": 61, "max": 120, "mean": 21.4 },
"totalCostUsd": 38.21,
"meanCostUsd": 0.91,
"totalInputTokens": 18450000,
"totalOutputTokens": 412000,
"totalCacheReadTokens": 15200000,
"totalCacheCreationTokens": 980000
}
}
turns percentiles are nearest-rank (rank = ceil(p/100 * N), 1-based). An agent with no runs in the window returns 200 with runCount: 0 and an all-zero distribution (never a 404 or error).
Get Event Stream¶
Returns all events for a specific stream. The id parameter must be URL-encoded (use %23 for #).
Response:
[
{
"id": "uuid",
"type": "IssueApproved",
"data": { "repo": "...", "issueNumber": 42, "title": "..." },
"timestamp": "2026-04-02T06:56:22Z"
}
]
Token Usage¶
GET /api/usage
GET /api/usage?agentId=dev-e-node
GET /api/usage?repo=dashecorp%2Frig-conductor
GET /api/usage?days=7
Returns per-agent token usage totals including cache token counts.
Query parameters:
| Param | Default | Description |
|---|---|---|
agentId |
(all) | Filter to a single agent |
repo |
(all repos) | Filter to a specific repo |
days |
(all time) | Rolling window in days. Required to compare with /api/costs/summary — without it the endpoint returns all-time projection totals which are naturally larger than any windowed cost summary. |
Response:
[
{
"agentId": "dev-e-node",
"totalInputTokens": 1200000,
"totalOutputTokens": 480000,
"totalCacheReadTokens": 5600000,
"totalCacheCreationTokens": 920000,
"totalCostUsd": 39.591,
"byRepo": [
{
"repo": "dashecorp/rig-conductor",
"inputTokens": 800000,
"outputTokens": 300000,
"cacheReadTokens": 3200000,
"cacheCreationTokens": 600000,
"costUsd": 25.4
}
]
}
]
Cost Summary¶
Returns agent-level cost breakdown by category for the rolling window. Uses the same raw-event source as /api/usage?days=N — both endpoints agree for the same window.
Cost computation (fixed in #148):
- Events with zero tokens (
inputTokens=outputTokens=cacheReadTokens=cacheCreationTokens=0) contribute$0, regardless of anycostUsdvalue in the event. This prevents phantom idle costs. - Known Anthropic models with cache tokens (
cacheReadTokens > 0 || cacheCreationTokens > 0) are recomputed using the Anthropic pricing table inAnthropicPricingrather than trusting the agent-reportedcostUsd, which historically excluded cache costs. - Unknown models and non-Anthropic models retain the agent-reported
costUsd, including when cache tokens are present. - Legacy events (no cache token fields) continue to use the agent-reported
costUsd.
TOKEN_USAGE event now accepts optional cache token fields:
{
"type": "TOKEN_USAGE",
"agentId": "dev-e-node",
"repo": "dashecorp/rig-conductor",
"issueNumber": 148,
"model": "claude-sonnet-4-5",
"inputTokens": 10,
"outputTokens": 4994,
"cacheReadTokens": 160855,
"cacheCreationTokens": 28927,
"costUsd": 0.242194,
"category": "work"
}
See cost attribution for the full pricing model and fix details.
Daily Costs¶
Returns a per-agent, per-day breakdown for the rolling window. Add &by=repo to group by date, repository and agent, adding a repo field to each entry. Without by, the response shape is unchanged. /public/cost-summary never exposes repository names.
Response:
{
"period": "7d",
"entries": [
{ "date": "2026-04-23", "agentId": "dev-e-node", "costUsd": 12.30 }
]
}
Stream Status¶
Returns per-agent Valkey stream status. The primary field to watch is lag inside each
consumer-group entry — it represents messages the consumer group has not yet delivered,
i.e. the real backlog. xlen (total stream length) is included for diagnostics only; it
does not decrease when messages are acknowledged and will accumulate until the stream is
explicitly trimmed.
Response:
{
"dev-e-dotnet": {
"xlen": 17,
"groups": {
"agents": { "lag": 0, "pending": 0 }
}
},
"dev-e-node": {
"xlen": 27,
"groups": {
"agents": { "lag": 2, "pending": 1 }
}
}
}
lag— unread entries waiting to be delivered to the group (the real queue backlog)pending— entries delivered but not yet acknowledged (in-flight)xlen— total stream length (misleading as a backlog metric; stays high until trim)
When no consumer group has been created yet (stream never consumed), groups is empty and
xlen reflects the raw message count.
List Tenants (Cockpit roster)¶
Operator-facing read of the rig_control tenant allowlist — the source the resolver consults —
projected to a small DTO the Rig Cockpit (iOS app + web SPA via the rig-cockpit-worker /api/*
proxy) renders as a Tenants tab (rc#1919). Fills the gap that GET /api/tenancy cannot: the
census returns only a count + bare lower-cased tenant-ID strings, no name/status/type.
Auth: No in-app token. Same Cloudflare-Access posture as the sibling POST/DELETE
/api/admin/tenants write paths and the rest of /api/admin/*. Do not expose via public
ingress.
Query params: none. Returns the full roster (no owner-scoping today — effectively 1 active tenant = invotek; owner-scoping is future work if the roster grows).
Response (200):
{
"tenants": [
{
"id": "invotek",
"name": "Invotek",
"githubOrg": "dashecorp",
"status": "active",
"type": "first-party",
"installationId": 12345678
}
]
}
Per-row fields:
| Field | Type | Description |
|---|---|---|
id |
string | Stable lower-case tenant slug (Marten document identity) |
name |
string | Human-readable name |
githubOrg |
string | GitHub org that maps to this tenant (lower-case) |
status |
string | "active" or "suspended" — only "active" tenants resolve at the ingress boundary |
type |
string | Operator-policy classification, raw — "first-party" or "b2c" (rc#1873). Surfaced as-is; clients render their own label, no server-side B2B/B2C mapping |
installationId |
long? | GitHub App installation id when known; null until per-tenant App installs are mapped |
The projection is closed — adding a field to the Tenant Marten document does NOT silently leak
through this endpoint. Internal-only fields stay internal.
Force-Done (Epic override)¶
Operator escape hatch for a parent epic that was blocked by the epic-completion guard
(rc#459). Use when the team has deliberately abandoned remaining sub-issues and wants to
declare the epic done without waiting for all subs to reach done/production.
Auth: the operator-token gate (rc#2369), enforced in production since 2026-09-25 (rc#2435): send
X-Rig-Operator-Token, or get 403. Do not expose via public ingress.
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
repo |
string | ✅ | Repository slug, e.g. dashecorp/rig-conductor (URL-encode the /) |
issueNumber |
int | ✅ | GitHub issue number of the parent epic |
Responses:
| Status | Body | Meaning |
|---|---|---|
| 200 | {"forcedDone": true, "previousState": "deploying", "repo": "...", "issueNumber": N} |
Transition emitted |
| 200 | {"alreadyDone": true, "state": "done", ...} |
Epic was already done/production — no-op |
| 404 | {"error": "Issue ... not found in conductor"} |
Issue not tracked in conductor |
Example:
curl -X POST -H "X-Rig-Operator-Token: $RIG_OPERATOR_TOKEN" \
"http://rig-conductor-api:8080/api/admin/issues/force-done?repo=dashecorp%2Frig-conductor&issueNumber=433"
Location fence status (rig-conductor#2561)¶
Read-only view of the multi-location fence (multi-location fencing). It never
fetches the map; it reports what the conductor holds. With LOCATION unset the answer is {"enabled": false, ...} with
empty lists.
Response (200):
{
"enabled": true,
"location": "gcp",
"mapConfigured": true,
"mapFetchedAt": "2026-10-02T12:00:00+00:00",
"mapAgeSeconds": 12.4,
"mapStale": false,
"lastFetchError": null,
"lastFetchFailedAt": null,
"tenants": [
{ "tenantId": "invotek", "locationId": "gcp", "epoch": 3, "state": "active", "githubOrg": "dashecorp", "installationId": 12345678 }
],
"recentBlocks": [
{ "at": "2026-10-02T12:00:05+00:00", "tenantId": "acme", "action": "assignment.publish", "target": "acme/app#5", "reason": "mapped_elsewhere" }
]
}
recentBlocks holds the last 100 refusals, newest first. reason is one of map_missing, map_unreachable,
map_stale, tenant_unknown, tenant_not_in_map, tenant_ambiguous, mapped_elsewhere, tenant_not_active,
fence_error.
While fencing is on, POST /api/merge answers 200 {"triggered": false, "fenced": true, "reason": ...} for a fenced
tenant, GET /api/assignments/next and GET /api/pr-reviews/next answer 204, and the GitHub webhook answers 200 with
"fenced": true once it has recorded the delivery.
Rig Cockpit task routes (rc#2104)¶
POST /api/cockpit/task/propose, GET /api/cockpit/task/status (the store state plus the projected stage, detail and timeline) and POST /api/cockpit/task/release (releases a MergeGate-held
Rig-E task PR at exactly the signed head), signed by the Rig Cockpit Worker. They answer 503
not_configured until COCKPIT_TASK_SIGNING_KEY is provisioned. See Operator Task Routes.
Release promotion, shadow (rc#2733)¶
GET /api/promotions?repo=owner/name answers {repo, waiting, recent}: every waiting release and the ten newest, from the
release streams. The conductor resolves the repo's tenant itself; a repo
outside dashecorp, or one with no release, answers empty lists.
It is not GET /api/releases, which is the dashboard's release-notes list (release notes).
POST /api/webhook/release is the release App's webhook. It answers 503 not_configured until RELEASE_APP_WEBHOOK_SECRET
holds a usable secret, which must differ from GITHUB_WEBHOOK_SECRET. Past that: 413 over 512 KiB, 401
invalid_signature, 400 for a signed delivery that is not GitHub's shape, otherwise 200. It records each run's release
(waiting, superseded, deployed, failed) and approves nothing. See Release Promotion.
Autonomy level (rc#2640)¶
GET /api/autonomy answers the rig-wide autonomy level and GET /api/autonomy/history?limit=50 (newest first, 1–500) the
hash-chained log behind it. Both are read-only and need no credential. Nothing enforces the level yet: every point is in
shadow mode. See the decision.
The body carries level, name, standing, override, seq, headHash, anchorFingerprint,
pinnedAnchorFingerprint, health, detail and enforcement ({"merge": "shadow", "deploy": "shadow"}). level is a slug: full-speed, guarded, supervised or full-security. It is full-security whenever health is not
ok: empty (no passkey pinned yet), chain_invalid, anchor_mismatch, seq_regression (the log ends before its
Valkey high-water mark), tripped (the chain is whole again but has not recorded Full security since the latest
tripwire incident) or store_unavailable. The first read after the
operator's passkey is pinned in deploy/k8s/gates.yaml writes the genesis entry and posts its fingerprint to #admin.
Changing the level needs the operator token (X-Rig-Operator-Token). Each route answers
{"outcome": …, "state": <the body above>}.
| Route | Body | Outcomes |
|---|---|---|
POST /api/autonomy/raise |
{"to": "supervised"} |
raised; no_op (200) when the level is already there or higher, which is always the case until the passkey is pinned |
POST /api/autonomy/lower/challenge |
{"to", "durationMinutes": 30\|120\|480\|1440\|null, "expectedSeq"} |
200 {challengeId, kind, params, origin, validUntil}; 409 not_configured (no passkey pinned), no_chain, stale, not_a_lowering; 400 bad_duration |
POST /api/autonomy/lower |
{"challengeId", "assertion": {credentialId, authenticatorData, clientDataJson, signature}} |
lowered; 410 for an unknown, expired or used challenge; 403 when the passkey or the replay refuses it; 409 stale |
A challenge is good once and for five minutes. Every refused lowering is posted to #admin, and so is every change.
Where the location fence refuses the default tenant, a change answers 503 location_fenced and writes nothing. Once the
passkey is pinned the conductor writes two entries itself: reverted by expiry when a time-box ends, and raised to
Full security by tripwire once a tripwire's finding has cleared; each incident is posted to #admin once.
POST /api/autonomy/decision tells a deploy job which GitHub environment to use. It needs no operator token: the job
sends its GitHub Actions OIDC token (Authorization: Bearer, audience rig-conductor-autonomy), which must be RS256,
signed by GitHub, unexpired, and for a run on refs/heads/main in dashecorp. Body: {"point": "deploy", "class":
"code|additive|destructive|…", "legacyEnvironment", "gatedEnvironment", "ungatedEnvironment"}. Answer: {environment,
gated, wouldGate, outcome, level, enforced, decisionId}. While AUTONOMY_ENFORCE_DEPLOY is shadow, environment is
always legacyEnvironment. Errors: 401 {error} with the token check's reason, 400 bad_request, 503
jwks_unavailable. Every answer is recorded with the run (AutonomyDecisionRecord).
Architecture¶
POST /api/events → SubmitEvent (Use Case) → IEventStore (Port) → MartenEventStore (Adapter) → PostgreSQL
GET /api/queue → IIssueQuery (Port) → MartenIssueQuery (Adapter) → PostgreSQL (Marten projection)
Clean Architecture: endpoints delegate to use cases/ports, never touch Marten directly.