INTEGRATION.md was written for the window that just closed -- it told integrators the version had NOT been bumped yet and that a sidecar on `edge` reports 2 while already carrying v3 kinds. That guidance is now wrong in the direction that matters, so the version section states 3 (header, /health, ws.hello, the 409 example and the §8 worked example) and replaces the "until then" paragraph with what a v2 integration actually has to do to upgrade: change the constant it sends, and nothing else, because nothing that existed in v2 changed shape. v3.md gains §4.1 for what the bump touches and, more importantly, WHY the website's boot migration is gated on a marker row: schema.sql is re-run on every boot and uo_link_config.protocol is admin-editable, so an ungated UPDATE would silently un-pin an operator running an older sidecar. That is the one piece of the cutover a reader could not infer from the code being one constant. Progress tables: 5b done, 6 in review. Co-Authored-By: Claude <noreply@anthropic.com>
54 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: 3header. -
GET /healthand the WebSocketws.helloframe include"protocol": 3. -
Optionally, send
X-UOLink-Version: 3on your requests. If it disagrees with the sidecar, the request is rejected 409 Conflict:{ "error": "protocol version mismatch", "sidecar_protocol": 3, "client_protocol": "2" }
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.
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.
Upgrading a v2 integration. The bump is an operator-visible hard break in one direction only: a
client still declaring 2 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
v3 sidecar. Nothing that existed in v2 changed shape, 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": 3,
"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": 3 }
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.
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.
caps.skillandcaps.totalSkillare in tenths, the way ServUO stores them:1000is100.0skill and7000is700.0total. Rendering the raw number is actively misleading. The other caps (stat,str, …) are plain integers.connectis present only if the operator setBridge.PublicConnectAddress. The shard's real listen address (Server.cfg) is never published; nor areStaff.cfg,Email.cfg,DataPath.cfg,Bridge.cfg,Compiler.cfg,Reports.cfgorClient.cfg. The frame is built from an explicit allowlist inBridgeRuleset.cs—Config.Entriesis 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.
maxPointsof0means UNCAPPED, not "zero points allowed". ServUO's idiom for an uncapped system isdouble.MaxValue(DespiseCrystals,ShameCrystalsandVoidPoolall use it), which the plugin normalises to0rather than emitting a nonsense integer. On a real shard most systems are uncapped, so a UI that renderspoints / maxPointsmust special-case this or it will divide by zero on the common path.nameStringis usuallynull. The shard'sNameis aTextDefinition, which may carry a literal or a cliloc id, and in practice most systems use the cliloc — sonameNumberis set andnameStringisnull. Resolve clilocs consumer-side; failing that, humanising thesystemkey ("CleanUpBritannia" → "Clean Up Britannia") reads better than showing a bare number. This is the same contracttitles.rewardalready documents.playerscounts players who actually hold points, not the size of the system's table. Ten of the ~25 systems haveAutoAdd = trueand 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.- Entries carry
serialandnameonly — neveracctorwebId. 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, location{}, 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. |
{"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":[{"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.
nameisnullfor nearly every item;clilocis the real label. Items carry aLabelNumber, not a name. The plugin deliberately never callsVendorSearch.GetItemName, which builds anObjectPropertyList, 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 bundledUltima.StringListcannot read them, so the in-game gump has the same gap.) Resolve clilocs consumer-side; a non-nullnameis a player-set literal and is strictly more specific, so prefer it over the cliloc.locationis 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'smarket.locationrule removes the whole object. Treat a missinglocationas "not published", not as an error.truncatedmeans the shop holds more than the frame carries.countis what was published,totalis what the shop actually holds, capped byBridge.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.child: truemeans the price buys the ENCLOSING CONTAINER. ServUO prices a container as a unit and everything inside inherits that price with noVendorItemof its own;DoSearchsurfaces the same flag. A UI that prints the container's price against each item inside it is lying about the shard.- Opted-out vendors are absent, and that is a privacy control.
pv.VendorSearchis 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 forMap.Internaland a null backpack, matchingDoSearch. Processvendor.listing.removepromptly: it is how a player revoking that consent reaches you. - 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[].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. Do not expect the shard to resolve them for you — on any modern client ServUO's ownUltima.StringListcannot read the client's compressed cliloc files, soVendorSearch.GetItemNamereturnsitem.Nameand the in-game Vendor Search gump has the same gap. Building that table is a consumer-side job; the website's is described inwebsite/CLILOCS.md. 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.
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,titlerequired.body(HTML supported),image(a UO gump id; a neutral scroll if omitted),url(a browser button in the gump) optional.announcedefaults to true — the criers proclaim the title. Send"announce": falseto 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
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.
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 |
| 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": "3" };
// 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.