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:
| Client | Auth | What 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/jsonfor request and response bodies. - Time: all timestamps are RFC 3339 / ISO 8601 UTC strings.
- Source of truth for shapes: the
ConfigBundleand its nested types mirror the Rust brain atbrain/src/config.rs. The TypeScript mirror lives insaas/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
serverid; 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):
versionu64— monotonic; bumped on every publish. Sidecar applies only if it changed.policy—FeaturePolicy, one boolean per optional subsystem. Omitted ⇒ on (the brain's#[serde(default)]is all-on). Seebrain/src/policy.rs.game—GameContext | null(name,premise,goal).null⇒ default survival framing.flavor—FlavorConfig(enabled,global,themes[]{text,weight},themes_per_bot,seed).population—PsycheConfig: OCEAN + habitTraitRange{mean,spread}ranges, plussession_min: [min, max]minutes.speech—SpeechSettings:limits(per_5min/per_hour/per_day,economy ∈ Silent|Frugal|Normal|Chatty) andphrase_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 & path | Returns | Notes |
|---|---|---|
GET /v1/servers | ServerSummary[] | Servers the session can see. |
GET /v1/servers/{id}/overview | OverviewKpis | KPI strip (survivors, players, lines, spend, cache, p50). |
GET /v1/servers/{id}/roster | Survivor[] | Current AI roster (name, alignment, goal, state). |
GET /v1/servers/{id}/activity?limit={n} | ActivityEvent[] | Recent feed (default limit=20). |
GET /v1/servers/{id}/economy | EconomyBreakdown | TTS / 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 & path | Returns | Notes |
|---|---|---|
GET /v1/admin/tenants | Tenant[] | All tenants (id, name, plan, serverCount, monthlySpendUsd, status, createdAt). |
GET /v1/admin/tenants/{id} | Tenant + servers | Drill-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)