Files
docs/link/INTEGRATION.md
wtclaude eabaf2635f docs(link): protocol 6 part b -- leases, participation, and what the walk found
Six files. `v6.md` gains sections 7-10: one version with two halves, the lease
plane, the participation ledger, and 11b's verification.

Three corrections to what the plan and the design of record assumed.

Phase 11b is FIVE repos, not four, and in the opposite direction from 11a's
correction: a lease's ledger row had no reconcile path anywhere, because the step
that made it names `core.lease` and that is core's own action. `website` joins.

EVENTS.md's §D frames the 258 `Config.Get` call sites as splitting into two
patterns. Measured on 57.4: of the 158 non-Bridge sites in `Scripts/`, roughly
eight are read live. The allowlist is not a curated subset of a large pool, it is
nearly the whole of what exists. And `Config.Set` has exactly one caller in the
entire tree, so on a stock shard a GM cannot drift a configuration lease even
deliberately -- which is why proving `drifted` needed a scaffolding verb.

§G's "participation attribution is now the largest piece of new UO work" closes,
and the live-config-lease row goes to built-with-one-key.

§10.1 records the defect the phase's own deferral found in 11a's shipped code:
`bridge.busy` answered 200 instead of 425 because the frame carried two `kind`
fields and parsers take the last. Unreachable in 11a by construction; produced on
the first collision here.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 19:32:11 -05:00

77 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; rotate by editing sidecar.toml and restarting.

To read it back afterwards, ask the sidecar rather than hunting through the startup log or the TOML:

$ uo-link-sidecar --print-config --config /etc/runicgateway/sidecar.toml
{
  "component": "uo-link-sidecar",
  "config_created": false,
  "config_path": "/etc/runicgateway/sidecar.toml",
  "protocol": 4,
  "shard": { "bind": "127.0.0.1:7788" },
  "store": { "path": "/var/lib/runicgateway/uo-link.db" },
  "token_generated": false,
  "version": "2.0.0",
  "web": {
    "auth_required": true,
    "auth_token": "c0f04ace…",
    "bind": "127.0.0.1:8080",
    "ws_path": "/ws"
  }
}

That is the same set of values Admin → Shard asks for — base URL and WS URL are web.bind (substituting a reachable host if it is 0.0.0.0) plus web.ws_path. The output contains the token in clear text, so treat it as a secret: it belongs in a terminal, not in a log or a CI artifact. --print-config also performs first-run setup, writing the config file and generating a token if there is none, and reports whether it did via config_created / token_generated.


2. Protocol version

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

The current version is 6. It is not released yet — it lives on edge and ships with the event system's cutover; the last released pairing is protocol 5, sidecar v2.1.0 + overlay v1.1.0, resolved as bundle 2026.09.01, never as "latest of each".

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

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

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

    { "error": "protocol version mismatch", "sidecar_protocol": 6, "client_protocol": "5" }
    

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.

v6 (Protocol 6) is the first bump that adds a promise rather than data: a command carrying an idempotencyKey is executed at most once (see Retrying a command safely in §6). It also adds champ.boss.killed, and the event plane — leases and the run-scoped participation ledger, six endpoints, all of them gated on the shard by Bridge.EventsEnabled and answering 403 when an operator has not switched it on. See v6.md.

v3 (Protocol 3.0) adds world.ruleset, points.board and vendor.listing / vendor.listing.remove, with the GET /ruleset, /points and /market reads that serve them from the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that ignores unknown kinds keeps working against the live feed, but the three new endpoints require a v3 sidecar. There is deliberately no feature-negotiation array — v3 implies all three kinds, so the version number alone tells you what is available.

v4 (Protocol 4.0) adds guild.roster and guild.leave, and grows GET /guilds a roster key (v4.md). Before it, a guild's membership was a count; now the members themselves are on the wire. Additive in the same shape as the previous two bumps — nothing that existed in v3 changed, so a v3 consumer that ignores the new kinds and the new key keeps working against a v4 sidecar, once it declares 4.

v5 (Protocol 5) adds three things at once (v5.md) — house.decay gains ownerName and a schedule, vendor.listing gains ownerAcct and a fees block, and account.login.result is a new kind carrying the verdict its long-standing account.login.attempt companion fires too early to know. Three at once because a bump costs a release, a bundle and an operator update on every shard, so a field left out costs a whole second round of that.

Additive again: no existing field changed shape, and no new endpoint — every v5 addition rides kinds that already existed or a kind that behaves like any other on the feed. Two consumer notes, both about ABSENCE rather than presence, because both are easy to read as an error:

  • schedule.estimatedCollapse is omitted whenever it is not exactly knowable — which, on a dynamic-decay shard, is every stage before IDOC.
  • fees is omitted entirely by a pre-v5 overlay, and reduces to {"exempt": true} for a commission vendor. Neither means "this vendor has no money".

v6 (Protocol 6) is the first bump that is mostly about a guarantee rather than about data (v6.md). A command may now carry an idempotencyKey, and the shard promises to execute a key at most once: a repeat is answered with the original reply rather than re-run. That is what makes a world-writing command safe to retry at all — before it, a lost acknowledgement and a command that never applied were the same event as seen from the caller. See §6's write plane for how to send one, and §7 for the one new status code it introduces.

It also adds champ.boss.killed, a champion's defeat with the damage table only the shard ever sees. Previously this was inferable from champ.update losing its bossUp alongside a nearby mob.killed — a signal that also fires when a GM resets a spawn and that says nothing about who did the work.

Additive again: no existing field changed shape, no new endpoint, and a client that sends no key behaves exactly as it did under v5.

Upgrading a pinned client. Every bump is an operator-visible hard break in one direction only: a client still declaring the old number gets a 409 on every protected route and, on the WebSocket, a closed connection on the ws.hello mismatch. So update the pinned version at the same time you deploy the new sidecar. Nothing existing has ever changed shape across a bump, so that is the whole migration — the website does it with a one-shot boot migration of its uo_link_config.protocol row (v3.md §4.1); a third-party client changes the constant it sends.


3. Health

GET /health          (no auth)
{
  "status": "ok",                      // "ok" when plugin connected AND db reachable, else "degraded"
  "protocol": 4,
  "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": 4 }

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 === 4 */ 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, fired from a sink that runs before the auth decision, so it fires on successful logins too. Use account.login.result for the verdict. No password ever leaves the shard
account.login.result acct, ip, accepted, reasonProtocol 5. The verdict of the attempt above, which the attempt structurally cannot carry. reason is an ALRReason (BadPass, Invalid, Blocked, InUse, BadComm) and is present only when accepted is false, because the enum's zero value would read as a failure reason on an accept. Build "someone tried to get into your account" on THIS kind

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, ownerName, ownerAcct, schedule:{...}, 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.

* Protocol 5. ownerName is the owner's character name (ownerAcct is the game account, and the only one of the two that identifies a person). schedule is a nested object:

field meaning
dynamicDecay whether this shard runs ServUO's dynamic decay (Core.ML). Always present
nextStage ISO-8601 UTC: when the house leaves its current stage. Absent under static decay, which keeps no stage clock
decayPeriodSec seconds from a full refresh to collapse; lets a reader turn lastRefreshed into a percentage
estimatedCollapse ISO-8601 UTC — present only when it is exact, see below

estimatedCollapse is absent far more often than not, and that is deliberate. Under dynamic decay ServUO draws each stage's duration at random when the stage is entered, so collapse is exactly knowable only once the house is already at IDOC — at which point the next transition is the collapse. Under static decay it is a pure function of lastRefreshed + decayPeriodSec and is exact at every stage. It is omitted rather than approximated, because an absent field is honest where a wrong date is a dated promise. Treat its absence as "not knowable", never as "not yet read" — and never fall back to computing one yourself under dynamic decay.

{"kind":"house.decay","serial":"0x400142F9","from":"Greatly","to":"IDOC",
 "map":"Felucca","x":1480,"y":1600,"z":0,"region":null,"name":"Millrace",
 "ownerSerial":"0x1FB","ownerName":"Zara Crowe","ownerAcct":"seed_002",
 "schedule":{"dynamicDecay":true,"nextStage":"2026-09-01T20:33:15.7525479Z",
             "decayPeriodSec":432000,"estimatedCollapse":"2026-09-01T20:33:15.7525479Z"},
 "ban":{"x":1482,"y":1604,"z":0},
 "builtOn":"2026-06-03T14:02:44Z","lastRefreshed":"2026-08-25T17:21:14Z"}

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.
champ.boss.killed (Protocol 6) category, bossSerial, boss, bossType, map, x,y,z, region, killer, damagers — plus serial, type, level naming the ALTAR when the kill could be attributed to one The boss went down. A real event, not a polled diff — see below.

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.

champ.boss.killed (Protocol 6)

The one champion frame that is not polled. It fires on the death itself, so unlike everything above it is an event rather than a difference between two snapshots — which means a first sighting of it is the thing being reported rather than a baseline to compare against.

{"kind":"champ.boss.killed","category":"champion","bossSerial":"0xD8D","boss":"Semidar",
 "bossType":"Semidar","map":"Felucca","x":1496,"y":1628,"z":-5,"region":"Britain",
 "serial":"0x400150E8","type":"Abyss","level":0,
 "killer":{"serial":"0x2E0","name":"tester","player":true},
 "damagers":[{"serial":"0x2E0","name":"tester","player":true,"damage":100240},
             {"serial":"0x24C","name":"Darrow","player":true,"damage":120}],
 "t":1788551315000}
  • serial means the ALTAR here, matching champ.update, so the two join without a rule about which of two serials means what. It is absent — with type and level — when the boss could not be attributed to a spawn, which happens when one pops and dies inside a single sweep interval. The kill is still reported; it simply arrives without its altar.
  • damagers is every player who damaged it, highest first, each the standard actor object plus a damage total. Totals are summed per player, so nobody appears twice. Entries are included whether or not the shard still considers them valid for looting rights — someone who fought two thirds of the fight and then died took part in it. Capped at 20.
  • region is the nearest NAMED region and is absent in open countryside. It is not the most specific region containing the boss: an active champion altar registers an unnamed region of its own over its spawn area, so the innermost answer is always nameless. Absent means "nowhere with a name", never "the shard would not say".
  • category is champion or sea. There is no mini — a MiniChamp has no boss.

This does not replace champ.remove. A slain sea boss still produces one, because it also leaves the board. A champion altar stays on the board and goes to cooldown as usual.

damagers names players and ranks them. The sidecar serves it verbatim, as it serves everything; deciding who may see a damage table is the consuming site's job. The website's own answer is staff by default with the kill itself public — see v6.md §4.

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 leader/alliance/name changed, its member count moved, or its first sight this connection.
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).
guild.roster (4) id, name, abbr, total, seq, more, members (array of actor objects carrying rank) The full member list. Emitted whenever the member set changes. seq 0 supersedes whatever roster you hold for that guild; more: false ends it.
guild.leave (4) id, name, who (serial string) Real-time: a member left. Advisory — see below.

The leader/who actor object is {serial, name, acct?, webId?, player}acct/webId present when the mobile has an account / a linked website user. Note guild.leave's who is a bare serial string, not an actor object: the mobile has already left, so there is nothing to attribute.

A roster member carries rank as well. rank is 04 with 4 being Leader, plus rankCliloc (the cliloc the game names that rank with) or rankName when a shard uses custom rank definitions with literal names. Only the raw rank is sent: ServUO ships no text for those clilocs, so turning 1062960 into "Warlord" is the consumer's job.

Two things to get right, both of which bite:

  • Several members can hold rank 4. guild.update's single leader is the guild's founder-leader; it is not the set of leaders. If you need "who leads this guild", read the roster's ranks and treat leader as one more entry rather than the answer.
  • An absent rank means "not known" — never 0, and never leadership. It has one deliberate cause: PlayerMobile.GuildRank reports Leader for any account at GameMaster or above whatever their real rank, so the bridge omits the rank for staff rather than publishing a claim it knows is false. Defaulting a missing rank to 0 silently demotes them; reading absence as leadership republishes exactly the lie the bridge avoided.

On Protocol 4. Before it, a guild's membership was a count and a leave surfaced only as that count dropping. guild.roster carries the members themselves, and guild.leave names who went.

guild.leave is advisory: any change to the member set re-emits the whole roster, so a consumer holding a membership table stays correct even if it ignores every leave event. Handle it when you want a "so-and-so left" feed to update without waiting for the sweep.

Rosters can arrive in several frames. Members per frame are capped so a large guild cannot produce an unbounded line (~69 bytes per member; the default cap is 500). Every realistic guild arrives as one frame with seq: 0, more: false and needs no special handling — but if you consume the raw stream, accumulate from seq 0 and apply on more: false, discarding a partial roster if a frame arrives out of order or the shard reconnects. GET /guilds hands you rosters already reassembled. A guild with no members emits one frame with an empty array, so an emptied roster is distinguishable from an absent one.

{"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}
{"kind":"guild.roster","id":1042,"name":"The Silver Hand","abbr":"TSH",
 "total":14,"seq":0,"more":false,
 "members":[{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
            {"serial":"0x77","name":"Bran","acct":"bran","player":true}],
 "t":1752489282000}
{"kind":"guild.leave","id":1042,"name":"The Silver Hand","who":"0x77","t":1752489283000}

acct is genuinely optional on a member — a character can have no account at all — so do not assume it is present.

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.

Shard ruleset (Protocol 3.0)

How the shard is actually configured, published by the shard itself. Not a sweep — it changes only when an operator edits Config/*.cfg, so it is emitted once per shard↔sidecar connect (and on [bridge reload), exactly like server.hello.

kind fields notes
world.ruleset rev, shard, expansion, connect?, systems, caps, housing, accounts, vetRewards, loot, vendors, champions?, treasureMaps, vvv?, store, schedule? The whole ruleset, always complete — never a delta, so the latest frame replaces the previous one outright. Every block except shard/expansion is optional and is omitted when its system is off, so absence means "not applicable here", not "unknown".

rev is the shard's FNV-1a of the body: identical rev means the ruleset is unchanged and this frame is just a reconnect re-send, so a consumer can skip the write. It is deliberately not String.GetHashCode(), which is seeded per process and would change on every shard restart.

{"kind":"world.ruleset","rev":"1a2b3c4d","shard":"UOMysticmoon","expansion":"EJ",
 "systems":{"cityLoyalty":true,"vvv":true,"factions":false,"siege":false,"chat":true,
            "store":true,"dailyRares":true,"honesty":true,"shadowguard":true,
            "treasureMaps":true,"vetRewards":true,"testCenter":false},
 "caps":{"skill":1000,"totalSkill":7000,"stat":225,"str":125,"dex":125,"int":125,
         "strMax":150,"dexMax":150,"intMax":150},
 "housing":{"accountHouseLimit":1},
 "accounts":{"perIp":3,"charSlots":7,"autoCreate":true},
 "vetRewards":{"enabled":true,"rewardIntervalDays":30},
 "loot":{"feluccaLuckBonus":1000,"feluccaBudgetBonus":100,"feluccaMaxProps":11},
 "vendors":{"restockDelayMinutes":60,"maxSell":500,"economyStockAmount":500},
 "champions":{"powerScrolls":6,"statScrolls":16,"scrollChance":0.1,
              "transcendenceChance":50.0,"rankThresholds":[5,10,13]},
 "treasureMaps":{"enabled":true,"lootChance":0.01,"resetDays":30},
 "vvv":{"enabled":true,"startSilver":2000,"enhancedRules":false},
 "store":{"enabled":true,"currencyName":"Sovereigns"},
 "schedule":{"autoSaveEnabled":true,"autoSaveFrequencyMinutes":15,"autoRestartEnabled":false},
 "t":1752489280000}

Two things consumers get wrong.

  1. caps.skill and caps.totalSkill are in tenths, the way ServUO stores them: 1000 is 100.0 skill and 7000 is 700.0 total. Rendering the raw number is actively misleading. The other caps (stat, str, …) are plain integers.
  2. connect is present only if the operator set Bridge.PublicConnectAddress. The shard's real listen address (Server.cfg) is never published; nor are Staff.cfg, Email.cfg, DataPath.cfg, Bridge.cfg, Compiler.cfg, Reports.cfg or Client.cfg. The frame is built from an explicit allowlist in BridgeRuleset.csConfig.Entries is never enumerated, because that would sweep in every key on the server.

Absent entirely if the shard runs Bridge.RulesetEnabled=false or an older plugin. Render from GET /ruleset (§6) on connect, then keep live with this event.

This supersedes the world.systems frame sketched in PROTOCOL_2.md §10.4 and never implemented; the systems block above is what that asked for.

Points / loyalty leaderboards (Protocol 3.0)

ServUO carries ~25 separate point currencies — Queen's Loyalty, Void Pool, Casino, Clean Up Britannia, the nine city loyalties, Blackthorn, the Doom / Khaldun / Kotl treasure systems — every one a standing players accumulate over months, and none of them visible outside an in-game gump before 3.0.

A diff sweep (default 300 s), one frame per system rather than one large frame for all of them, matching champ.update / guild.update. A system is emitted only when its top N or its participant count actually changes.

kind fields notes
points.board system, nameString, nameNumber, maxPoints, showOnGump, players, top[] One system's complete board — never a delta. The latest frame for a system replaces the previous one outright. top[] entries are {rank, serial, name, points}.

system is the shard's own PointsType enum name (QueensLoyalty, CleanUpBritannia, …) and is the board's stable key. There is deliberately no points.remove: the set of systems is fixed at startup by PointsSystem.Configure, so a system cannot disappear at runtime — the same argument city.update makes for cities.

{"kind":"points.board","system":"QueensLoyalty",
 "nameString":"Queen's Loyalty","nameNumber":1114938,
 "maxPoints":15000,"showOnGump":true,"players":842,
 "top":[{"rank":1,"serial":"0x1A2B","name":"Darrow","points":29500},
        {"rank":2,"serial":"0x1A2C","name":"Mireille","points":21000}],
 "t":1752489280000}

Four things consumers get wrong.

  1. maxPoints of 0 means UNCAPPED, not "zero points allowed". ServUO's idiom for an uncapped system is double.MaxValue (DespiseCrystals, ShameCrystals and VoidPool all use it), which the plugin normalises to 0 rather than emitting a nonsense integer. On a real shard most systems are uncapped, so a UI that renders points / maxPoints must special-case this or it will divide by zero on the common path.
  2. nameString is usually null. The shard's Name is a TextDefinition, which may carry a literal or a cliloc id, and in practice most systems use the cliloc — so nameNumber is set and nameString is null. Resolve clilocs consumer-side; failing that, humanising the system key ("CleanUpBritannia" → "Clean Up Britannia") reads better than showing a bare number. This is the same contract titles.reward already documents.
  3. players counts players who actually hold points, not the size of the system's table. Ten of the ~25 systems have AutoAdd = true and therefore keep a zero-point row for every character that has ever logged in, so the raw table size would report the shard's entire character census as that system's participants.
  4. Entries carry serial and name only — never acct or webId. A board is the widest-audience surface the bridge has, so the account name of every ranked player deliberately does not cross the wire; resolve serial → site user from your own link mirror if you need it.

Absent entirely if the shard runs Bridge.PointsLeaderboardEnabled=false or an older plugin. Render from GET /points (§6) on connect, then keep live with this event.

char.profile gains a points block

Read-model enrichment on the existing kind — there is no request kind for one character's points, the same precedent titles set in PROTOCOL_2.md §10.3:

"points":[{"system":"QueensLoyalty","nameString":"Queen's Loyalty","nameNumber":1114938,
           "points":29500,"maxPoints":15000}]

Systems where the character has no entry, or an entry at zero, are omitted — otherwise every sheet would carry ~25 zeroes. maxPoints follows the same 0 == uncapped rule as the board.

rank is absent by default and appears only when the shard runs Bridge.PointsProfileRank=true: a points lookup stops at the character's own row, but a rank must count every row that beats them, in every system, on every profile build. Derive rank from points.board instead for anyone in the top N.

Player-vendor marketplace (Protocol 3.0)

The shard-wide shop index: every player vendor's shop name, owner, location and priced inventory — the same set the in-game Vendor Search gump reads, published so a site can offer the same search from outside the game.

An amortized round-robin diff sweep, not a snapshot RPC, and the distinction is load-bearing: rpc.rs::try_route correlates a reply on the FIRST frame carrying a matching reqId, so a chunked reply sharing one reqId would deliver chunk 1 to the HTTP caller and leak chunks 2..N onto the broadcast feed. A whole-world snapshot could not fit in one frame inside the 10 s reply timeout either. The per-account vendor.snapshot RPC (§5) is unaffected and still serves the player portal.

Each tick inventories at most Bridge.MarketSweepBatch vendors (default 25) starting from a persistent cursor, so per-tick cost is bounded independently of world size; full coverage takes ceil(vendors / batch) × MarketSweepSeconds. A vendor is emitted only when its contents, prices, shop name or location actually change.

kind fields notes
vendor.listing serial, shopName, ownerSerial, ownerName, ownerAcct, location{}, fees{}, count, total, truncated, items[] One vendor's complete shop — never a delta. The latest frame for a serial replaces the previous one outright.
vendor.listing.remove serial The shop is gone from the index: dismissed, expired, or its owner switched off the in-game Vendor Search flag.

* Protocol 5. ownerAcct is the owner's game account — ownerName is a character name and identifies nobody, so this is the field that makes a shop resolvable to a person at all.

fees describes ServUO's vendor dismissal rule (PlayerVendor.PayTimer: at each tick the charge is compared with the funds, and the vendor is destroyed when the charge wins):

field meaning
exempt true for a commission vendor, which has no pay timer and is never dismissed for fees. When true, no other field is present
newVendorSystem which of ServUO's two vendor systems is in force; it decides all three quantities below
chargePerPeriod what is deducted at each tick
funds gold available to pay it (holdGold and bankAccount are the raw parts)
payIntervalSec seconds between ticks: 86400 under the new system, one UO day (≈2 real hours) under the old
nextPayAt ISO-8601 UTC: the next tick
periodsRemaining ticks survived before the one that finds the charge unpayable
dismissalAt ISO-8601 UTC: the tick the vendor is destroyed on. This is the field to build on

There is deliberately no daysRemaining: under the old vendor system a pay period is a UO day, so a "days" field would be wrong by a factor of twelve on exactly the shards least likely to notice. dismissalAt is an instant and needs no units. It assumes no further sales or deposits — but unlike house.decay's estimatedCollapse there is no randomness in it: given the current funds it is exact.

Treat exempt: true and a distant dismissalAt as different things. "Never dismissed" and "dismissed in 400 days" render differently, and conflating them is how a vendor that cannot expire ends up in an expiry warning.

{"kind":"vendor.listing","serial":"0x40001234",
 "shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
 "ownerAcct":"darrow_acct",
 "location":{"map":"Trammel","x":1421,"y":1699,"z":0,
             "region":"Britain","house":"Darrow's Villa"},
 "fees":{"exempt":false,"newVendorSystem":true,"chargePerPeriod":10548,
         "funds":82504,"holdGold":82504,"bankAccount":0,"payIntervalSec":86400,
         "nextPayAt":"2026-09-01T21:01:21Z","periodsRemaining":7,
         "dismissalAt":"2026-09-08T21:01:21Z"},
 "count":2,"total":2,"truncated":false,
 "items":[{"serial":"0x40012ABC","itemId":3922,"hue":0,"amount":1,
           "price":25000,"name":null,"cliloc":1023721},
          {"serial":"0x40012ABD","itemId":7026,"hue":1157,"amount":3,
           "price":500,"name":"a shard sigil","cliloc":1041243}],
 "t":1752489280000}

Six things consumers get wrong.

  1. name is null for nearly every item; cliloc is the real label. Items carry a LabelNumber, not a name. The plugin deliberately never calls VendorSearch.GetItemName, which builds an ObjectPropertyList, serialises it and byte-parses the packet per item — a multi-hundred-millisecond stall across a full pass. (It would not work anyway: every current client ships its cliloc files compressed and ServUO's bundled Ultima.StringList cannot read them, so the in-game gump has the same gap.) Resolve clilocs consumer-side; a non-null name is a player-set literal and is strictly more specific, so prefer it over the cliloc.
  2. location is one nested object, and it may be absent entirely. It is nested so that a consumer gating vendor whereabouts gates one field rather than five that can drift apart — the website's market.location rule removes the whole object. Treat a missing location as "not published", not as an error.
  3. truncated means the shop holds more than the frame carries. count is what was published, total is what the shop actually holds, capped by Bridge.MarketMaxListings (default 250). A commodity reseller with thousands of stacked resources is real and an uncapped frame for one is measured in megabytes. Say "showing 250 of 3,104" rather than presenting a partial shop as complete.
  4. child: true means the price buys the ENCLOSING CONTAINER. ServUO prices a container as a unit and everything inside inherits that price with no VendorItem of its own; DoSearch surfaces the same flag. A UI that prints the container's price against each item inside it is lying about the shard.
  5. Opted-out vendors are absent, and that is a privacy control. pv.VendorSearch is the player's own in-game toggle and the sweep honours it — hide your vendor in game and it is hidden here too. The same goes for Map.Internal and a null backpack, matching DoSearch. Process vendor.listing.remove promptly: it is how a player revoking that consent reaches you.
  6. Prices are inherently stale, by design. The round-robin sweep means a shop can be a full cycle behind. Any UI over this must say how old the data may be — the website derives it from the oldest vendor row.

Entries carry ownerSerial/ownerName and never acct or webId, the same rule points.board follows. Absent entirely if the shard runs Bridge.MarketEnabled=false or an older plugin. Render from GET /market (§6) on connect, then keep live with these events — though note that a live firehose of whole vendor inventories is the largest stream the bridge produces, and a consumer that only needs a browsable index (as the website does) is better served by the REST read plus the periodic re-sweep.


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[].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. Do not expect the shard to resolve them for you — on any modern client ServUO's own Ultima.StringList cannot read the client's compressed cliloc files, so VendorSearch.GetItemName returns item.Name and the in-game Vendor Search gump has the same gap. Building that table is a consumer-side job; the website's is described in website/CLILOCS.md.
  • titles (Protocol 2.0): selected is the index into reward currently displayed (-1 if none). fameKarma/skill are computed display titles, omitted when the character has none. reward entries 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 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.

Publish / remove Town Cryer news (Protocol 2.1)

Distinct from the scrolling-crier lines above: this puts a full article — title, HTML body, image, and a "more info" URL — into the in-game Town Cryer News gump, and (by default) has the criers proclaim the title in-world.

POST /news
{ "id": "42", "title": "Double XP Weekend",
  "body": "<CENTER>Double XP Weekend</CENTER><BR><BR>Starts Friday 7PM.",
  "image": 1614, "url": "https://yoursite/news/42" }

200 {"kind":"news.ok","id":"42"}. Re-posting the same id replaces the prior article in place.

  • id, title required. body (HTML supported), image (a UO gump id; a neutral scroll if omitted), url (a browser button in the gump) optional.
  • announce defaults to true — the criers proclaim the title. Send "announce": false to post silently (e.g. a correction).
DELETE /news/{id}

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

Caps apply (title/body length, max active articles). The website is the source of truth: the shard rebuilds its news list on restart and does not persist yours, so the sidecar automatically re-pushes your articles (silently) whenever the shard reconnects. Stock ServUO news is left intact — your articles are tracked separately.

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".

Retrying a command safely — idempotencyKey (Protocol 6)

Any command in this section may carry an idempotencyKey, and the shard promises to execute a key at most once. A repeat is not re-run: it is answered with the original reply, restamped with the repeat's own correlation id and marked "replayed": true.

POST /admin/broadcast
{ "actor":"event:412", "text":"The gates open at dusk.", "idempotencyKey":"5f2c…" }

 200 { "kind":"admin.ok", "reqId":"r-1", "action":"broadcast", "t":1788550182074 }
 200 { "kind":"admin.ok", "reqId":"r-2", "action":"broadcast", "t":1788550182074, "replayed":true }

Note the second reply's t: it is the first attempt's, because it is the stored answer rather than a fresh execution. The world write happened once.

This is what makes a command safe to retry after a timeout. Without a key, a lost acknowledgement and a command that never applied are the same event as seen from here, and the only safe policy is to give up on the announcement rather than risk sending it twice.

Four rules for a caller:

  • A key belongs to your unit of work, not to the attempt. Derive it from something stable — the website's event runner uses sha256(runId|stepId) — so every retry of one action carries the same key and a different action never collides with it. A fresh value per call satisfies the field and defeats the entire mechanism.
  • A key is remembered for one hour, bounded at 4096 keys per shard. Retry inside that window.
  • A repeat that arrives while the original is still in flight is answered 425 Too Early ({"kind":"bridge.busy"}). Nothing ran; come back. It is transient by construction.
  • A replayed reply is an ordinary 200. Treat it exactly as you would have treated the answer you lost; replayed is for your log.

Sending no key is exactly the pre-protocol-6 behaviour, which is the right choice for a command a human just pressed a button for and can see the result of.

The DELETE forms (/towncrier/{id}, /news/{id}) take no key: their idempotency is inherent — the second removal of an entry is a no-op the shard is already happy to perform.

The event plane — leases (Protocol 6)

Off by default. Every endpoint below answers 403 unless the operator has set Bridge.EventsEnabled on the shard. That is deliberately not the admin write plane's switch: enabling admin writes is consenting to staff moderation from a screen a human is looking at, and enabling this is consenting to your world being changed and watched on a schedule, unattended.

A lease is a live configuration value held at a new setting for a bounded time. The shard restores the baseline when the deadline passes whether or not you are ever heard from again — so the worst case is a world back at baseline early, never one stuck changed indefinitely.

GET /lease
 200 { "kind":"lease.list.ok", "leases":[
    { "key":"PlayerCaps.SkillCap", "label":"Starting skill cap", "type":"float",
      "min":1000, "max":1500, "default":"1000", "current":"1000", "held":false } ] }

POST /lease
{ "key":"PlayerCaps.SkillCap", "value":"1200", "holdMs":600000,
  "untilMs":1788567000000, "runId":"77", "idempotencyKey":"…" }
 200 { "kind":"lease.ok", "baseline":"1000", "applied":"1200", "untilMs":1788567000000 }

POST /lease/release
{ "key":"PlayerCaps.SkillCap", "expected":"1200", "baseline":"1000" }
 200 { "kind":"lease.ok", "released":true, "current":"1000" }

Five things a caller needs:

  • The catalog is an allowlist and it is short. A shard advertises only keys it has verified take effect. Most of ServUO's configuration is cached at type initialisation, where a lease would apply cleanly and do nothing — the worst failure this feature has — so lease.list is the authority and "any config key" is not offered.
  • holdMs is what the shard honours; untilMs is for display. Send both. An absolute deadline is measured against two clocks, and a shard whose clock runs fast would restore your lease the moment it took it.
  • Values cross as text, whatever type says. "1200", not 1200. Comparison is done on parsed values at the other end; the text is so a compare-and-set is comparing what you sent.
  • released can answer lease.drifted at 200. That means somebody moved the value while you held it, the shard did not overwrite them, and current is what is there now. It is not an error: the mechanism did its job, and only a human can decide what should happen next.
  • held means the shard still has a record of the lease, not that the value is still overridden. A lease whose deadline has fired stays listed with expired: true until you release it, so a reconcile in that window does not read a working backstop as a lost resource. A shard restart, by contrast, reverts every lease and clears the record — held: false is how you learn that.

The event plane — participation (Protocol 6)

A run-scoped tally of who took part: presence in a declared area, plus kill credit inside it, keyed by character serial. The shard computes a score and you store it; the components ride along so you can explain it.

POST /participation
{ "runId":"99", "map":"Felucca", "x":1496, "y":1628, "radius":40, "holdMs":3600000 }
 200 { "kind":"participation.ok", "runId":"99", "members":0, "closed":false }

POST /participation/99/snapshot
{ "idempotencyKey":"…" }
 200 { "kind":"participation.snapshot.ok", "runId":"99", "members":2, "killWeight":5,
        "participants":[ { "serial":"0xCB20", "name":"Jarvis", "acct":"seed_001", "webId":"17",
                           "seconds":3600, "minutes":"60.00", "kills":3, "score":"75.0000",
                           "firstMs":1788550182074, "lastMs":1788553782074 } ] }

POST /participation/99/close
 200 { "kind":"participation.ok", "closed":true, "known":true, "members":2 }
  • The area is a point and a radius, not a region name. The most specific region containing an event is routinely anonymous on a UO map — an active champion spawn registers a nameless region over its own area — so a region-named venue would be undeclarable for exactly the places events happen.
  • snapshot is a POST for a read, because it carries your idempotencyKey. On a well-attended run the shard walks its members across game ticks rather than in one call, so a repeat arriving mid-walk is answered 425. Come back.
  • The tally is persisted in the world save, so it survives a restart mid-event. close on a run the shard has forgotten answers known: false at 200 rather than an error: nothing is being counted for it either way.
  • refused on a snapshot is the number of members the shard's cap turned away. A truncated tally says so rather than quietly being short.
  • webId is present only where the character's game account is linked to a website user. Most characters carry none; treat its absence as ordinary.

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 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. Ordered by name. Survives a sidecar restart.

Each entry is a guild.update payload plus, from Protocol 4, a roster key holding the member list — already reassembled, so the frame-splitting described in §4 never reaches this endpoint:

→ { "guilds": [ {"kind":"guild.update","id":1042, ..., "roster":[
      {"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
      {"serial":"0x77","name":"Bran","acct":"bran","player":true} ]}, ... ] }

A guild that has had a guild.update but no roster yet has no roster key at all — deliberately distinct from "roster": [], which means the guild is genuinely empty. Do not conflate "not known" with "known to be empty".

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.

Shard ruleset (Protocol 3.0)

GET /ruleset
→ { "ruleset": {"kind":"world.ruleset","rev":"1a2b3c4d","shard":"UOMysticmoon",
      "expansion":"EJ","systems":{...},"caps":{...},"accounts":{...}, ... } }

The shard's published ruleset (§4 for the full frame and its two gotchas). Served from the sidecar's store, so it answers while the shard is down — a rules page that goes blank during a restart is worse than one that is briefly stale. Keep it current with the world.ruleset stream.

{"ruleset": null} means the shard has never published one — an older plugin, or Bridge.RulesetEnabled=false. That is a real answer distinct from a published ruleset, and worth rendering differently ("not published yet") rather than as an empty ruleset.

Points / loyalty leaderboards (Protocol 3.0)

GET /points
→ { "boards": [ {"kind":"points.board","system":"QueensLoyalty","nameString":"Queen's Loyalty",
      "nameNumber":1114938,"maxPoints":15000,"showOnGump":true,"players":842,
      "top":[{"rank":1,"serial":"0x1A2B","name":"Darrow","points":29500}, ...],"t":...}, ... ] }

GET /points/{system}                 # e.g. /points/QueensLoyalty
→ {"kind":"points.board","system":"QueensLoyalty", ... }

Every system's latest board, or one by its PointsType name (§4 for the frame and its four gotchas). Served from the sidecar's projection, kept current by the points.board stream, ordered by display name. Survives a sidecar restart — which matters more here than for live state, since these are standings built over months and blanking them during a restart reads as data loss.

GET /points/{system} returns 404 for a system the shard has never published (an unknown name, or one excluded by Bridge.PointsSystems). That is distinct from a published board nobody has scored in yet, which is 200 with an empty top[] — and the two are worth rendering differently.

Player-vendor marketplace (Protocol 3.0)

GET /market?limit=200&offset=0
→ { "vendors": [ {"kind":"vendor.listing","serial":"0x40001234",
       "shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
       "location":{"map":"Trammel","x":1421,"y":1699,"z":0,
                   "region":"Britain","house":"Darrow's Villa"},
       "count":2,"total":2,"truncated":false,"items":[ ... ],"t":...}, ... ],
    "total": 137, "limit": 200, "offset": 0 }

Every vendor's latest shop, exactly as vendor.listing published it (§4 for the frame and its six gotchas). Served from the sidecar's projection, so it answers while the shard is down.

This is the only PAGED read the sidecar serves, because it is the only board that can be a whole world's inventory. limit is clamped to 1..1000 (default 200); total is returned so a caller knows when to stop rather than paging until it sees a short page, which would race a concurrent sweep. Ordering is by serial, not by shop name — a serial is stable while a shop name is renameable, so a rename mid-walk cannot make a vendor skip or repeat a page.

The route is /market and deliberately not /vendors: /vendors/{account} next door is the per-account RPC (§5), and two routes a prefix apart meaning "this player's shops" and "every shop on the shard" is a trap nobody wins.

Frames are served verbatim, owner names and coordinates included. That is not an oversight: the sidecar defines no audiences. Deciding who may see what is the consuming site's job — see v3.md §3 for how the website does it.


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
403 Refused by the operator — the admin write plane, or the event plane (Bridge.EventsEnabled), is switched off on the shard
404 Not found (unknown account / character / id, a not-linked account, an unoffered lease key, or a run the shard is not counting)
409 Conflict — protocol version mismatch, or an account name already taken on POST /accounts/create
425 Too Early — a command with this idempotencyKey is still in flight on the shard (Protocol 6). Nothing ran; retry
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."

425 vs 409: both are conflicts of a sort and they want opposite responses. A 409 is a deployment fault — your pinned protocol version disagrees with the sidecar's — and retrying it will never help. A 425 is a retry that will succeed on its own. They are deliberately different codes so a retry loop cannot quietly swallow a mismatched deployment.


8. Putting it together

A typical character page:

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

// 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.