Files
docs/link/INTEGRATION.md
Claude bba7cc7714 feat(protocol2): house registry board (Part B ph.3)
Overlay BridgeHousing (new): a diff sweep over BaseHouse.AllHouses ->
house.update / house.remove (owner, region, location, decay level, co-owners,
friends, placement price), complementing the existing house.decay transition
feed. HousingSweepSeconds (300s); wired into [bridge reload|sweepnow|status.
Stock ServUO has no "for sale" flag, so this is an owner->houses registry;
price is the placement value, not a listing.

Sidecar: houses board table with upsert/delete/all; main routes house.update/
remove into it; GET /houses served from the store.

Docs: INTEGRATION.md house.* events + /houses endpoint; PROTOCOL_2 ph.3 built.

Verified: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors, 0 warnings). Live run pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 08:05:47 -05:00

36 KiB
Raw Blame History

uo-link Sidecar — Website Integration Guide

This is the API the website talks to. The sidecar is the only thing the site connects to; it relays to and from the ServUO shard over a private loopback socket. The game itself exposes no ports and is never reachable directly.

website ──WebSocket (live feed) + REST (queries/commands)──►  sidecar  ──loopback──►  shard
  • Base URL — default http://127.0.0.1:8080 (WebSocket: ws://127.0.0.1:8080). Configurable in sidecar.toml (web.bind) or UOLINK_WEB_BIND. If you serve the site from another host, bind the sidecar to 0.0.0.0:8080 and put it behind TLS.
  • Content type — all request and response bodies are JSON (application/json).
  • Timestamps — every t field is epoch milliseconds (UTC). Human-readable timestamps (e.g. house.decay.builtOn, /health.last_event) are ISO-8601 UTC.
  • Serials — game object ids are hex strings like "0x24C" (mobiles) or "0x40013AAD" (items). Treat them as opaque keys.

1. Authentication

Every route except GET /health requires the shared token from sidecar.toml (web.auth_token). Present it any of these ways:

Transport How
REST Authorization: Bearer <token>
REST X-Api-Key: <token>
WebSocket ?token=<token> in the connect URL (browsers can't set headers on a WS handshake)

Missing or wrong token → 401 {"error":"missing or invalid auth token"}. The token is compared in constant time. It is generated automatically on first run (the sidecar logs it); rotate by editing sidecar.toml and restarting.


2. Protocol version

The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.

  • Every response carries an X-UOLink-Version: 2 header.

  • GET /health and the WebSocket ws.hello frame include "protocol": 2.

  • Optionally, send X-UOLink-Version: 2 on your requests. If it disagrees with the sidecar, the request is rejected 409 Conflict:

    { "error": "protocol version mismatch", "sidecar_protocol": 2, "client_protocol": "1" }
    

Pin the version you built against and compare it to the header (or /health.protocol) at startup.

v2 (Protocol 2.0) added the account-provisioning surface (§6.x: POST /accounts/create, DELETE /link/{account}) and the account.* events. Outbound event kinds are additive — a v1 client that ignores unknown kinds keeps working against the live feed — but the new endpoints require a v2 sidecar. If you send X-UOLink-Version: 1, calls to the new endpoints are refused with the 409 above.


3. Health

GET /health          (no auth)
{
  "status": "ok",                      // "ok" when plugin connected AND db reachable, else "degraded"
  "protocol": 1,
  "plugin_connected": true,            // is the shard link up right now?
  "database": "ok",                    // "ok" | "error"
  "uptime": "3d 12h",
  "last_event": "2026-07-10T22:08:27Z" // last line received from the shard; null if none yet
}

Always returns HTTP 200 (read status/plugin_connected for real state). Use it for liveness checks and to detect when the shard has dropped (plugin_connected: false).


4. WebSocket live feed

GET /ws?token=<token>        (WebSocket upgrade)

A push-only stream of game events as they happen. You do not send commands over the WebSocket — use REST for that. The socket carries one JSON object per text frame.

On connect, the first frame is:

{ "kind": "ws.hello", "protocol": 1 }

Then a continuous stream of event frames, each with at least t (epoch ms) and kind. Route on kind.

Notes:

  • Live-only, no replay. A client that connects now sees events from now on. For history/backfill, use GET /history.
  • The sidecar sends WebSocket ping frames every ~30s for keepalive; browser clients answer automatically.
  • You may occasionally see a {"kind":"pong",...} frame (the sidecar's internal heartbeat to the shard). Ignore any kind you don't handle.
  • A client that falls far behind is dropped rather than allowed to stall others — reconnect and backfill via REST if that happens.

Minimal browser client

const ws = new WebSocket(`ws://127.0.0.1:8080/ws?token=${TOKEN}`);
ws.onmessage = (m) => {
  const ev = JSON.parse(m.data);
  switch (ev.kind) {
    case "ws.hello":      /* check ev.protocol === 1 */ break;
    case "mob.login":     onLogin(ev); break;
    case "vendor.sale":   onSale(ev); break;
    case "house.decay":   onIdoc(ev); break;
    // ...handle the kinds you care about; ignore the rest
  }
};
ws.onclose = () => setTimeout(connect, 2000); // reconnect + backfill via /history

Event catalog

Every event has t (epoch ms) and kind. A nested actor object looks like {"serial","name","acct","player"} (acct present only for player-owned mobiles).

Lifecycle

kind fields notes
server.hello shard, bootId, connects, items, mobiles, accounts Sent to the sidecar on every shard (re)connect. bootId changes on a shard restart; stable across sidecar reconnects — use it to tell "shard restarted" (drop caches) from "sidecar reconnected".
server.shutdown Clean shutdown.
server.crashed error Not always sent (a hard crash may skip it).
world.save.before / world.save.after (after adds items, mobiles) Save-cycle boundaries; a natural consistency checkpoint.

Sessions & identity

kind fields
mob.login who, map, x, y, z, webId (present if the account is linked)
mob.logout who
account.login.attempt acct, ip — an authentication attempt (no password ever leaves the shard)

Economy & commerce

kind fields notes
gold.change acct, old, new, delta AccountGold flow (gold in bank/account, not physical coins).
vendor.buy who, vendor, item, itemSerial, amount, perUnit, total, committed:false NPC vendor purchase (validation stage).
vendor.sell who, vendor, item, itemSerial, amount, perUnit, total, committed:false NPC vendor sale.
vendor.sale buyerSerial, buyerAcct, ownerSerial, ownerAcct, vendorSerial, itemType, itemSerial, itemId, amount, price, commission, committed:true Player vendor sale, at the committed transaction. Carries both buyer and owner accounts — the pair that flags laundering when they match.
vendor.placed owner, vendor A player vendor was placed.
{"kind":"vendor.sale","committed":true,"buyerAcct":"wttest","buyerSerial":"0x2E0",
 "ownerAcct":"seed_000","ownerSerial":"0x1F5","vendorSerial":"0x2E1",
 "itemType":"Longsword","itemSerial":"0x40015218","itemId":3937,"amount":1,
 "price":100,"commission":0,"t":1783720195626}

Character progression & vitals

kind fields notes
char.vitals serial, hits,hitsMax, mana,manaMax, stam,stamMax, str,dex,int, map, x,y Periodic snapshot of each online player (~every 30s; configurable). Diff successive snapshots to detect change.
skill.gain who, skill, gained, base, cap Player skill gains only (NPC gains are filtered out).
fame.change / karma.change who, old, new Player only.
quest.complete who, quest

Death & PvP

kind fields
player.death who, killer
player.murdered victim, murderer
mob.killed killed, killer — only kills that involve a player

Housing / IDOC

kind fields
house.decay serial, from, to, map, x,y,z, region, name, ownerSerial, ownerAcct, ban:{x,y,z}, builtOn, lastRefreshed

from/to are decay stages (LikeNew, Slightly, Somewhat, Fairly, Greatly, IDOC, Collapsed, …). Emitted only on a transition, so watch for to == "IDOC". ban is where a player would stand to see the sign.

{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
 "map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
 "ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
 "builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"}

Economy supply (periodic)

kind fields
economy.supply accounts, gold — total money supply across all accounts (~every 5 min; configurable)

Cheat detection & staff audit

kind fields notes
cheat.fastwalk who, ip The shard's own speed-hack detector fired.
audit.set staff, prop, target, targetSerial, old, new A staff member used [set to change a property. staff may be null.
audit.command staff, command, args A staff command was invoked.
admin.audit origin, action, actor, target, reason, plus action-specific (durationSec, sessions, hue, text) A moderation action was applied. origin is "web" (from the site, actor:"web:<user>") or "in-game" (a staff member in the game client). Broadcast to every dashboard so your moderation log stays complete regardless of who acted. Emitted alongside the admin.ok reply for web actions; see §6.

Account linking & provisioning

kind fields notes
link.request code, account, char, ttlSec A player ran [link in game. Show them a prompt to enter code on the site; you then confirm it via POST /link/confirm. See §6.
account.audit origin, action, actor, target, websiteUserId A provisioning action was applied from the site (origin:"web", actor:"web:<user>"). action is create or unlink; target is the account. Broadcast to every dashboard. Never carries the password. Emitted alongside the account.ok reply; see §6.
account.unlinked origin, account, websiteUserId, char A player ran [unlink in game (origin:"in-game"), severing the tie themselves. Drop the link from any roster you cache and reconcile your own record.

Help-page (support) queue

kind fields notes
page.new pageId, sender, type, message, map, x,y,z, sentMs, handled, handler A player opened a help page (support ticket). pageId is the sender's serial (one page per player). type is Bug/Stuck/Account/Question/Suggestion/Other/VerbalHarassment/PhysicalHarassment. sender is the usual actor object (with webId if the account is linked).
page.updated same as page.new A page's handled state changed (a staffer claimed/released it in game).
page.closed pageId The page left the queue (resolved, cancelled, or the player logged out).

The queue has no in-game event, so it's polled (PageSweepSeconds, default 5s) — expect a few seconds' latency, and use GET /pages for the authoritative current queue on connect. See §6 to snapshot, respond, and close.

Champion spawns

Champion spawns have no in-game event either, so they're polled (ChampSweepSeconds, default 10s) and emitted only on change. Three families share the champ.update kind, told apart by category:

category source what it is
champion ChampionSpawn the classic altar spawn (Felucca-style): type, level, kills, boss, cooldown
mini MiniChamp the TerMur mini-champ controller: type, level; auto-restarts, no kill counter
sea BaseSeaChampion a High Seas world-boss mobile, alive only while summoned
kind fields notes
champ.update serial, category, type, name, status, active, map, x,y,z, bossUpplus category-specific fields below A spawn's state changed (or its first sight this connection).
champ.remove serial The spawn left the board: a controller was deleted, or a sea boss was slain/despawned. Drop the row.

status is one of:

  • active — running (or, for sea, the boss is alive).
  • cooldown — stopped with a restart pending. For champion, restartAt (ISO-8601 UTC) is the ETA; mini always re-arms but exposes no ETA.
  • dormant — stopped with nothing scheduled (champion only; a GM must turn it back on).

Category-specific fields on champ.update:

category extra fields
champion level (016), rank, kills, maxKills, autoRestart, boss (when bossUp), restartAt (when cooldown), expireAt (ISO-8601 UTC — when the current level times out if kills stall, present while active)
mini level, maxLevel, autoRestart (always true); bossUp is always false
sea boss (its name), hits, hitsMax; bossUp is always true; roams, so x,y,z and hits update as it moves/takes damage
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
 "name":"Abyss","status":"active","active":true,"level":9,"rank":3,"kills":120,
 "maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,"z":0,
 "expireAt":"2026-07-14T11:00:00Z","t":1752489280000}

{"kind":"champ.update","serial":"0x0002ABCD","category":"sea","type":"Charybdis",
 "name":"Charybdis","status":"active","active":true,"bossUp":true,"boss":"Charybdis",
 "hits":4200,"hitsMax":5000,"map":"Trammel","x":4123,"y":2311,"z":-5,"t":1752489280000}

The events are live deltas; for the current board of all spawns at once, use GET /champs (§6) — that's what you render on connect, then keep live with these events.

Guilds (Protocol 2.0)

Guilds expose only one in-game event (a member joining), so the roster is polled (GuildSweepSeconds, default 60s) and diffed. Like champion spawns, guild.update is a full-state upsert emitted only on change — treat a guild id you've never seen as "newly created", and drop one on guild.remove. guild.join is the one real-time event, on top of the board.

kind fields notes
guild.update id, name, abbr, members, online, alliance (or null), leader (actor object or null) A guild's roster/leader/alliance changed, or its first sight this connection. A leave shows up here as members dropping.
guild.remove id The guild disbanded (leader gone) or was removed. Drop the row.
guild.join id, name, abbr, who (actor object) Real-time: a player joined a guild (EventSink.JoinGuild).

The leader/who actor object is {serial, name, acct?, webId?, player}acct/webId present when the mobile has an account / a linked website user.

{"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH","members":14,
 "online":3,"alliance":"Britannian Pact",
 "leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
 "t":1752489280000}
{"kind":"guild.join","id":1042,"name":"The Silver Hand","abbr":"TSH",
 "who":{"serial":"0x77","name":"Bran","acct":"bran","player":true},"t":1752489281000}

Render the current board from GET /guilds (§6) on connect, then keep it live with these events.

Town governors (Protocol 2.0)

In modern ServUO the "mayor" of a town is the City Loyalty Governor. The set of cities is polled (CitySweepSeconds, default 300s); each city emits city.update (full-state upsert) only when its governor, governor-elect, or election phase changes. No events at all unless the shard runs the City Loyalty system.

kind fields notes
city.update city, governor (actor or null), governorElect (actor or null), electionPhase, candidates, autoPickAt (ISO-8601 UTC, when an election is ongoing) A city's governance changed. Derive "the governor changed" by comparing to your stored board.

electionPhase is one of none / nominate / vote / pending. Cities: Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia.

{"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0,
 "governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
 "governorElect":null,"t":1752489280000}

Render the current board from GET /governors (§6) on connect, then keep it live with these events.

Presence (Protocol 2.0)

Who's online and where. A population snapshot is polled (PresenceSweepSeconds, default 30s) and emitted only when it changes; region transitions arrive in real time.

kind fields notes
presence.online count, byFacet {map: n}, byRegion {region: n} The current online population. Emitted when the count or any breakdown changes. GET /online gives the latest; GET /history?kind=presence.online the time series.
region.enter from (or null), to (or null), map, who (actor object) A player crossed into a new named region. from/to are region names (Wilderness is unnamed). Cheap "who's where" feed.
{"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30},
 "byRegion":{"Britain":18,"Wilderness":9,"Despise":2},"t":1752489280000}
{"kind":"region.enter","from":"Britain","to":"Despise","map":"Felucca",
 "who":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},"t":...}

Houses (Protocol 2.0)

The house registry — one row per house, complementing the house.decay transition feed (§ above). Polled (HousingSweepSeconds, default 300s) and diffed like the other boards.

kind fields notes
house.update serial, name, owner (actor or null), coOwners, friends, region, map, x,y,z, decay, price, builtOn, lastRefreshed A house's owner/region/decay/co-owners changed, or first sight this connection. decay is the level name (e.g. LikeNew). price is the placement value — stock ServUO has no "for sale" flag, so this is not a listing.
house.remove serial The house was demolished or no longer exists. Drop the row.
{"kind":"house.update","serial":"0x40001234","name":"The Silver Anvil","decay":"LikeNew",
 "price":432100,"map":"Felucca","x":1420,"y":1631,"z":0,"region":"Britain",
 "owner":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},
 "coOwners":2,"friends":5,"builtOn":"2026-01-02T00:00:00Z","lastRefreshed":"2026-07-10T00:00:00Z",
 "t":1752489280000}

Render from GET /houses (§6) on connect, then keep live with these events.


5. REST — read queries

These fetch live state from the shard (correlated round-trip). Typical latency is a few milliseconds; the sidecar waits up to 10s for the shard before returning 504.

Character profile

GET /char/{account}/{slot}          # by account + character slot (0-based)
GET /char/serial/{serial}           # by serial, e.g. /char/serial/0x24C

Full character sheet: stats, all trained skills, worn equipment with flattened item mods. Works for offline characters too. GET /char/serial/... falls back to the last cached profile if the shard is unreachable (so a page still renders during a shard restart).

{
  "kind": "char.profile", "serial": "0x24C", "name": "Darrow", "title": null,
  "body": 400, "hue": 33770, "online": false, "acct": "whitlocktech",
  "stats": { "str":120,"dex":120,"int":123, "hits":110,"hitsMax":110,
             "mana":123,"manaMax":123, "stam":120,"stamMax":120,
             "fame":0,"karma":0,"luck":0,
             "resist": {"phys":44,"fire":44,"cold":44,"pois":44,"energy":44} },
  "skills": [ {"n":"Swords","base":120.0,"value":120.0,"cap":120.0,"lock":"Up"}, "..." ],
  "equipment": [
    { "serial":"0x40013AAD","layer":"Shirt","itemId":7933,"hue":33,
      "cliloc":1027933,"mods":{} },
    { "serial":"0x4002B3","layer":"OneHanded","itemId":5046,"hue":0,"cliloc":1023721,
      "weapon":{"minDamage":16,"maxDamage":18},
      "mods":{"WeaponDamage":50,"HitLightning":40} }
  ]
}

Field notes:

  • skills[].base is trained value, value includes item/temp bonuses, cap is the cap. Do not assume base <= cap — GM characters can exceed it.
  • equipment[].mods is a flattened map of every non-zero AOS attribute on the item (weapon or armor). Empty {} for plain items.
  • Item names are usually clilocs, not strings: use name when present, otherwise resolve cliloc against a UO cliloc table on the site.
  • Errors: unknown account → 404 {"kind":"bridge.error","reason":"unknown account"}; bad slot → 404/400 similarly.

Account roster

GET /roster/{account}

Lightweight list of an account's characters (up to 57), including offline ones. Use this for a character-picker, then fetch the full profile on demand.

{ "kind":"account.roster", "acct":"whitlocktech",
  "chars":[ {"slot":0,"serial":"0x24C","name":"Darrow","body":400,"online":false} ] }

Player vendors

GET /vendors/{account}

Every player vendor owned by any character on the account, with held gold and current listings.

{ "kind":"vendor.snapshot", "acct":"seed_000",
  "vendors":[
    { "serial":"0x2C0", "shopName":"Seed Shop 810", "holdGold":24186,
      "ownerSerial":"0x1F5", "map":"Felucca", "x":1402, "y":1604,
      "listings":[
        {"serial":"0x4001440F","itemId":3937,"amount":1,"price":69819,"forSale":true}
      ] } ] }

6. REST — commands & history

The in-game [link flow: the player runs [link, the shard emits a link.request event (over the WebSocket) carrying a one-time code. Your site shows the logged-in website user a box to enter that code, then:

POST /link/confirm
{ "code": "AB12CD", "websiteUserId": "9931" }
  • Success → 200 {"kind":"link.ok","code":"AB12CD","account":"PerryAdimn","websiteUserId":"9931"}. The game account is now permanently tagged with your websiteUserId (persisted on the shard); subsequent mob.login events for that account carry webId.
  • Bad/expired code → 404 {"kind":"link.error","code":"AB12CD","reason":"unknown or expired code"}.

Codes are one-time and expire (default 5 min).

GET /link/{account}
  • 200 {"account":"PerryAdimn","websiteUserId":"9931"} if linked.
  • 404 {"account":"PerryAdimn","linked":false} if not.

(This reads the sidecar's mirror of confirmed links — no shard round-trip.)

Create a game account (Protocol 2.0)

Provision a game account from your signup form and link it to the website user in one step. Requires a v2 sidecar. Whether this is honored depends on the shard's signup mode (website/hybrid accept it; game refuses).

POST /accounts/create
{ "actor": "whitlocktech", "account": "bob", "password": "hunter2",
  "websiteUserId": "9931", "ip": "203.0.113.7" }
  • actor — the website user/staff id, recorded in the audit. Required.
  • account, password — the game-client credentials the player chose. The password is hashed on the shard and never appears in any reply, event, or log.
  • websiteUserId — the site user to auto-link.
  • ipthe end user's browser IP, which you read from your own request context (remote-addr, or a trusted X-Forwarded-For). The shard enforces its per-IP account cap with this, exactly as it does for in-game signups. The sidecar cannot see the browser's IP (it only sees your server), so you must send it.

Responses:

  • Success → 200 {"kind":"account.ok","action":"create","account":"bob","websiteUserId":"9931"}. The account exists and is linked; subsequent mob.login events carry webId.
  • Name already taken → 409 {"kind":"account.error","reason":"account already exists"}.
  • Per-IP cap hit → 429 {"kind":"account.error","reason":"ip account limit reached"}.
  • Signups disabled for this mode → 403 {"kind":"account.error","reason":"signups disabled for this mode"}.
  • Missing browser IP (when the shard requires it) → 400 {"kind":"account.error","reason":"client ip required"}.
  • Bad username/password, or a missing field → 400.

Abuse control beyond the per-IP cap (captcha, email verification, signup rate) is your site's responsibility.

Sever a game account's tie to its website user, from the site side. Requires a v2 sidecar.

DELETE /link/{account}
{ "actor": "whitlocktech" }
  • Success → 200 {"kind":"account.ok","action":"unlink","account":"bob"}. The WebsiteUserId tag is cleared on the shard and the sidecar's link mirror is dropped, so attribution stops immediately.
  • Not linked → 404 {"kind":"account.error","reason":"not linked"}.
  • Protected staff account → 403 {"kind":"account.error","reason":"target is protected staff; refused"}.
  • Missing actor400.

A player can also unlink themselves in game with [unlink; that emits an account.unlinked event (see §4) so you can reconcile your record.

Publish / remove town-crier news

Push a message that every in-game town crier announces until it expires.

POST /towncrier
{ "id": "news-42", "lines": ["Hear ye!", "Market tax is now 5%."], "durationSec": 3600 }

200 {"kind":"towncrier.ok","id":"news-42"}. Re-posting the same id replaces the prior entry.

DELETE /towncrier/{id}

200 {"kind":"towncrier.ok","id":"news-42"}, or 404 {"kind":"towncrier.error","reason":"unknown id"}.

Caps apply (line count/length, active entries, duration); an over-cap post returns towncrier.error.

Staff moderation — the write plane

Account and session moderation against the live shard. These are privileged. The sidecar does not model per-user roles — your site must authenticate the staff user and check their permission before calling. The shard trusts the loopback socket and applies each command with CoOwner-level authority, with one hard floor it enforces itself: any target at or above CoOwner (e.g. the Owner account) is refused (403). The whole plane is opt-in on the shard (AdminWriteEnabled in Bridge.cfg); when it's off, every call returns 403 "admin write plane disabled".

Every request requires an actor — the website username/id of the staff member taking the action. It is recorded in the shard console log, the ban's BanDealer tag, and the admin.audit event, so actions are always attributable. A missing actor is 400.

POST /admin/kick        { "actor":"jane", "account":"griefer42" }        # or "serial":"0x2E0"
POST /admin/ban         { "actor":"jane", "account":"griefer42", "durationSec":604800, "reason":"harassment" }
POST /admin/unban       { "actor":"jane", "account":"griefer42" }
POST /admin/broadcast   { "actor":"jane", "text":"Server restart in 5 minutes", "hue":53 }
  • kick — disconnects every live session of the target account (including one parked at character-select). Target by account or serial. Reply carries sessions (how many were cut).
  • ban — bans the account (works offline) and disconnects any live sessions. durationSec > 0 is a timed ban that auto-expires; 0/absent is indefinite. Clamped to the shard's AdminBanMaxDurationSec.
  • unban — clears the ban.
  • broadcast — a system message to everyone online. hue optional (default 53, staff green). Length-capped by the shard.

Success → 200 with an admin.ok:

{ "kind":"admin.ok", "reqId":"r-2", "action":"ban", "target":"griefer42", "durationSec":604800, "sessions":1 }

Failure → an admin.error with a mapped status:

Status When
400 missing actor, malformed body, or bad parameter
401 missing/invalid auth token
403 target is protected (at/above the floor), or the write plane is disabled on the shard
404 unknown or accountless target
503 / 504 shard not connected / didn't reply in time

Each applied action also emits an unsolicited admin.audit frame on the WebSocket (§4) with origin:"web", so every connected dashboard — not just the caller — sees it. In-game moderation by staff in the game client surfaces the same way with origin:"in-game".

Help-page (support) queue

Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own roles, like the moderation endpoints above.

GET  /pages                       # the open queue, newest state
POST /pages/{pageId}/respond      { "message":"...", "close": false }
POST /pages/{pageId}/close
  • GET /pagespages.list with a pages array; each entry is the same shape as a page.new event's fields (§4). This is the authoritative queue — use it on (re)connect, then keep it live with the page.new / page.updated / page.closed events.
  • respond delivers a message to the player exactly as an in-game staff reply does: a gump now if they're online, otherwise queued for their next login. It shows as coming from "Staff". Pass "close": true to resolve the page in the same call. → 200 page.ok.
  • close removes the page from the queue. → 200 page.ok.
  • Unknown pageId404 page.error; a respond with no message400.
POST /pages/0x24C/respond   { "message": "A GM is on the way.", "close": true }
 { "kind":"page.ok", "action":"respond", "pageId":"0x24C", "closed":true }

History (from the sidecar's database)

GET /history?kind={kind}&limit={n}     # kind optional, limit default 100 (max 1000)
GET /economy?limit={n}                 # the money-supply series (economy.supply events)

Recent events, newest first, served from SQLite (no shard needed). This is your backfill when a WebSocket client (re)connects, and the source for feeds like "recent sales" or "latest IDOC".

GET /history?kind=vendor.sale&limit=50
→ { "events": [ {"kind":"vendor.sale", "...": "...", "t": 1783720195626}, ... ] }

GET /economy?limit=200
→ { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] }

Champion-spawn board

GET /champs

The current state of every champion spawn at once — the live board. Served from the sidecar's own projection (no shard round-trip), kept current by the champ.update / champ.remove stream (§4). Render this on page load, then subscribe to those events to update in place. Each entry is exactly a champ.update payload (same fields, same category split); the list is ordered by name.

GET /champs
→ { "spawns": [
     {"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
      "name":"Abyss","status":"cooldown","active":false,"level":0,"rank":0,"kills":0,
      "maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,
      "z":0,"restartAt":"2026-07-14T10:45:00Z","t":1752489280000},
     {"kind":"champ.update","serial":"0x40099999","category":"mini","type":"AbyssalLair",
      "name":"AbyssalLair","status":"active","active":true,"level":2,"maxLevel":5,
      "bossUp":false,"autoRestart":true,"map":"TerMur","x":987,"y":328,"z":11,"t":...}
   ] }

A row survives a sidecar restart (it's in SQLite), so the board reflects the last-known state even during a shard outage. A sea boss appears when summoned and is removed when slain.

Guild board (Protocol 2.0)

GET /guilds
→ { "guilds": [ {"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH",
      "members":14,"online":3,"alliance":"Britannian Pact",
      "leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
      "t":1752489280000}, ... ] }

Every guild's latest roster snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the guild.* stream (§4). Render on load, then subscribe. Each entry is exactly a guild.update payload; ordered by name. Survives a sidecar restart.

Governor board (Protocol 2.0)

GET /governors
→ { "cities": [ {"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0,
      "governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},
      "governorElect":null,"t":1752489280000}, ... ] }

Every city's latest governance snapshot — the live board, kept current by the city.update stream (§4). Empty if the shard does not run the City Loyalty system. Ordered by city.

Online population (Protocol 2.0)

GET /online
→ {"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30},
   "byRegion":{"Britain":18,"Wilderness":9},"t":1752489280000}

The current online population — total plus per-facet and per-region breakdowns. The latest presence.online snapshot (from SQLite, so it survives a sidecar restart); keep it live with the presence.online stream (§4). count: 0 with empty maps if the shard hasn't reported yet. For the population time series, GET /history?kind=presence.online.

House registry (Protocol 2.0)

GET /houses
→ { "houses": [ {"kind":"house.update","serial":"0x40001234","name":"The Silver Anvil",
      "decay":"LikeNew","price":432100,"map":"Felucca","x":1420,"y":1631,"z":0,"region":"Britain",
      "owner":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},
      "coOwners":2,"friends":5,"builtOn":"...","lastRefreshed":"...","t":...}, ... ] }

Every house's latest snapshot — owner→houses map. Served from the sidecar's projection, kept current by the house.* stream (§4). Ordered by name. Survives a sidecar restart.


7. Status codes

Code Meaning
200 OK
400 Bad request (malformed body, invalid parameter, or a shard *.error that isn't a not-found)
401 Missing or invalid auth token
404 Not found (unknown account / character / id, or a not-linked account)
409 Conflict — protocol version mismatch, or an account name already taken on POST /accounts/create
429 Too many requests — the shard's per-IP account cap was hit on POST /accounts/create
500 Internal error (e.g. database)
503 Shard not connected — the query needs the live game and it's down
504 Shard connected but didn't reply within 10s

503 vs 404: a 503 is transient (shard restarting — retry), a 404 is a real "doesn't exist."


8. Putting it together

A typical character page:

const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" };

// 1. render the roster
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());

// 2. full sheet for the selected character
const res = await fetch(`${BASE}/char/${account}/${slot}`, { headers: H });
if (res.status === 503) showBanner("Game server is restarting…");
else renderProfile(await res.json());

// 3. live vitals: subscribe to the feed and update hp/mana as char.vitals arrives
//    (see the WebSocket client in §4)

// 4. recent sales widget
const sales = await fetch(`${BASE}/history?kind=vendor.sale&limit=20`, { headers: H })
  .then(r => r.json());

9. Caveats & current limits

  • No rate limiting yet. The sidecar does not throttle callers; put it behind your own gateway if it's public. Profile/roster/vendor queries hit the live shard, so cache them site-side.
  • WebSocket is push-only and live-only. No client→server messages, no replay. Backfill via /history.
  • Cache freshness. GET /char/serial/... may serve a stale cached profile when the shard is down; the account+slot form always goes live (503 if down).
  • bootId on server.hello is your signal to invalidate site-side caches: if it changed, the shard restarted.
  • Protocol changes bump X-UOLink-Version. Compare it on startup and fail fast rather than mis-parsing a newer shape.