Overlay BridgeProfile: char.profile gains a titles block (selected index, fameKarma, skill, and the raw reward-title list) read from PlayerMobile's public title accessors. No new stream, no sidecar change — it rides the existing char.profile served by GET /char. Reward entries may be a cliloc number as a string or a literal; resolve numeric ones website-side like item names. Docs: INTEGRATION.md char.profile titles field; PROTOCOL_2 ph.4 built. Part B phase 5 (Factions/VvV) remains deferred by owner decision. Verified: 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>
36 KiB
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 insidecar.toml(web.bind) orUOLINK_WEB_BIND. If you serve the site from another host, bind the sidecar to0.0.0.0:8080and put it behind TLS. - Content type — all request and response bodies are JSON (
application/json). - Timestamps — every
tfield 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: 2header. -
GET /healthand the WebSocketws.helloframe include"protocol": 2. -
Optionally, send
X-UOLink-Version: 2on 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 anykindyou 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, bossUp — plus 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, forsea, the boss is alive).cooldown— stopped with a restart pending. Forchampion,restartAt(ISO-8601 UTC) is the ETA;minialways re-arms but exposes no ETA.dormant— stopped with nothing scheduled (championonly; a GM must turn it back on).
Category-specific fields on champ.update:
| category | extra fields |
|---|---|
champion |
level (0–16), 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} }
],
"titles": { "selected": 0, "fameKarma": "Lord", "skill": "Grandmaster Swordsman",
"reward": ["1154060", "The Bold"] }
}
Field notes:
skills[].baseis trained value,valueincludes item/temp bonuses,capis the cap. Do not assumebase <= cap— GM characters can exceed it.equipment[].modsis 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
namewhen present, otherwise resolveclilocagainst a UO cliloc table on the site. titles(Protocol 2.0):selectedis the index intorewardcurrently displayed (-1if none).fameKarma/skillare computed display titles, omitted when the character has none.rewardentries may be a cliloc number as a string or a literal string — resolve numeric ones against your cliloc table, same as item names.- 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 5–7), 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
Confirm an account link
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 yourwebsiteUserId(persisted on the shard); subsequentmob.loginevents for that account carrywebId. - Bad/expired code → 404
{"kind":"link.error","code":"AB12CD","reason":"unknown or expired code"}.
Codes are one-time and expire (default 5 min).
Look up an existing link
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.ip— the end user's browser IP, which you read from your own request context (remote-addr, or a trustedX-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; subsequentmob.loginevents carrywebId. - 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.
Unlink an account (Protocol 2.0)
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"}. TheWebsiteUserIdtag 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
actor→ 400.
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
accountorserial. Reply carriessessions(how many were cut). - ban — bans the account (works offline) and disconnects any live sessions.
durationSec > 0is a timed ban that auto-expires;0/absent is indefinite. Clamped to the shard'sAdminBanMaxDurationSec. - unban — clears the ban.
- broadcast — a system message to everyone online.
hueoptional (default53, 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 /pages →
pages.listwith apagesarray; each entry is the same shape as apage.newevent's fields (§4). This is the authoritative queue — use it on (re)connect, then keep it live with thepage.new/page.updated/page.closedevents. - 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": trueto resolve the page in the same call. → 200page.ok. - close removes the page from the queue. → 200
page.ok. - Unknown
pageId→ 404page.error; a respond with nomessage→ 400.
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). bootIdonserver.hellois 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.