Skip to content

API reference

The daemon (nerthusd) exposes its entire functional surface over loopback HTTP as a closed route table — fixed at build time in daemon/routes.ps1. Every consumer (the Verb-Nerthus* cmdlets, the web dashboard, a future Discord/Margonem bot) reaches Nerthus through this one API; if a behavior is not a route here, no client can do it. Each cmdlet wraps exactly one route.

This page is the shared contract every endpoint obeys — base URL, auth, the cmdlet mapping, the middleware chain, envelopes, status codes, jobs, and the event stream. The per-domain pages below carry the route tables and a worked, executed example per route.

The pages

Each page owns one slice of the route table, in the Tussal/Eraster world:

Every example on these pages is executed

The request in each HTTP tab is the exact request CI sends against a real daemon on the canonical Tussal/Eraster fixture; the documented response is asserted. A wrong example turns the pipeline red — the reference cannot drift from the code, because the reference is the test corpus (tests/DocExamples.Tests.ps1). The JavaScript tab is a projection of the same request, shown for browser consumers, not independently executed. Names in paths are URL-encoded; the examples run with a full-capability token unless a section shows a scoped one.

Base URL and auth

  • Base URLhttp://127.0.0.1:{port}/v1/api. The daemon binds 127.0.0.1 on an OS-assigned port (pin it with config server.port), written to .nerthus/runtime/daemon.port; discovery and auto-spawn are in Architecture.
  • Versioning — the version lives in the path (/v1/api/...); a breaking change would add /v2/api/... alongside.
  • Bearer auth — every request carries Authorization: Bearer {token}. Token kinds (machine, session, named) and minting are owned by Permissions.
  • Capability — each route declares one required capability (entity.read, pu.award, player.write.own); .own grading and the role bundles are owned by Permissions, the id list by Capabilities.

The cmdlet mapping

Every Verb-Nerthus* cmdlet does the same three things: discover the daemon (walk up to the nearest .nerthus/ marker, read runtime/daemon.port + daemon.token, auto-spawn and wait up to 60 s for GET /health), make one HTTP call to the single route it wraps, and project the JSON into [PSCustomObject]s (collection cmdlets emit one object per item). A non-2xx throws one generic terminating error carrying the status and body; on a 401 the client re-reads the possibly-rotated token once and retries. A convenience that needs two domain calls is a workflow route surfaced as one cmdlet (Close-NerthusSessionPOST /workflows/close-session). Verbs come from the approved set (Code style); Find-* (search) is kept distinct from Get-* (fetch by key).

-WhatIf?dryRun. Mutating cmdlets declare SupportsShouldProcess; -WhatIf appends ?dryRun=true, so the daemon validates and reports the would-be change without touching disk. A handful short-circuit locally instead (no HTTP call): Stop-Nerthus, Set-NerthusMode, Set-NerthusSessionHash, Set-NerthusSessionGraph, Invoke-NerthusSessionDistribution, Invoke-NerthusSessionLogFetch, Invoke-NerthusReindex, Set-NerthusNarratorPermission, and Get-NerthusSessionLog -Refresh/-FetchMissing. Three passthrough parameters:

Parameter Effect
-Daemon <uri> Skip discovery; talk to an explicit daemon base URL.
-Token <string> Override the discovered token (e.g. an identity-bound session token).
-AsJob On Invoke-NerthusPUAssignment only: send ?async=true, return the 202 job handle.

The middleware chain

Every request flows through one fixed pipeline; the route table is consulted in the middle and never extended at runtime:

Authenticate (401) → RouteMatch (404) → CapabilityCheck (403)
  → WriteGate (403) → ContentType (415) → body parse (400)
  → BeforeWrite (422) → dispatch
  • Authenticate precedes RouteMatch: an anonymous probe of an unknown path is 401, not 404. Only the public routes ( capability) answer without a token.
  • WriteGate: every Write-flagged route funnels through Assert-NerthusWriteAllowed, which refuses with 403 in read-only mode, on a not-yet-adopted repo (SchemaTooOld), on index-format drift (SchemaTooNew), or on a stale sync (SyncStale). The Write flag is per route, never derived from the method — a read-only POST (e.g. POST /resolve) is never gated.
  • BeforeWrite fires after the gate; a throw rejects the write 422 WriteRejected. Closed-schema validation (unknown @typ/tag) lives in the handlers.
  • dispatch also fires the After* hooks (audit, SSE, Discord); a throw there is a warning — the write already stands. The hook list is owned by Architecture.

The Write flag marks routes that mutate lore/index files, plus durable governance/identity state and the log-fetch archive writes. Deliberately unflagged: the control-plane recovery routes (POST /import, POST /admin/mode, POST /admin/shutdown, POST /sync) that must stay operable to recover from a lock; POST /discord/send (writes only delivery state); and the in-memory rebuilds (POST /name-index/rebuild, POST /sessions/graph).

Envelopes

Mutating bodies are JSON. Responses are always JSON, in one of three shapes:

  • Single object — one entity, session, or report, returned directly.
  • List envelope{ "count": N, "items": [ … ] }, never paginated (loopback, single-consumer). A few routes name their collection after the domain (GET /routesroutes, GET /pu/historyassignments, GET /economy/timelinepoints, GET /capabilitiescapabilities, GET /entities/{name}/sessionssessions, GET /sessions/narrator/{nick}/profilesessions). The wrapping cmdlet unwraps whichever the route uses.
  • Job envelope — the monthly PU batch (POST /workflows/award-pu) runs as an in-daemon job. It executes synchronously; by default the route returns 200 with the result inline plus a JobId. ?async=true (-AsJob) returns 202 + { jobId, statusUrl, status }; poll GET /jobs/{id} (completed/failed) and GET /jobs/{id}/result. Jobs are synchronous — no progress stream, no cancel.

Every mutating route accepts ?dryRun=true: validation runs and the response describes the would-be change without touching disk. Two exceptions still run their network step under a dry run — POST /sync (refreshing origin refs is the point; it skips commit/push and reports wouldPublish) and POST /sessions/graph (an in-memory rebuild).

Status codes

Code Meaning
200 Success (including every dry run).
201 Resource created (New-Nerthus*, an added character-info entry).
202 Job accepted (?async=true); poll statusUrl.
400 Malformed JSON body or missing required parameter.
401 Missing or invalid Bearer token (also anonymous probes of unknown paths).
403 Capability denied, an own-scoped caller targeting a foreign subject, or the write gate refusing (SchemaTooOld / SchemaTooNew / SyncStale).
404 Route not in the closed table, or resource not found.
409 Conflict — an ambiguous Margonem id, a settle op the ledger echo already lists (PULedgerDrift / TransferLedgerDrift), a stale character-info match, an already-taken (name, type), a webhook overridden by an overflow block, or a POST /sync a daemon cannot run (SyncDisabled / NotAGitRepo / GitMissing / NoSyncBranch).
415 Missing/wrong Content-Type on a mutating call.
422 Body parsed but failed domain validation — unknown @typ/tag, PUUnresolvedCharacters, a BeforeWrite rejection.
500 Unhandled handler error (its message is the body's error).

Error bodies are contract. Every error carries { "error": "<id>", … }; the id string is stable and tested. SchemaTooOld fires in read-only mode and on an un-adopted repo — the remedy is Initialize-NerthusRepo (POST /import). SchemaTooNew fires when a clone pulled an index-format bump its module does not understand yet. SyncStale carries the remedy ladder — the rule is owned by Sync; PUUnresolvedCharacters returns its grouped unresolved[] body (PU model).

Server-Sent Events

GET /v1/api/events (capability events.subscribe) keeps an open text/event-stream, handed to a dedicated pump thread so it never stalls ordinary requests. Frames are named events with sequence ids (id: 42 / event: pu:awarded / data: {…}), a keepalive comment every 30 s, and ?since=<seq> replays buffered events after <seq> (?since=0 = the whole buffer). Auth is in the Authorization header, never the URL — a browser consumes the stream with fetch + ReadableStream, not EventSource.

Event Fires when
entity:write An entity is created/updated/soft-deleted.
session:distributed A session was distributed to its participants' files.
pu:awarded A monthly PU batch committed.
transfer:applied A session's @Transfer directives were applied to holdings.
governance:changed A narrator's Uprawnienia row was edited.
sync:completed A sync tick finished — data carries the action, outcome, and SHAs (Sync).

See also