Documentation menu
Daemon HTTP API
The local dashboard, CLI, and future relay clients use this API. Browser code must not
read daemon files or import daemon implementation modules. All paths below are relative
to the authenticated listener. Responses are JSON; timestamps are ISO 8601 UTC strings.
Session IDs are <group>/. Task IDs are integers scoped to their group. Approval,
question, message, note, and event IDs are database-wide integers.
Transport and authentication#
The daemon listens on 127.0.0.1 at the port in $NID.
CLI requests send Authorization: Bearer <daemon token>. Keep that token out of browser
code, URLs, storage, and HTML. Agent MCP requests use a per-session bearer token and
include session in the RPC envelope; those credentials cannot access human methods
or the SSE stream.
The separate dashboard listener accepts its HttpOnly SameSite=Strict session cookie
after a one-time login exchange. It routes to the same API with a human dashboard
principal. Dashboard cookies never authorize agent RPC envelopes, shutdown, or
dashboard.* methods. The listener validates Host and Origin before calling the API.
See Dashboard listener for the login exchange and the checks.
POST /rpc, with Content-Type: application/:
{"method":"sessions.list","params":{"group":"web"}}
Success is {"ok":true,"result":...} (undefined becomes null). A method failure is
{"ok":false,"error":"readable reason"} with HTTP 200. Human authentication failures
are HTTP 401; malformed JSON/envelopes are HTTP 400; unsupported paths/verbs are HTTP
- Invalid agent credentials return an RPC error. Bodies are limited to 16 MiB.
Unknown methods return
unknown method <name>; unknown tools returnunknown tool <name>. Clients must inspectok, not just the HTTP status. Requests do not use JSON-RPC 2.0 IDs or batch arrays. No CORS access is granted.
Interactive front door#
These human RPCs work through both daemon bearer authentication and the dashboard cookie.
team returns {group: string, started: boolean}. It loads
the repository's nid.yaml, selects the named group or the first configured group,
starts missing sessions, resumes stopped sessions, and sends the goal to the lead
(or the first session in a peer team). Existing sessions keep running. The group goal
is updated and the goal is delivered once as a human message. started is true when
no session was running before this call. Concurrent starts are serialized by group.
Configured worker prompts are retained; a new lead receives its configured prompt
along with the goal. A group name belonging to another repository is rejected.
group returns the shared terminal/dashboard completion card:
{
group: string,
goal: string,
done: boolean,
doneNote?: {text: string, author: string, at: string},
tasks: {total: number, done: number, open: number, claimed: number,
blocked: number, review: number, cancelled: number},
agents: [{id: string, name: string, agent: string, state: SessionState,
costUsd: number, tokens: {input: number, output: number}}],
spend: {costUsd: number, estimatedUsd: number, billedUsd: number, budgetUsd: number | null},
tokens: {input: number, output: number},
diffstat: {files: number, insertions: number, deletions: number,
perFile: [{path: string, ins: number, del: number}]},
commits: number,
changedSummary: string[],
rateLimit: RateLimitInfo | null
}
done means the latest lead decision starts with DONE:; a solo session also counts
as the lead. Submitting another goal through team.start clears the previous DONE
card. Worker notes never mark a multi-agent group done. changedSummary contains done
task titles and summaries. Diff totals and commit counts compare the integration
branch with the group's original base; unintegrated work is excluded. Binary files
count as files with zero line counts. Git totals are cached by base and integration
HEAD, making two-second polling inexpensive. rateLimit is the worst reported state,
or null when there is no report. costUsd = estimatedUsd + billedUsd.
session returns "ok" and interrupts that session's turn,
including dismissing pending approvals/questions. Existing interrupt {target}
continues to accept a session or group.
approvals and the existing
approve RPC return resolved approval IDs (number[]). allow defaults to true;
remember accepts only "session". With allow: true, it remembers the matching
tool or command rule for this running agent session. Known command CLIs match their
executable and verb (for example npm install); other simple commands match exactly.
Compound shell commands and broad permission grants always require another answer.
Tool identities come from driver metadata, never display text. Unidentified requests
(including ACP requests without a stable tool identity) cannot be remembered.
Rules end when the session's driver stops/restarts or the daemon restarts.
With allow: false, optional message is delivered as human guidance to the agent;
Claude also receives it in its tool rejection response. Approval records keep their
existing shape {id, sessionId, title, detail, status, createdAt}. Titles now explain
the action using the requesting session's name, such as lead wants to run \npm install x`
or codex2 wants to edit src/a.ts`; detail contains the full request for expansion.
Automatically allowed requests remain in approval history and emit an allowed event.
Stored/streamed tool_call and tool_result events include a one-line summary
(at most 240 characters) for collapsed display, alongside the existing tool, id,
full input/output and result ok. Match calls/results by id when the driver
supplies one. Older stored events may omit summary; clients should fall back to
the tool name. Text output retains its original newlines for the expanded view.
For isolated UI journeys, copy test/ into a temporary Git repo
as nid.yaml, or use writeJourneyConfig(repo) from test/. Its mock
lead runs a deterministic journey on the first goal: command approval, task creation
and claim, a journey.txt change, integration, and a DONE: decision. Denial aborts
the journey. The mock also accepts APPROVE, TASK <path> <text>,
DONE <summary>, and JOURNEY as explicit script lines. No command is actually
executed by APPROVE. Always give test daemons a temporary NID_HOME.
Dashboard listener#
The browser dashboard has its own listener, also bound to 127.0.0.1. The daemon starts
it at launch on port 7420 (or a free port when 7420 is taken; NID
pins the port, off defers it to the first dashboard.open) and records the port as
dashboardPort in daemon.json. It serves the UI and the same POST /rpc and
GET /events and GET /terminal as above, authenticated by a session cookie instead of a bearer token.
Signing in:
- A bearer client calls
dashboard(bearer only). Result:.open {port?} {port, url, loginUrl}; passing a differentportmoves the listener there.nid dashboard [--port n] [--no-open]does this and opensloginUrl. loginUrlishttp:/. The token is single use and expires after 60 seconds./ 127 .0 .0 .1:<port>/ login?token=<48 hex> GET /answerslogin?token=... 303 Location: /withSet-Cookie: nid. Invalid, reused or expired tokens get 401; a cross-site navigation (_session=<random 256-bit id>; HttpOnly; SameSite=Strict; Path=/ ; Max-Age=2592000 Sec-Fetch-Site: cross-site|same-site) gets 403. Sessions live in daemon memory, so a daemon restart signs browsers out.POST /logout(same-origin) deletes the session and clears the cookie (204). It has no body or RPC envelope, does not stop agents, and is idempotent without a live cookie. It still requires a matching Origin orSec-Fetch-Site: same-origin.
Checks on every request, before anything reaches the API:
| Check | Refusal |
|---|---|
Host is exactly 127 or localhost:<port> (DNS rebinding) | 421 |
Origin, when present, equals http:/ | 403 |
Sec-Fetch-Site of cross-site/same-site (except a plain GET /) | 403 |
/rpc, /events, /terminal and assets need a live session cookie | 401 (GET / shows a sign-in hint page) |
POST /rpc carries our Origin, or Sec-Fetch-Site: same-origin when there is no Origin | 403 |
POST /rpc has Content-Type: application/ (no CORS-simple form posts) | 415 |
/rpc is POST only, /events and /terminal GET only | 405 |
Behind the cookie, the API is the human API except that agent envelopes (session set),
shutdown and dashboard.* are refused. The daemon bearer token is not accepted on this
listener and the cookie is not accepted on the bearer port. Responses carry
Content-Security-Policy: default-src 'self'; ...; frame-ancestors 'none',
X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer;
API responses are Cache-Control: no-store. The page never receives the daemon token.
Limits: at most 32 outstanding login links and 64 signed-in browsers (the oldest are
dropped first); at most 64 open /events streams per daemon (NID),
beyond which /events answers 503 with Retry-After: 5; a stream whose client stops
reading is closed once 2 MiB are queued (EventSource reconnects and resumes); request bodies
are limited to 16 MiB. Bearer tokens are compared in constant time.
Residual risk: browsers do not isolate cookies by port, so the session cookie is also sent
to other servers on 127.0.0.1 (any port). A hostile local web server the browser visits could
capture it; such a process already runs as a local user, but on shared multi-user machines
sign out (or let the daemon restart) after use. Login links are single use and expire in 60s,
so a link left in browser history is harmless.
A hosted or relayed UI must reach the daemon only through these same endpoints.
Human RPC methods#
? denotes an optional field; {} means no parameters. All methods require human
authentication. The data records are specified in src/; their public
fields are summarized below. Optional fields omitted in a patch remain unchanged.
| Method | Params | Result and method-specific errors |
|---|---|---|
ping | {} | {pid, version, fingerprint} |
dashboard.open | {port?:number, cwd?:string} | {port, url, loginUrl}; starts/moves the dashboard listener and issues a one-use login URL. cwd (the CLI's working directory) becomes the goal box's prefilled repository when it is inside a git repo. Port 0 chooses a free port; otherwise integer 1–65535. Daemon bearer only; see login exchange. Errors for invalid/unavailable port. |
shutdown | {} | "shutting down"; schedules daemon exit. Daemon bearer only. |
agents.list | {dir?} | AgentInfo[]: built-ins (claude, codex, opencode, kimi, mock) then custom agents (presets, ~/, and the agents: section of the nid.yaml nearest to dir). Each {name, mode: "builtin"|"pty"|"acp", builtin, preset, source: "builtin"|"preset"|"user"|"repo", untested, found: boolean|null, command, description?}; found says whether the command is on the daemon's PATH (null for mock). |
groups.list | {} | Group[], each extended with sessions and tasks counts and cumulative spend: costUsd (all reported), billedUsd (API mode), estimatedUsd (subscription estimates) and budgetSpentUsd (what budgetUsd is compared with: billed, or all with budgetIncludesEstimates). |
group.update | {group, goal?, policy?, setup?, budgetUsd?, settings?} | Updated Group. budgetUsd:null removes the cap; otherwise finite nonnegative USD. settings is merged key by key into the group's settings; a key set to null returns to its default. Errors for missing group, invalid budget, or unknown/invalid settings. Raising a cap wakes queued work. |
group.remove | {group, keepBranches?:boolean} | "removed <group>". Stops sessions, removes group data and isolated worktrees; deletes branches unless kept. Errors for missing group or failed git/filesystem operations. Confirm destructive intent in the UI first. |
spawn | {group, dir, agent, name?, role?, goal?, policy?, setup?, budgetUsd?, settings?, options?, prompt?, configFile?} | Session. Creates group if needed. agent is a built-in or a custom agent resolved like agents.list for dir (or from configFile, a nid.yaml path); custom sessions store the definition in options. Errors for bad group/session name, unknown agent (listing the known ones), duplicate session, invalid budget, or driver/git/setup failure. dir is a path on the daemon's machine. |
sessions.list | {group?} | Session[] with quietSince, quietMs, running:boolean and effective:{permissions, permissionsFrom, mcp} (the profile and MCP mode after group and ~/ defaults; permissionsFrom is session, session (yolo), group, config.yaml or default). Running PTY sessions also have terminal:true and terminalControlled:boolean. Omit group for all projects. |
session.get | {session} | Session with quiet and optional terminal fields as above. Errors if absent. |
session.rotate | {session, mode?:"auto"|"compact"|"handoff"} | Force a context rotation now (idle) or when the current turn ends (working/limited); returns a message. compact uses the agent's own compaction (Claude Code /compact, Codex thread/) and falls back to a handoff when the driver has none; handoff asks the agent for handoff_write, starts a fresh vendor conversation on the same nid session id and delivers the handoff plus the board as its first message; auto (default unless the group sets rotate.mode) prefers compaction. Each rotation is recorded as a rotation event and in Session. Errors when the session is not running, already rotating, under terminal control, or mode is invalid. |
session.model | {session, model} | ModelChange {session, model, applied: "now"|"next-turn"|"on-resume"|"on-restart", message, warning?}. Validates model against models.list for the session's agent (an unknown one errors with a did-you-mean; an empty list accepts any name), stores it in options.model (modelSource: "session") and switches between turns, never mid-turn: Codex/ACP live (Driver), Claude Code by restarting on the same conversation with --model, PTY agents only after a restart, stopped sessions on resume. Billing is kept. warning carries a vendor plan restriction. The model the agent reports is Session.model. |
group.model | {group, model, role?} | (ModelChange | {session, error})[]: session.model for each session of the group (or of role); one session's error doesn't stop the others. |
models.list | {agent?, dir?, group?, role?, refresh?} | ModelList[] (one agent, or all): {agent, models: [{id, label?, description?, aliasOf?, vendorDefault?, unavailable?}], source, default, defaultSource?, switch: "live"|"resume"|"restart", note?, discoveredAt?}. Never spends tokens; Codex's list is cached 10 minutes (refresh asks again). An empty models means unknown. |
session | {session, profile:"worktree"|"ask"|"yolo"} | Set a session's permission profile (options; yolo also sets the legacy options.yolo). A running session restarts on the same vendor conversation so its driver applies the profile. Returns the Session. Errors on an unknown profile. |
session | {session} | Human override: clears rateLimit and wakes held deliveries on a running session (idle, or working if still busy). Returns the Session. Errors when neither a rateLimit record nor limited state exists; warning records can also be cleared. |
send | {to, text} | Human-readable delivery acknowledgement. to is a full session ID or group ID (broadcast). Errors if target is absent. Messages can initiate agent turns. |
stop | {target} | Stopped session ID array. Target is a session or group; errors if absent. |
resume | {target, note?, continue?} | Resumed session ID array. Skips running sessions, and a group resume also skips sessions another resume is starting. Optional note is delivered on resume. continue: true (CLI resume --continue): a session stopped mid-turn gets one message quoting the interrupted input instead. Errors if absent or driver restart fails. |
session | {session, force?} | Native takeover (nid attach --native): stops the session's driver and sets state native; returns {sessionId, agent, billing?, cwd, vendorSessionId, command: string[], env, notes: string[]} for the caller to run with inherited stdio in cwd (env merged over its own). billing contains the session's resolved mode, never a key; the caller must apply that mode locally before launching (the CLI resolves it from its own environment/secret store and releases on failure). Commands: claude claude --resume <id> --mcp-config <file> --allowedTools mcp, codex codex resume <thread> -c mcp, opencode opencode -s <id> with OPENCODE adding the nid MCP server, kimi kimi -S <id> (no MCP). Locks and claimed tasks stay; messages queue and stall/quiet checks skip the session. Errors: unknown session, unsupported agent (PTY/custom agents: use the terminal API), no vendorSessionId yet, already native, or mid-turn/waiting without force:true (force cuts the turn off). |
session | {session} | Ends a native takeover: restarts the driver on the same conversation (resume path), queues a low-priority note telling the agent the human worked in its conversation, delivers queued messages; returns the Session. Errors if the session is not native or the resume fails (state error). resume with the session id does the same; a group resume skips native sessions. |
session | {session, viewer, active:boolean} | {controlled, controller:string|null}. Take/release a PTY terminal input lease using the viewer ID from its open /terminal stream. One controlling viewer at a time; disconnect releases automatically. Holds message delivery, turn-end detection, quiet warnings and stall handling while active. |
session | {session, viewer, data} | null. Send base64 raw input, at most 64 KiB decoded per request. Requires this viewer's control lease. Ctrl-C reaches the agent. |
session | {session, viewer, cols, rows} | null. Resize a PTY (integer dimensions 1–1000); requires this viewer's control lease. Other viewers receive a resize event. |
interrupt | {target} | "ok"; interrupts current work for session/group without removing it. Errors if target absent or driver interrupt fails. |
board | {group} | {group, sessions, tasks, notes, locks, inbox, questions}; sessions are summaries {id,agent,role,state,status,rateLimit} (rateLimit is nullable); questions are pending only. Errors if group absent. Does not mark inbox read. |
task.create | {group, title, detail?, deps?:number[], owner?, budgetMinutes?} | Task. Owner is a full ID or name in group. budgetMinutes (positive, at most a week) sets meta, the task's stall budget; omitted = the group's stallMinutes. Errors for empty title, missing group/deps, invalid owner or budget. |
task.update | {group, id, status?, summary?, owner?, title?, detail?, budgetMinutes?} | Updated Task. budgetMinutes: null restores the group default; transitioning into claimed restarts meta.claimedAt. Empty/null owner releases assignment. Errors if task absent or budget invalid. This human override edits metadata; it does not commit or integrate work. Dependencies are specified on creation. |
review.list | {group} | [{task, review, reviewer}]: tasks in status review with their open review task (or null) and its nullable owner. Errors if group absent. |
review.approve | {group, task:number, notes?} | Outcome text. Human override of the review gate: task is the reviewed task or its review task. Integrates the author's reviewed commit exactly like task; on a merge conflict the task goes back to its author (claimed) to resolve, then integrates without another review. Errors if the task is not in review. |
review | {group, task:number, notes} | Outcome text. Sends the task back to its author (claimed) with the notes as a message, appended to the task detail and posted to the board; the review task is done. Errors for empty notes or a task not in review. |
note | {group, text, kind?} | BoardEntry. Kind defaults to note; use note, decision, blocker, or finding. |
approvals.list | {session?, all?:boolean} | Approval[]; pending by default, all statuses when all:true. |
approve, approvals | {session?, id?:number, allow?:boolean, remember?:"session", message?:string} | Resolved live approval ID array. Defaults to allow; allow:false denies. remember stores a scoped allow rule; denial message guides the agent. Omitted selectors resolve every pending approval. Stale approvals with no live driver waiter are denied and omitted from result. See Interactive front door. |
questions.list | {group?, all?:boolean} | Question[]; pending by default. |
answer | {id:number, text} | Answered Question. Errors for missing question, nonpending question, or empty answer. |
events | {group?, sessions?:string[], after?:number, limit?:number, wait?:number, tail?:boolean} | StoredEvent[] ascending by ID. sessions overrides group. Default after:0, limit:500, wait:0; wait is milliseconds, capped at 30000. tail:true returns the newest limit events, default 50, without waiting. |
session.events | {session, before?:number, limit?:number} | Newest StoredEvent[] strictly before before, returned oldest first. Omit before for newest page. Default limit 100; allowed 1–1000. Errors for missing session or invalid cursor/limit. Empty array means no older retained events. |
inbox | {group?, after?:number} | Human-addressed Message[] after ID (default 0), then marks the group's inbox read; omitted group marks all groups. |
status | {} | {groups:GroupStatus[], totals}: per-group state/task counts, pending approvals/questions, unread messages, spend (costUsd, billedUsd, estimatedUsd, budgetSpentUsd as in groups.list)/budget, budget pause flag and quiet sessions; aggregate totals include groups/sessions/task counts, costUsd/billedUsd/estimatedUsd, nullable budget, pausedGroups. Each group also has limits: ({sessionId} & SessionRateLimit)[] for sessions near (warning) or at (limited) their provider usage limit, and billing: {subscription:number, api:string[]} (count of subscription-billed sessions and the ids of API-billed ones, as decided at their last start). Each group also has compression: {count, originalTokens, compressedTokens, enabled, proxy, memory} (tokens saved in its traffic, resolved switches) and totals have compression: {count, originalTokens, compressedTokens}. |
compression | {group?, session?} | CompressionReport {groups:[{groupId, settings, totals, bySource}], sessions:[{sessionId, groupId, count, originalTokens, compressedTokens, bySource, settings?}], totals, error?}. settings is the resolved compression setting with from (default, global, group, session) per key; a session lists its own settings only when it overrides. error reports an invalid ~/ compression: (defaults are used). Counters persist across daemon restarts. Errors for an unknown group or session. Counters may carry expands and expandedTokens: expand(id) calls by the session and the original tokens they returned (also listed as source expand in bySource); net savings = originalTokens − compressedTokens − expandedTokens. |
compression | {groups:string[]} | GroupRunStats[] in the given order: {groupId, goal, compression:{enabled, proxy, terse, sessionOverrides}, sessions:[{id, agent, role, model?}], tokens:{input, output, cacheRead, cacheWrite, fresh}, turns, costUsd, billedUsd, estimatedUsd, saved:{totals, bySource}, tasks:TaskCounts (work tasks), reviewTasks, changesRequested, reassigned, done:{at, text}|null, startedAt, endedAt, wallMs}. input includes cache reads and writes; fresh is the rest. endedAt is the lead's DONE note time, else the last event. Errors for an empty list or an unknown group. |
diff | {target, stat?:boolean} | Diff text for session/group. Session diff includes committed and uncommitted work; group diff shows integrated work. stat:true returns a diffstat. Errors for absent target or git failure. Nonisolated/no-change cases return explanatory text. |
learn | {group?} | [{group, repo, learnings:[{key, kind, text, count, agents, groups, firstSeen, lastSeen}]}] for one group, or every group without group. Mines the event log, approvals, board notes and tasks (see learnings); read-only, the CLI previews or writes the repo file. kind: command, path, approval, review, stall, conflict. |
land | {group, pr?:boolean, base?:string, draft?:boolean} | Human-readable merge outcome, or Pull request: <url>. Default merges integration branch into the repository checkout. pr:true pushes that branch to origin and creates/reuses a GitHub PR; checkout is unchanged. Optional base defaults to the GitHub default branch; draft only affects new PRs. Errors for missing/non-git group, GitHub authentication/API or git push failure; local landing errors on dirty tracked files/conflicts. |
pr.checks | {group} | {sha, url, checks:[{name,state,url?}], createdTasks:number[], checkedAt, watching}. Fetches the saved PR's current head and CI checks, creates deduplicated failure tasks and offers them to teammates. State: pass, fail, pending, skipping, cancel. Errors when no saved PR, authentication fails, or GitHub is unavailable. Concurrent polls share one request. |
pr.status | {group} | {last:CheckReport|null, watchMinutes:number|null, error:string|null}; cached latest CI snapshot and watch status, without network requests. |
pr.watch | {group, minutes?:number|null} | Same as pr.status. Checks immediately, then polls every minutes (default 1, range 0.01–1440). null stops polling. The interval persists in settings and watches re-arm after daemon restart; cached results last for this daemon lifetime. Errors as pr.checks on the initial poll; later errors are returned by pr.status. |
memory.list | {repo?, group?} | {repo, entries:MemoryEntry[]} for the repository containing repo or group's repository (project memory). MemoryEntry: {id, text, kind:"decision"|"finding"|"note", agent, group:string|null, date, source:"board"|"cli"|"dashboard", hits, noteId?, updatedAt?}. Errors when neither is given, repo is not in a git repository or the group has none. |
memory.add | {repo?, group?, text, kind?, agent?, source?} | {entry, duplicate}; a near-duplicate raises the existing entry's hits instead of adding one. Errors for empty text or an invalid kind. |
memory.update | {repo?, group?, id, text?, kind?} | The updated MemoryEntry. Errors for an unknown id. |
memory.remove | {repo?, group?, id} | The removed MemoryEntry. Errors for an unknown id. |
memory.clear | {repo?, group?} | {removed:number}. |
memory.release | {repo?, group?, id} | The MemoryEntry, no longer quarantined. Entries a revoked remote device added carry quarantined: {reason, at} and device; they are left out of every prompt until released (or removed) on the machine. Errors for an unknown id. |
config.read | {dir?, file?} | {file, groups:GroupConfig[]} parsed from nid.yaml/yml. Searches dir and ancestors unless file supplied; relative file resolves against dir. Default dir is daemon cwd. Errors for no config, unreadable file, invalid YAML/schema, duplicate names. |
up | {dir?, file?, group?} | [{session, action:"spawned"|"resumed"|"running", detail?}]. Same config resolution as config.read. Starts all declared groups or just group. Reuses CLI behavior: spawn missing sessions, resume stopped sessions, leave running ones alone. Errors as config.read, unknown config group, or spawn/resume failure. Earlier successes remain if a later session fails. |
init.detect | {dir} | RepoDetection for nid init (see src/init.ts): {root, group, configFile:string|null, existingGroups, agents:[{name, builtin, vendor, found, login:"yes"|"no"|"unknown", loginHint?, untested, description?}], setup:string|null, setupSources, copy:string[], presets:[{id:"lead"|"review"|"solo", title, description, policy, sessions:[{agent, name, role?}], reviewers?}], errors}. root is the git top-level containing dir; agents and logins are detected on the daemon's machine (PATH, credential files, API key variables); copy lists untracked/ignored local files such as .env* and *.local.*. Errors when dir is missing, absent or not inside a git repository. |
init.write | {dir, preset?, group?, goal?, budgetUsd?, setup?:string|null, copy?:string[], yolo?:boolean, overwrite?:boolean, dryRun?:boolean} | {file, root, group, yaml, summary:string[], next:string[], written}. Detects like init.detect, then writes <root>/ for the chosen preset (default: the first; setup/copy default to the detected ones, setup:null for none; budget default 20). The YAML always validates as a nid.yaml. dryRun:true returns it without writing. Errors for an existing nid.yaml/yml unless overwrite:true, unknown preset, invalid group name/budget, or copy paths outside the repository. Start the group afterwards with up {dir: root, file, group}. |
notify.test | {group?} | NotifyRecord {at, event, deliveries:[{target, status, error?}]}. Sends a test notification to the desktop and every webhook (global config plus the group's settings), bypassing de-duplication, filters and rate limits. Webhook targets are masked to scheme and host. |
notify.config | {group?} | {desktop, webhooks:[{target, kind, events}], events, configFile, configError}: effective targets for the group (or global only). |
notify.recent | {group?, limit?:number} | NotifyRecord[] (oldest first, at most 100, kept in daemon memory) with each delivery's status: sent, failed, deduped, rate-limited, filtered. |
repos.recent | {} | Distinct path string array: group repositories newest first, followed by additional session working directories ordered by latest update. Paths refer to the daemon machine. |
repos.suggest | {refresh?:boolean} | {launch, repos:[{path,name,source}]}: launch is the git repo the last dashboard ran in (or null); repos lists it, then recent group repositories, then git checkouts found by a bounded scan (home 2 levels deep, common code folders 3; at most 1.5s/4000 directories; cached 5 minutes; refresh rescans). source is launch, recent or found. NID_REPO_SCAN (daemon env) is off or a path-list of roots to scan instead. |
repos.check | {dir:string} | {ok:true, root} or {ok:false, problem, message, command?} with problem missing, not-repo or no-commits; command is the exact git command that fixes it. |
Session options are {model?, permissions?:"worktree"|"ask"|"yolo", yolo?, shared?, autoRestart?, extraArgs?:string[], billing?:"auto"|"subscription"|"api", compression?:boolean|CompressionSetting}.
compression overrides the group's and ~/'s compression keys for this session (invalid values error at spawn).
billing overrides the group's and ~/'s billing setting for this session; an
invalid value errors at spawn, and a mode the agent cannot use fails the start with the reason
(for example no subscription login, no API key, or an agent whose billing nid does not manage).
Group policies are lead or peers. GroupConfig contains id, absolute dir, optional
goal, policy, setup, budgetUsd, autoRestart, settings (GroupSettings from the
group's top-level settings keys) and sessions. Each configured
session contains agent, resolved name, and optional model, yolo, shared,
autoRestart, role, prompt, billing, compression.
Pull requests and CI tasks#
land {pr:true} uses gh when installed and authenticated for the origin host
(gh auth status --hostname); otherwise, when the daemon has GH_TOKEN or GITHUB_TOKEN, the
REST API. Error text is redacted (tokens, URL credentials). The origin fetch URL identifies the GitHub repository; git pushes to origin's
configured push URL. PR creation saves Group. It summarizes
the group goal, tasks and summaries, authors/reviewers, session cost and token totals. An open
PR with matching head and base is reused. Subsequent PR landing pushes new integrated work.
Each failing check yields an open task with meta and log links in
its detail. The name/head pair is unique across repeated polls even if its task is already
done. Pending/cancelled/skipped checks do not create tasks. REST polling includes check runs
and legacy statuses with pagination. pr.watch is optional background polling, resumes after
daemon restart, stops on explicit cancellation or group removal, and does not push agent fixes automatically.
For local API integration tests, NID_GITHUB_API overrides the API base and selects REST
instead of gh; normal deployments should leave it unset.
Local worktree files#
spawn accepts settings: {copy?:string[], link?:string[]}; group.update accepts the same
settings patch for future sessions. The lists are persisted on Group.settings; omitted lists
are unchanged, empty lists clear them. config.read/up also accept these keys at group level
in nid.yaml. Example RPC:
{"method":"group.update","params":{"group":"web","settings":{"copy":[".env","config/**/*.json"],"link":["node_modules"]}}}
Paths are relative to Group.repo. Copy supports files/directories and *, ?, ** globs;
link supports directories/globs (symlinks on Unix, junctions on Windows). New isolated worktrees
are populated before setup. Existing files are preserved and tracked files are skipped.
Missing/unmatched sources emit a session notice. Absolute, parent-traversing and .git paths
are rejected; source symlinks are skipped. Git info/exclude entries prevent normal task commits
from including copied/linked paths. Excludes are shared across the repository's worktrees.
Existing/resumed and shared-checkout sessions are not populated.
Group settings#
Group.settings holds optional per-group settings (all keys optional; omitted keys use the
default). They can be set in nid.yaml (as top-level keys of a group), with
nid group set <group> key=value..., or with group.
| Key | Type | Default | Feature |
|---|---|---|---|
stallMinutes | minutes | 30 | Default per-task time budget |
stallGraceMinutes | minutes | 10 | Grace after a stall notice before the task is released |
maxReassign | integer | 2 | Automatic releases per task before escalating to the human |
idleClaimMinutes | minutes (0 = off) | 2 | An owner idle this long with a claimed task is re-prompted |
idleClaimNudges | integer | 3 | Re-prompts (each gap twice the last) before the idle owner's claims are released |
limitReleaseMinutes | minutes | 20 | Release a rate-limited session's task when the wait is longer |
limitWarnPercent | 1–100 | 90 | Usage percentage that shows an early warning |
copy | string[] | [] | Untracked paths/globs copied into each new worktree |
link | string[] | [] | Directories symlinked (junctions on Windows) into each new worktree |
review | off|auto|reviewers | off, or reviewers when the reviewers list is nonempty | Review gate before integration; explicit off disables it even with a reviewers list |
reviewers | string[] | [] | Session names that review when review is reviewers |
notify | {desktop?, webhooks?:[{url, kind?:"slack"|"discord"|"json", events?}], events?} | desktop on | Notifications |
pr | {url, number, base?, watchMinutes?} | — | Set by land --pr / pr.watch |
fallback | "api"|"off" | off (or fallback: in ~/) | Opt-in rate-limit fallback: when a session billed on its subscription becomes limited, it resumes on the same conversation with API billing until the limit resets (Session), then switches back; skipped without an API key or when the budget is spent. Each switch is a session notice, a board note, a human message and a limit notification |
excludes | string[]|false | built-in artifact patterns | Extra gitignore patterns nid leaves out of its automatic commits (untracked files only; tracked files are never excluded); false turns artifact filtering off. See docs/configuration.md |
defaultExcludes | boolean | true | false drops the built-in artifact patterns (__pycache__/, node_modules/, *.log, ...) and keeps only excludes |
mcp | "project"|"all"|"none" | project (or mcp: in ~/) | MCP servers the group's agents load (options.mcp wins): project = nid's server plus the repository's own MCP config, all = also the user's servers and connectors, none = only nid's server. See docs/configuration.md |
permissions | "worktree"|"ask"|"yolo" | worktree (or permissions: in ~/) | Permission profile for the group's sessions (options wins): worktree auto-allows reads, in-worktree edits, test/build/lint runners and read-only git; ask routes every request to the human; yolo disables prompts from the next agent start. Auto-allowed requests emit approval events with approvalId: 0 and auto (the reason) and create no approval record. See security |
crashResume | "stopped"|"continue" | stopped (or crashResume: in ~/) | After the daemon stopped without a clean shutdown (crash, kill, reboot, logoff), what the next (unattended) start does with the group's sessions that were running: stopped leaves them stopped and tells the human and the lead, quoting mid-turn inputs; continue resumes them and sends each mid-turn session one message quoting its interrupted input (crash-loop guard and NID still apply). The human continues by hand with resume {target, continue: true}. See troubleshooting |
billing | "auto"|"subscription"|"api" or {default?, <agent>?: mode} | auto | Subscription login vs API key for the group's sessions, one mode or per agent name; a session's options wins, ~/ billing: applies when the group sets nothing. See docs/billing.md |
compression | boolean or {enabled?, board?, messages?, diffs?, ci?, handoff?: boolean, minTokens?:integer, ttlHours?:number, proxy?:"off"|"on", terse?, proxyTerse?, memory?, learnAfterDone?: boolean} | {enabled:true, every source on, minTokens:300, ttlHours:24, proxy:"off", terse:false, proxyTerse:false, memory:true, learnAfterDone:false} | Token compression. Merged per key: defaults < compression: in ~/ < this group setting < a session's options; true/false is shorthand for {enabled}. enabled switches compression of nid's own traffic, the source keys narrow it. Reported by compression. See docs/compression-settings.md |
models | {<agent>?: string, roles?: {<role>: string | {<agent>: string}}} | none | Default models for sessions spawned into the group, over the file's top-level models: and ~/ (see docs/configuration.md#default-models). Running sessions change with session.model / group.model |
rotate | {mode?:"auto"|"compact"|"handoff"|"off", contextPercent?:10–100, maxTurns?:integer} | auto, 85, none | Long-running sessions: rotate a conversation when its context window is contextPercent full or after maxTurns turns (see session.rotate) |
budgetIncludesEstimates | boolean | false | Count estimated subscription spend (Claude Code reports an API-equivalent cost even on a plan) against budgetUsd, not just billed API spend |
Notifications#
The daemon raises notifications for: approval (an agent requests permission), question
(ask_human), blocked (task marked blocked), stall, limit (rate limit), done (a DONE:
decision note), budget (budget reached), review and pr. Targets are the desktop (Windows toast
via PowerShell, macOS osascript, Linux notify-send; skipped when unavailable;
NID disables it) and webhooks from $NID:
notify:
desktop: true
events: [approval, question, blocked, stall, limit, done, budget] # optional filter
webhooks:
- url: https://hooks.slack.com/services/... # Slack: {"text": ...}
- url: https://discord.com/api/webhooks/... # Discord: {"content": ...}
- url: https://example.com/nid
kind: json # {event, group, session, title, body, suppressed, at, source}
events: [done]
merged with the group's settings (group desktop/events win; webhooks are combined).
An event key is sent once per 10 minutes; each target gets at most 6 notifications a minute, and
the next delivery reports how many were suppressed.
Remote access (cloud.*)#
Remote access is docs/remote.md. These methods drive it from the CLI
(nid cloud ..., behind --experimental) and the local dashboard; both listeners expose them.
Operations that turn "can reach" into "can read" (link and re-link, relay change, key rotation,
pairing, raising a role, widening a scope) run the platform user verification inside the
daemon, in the method itself (2.4): no parameter can assert
that a person was verified, so it does not matter which local caller asks. A refused or failed
verification is an error naming the reason; noUserVerification is accepted only on a machine
without a verifier (first link, and pairing capped at view).
The Remote column is the method's tier for a paired device (src/,
5.3): READ for every role, ADMIN for admin
devices, LOCAL never through the channel. From a device, roles can only be lowered, scopes only
narrowed, a device cannot revoke itself, a scoped admin reaches only devices inside its scope, and
cloud shows its code on the machine only.
| Method | Remote | Params | Result and method-specific errors |
|---|---|---|---|
cloud.status | READ | {} | CloudStatus {enabled, linked, relay, daemonId, fingerprint, publicKey, account, linkVerifiedBy, connection:{connected, since, error}, devices, devicesOnline, unknownDevices:{count, lastAt, lastDeviceId}, rotation:{oldFingerprint, graceUntil}|null, pairing:{mode, state, expiresAt, failures, consumedBy, competing, awaitingConfirmation}|null, hostedConsoleMaxRole, verifier:{available, method, reason?}|null}. |
cloud.login | LOCAL | {relay, token, daemonId?, account?, noUserVerification?} | CloudStatus. Links this daemon to a relay (wss://, ws:// only for loopback) with the connector token the relay issued; the first link generates the daemon key (cloud-key in the secrets store) and daemon id, a re-link keeps both. User verification required; a re-link without it is refused. |
cloud.logout | LOCAL | {} | CloudStatus. Drops the connector token and the link; keeps the key and the paired devices. |
cloud.relay | LOCAL | {url} | CloudStatus. Routes through another relay (self-hosted exit route). User verification required. |
cloud.on, cloud.off | LOCAL | {} | CloudStatus. Opens or closes the outbound connection without unlinking. on errors when not linked. |
cloud | LOCAL | {compromised?:boolean} | {fingerprint, graceUntil:string|null, repair:number}. Routine rotation keeps the old key 30 days to announce the new one; compromised destroys it at once and marks every device re-pair (3.5). User verification required. |
cloud | ADMIN | {mode?:"qr"|"code", role?:"view"|"operate"|"admin", groups?:string[]|null, console?, noUserVerification?} | Locally: PairingInfo {id, mode, role, scope, expiresAt, url? (QR, contains the one-time secret), code? (80-bit short code), routingToken?, daemonFingerprint, consoleHost}, valid 120 s or one pairing. From an admin device: {id, expiresAt, shownOn:"machine"} (never the secret), at most the caller's role and within its scope, and never replacing a code the machine is showing. Refused without a verifier unless noUserVerification (local only; caps the device at view). console (local only) is the console origin the URL points at; cloud caps devices paired through the hosted console. |
cloud | LOCAL | {} | The live PairingInfo again, or null. |
cloud | LOCAL | {offerId?} | [{id, offerId, label, platform, fingerprint, deviceId, requestedRole, role, scope, reportedConsole, expiresAt}]: devices that completed the pairing handshake (message 3) and wait for the human. label and platform are cleaned (chosen by whoever scanned). |
cloud | LOCAL | {id, offerId, accept:boolean} | {ok:true, recordId, role} or {ok:false, reason}. Accepting runs user verification with the prompt Pair device <fingerprint> as <role>, <scope>? Label: <label> (short codes add the daemon fingerprint) and writes the registry row. Errors for an unknown or expired request, or a replaced code. |
cloud | LOCAL | {offerId?} | null. Drops the code and declines anything waiting on it, with a notice. If offerId is supplied, only that offer is cancelled; stale CLI cleanup cannot cancel a newer code. |
cloud | ADMIN | {} | Device[]: {recordId, deviceId, publicKey, fingerprint, label, platform, role, scope:string[]|null, consoleHost, verifiedBy, noUserVerification, createdAt, lastSeenAt, rotations, status:"active"|"repair", reportedConsole, online, pushSubscribed}. |
cloud | ADMIN | {id, label} | Device. id is the record id or the device id. |
cloud | ADMIN | {id} | {revoked: Device, summary}. Closes the device's channels at once, refuses its handshakes, tells the relay to drop its routing, and quarantines what it left behind (summary, e.g. 3 tasks, 1 note, 2 memory entries; 3.4). A device cannot revoke itself. |
cloud | LOCAL | {except?} | {revoked: [{device, summary}]}: revokes every device except except. |
cloud | ADMIN | {id, role} | Device. Lowering is immediate; raising is local only, needs user verification and respects the --no-user-verification and hosted-console caps. |
cloud | ADMIN | {id, groups:string[]|null} | Device. null = every group. Narrowing is immediate; widening is local only and needs user verification. |
The connector's wire protocol to the relay (relay_ctl operations on channel 0 for routing tokens
and revocation, and the self-hosted relay's HTTP and WebSocket endpoints) is
docs/remote.md appendix C;
the frames inside a device channel are appendix B.
A device reaches every other method only as allowed by the allowlist above; destructive ones
(group.remove, memory.clear, session, taking terminal control)
answer the first call with {twoStep:true, confirm, expiresInSeconds:60, message} and run on a
second call that repeats the parameters plus confirm.
Public records#
- Group:
id, nullablerepoandbase,goal,policy,setup, nullablebudgetUsd,settings,createdAt. - Task:
id,groupId,title,detail,status, nullableowner,createdBy,deps,summary,meta(feature bookkeeping:budgetMinutes?,claimedAt?,reassignments?:[{from, at, reason}],review?,check?),createdAt,updatedAt. Statuses: open, claimed, review (finished, waiting for a reviewer; see review.*), blocked, done, cancelled.meta.reviewon a reviewed task:{task, reviewer, round, head, verdict?, by?, notes?, approved?}(its current review task, round number, reviewed commit); on a review task:{of, author, round, head}. - Session:
id,groupId,name,agent,role,state,cwd, nullablebranch,vendorSessionId,pid,status,options,costUsd(all reported cost),billedUsd(the part paid through an API key: turns run while the session's resolvedbilling.modeis"api"; sessions without a resolved mode useoptions, and the mock driver counts as billed unless.billing billing:"subscription"; the rest ofcostUsdis an API-equivalent estimate on a subscription plan; absent on records from older daemons = all billed), nullableusageWindow({usedPercent?, resetsAt?, kind?, at}: the latest plan usage window the agent reported, at any level),inputTokens,outputTokens,turns, nullablecontext({used, size, updatedAt, turns, rotations, lastRotation?:{at, mode, reason}}: tokens in the context window after the last model call and the window size as reported by the agent, null when unknown;turnscounts turns since the conversation started or was last rotated), nullablebilling({mode:"subscription"|"api"|"unmanaged", requested:"auto"|"subscription"|"api", detail}: how the agent was billed at its last start, e.g. detailclaude.ai maxorAPI key from $ANTHROPIC;_API _KEY unmanagedfor agents that choose billing themselves;fallback?:{until, since, reason}while a rate-limit fallback bills the API key (group settingfallback); null before the first start), nullablerateLimit({status:"ok"|"warning"|"limited", resetsAt, usedPercent?, kind?, message?, since?}; nid stores only "warning" and "limited"),createdAt,updatedAt. States: starting, idle, working, waiting, limited (provider usage limit reached; resumes atrateLimit), native (driver paused for a native takeover; see.resetsAt session), stopped, error. A daemon restart turns.takeover nativesessions intostopped. Quiet fields are nullable timestamps/durations computed at read time.rateLimitis persisted:warningwhen a usage window is at least the group'slimitWarnPercent(default 90) full,limitedwhile the provider limit is hit. Alimitedsession holds all deliveries untilresetsAt(plus a few seconds), then returns to idle, re-queues the input of a turn the limit cut short and delivers what was held. When the wait is longer than the group'slimitReleaseMinutes(default 20) its claimed tasks are released back to open (recorded in taskmetaand on the board) and offered to idle teammates. The limit survives stop/resume and daemon restarts..reassignments sessions.listandsession.getaddcompression: {count, originalTokens, compressedTokens, enabled}: tokens compression saved in the session's traffic (persisted) and whether compression is on for it. - BoardEntry:
id,groupId,author,kind,text,createdAt. - Lock:
groupId,path,session,reason,expiresAt(expired locks are removed when read). - Message:
id,groupId,from,to,text,createdAt. - Approval:
id,sessionId,title,detail,status(pending, allowed, denied),createdAt. - Question:
id,groupId,sessionId,text,status(pending, answered, timeout), nullableanswer,createdAt, nullableansweredAt. - StoredEvent:
id,sessionId,ts,event(tagged AgentEvent below).
Agent RPC tools#
Agents POST the same RPC envelope with session:"web/ and their per-session
bearer credential. The result is always {text:string, notices:string[]}. Notices are
messages delivered while the agent was working. Tools are scoped to the authenticated
session's group; human credentials/cookies cannot impersonate a session.
| Method | Params | Text result / errors |
|---|---|---|
board_read | {since | Formatted group goal, team, tasks, notes after cursor, locks. With board compression on (default): compact, and incremental after the first read in a conversation (full:true for everything); a footer carries the expand id of the full board. With it off: latest 40 notes after cursor, as before. See compression. |
expand | {id} | The original text of a compressed block (c_ + 8 hex). Errors for unknown or expired ids. |
board_note | {text, kind?} | Board acknowledgement; defaults kind to note. A lead decision starting DONE also notifies the human. |
task_create | {title, detail?, deps?:number[], owner?, budget | Created task number/title. Errors as human task.create. |
task_claim | {id:number} | Claimed task and sync result. Errors for missing task, other owner, nonopen status, unfinished dependencies, or git sync failure. |
task_update | {id:number, status, summary?} | Outcome. Supports done, blocked, open, cancelled (claimed is retained by the internal fallback). Done requires summary and commits/integrates isolated work; when the group's review gate is on, done commits the work and moves the task to review instead (a review task is created for a reviewer), and integration waits for approval. Review tasks themselves are closed with review_approve / review_request_changes, not task_update. Errors for missing task, disallowed ownership/policy, missing done summary. Merge conflicts return instructions to resolve and retry, keeping work uncompleted. |
lock_acquire | {paths:string[], ttl | Lock acknowledgement or conflict details. Default TTL 60 minutes. Empty paths error. Acquisition is all-or-nothing. |
lock_release | {paths?:string[]} | Number released; omit paths to release every owned lock. |
msg_send | {to, text} | Delivery acknowledgement. Target is full ID, teammate name, *, or human; cross-group/unknown sessions error. |
status_set | {text} | Status acknowledgement (text truncated to 200 characters). |
handoff_write | {summary} | Stores a handoff (up to 20,000 characters). During a nid-requested handoff rotation it is the summary the fresh conversation starts from; called on its own it makes nid rotate the session with that summary when the current turn ends. Empty summary errors. |
ask_human | {question, timeout | Blocks until answered, timed out, or session stopped, then returns answer/instructions. Default 30 minutes; positive finite timeout required, capped at 24 hours. Empty question errors. |
review_approve | {task:number, notes?} | Outcome. task is the reviewed task or its review task. Only the assigned reviewer or the lead may call it. Integrates the author's reviewed commit and marks the task done (reviewer recorded in meta.review); conflicts send it back to the author. |
review | {task:number, notes} | Outcome. Same permissions. The task goes back to its author (claimed) with the notes; the review task is done. Notes are required. |
sync | {} | Git merge outcome/conflict instructions or nonisolated-worktree message. Git failures return RPC errors. |
Completing a task with a summary does not require a new file edit. When its work is
already integrated, task succeeds and explains that there are no new
changes to commit. Edits that normalize away during staging (such as line-ending-only
changes) also avoid an empty commit. If a submodule still has unfinished changes,
completion instead asks the agent to check git status and commit those changes first.
Raw terminal stream#
GET / opens a running PTY's raw terminal. It requires
human bearer authentication or a dashboard cookie; agent credentials cannot read it or call
terminal RPCs. Missing sessions and structured/stopped sessions return HTTP 400 JSON errors.
The response is text/. Events are JSON:
| Event | Data |
|---|---|
snapshot | {viewer, data, cols, rows, controlled, controller}. First frame; data is base64 raw terminal bytes (rolling buffer, about 256 KiB). viewer identifies this connection for control RPCs; controller is the controlling viewer ID or null. |
data | {data}: subsequent raw bytes, base64 encoded. Preserve byte boundaries when decoding UTF-8; use a streaming decoder or pass bytes to a terminal emulator. |
control | {controlled, controller} whenever input ownership changes. |
resize | {cols, rows} when the controlling viewer changes terminal dimensions. |
closed | {reason} when the driver stops or is replaced. Close the client stream. |
Heartbeats arrive every 15 seconds. There are at most 64 terminal streams per daemon (503 beyond the cap), with a 2 MiB output queue per connection; slow clients are disconnected. This stream has no event IDs or historical replay cursor. Reconnect gets a new viewer ID and fresh snapshot; reset the terminal emulator before replaying it. A rolling raw buffer may lack older screen state.
To type, call session with active:true and this stream's viewer ID, then use
session / session. Another controlling viewer, an expired
viewer ID, a viewer from another session, malformed base64 or invalid dimensions returns an RPC
error. Input never goes through the chat queue. Control pauses automated turn-end detection and
delivery (including human chat messages) without stopping the process. Release with active:false;
closing the stream releases automatically. Queued messages resume after any pending PTY work
settles. Control is ephemeral and is lost on stop or daemon restart.
nid attach --tty uses this API and reserves Ctrl-] for local detach. The dashboard bundles
xterm.js and its CSS in its offline assets, starts read-only, and enables input after Take control.
Server-Sent Events#
GET /events with human authentication returns text/. Browser clients
use a same-origin EventSource and their cookie. Native/relay clients use daemon bearer
authentication. Query parameters:
group: repeatable or comma-separated group IDs.session: repeatable or comma-separated full session IDs.after: nonnegative event ID, default 0.
Nonempty Last-Event-ID takes precedence over after (an invalid or empty supplied
header returns HTTP 400). Group and session filters intersect; values within each filter
are ORed. Session filters still receive group-wide hints affecting their group, such as
task and goal changes. Group streams automatically include sessions created later.
Each connection first receives hello. Refetch snapshots on every hello because
change hints are not persisted. Stored events strictly newer than the cursor are
replayed chronologically, then live events follow without a subscription gap. Replay
retains at most the newest 1000 matching events; overflow sends resync before that
bounded replay. Fetch older transcript pages through session.events as needed. Event
retention can remove historical rows; this stream is not a permanent audit log.
Only agent frames carry an SSE ID. EventSource automatically sends that ID on reconnect;
deduplicate by ID when combining replay, RPC transcript pages and live activity. The
hello is the global high-water mark, not necessarily a matching event.
Comments : heartbeat are emitted every 15 seconds. Disconnects remove listeners and
timers; clients falling behind more than 2 MiB of buffered frames are disconnected and
can reconnect/replay. A daemon restart or reconnect can lose ephemeral hints, hence the
snapshot refresh requirement.
hello#
event: hello
data: {"lastEventId":42,"version":"0.1.0-alpha.2"}
agent#
StoredEvent plus groupId. The ID is global, monotonically increasing while the same
database is used, and is unrelated to task or approval numbering.
id: 43
event: agent
data: {"id":43,"sessionId":"web/worker","groupId":"web","ts":"2026-10-07T20:00:00.000Z","event":{"type":"text","text":"Tests passed"}}
AgentEvent variants (all fields below are inside event):
| type | Fields |
|---|---|
| turn_start | input:string |
| text, thinking, notice, error, stderr | text:string |
| tool_call | tool:string, id?:string, summary?:string, input?:unknown |
| tool_result | ok:boolean, id?:string, tool?:string, summary?:string, output?:string |
| turn_end | ok:boolean, costUsd?:number, costKind?:"billed"|"estimated" (with costUsd; absent on older events = billed; agents that report only tokens get a list-price estimate, always estimated, see src/pricing.ts), inputTokens?:number, outputTokens?:number, error?:string |
| state | state:SessionState, detail?:string |
| approval | approvalId:number, title:string, status:pending/ |
| rotation | mode:"compact"/, reason:string, ok:boolean, fromVendorId?, toVendorId? (handoff: old and new vendor conversation ids), summary?:string (the handoff), error?:string |
change#
event: change
data: {"kind":"approval","group":"web","session":"web/worker"}
Nonpersistent invalidation hint {kind, group, session?}. These have no ID and cannot
be individually resumed. Mutations committed by human RPCs, agent tools, and background
drivers all emit hints. Rolled-back transactions emit none. Clients may coalesce bursts.
| kind | When | Suggested snapshot refresh |
|---|---|---|
| group | Group created/updated/removed, budget pause changes | groups.list, status, board |
| session | Session created; state, status, cost, tokens, driver identity/PID changes | sessions.list, session.get, status |
| board | Tasks/notes/locks created/changed/released or expired locks removed | board, status |
| approval | Approval created or resolved | approvals.list, sessions.list, status |
| question | Question asked, answered or timed out | questions.list, board, status |
| message | Message queued (including human inbox), inbox read marker advances | inbox or board, status |
Quiet notices are agent notice events; refresh session/status snapshots as the UI's
clock advances to keep quiet duration current. Locks expire when read, not on a timer.
resync#
event: resync
data: {"reason":"replay_limit","lastEventId":5000}
Reasons are replay_limit (more than 1000 matching retained events after the cursor)
or cursor_ahead (cursor beyond the current database's last stored event). Refetch
snapshots and transcript tails; for cursor_ahead reconnect without the obsolete cursor.
This hint has no ID. Never interpret an incomplete replay as the complete transcript.