Skip to content

Discord

Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.

The daemon delivers PU and @Intel notices to a player's Discord channel, named by their @discord tag (channel, category); the bot resolves the name and posts to it, and every send is recorded in a delivery log. This page covers sending a message and reading back the delivery outcomes. Sending reaches the network, so the example uses ?dryRun=true — a pure preview that POSTs nothing and writes no log record.

Sending needs discord.send; reading the log needs log.read. Every example runs against a live daemon — see how the reference is tested.

Routes

Method Path Cmdlet Cap Write
POST /discord/send Send-NerthusDiscordMessage discord.send
GET /discord/deliveries Get-NerthusDiscordDeliveryLog log.read
POST /discord/dispatch discord.dispatch

Cap is the required capability ( = public, no token); Write () marks routes that pass the write gate; a in Cmdlet is reached directly, no wrapper. Paths are relative to /v1/api; the cross-cutting contract — middleware, envelopes, status codes — is on the API reference index.

POST /discord/send is not Write-flagged — its only write is private delivery state, so it stays operable in read-only mode.

Preview a send without reaching Discord

POST /v1/api/discord/send posts a message to a channel — targeted by { channel, category } or resolved from an { entity }'s @discord. With ?dryRun=true it returns 200 { Delivered: false, Result: "WHATIF" }, echoes the entity and message, and makes no HTTP request — proven by the follow-up: the delivery log stays empty. Here the fixture Gracz Stefan, whose channel is resolved from his @discord.

POST /v1/api/discord/send?dryRun=true
Content-Type: application/json
Authorization: Bearer <token>

{ "entity": "Stefan", "message": "Test" }
await fetch("https://evocation.nerthus.pl/v1/api/discord/send?dryRun=true", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({
    entity: "Stefan",
    message: "Test",
  }),
}).then((r) => r.json());

Response 200:

{ "Delivered": false, "Result": "WHATIF", "Entity": "Stefan", "Message": "Test" }

The delivery log

GET /v1/api/discord/deliveries returns the { count, items } envelope of actual send outcomes, newest first. Filter with ?operation=, the label the send recorded — message by default, pu from the monthly PU batch, Intel from session close, and one label per internal notice (close-reminder, peer-watch, …). The filter runs before the cap: the envelope carries at most 500 matching records. A dry run never records, so on a fresh daemon the log is empty.

GET /v1/api/discord/deliveries
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/discord/deliveries", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 200:

{ "count": 0, "items": [] }

The worker dispatch — discord.dispatch

POST /v1/api/discord/dispatch is the ingest Worker's daemon-first path: the Worker at conjure.nerthus.pl verifies the interaction's Ed25519 signature, acks Discord, then offers the interaction here before deciding what a decline costs (Fleet owns the switch's design). The bearer named token is the trust boundary — the daemon trusts the forwarded Discord id exactly as the CI daemon trusts the same value as a trigger variable.

Body: { "interactionId": "<snowflake>", "kind": "close|report|query", "sentAtEpochMs": <epoch ms>, "vars": { … } } where vars is the exact trigger-variable dictionary the Worker builds. An absent kind is inferred from the vars, so a Worker deployed before the kinded contract keeps working.

kind vars it needs write gate what a decline costs
close NERTHUS_CLOSE_DISCORD_ID applies one CI pipeline — the trigger enacts the same close
report NERTHUS_REPORT_B64, NERTHUS_REPORTER_DISCORD_ID applies one CI pipeline — the trigger renders and opens the same merge request
query NERTHUS_QUERY_INTENT (status/open/pu), NERTHUS_QUERY_DISCORD_ID skipped the answer — there is no CI counterpart, and the Worker says the daemon is unavailable

A query writes nothing, so it is accepted on a read-only, sync-stale, or schema-drifted daemon: that state is exactly what someone asks /status about.

Status When
202 accepted: { "accepted": true, "kind", "interactionId", "duplicate", "daemonEpochMs" } — the daemon now owns the interaction: its tick enacts it and PATCHes the Discord followup itself, suffixed with the path marker
202 (replay) idempotent: the queue record is keyed by interactionId, so a replay returns duplicate: true and enqueues nothing
400 bad_request missing/malformed interactionId, sentAtEpochMs, or vars
409 too_late_for_report a report whose receipt is past near_deadline_ms (3000 ms). A close is idempotent at the far end, so a race with the Worker's timeout costs a redundant pipeline; a report is not, and two enactments would be two merge requests. Past the near-deadline the daemon hands it back deterministically
410 dispatch_stale receipt-relative lateness: the daemon's own receipt is more than window_ms (5000 ms) after sentAtEpochMs (the body echoes ageMs, windowMs, daemonEpochMs); a daemon clock behind the edge never refuses
422 not_dispatchable an unknown kind (the body echoes the three), or a kind whose required vars are missing
503 daemon_write_gated for close and report: the daemon is read-only, schema-drifted, or sync-stale, so it refuses work it cannot land and the Worker falls back to CI — a gated daemon degrades loudly to the trigger, never silently to nothing

The window and the drain bounds are configuration (integrations.discord.dispatch: window_ms, near_deadline_ms, skew_alert_ms, max_attempts, max_per_tick, tick_budget_ms — see the configuration reference). window_ms must stay 1500 ms under the Worker's DAEMON_DISPATCH_TIMEOUT_MS, so an accepted dispatch is always one the edge still waits on; a contract test holds the default pair together.

Per-kind counters (accepted, enacted, failed, refused) ride the anonymous GET /health under dispatch, which is where the fleet-health sweep reads the measured daemon share for its daily summary.

The anonymous refusal executes:

POST /v1/api/discord/dispatch
Content-Type: application/json

{ "interactionId": "1", "sentAtEpochMs": 1, "vars": {} }
await fetch("https://evocation.nerthus.pl/v1/api/discord/dispatch", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ interactionId: "1", sentAtEpochMs: 1, vars: {} }),
}).then((r) => r.json());

So does the stale refusal — a fixed timestamp is deterministically outside the 5-second window, which is exactly the receipt-relative rule this route lives by:

POST /v1/api/discord/dispatch
Content-Type: application/json
Authorization: Bearer <token>

{ "interactionId": "900000000000000001", "sentAtEpochMs": 1600000000000,
  "vars": { "NERTHUS_CLOSE_DISCORD_ID": "555000555", "NERTHUS_CLOSE_HINT": "" } }
await fetch("https://evocation.nerthus.pl/v1/api/discord/dispatch", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${dispatchToken}` },
  body: JSON.stringify({
    interactionId: "900000000000000001",
    sentAtEpochMs: 1600000000000,
    vars: { NERTHUS_CLOSE_DISCORD_ID: "555000555", NERTHUS_CLOSE_HINT: "" },
  }),
}).then((r) => r.json());

The 202 happy path is shown as a non-executed listing: its sentAtEpochMs must be within 5 seconds of the daemon's receipt, and a committed example carries a fixed timestamp by definition — the executable coverage of the accept path is the contract suite's (tests/contract/Api.Contract.Tests.ps1, the dispatch Describe).

POST /v1/api/discord/dispatch
Content-Type: application/json
Authorization: Bearer <token>

{ "interactionId": "900000000000000002", "sentAtEpochMs": 1784825000000,
  "vars": { "NERTHUS_CLOSE_DISCORD_ID": "555000555", "NERTHUS_CLOSE_HINT": "2026-07-01" } }
{ "accepted": true, "interactionId": "900000000000000002", "duplicate": false, "daemonEpochMs": 1784825000412 }