Obscurum AI
API

Control-plane API

The Obscurum AI control-plane API contract — config pull, telemetry, dashboard CRUD, billing, admin.

The control plane is a separate, API-only Laravel service. It is the source of truth for per-server configuration, versioning, telemetry, and billing. Two kinds of client talk to it:

ClientAuthWhat it does
Brain sidecar (the Rust brain, per game server)a server key (bearer)Pulls its ConfigBundle, pushes telemetry.
Dashboard / Admin (the Next.js apps, via @obscurum/api-client)a user session token (bearer)Reads telemetry, edits + publishes config, billing.
  • Base URL: https://api.obscurum.ai (the control-plane origin; never the app origin).
  • Transport: HTTPS only. All sidecar pulls are signed (see Auth).
  • Versioning: all routes are prefixed /v1.
  • Content type: application/json for request and response bodies.
  • Time: all timestamps are RFC 3339 / ISO 8601 UTC strings.
  • Source of truth for shapes: the ConfigBundle and its nested types mirror the Rust brain at brain/src/config.rs. The TypeScript mirror lives in saas/packages/api-client/src/types/config.ts — keep all three in lockstep.

Auth

All requests carry Authorization: Bearer <token>.

  • Server keys (sidecars) — long-lived, per-server, issued from the dashboard. Scoped to exactly one server id; can only read that server's config and post its telemetry.
  • User session tokens (dashboard) — short-lived, scoped to the servers a user owns.
  • Admin tokens — elevated; required for /v1/admin/*.

Signed sidecar pulls. Every sidecar request additionally carries:

X-Obscurum-Timestamp: <unix seconds>
X-Obscurum-Signature: <hex HMAC-SHA256( server_key_secret, "{METHOD}\n{PATH}\n{TIMESTAMP}\n{sha256(body)}" )>

The control plane rejects (401) a request whose signature is invalid or whose timestamp is outside a ±300s window (replay protection).

Error envelope

Non-2xx responses use one shape:

{ "error": { "code": "config_not_found", "message": "No server with id 'x'." } }

Status codes: 400 bad request · 401 auth · 403 forbidden (wrong scope) · 404 not found · 409 version conflict (stale publish) · 422 validation · 429 rate limited · 5xx server.

1. Sidecar — config pull

The brain pulls its tuning at startup and on a poll interval. The response body is the ConfigBundle the Rust brain deserialises via ServerConfig::from_json.

GET /v1/config?server={id}

Auth: server key (signed). Returns the current published ConfigBundle for {id}.

ETag / 304. The response carries ETag: "v{version}". The sidecar caches the last applied version and sends If-None-Match: "v{version}" on subsequent pulls; if the published version is unchanged the control plane returns 304 Not Modified with no body, so the brain skips re-applying an identical config.

Request headers:

If-None-Match: "v7"
X-Obscurum-Timestamp: 1718764800
X-Obscurum-Signature: 9f2c…

200 OK response (ETag: "v8"):

{
  "version": 8,
  "policy": {
    "llm_dialogue": true, "voice_in": true, "voice_out": true, "emotes": true,
    "phrase_cache": true, "flavor": true, "human_factors": true, "emotional_events": true,
    "game_knowledge": true, "global_goals": true, "squads": true, "memory": true
  },
  "game": { "name": "DayZ", "premise": "a brutal survival sandbox", "goal": "survive and outlast" },
  "flavor": {
    "enabled": true,
    "global": "Grim Chernarus winter.",
    "themes": [{ "text": "You carry a quiet gallows humour.", "weight": 1.0 }],
    "themes_per_bot": 1,
    "seed": 5
  },
  "population": {
    "openness": { "mean": 0.5, "spread": 0.2 },
    "conscientiousness": { "mean": 0.5, "spread": 0.2 },
    "extraversion": { "mean": 0.45, "spread": 0.22 },
    "agreeableness": { "mean": 0.4, "spread": 0.25 },
    "neuroticism": { "mean": 0.5, "spread": 0.22 },
    "afk_proneness": { "mean": 0.15, "spread": 0.12 },
    "break_proneness": { "mean": 0.1, "spread": 0.1 },
    "fatigue_rate": { "mean": 0.4, "spread": 0.18 },
    "session_min": [35.0, 180.0]
  },
  "speech": {
    "limits": { "per_5min": 8, "per_hour": 30, "per_day": 200, "economy": "Normal" },
    "phrase_cache": { "variety_target": 12, "max_reuse": 0.85, "allow_player_names": false }
  }
}

304 Not Modified — body empty; the sidecar keeps its cached bundle.

Field reference (mirrors brain/src/config.rs):

  • version u64 — monotonic; bumped on every publish. Sidecar applies only if it changed.
  • policyFeaturePolicy, one boolean per optional subsystem. Omitted ⇒ on (the brain's #[serde(default)] is all-on). See brain/src/policy.rs.
  • gameGameContext | null (name, premise, goal). null ⇒ default survival framing.
  • flavorFlavorConfig (enabled, global, themes[]{text,weight}, themes_per_bot, seed).
  • populationPsycheConfig: OCEAN + habit TraitRange{mean,spread} ranges, plus session_min: [min, max] minutes.
  • speechSpeechSettings: limits (per_5min/per_hour/per_day, economy ∈ Silent|Frugal|Normal|Chatty) and phrase_cache (variety_target, max_reuse, allow_player_names).

2. Sidecar — telemetry

POST /v1/telemetry

Auth: server key (signed). The sidecar batches counters and posts them on an interval. The control plane stores them for the dashboard's overview / economy widgets.

Request:

{
  "serverId": "chernarus-hardcore",
  "ts": "2026-06-19T16:42:00Z",
  "botCount": 24,
  "playersOnline": 18,
  "linesSpoken": 1204,
  "spendUsd": 2.14,
  "events": [
    { "kind": "spoke", "count": 980 },
    { "kind": "snap", "count": 12 },
    { "kind": "squad", "count": 7 },
    { "kind": "block", "count": 3 }
  ]
}

202 Accepted:

{ "accepted": true, "received": 4 }

Event kinds (open set, mirrors the dashboard activity feed): spoke, snap, squad, heard, plan, afk, spawn, logoff, block.

3. Dashboard — config CRUD + versioning

These back the cockpit's policy editor. {id} is a server id; the caller must own it.

GET /v1/servers/{id}/config

Auth: user session. Returns the current published ConfigBundle (same shape as §1). 200 OK.

PUT /v1/servers/{id}/config

Auth: user session. Publishes a new config. The control plane validates the bundle, bumps version (server-assigned — the client's version is the base it edited, used for optimistic concurrency), snapshots it into history, and returns the published bundle.

Request: a full ConfigBundle (the version field is the base the client started from).

200 OK: the newly published bundle with the incremented version.

409 Conflict if another publish landed since the client read (stale base version):

{ "error": { "code": "version_conflict", "message": "Config moved to v9; you edited v7." } }

422 if the bundle fails validation (e.g. themes_per_bot > themes length, negative limits).

GET /v1/servers/{id}/config/versions

Auth: user session. Returns the version history (newest first).

200 OK:

[
  { "version": 8, "publishedAt": "2026-06-19T16:00:00Z", "publishedBy": "owner@chernarus", "note": "Loosened squad caps" },
  { "version": 7, "publishedAt": "2026-06-18T20:10:00Z", "publishedBy": "owner@chernarus", "note": "Enabled voice-in" }
]

POST /v1/servers/{id}/config/rollback

Auth: user session. Re-publishes a prior version as a new head (history is append-only; rollback never rewrites). Body: { "version": 7 }.

200 OK: the new published ConfigBundle (with a fresh, higher version). 404 if the target version doesn't exist.

On the next sidecar pull the ETag differs, so the brain fetches and applies the rolled-back config automatically.

4. Dashboard — read models (overview)

Derived from stored telemetry; back the cockpit widgets. All user session auth.

Method & pathReturnsNotes
GET /v1/serversServerSummary[]Servers the session can see.
GET /v1/servers/{id}/overviewOverviewKpisKPI strip (survivors, players, lines, spend, cache, p50).
GET /v1/servers/{id}/rosterSurvivor[]Current AI roster (name, alignment, goal, state).
GET /v1/servers/{id}/activity?limit={n}ActivityEvent[]Recent feed (default limit=20).
GET /v1/servers/{id}/economyEconomyBreakdownTTS / LLM / cache-hit split.

Shapes are defined in saas/packages/api-client/src/types/domain.ts.

5. Billing / usage (stubs)

Minimal stubs for the billing widgets; expand when billing lands.

GET /v1/servers/{id}/usage

Auth: user session. Current billing-period usage summary.

200 OK:

{
  "serverId": "chernarus-hardcore",
  "periodStart": "2026-06-01T00:00:00Z",
  "periodEnd": "2026-06-30T23:59:59Z",
  "linesSpoken": 28140,
  "llmTokens": 4210000,
  "ttsCharacters": 1980000,
  "spendUsd": 63.42
}

GET /v1/billing/plans (stub)

Auth: user session. Returns the available plans (cloud, self-host, trial) with pricing. Shape TBD.

POST /v1/billing/checkout (stub)

Auth: user session. Starts a checkout session for a plan/usage tier. Shape TBD.

6. Admin (internal)

Elevated; admin token required. Backs the admin app.

Method & pathReturnsNotes
GET /v1/admin/tenantsTenant[]All tenants (id, name, plan, serverCount, monthlySpendUsd, status, createdAt).
GET /v1/admin/tenants/{id}Tenant + serversDrill-down (stub).
POST /v1/admin/tenants/{id}/suspend{ ok }Suspend a tenant (stub).

Endpoint summary

# sidecar (server key, signed)
GET    /v1/config?server={id}                 → ConfigBundle  (ETag / 304)
POST   /v1/telemetry                           → 202 accepted

# dashboard config (user session)
GET    /v1/servers/{id}/config                 → ConfigBundle
PUT    /v1/servers/{id}/config                 → ConfigBundle  (publish, bumps version)
GET    /v1/servers/{id}/config/versions        → ConfigVersion[]
POST   /v1/servers/{id}/config/rollback        → ConfigBundle  (new head)

# dashboard read models (user session)
GET    /v1/servers                             → ServerSummary[]
GET    /v1/servers/{id}/overview               → OverviewKpis
GET    /v1/servers/{id}/roster                 → Survivor[]
GET    /v1/servers/{id}/activity?limit={n}     → ActivityEvent[]
GET    /v1/servers/{id}/economy                → EconomyBreakdown

# billing (user session, stubs)
GET    /v1/servers/{id}/usage                  → UsageSummary
GET    /v1/billing/plans                        → (stub)
POST   /v1/billing/checkout                     → (stub)

# admin (admin token)
GET    /v1/admin/tenants                        → Tenant[]
GET    /v1/admin/tenants/{id}                    → (stub)
POST   /v1/admin/tenants/{id}/suspend            → (stub)

On this page