daemon-http-clients.md no longer says a token never gets past the browser boundary's 403 refusals. A valid configured bearer token in the Authorization header can pass the target-name check for a well-formed but unaccepted Host; it cannot pass a malformed Host or a refused Origin. The sentence links Browser access for the full rules. Docs only. Contributed by dajiaohuang; refs #2486, where the correction was reported.
16 KiB
Writing a daemon client
What a program that talks to the OpenRig daemon over HTTP needs and can't read from rig --help: how to find the
daemon, its health route, which bearer token each route family checks, the event streams, and some route
details. It describes the daemon as it is. It adds no promise beyond what each section says, and route shapes not
listed here can change.
Finding the daemon
rig status --json is the discovery surface. Its daemon object gives state and reachable, and pid and
port when they are known: a stopped daemon's response has no port, and a running daemon found through
OPENRIG_URL has no pid. reachable is true when the daemon answered its health probe, false when it is
positively down, and null when that isn't confirmed (see rig status in
the command reference). It reads the recorded state and probes; it never
starts a daemon.
The CLI itself finds the daemon in these orders:
- Data commands (everything that sends a request):
OPENRIG_URL(or the legacyRIGGED_URL); else$OPENRIG_HOME/daemon.jsonwhen it holds a validpidandport; else the config keysdaemon.hostanddaemon.port(environmentOPENRIG_HOSTandOPENRIG_PORT), which default to127.0.0.1:7433. - Status (
rig status,rig daemon status):OPENRIG_URL(orRIGGED_URL) first, probed directly; else the state file below. - Start and stop (
rig daemon start,stop): the state file,$OPENRIG_HOME/daemon.json, then the legacy~/.rigged/daemon.json.
daemon.json holds pid, port, host, db and startedAt. rig daemon start writes a new daemon's record
only after its health check passes, so while a start is pending, the file holds no record of the new daemon: it
may be missing, or hold an earlier daemon's stale record. It is the CLI's own state file, not a published
contract: prefer rig status --json.
GET /healthz
The liveness and identity read. It always returns status: "ok" and pid. Every other field is present only
in the case named:
| Field | When it is present |
|---|---|
euid |
On platforms with effective user ids. A self-report for the CLI's owner check, not authentication. |
databaseWarnings |
When the daemon opened its database with warnings. |
semver, commit, dirty, builtAt |
On a stamped (released) build; a development run has none. |
selfHostId, selfHostIdSource |
After the boot reconcile has set this host's id. |
eventLoop |
When the event-loop monitor runs: lagMeanMs, lagP99Ms, utilization (0 to 1), lastTickAgeMs and healthy. |
routeTimings |
With eventLoop. |
slowOperations |
When the slow-operation recorder runs. |
bind |
The bind plan: mode (explicit or default), hosts (the addresses bound), tailscaleDetected, and ignoredRoutingHost when a routing value was ignored. |
stuckSweep, wakeLadder |
The heartbeats of the stuck sweep and the wake ladder, when they run. |
Bearer tokens
The daemon reads two tokens from its environment at start:
OPENRIG_AUTH_BEARER_TOKENchecks Mission Control's write routes (POST /api/mission-control/actionand/notifications/test), the/api/hostswrites (add,update,remove,pair), and the/api/rig-modebinding writes.rig host pairhands this token to the paired machine, so a daemon that will be paired with needs it set (seerig host pairin the command reference).OPENRIG_TERMINAL_BEARER_TOKENchecks every/api/transportand/api/compactionroute,POST /api/terminal/followand/viewer/ensure, and everyGET /api/terminal/<name>route, the terminal websocket included. When the daemon is bound to an explicit address (rig daemon start --host) that isn't loopback or a tailnet address, and this token isn't set, these routes checkOPENRIG_AUTH_BEARER_TOKENinstead.
A route whose token isn't set lets every request through. Send a token as Authorization: Bearer <token>; the
GET /api/terminal/<name> routes, the websocket included, also take ?token=<token>. The CLI sends the terminal token from OPENRIG_TERMINAL_BEARER_TOKEN,
or else from the file $OPENRIG_HOME/terminal-token, which the person writes; the daemon doesn't create it.
A wrong or missing token gets HTTP 401. The routes that check OPENRIG_AUTH_BEARER_TOKEN, and /api/transport
and /api/compaction, answer { "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }.
The GET /api/terminal/<name> routes, the websocket included, answer { "error": "unauthorized", "hint" }. A valid configured bearer token in the Authorization header can pass the browser boundary's target-name check
for a well-formed but unaccepted Host. It cannot pass a malformed Host or a refused Origin; see
Browser access.
Event streams
All three are server-sent events (text/event-stream):
GET /api/events: the daemon's event log.?rigId=limits it to one rig. Each event carriesid: <seq>. WithoutLast-Event-ID,?replay=<n>first sends the newestnstored events (0: none, live events only);?replay=all, noreplay, or any other value sends the whole log first. ALast-Event-IDheader (whatEventSourcesends when it reconnects) sends the events after that id instead, whateverreplaysays. Live events that arrive during the replay follow it, without duplicates.GET /api/activity/events: live only, no replay or ids. Each message is named after its event type:seat.activity_changed,seat.rung_health,proof.judgedorproof.sources_changed. It tells a view to read again; it carries no derived state.GET /api/queue/sse(alias/api/queue/watch): live only, withid: <seq>, forqueue.created,queue.handed_off,queue.claimed,queue.unclaimed,qitem.fallback_routed,qitem.closure_overdue,inbox.absorbedandinbox.denied.
People, asks and updates without Slack
GET /api/gateway/participants returns the same JSON projection as rig gateway participants --json:
{ ok, participants, default, organizations, groups, warnings, participantFile }. Pick a person by kind: "person"
and use their id for a Feed view, or their address for the queue's destinationSession. The effective
default is { id, source }, or { id: null, reason }; it does not establish who is using the client.
Participant records omit connector credential pointers. Check warnings and participantFile.error for a partial
registry read; an unreadable human registry returns HTTP 503. See Gateway participants.
| Read | Meaning |
|---|---|
GET /api/queue/list?destinationSession=<address>&state=pending,in-progress,blocked&limit=100 |
Open rows for exactly that address. humanIntent: "update" identifies an FYI; other values are asks. Each row has qitemId, state, summary and body. |
GET /api/queue/open-human-updates?view=<person-id>&limit=20 |
Open human FYIs, even when no Slack delivery receipt exists. Returns { items, limit, truncated, perspective? }. |
GET /api/queue/<qitemId> and /<qitemId>/transitions |
The current row and its history, including closed state and recorded outcome. Closing or acknowledging a row is different from delivering it. |
GET /api/queue/human-updates?view=<person-id>&limit=20 |
Existing delivered-update history: explicit FYIs with a recorded Slack posting receipt, including closed rows. Items add deliveredAt and deliveryReceipt. It is not the open-update inbox. |
Both update lists accept integer limit values from 1 to 100 (default 20); invalid values return HTTP 400.
Open FYIs are ordered by priority, then newest first. view=all (also the default) shows all perspectives;
view=default selects the effective default person, or all when no default is available. A named view that cannot
resolve a person has perspective.basis: "unknown" and no items when a registry is available. With no registered
people there is no perspective filter: inspect perspective rather than assuming the requested view was applied.
A person's view also includes instance or unassigned items when that person is the default. These are views on a
trusted daemon, not per-person authentication or access control; existing daemon and browser access rules still apply.
Always display truncated: true as incomplete, even if fewer than limit items arrive. A person-view scan examines
at most 1,001 open updates (101 for delivered history) before filtering; older matching items may be outside that
window. These endpoints have no pagination cursor. Reading an open update never posts it to Slack, acknowledges it,
or manufactures a delivery receipt.
For example, this Node client takes a daemon URL, a registered person id and a rig name. It uses no Slack API and
makes only reads; use the daemon discovery and access rules above. In an ES module, process.argv.slice(1) is the
argument list for node --input-type=module -e '<code>' <url> <person-id> <rig>:
// client-people-example
const [base, personId, rig] = process.argv.slice(1);
async function get(path) {
const response = await fetch(new URL(path, base));
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
return response.json();
}
const registry = await get("/api/gateway/participants");
const person = registry.participants.find(p => p.kind === "person" && p.id === personId);
if (!person) throw new Error("Choose a registered person id");
const rows = await get(`/api/queue/list?destinationSession=${encodeURIComponent(person.address)}&state=pending,in-progress,blocked&limit=100`);
const updates = await get(`/api/queue/open-human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const receipts = await get(`/api/queue/human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const rigHistory = await get(`/api/queue/list?rig=${encodeURIComponent(rig)}&limit=100`);
const messages = await get(`/api/queue/outbox/list?rig=${encodeURIComponent(rig)}&limit=100`);
console.log(JSON.stringify({ participants: registry.participants, default: registry.default,
warnings: registry.warnings, participantFile: registry.participantFile,
asks: rows.filter(row => row.humanIntent !== "update"), updates, receipts, rigHistory, messages }, null, 2));
The queue and outbox lists are bounded snapshots, not subscriptions. A full queue list may need a larger limit.
An outbox entry's deliveryState reports the send attempt; only delivered confirms delivery. A queue transition,
an open FYI, or a closed ask is not that receipt. The rig-wide reads and their limits are described below; the existing
outbox/list?senderSession=<session> read remains available.
Route details
- A rig's queue history:
GET /api/queue/list?rig=<rig>&updatedSince=<ISO time>&sort=updatedreturns the rig's queue items (any whose source or destination seat is in the rig) updated at or after that time: open items (pending,in-progress,blocked) first, then the rest by latest update.limitdefaults to 100 and has no upper cap. The list is cut afterlimitrows in that order, so when it comes back full, raiselimitto read them all. Each item's own history isGET /api/queue/<qitemId>/transitions. This read scans the queue table, so read it occasionally rather than polling it. For live changes, treatGET /api/queue/sseevents as hints to readqueue/list?rig=again. Not every event names both seats (queue.claimedandqueue.unclaimedcarry only the destination), and not every state change is on that stream, so the events alone don't track a rig's items. - A rig's messages:
GET /api/queue/outbox/list?rig=<rig>returns the messages sent from or to the rig's seats, newest first;?senderSession=<session>still returns one sender's. One of the two is required; with both,senderSessionis used andrigis ignored.limitdefaults to 100. No event is sent when a message is delivered, so read this list to see delivery states. GET /api/queue/recent-transitionsreturns at most 20 rows. A positivelimitabove 20 is capped at 20; a missing, zero, negative or unparseablelimitmeans 20.POST /api/transport/sendtakesurgent: true, the same asrig send --urgent. The daemon records who marked it from the sender identity, not from the body.POST /api/scope/approveis whatrig scope slice approveandrig scope mission approvecall. The body takesscopeTier(sliceormission),scopePath, and optionallyapprovalScope(specordelivery, the default),missionsRoot,onBehalfOf,reApprove,reasonandlockedArtifacts. The approver is the request's sender identity (actorSessionin the body is recorded beside it, and a different value is superseded). Success is 201 withscopeTier,scopeId,scopePath,approvalScope,approvedBy,approvedAt,onBehalfOf,actionId,freezeFiredandreApproved. Refusals include 400 for an invalidscopeTier,scopePathorapprovalScope, or a missing actor (actor_required); 503action_log_unavailable; and, from the approval itself, 404scope_not_found, 409already_approvedornothing_to_reapprove, 503workspace_not_configured, 500audit_write_failed, and 400 for its other refusals. An unexpected error is 500internal.POST /api/proof/judgeis whatrig proof judgecalls. The body takesscope,item,verdict(accept,rejectorwithdraw),reason,expectedRevisionandexpectedPrevious(a judgment id ornull), and optionallyevidence,expectedEvidence,subject,operationId,replace,projectandprojectRoot. The judge is the request's sender identity. The response isjudgment, the slice'sreadiness,replayedandnotification: 201 for a new judgment, 200 when anoperationIdreplays an earlier one.POST /api/workflow/instantiaterequiresspecPath,rootObjectiveandcreatedBySession, and takesentryOwnerSession,targetRigandscope(see Project workspace forscope). Success is 201 withinstance,spec,entryQitemIdandentryOwnerSession, anyadvisories, andscopeIgnoredwhen a scope wasn't applied.GET /api/provider/usagereturns{ hostUsage }, this host's usage rows (host: "local", aproviderand astate). A Claude row'swindowsare the seats' status-line readings, one entry per reading, each with itswindow,usedPercent,resetsAt,asOfandseatSession. A Codex row haswindows: []. Treating the readings as one account rests on the deployment assumption the row'sprovenancenames (one account per host), not on a measured identity. Readings whose reset times disagree are reported asanomalies, and the row'sstatecan then beexplicit_unknown. Show the row'sstate,anomaliesand each reading's seat andasOfas reported, rather than merging readings that happen to match.- Slack channels and people:
GET /api/gateway/slack/channelsandGET /api/gateway/slack/userseach return one page from Slack, read with the daemon's configured bot token, which never leaves the daemon. Channels are public and unarchived, as{ id, name, isMember }; people are{ id, name, realName, displayName, isBot, deleted }, with no email. Passcursor(the previous page'snextCursor,nullon the last page) andlimit(default 100, at most 200). Inbound delivery needn't be enabled. Failures: 503slack_not_configuredwhen no bot token resolves, which is different from an empty list; 403slack_missing_scopewith the scope Slack names inneeded(an older app may lackusers:read); 429slack_rate_limitedwith Slack'sRetry-After; otherwise 502. Each request is a live Slack call, so page through a list when someone opens a chooser rather than polling it. Private channels and direct messages aren't listed. - Slices are found by folder name alone.
GET /api/slices/:name,/:name/doc/*,/:name/proof-asset/*andGET /api/review/slice/:nametake only the name; the detail route reads?missiononly together with?project. When two missions have a slice folder of the same name, the daemon keeps the first one it finds: that one is listed and served, and the other doesn't appear inGET /api/slicesat all. Keep slice names unique across missions, as Project workspace says.