docs/INTEGRATION.md is the API reference for building the front end against the sidecar: base URL, auth (Bearer / X-Api-Key / ?token=), protocol versioning, the rich /health, the WebSocket live feed with a full event catalog, every REST query and command (char/roster/vendors/link/towncrier/history/economy), the status-code table, a worked character-page example, and current caveats. Payloads are the real shapes captured during testing. Linked from the top-level README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16 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: 1header. -
GET /healthand the WebSocketws.helloframe include"protocol": 1. -
Optionally, send
X-UOLink-Version: 1on your requests. If it disagrees with the sidecar, the request is rejected 409 Conflict:{ "error": "protocol version mismatch", "sidecar_protocol": 1, "client_protocol": "2" }
Pin the version you built against and compare it to the header (or /health.protocol) at startup.
3. Health
GET /health (no auth)
{
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
"protocol": 1,
"plugin_connected": true, // is the shard link up right now?
"database": "ok", // "ok" | "error"
"uptime": "3d 12h",
"last_event": "2026-07-10T22:08:27Z" // last line received from the shard; null if none yet
}
Always returns HTTP 200 (read status/plugin_connected for real state). Use it for liveness checks and to detect when the shard has dropped (plugin_connected: false).
4. WebSocket live feed
GET /ws?token=<token> (WebSocket upgrade)
A push-only stream of game events as they happen. You do not send commands over the WebSocket — use REST for that. The socket carries one JSON object per text frame.
On connect, the first frame is:
{ "kind": "ws.hello", "protocol": 1 }
Then a continuous stream of event frames, each with at least t (epoch ms) and kind. Route on kind.
Notes:
- Live-only, no replay. A client that connects now sees events from now on. For history/backfill, use
GET /history. - The sidecar sends WebSocket ping frames every ~30s for keepalive; browser clients answer automatically.
- You may occasionally see a
{"kind":"pong",...}frame (the sidecar's internal heartbeat to the shard). Ignore anykindyou don't handle. - A client that falls far behind is dropped rather than allowed to stall others — reconnect and backfill via REST if that happens.
Minimal browser client
const ws = new WebSocket(`ws://127.0.0.1:8080/ws?token=${TOKEN}`);
ws.onmessage = (m) => {
const ev = JSON.parse(m.data);
switch (ev.kind) {
case "ws.hello": /* check ev.protocol === 1 */ break;
case "mob.login": onLogin(ev); break;
case "vendor.sale": onSale(ev); break;
case "house.decay": onIdoc(ev); break;
// ...handle the kinds you care about; ignore the rest
}
};
ws.onclose = () => setTimeout(connect, 2000); // reconnect + backfill via /history
Event catalog
Every event has t (epoch ms) and kind. A nested actor object looks like {"serial","name","acct","player"} (acct present only for player-owned mobiles).
Lifecycle
| kind | fields | notes |
|---|---|---|
server.hello |
shard, bootId, connects, items, mobiles, accounts |
Sent to the sidecar on every shard (re)connect. bootId changes on a shard restart; stable across sidecar reconnects — use it to tell "shard restarted" (drop caches) from "sidecar reconnected". |
server.shutdown |
— | Clean shutdown. |
server.crashed |
error |
Not always sent (a hard crash may skip it). |
world.save.before / world.save.after |
(after adds items, mobiles) |
Save-cycle boundaries; a natural consistency checkpoint. |
Sessions & identity
| kind | fields |
|---|---|
mob.login |
who, map, x, y, z, webId (present if the account is linked) |
mob.logout |
who |
account.login.attempt |
acct, ip — an authentication attempt (no password ever leaves the shard) |
Economy & commerce
| kind | fields | notes |
|---|---|---|
gold.change |
acct, old, new, delta |
AccountGold flow (gold in bank/account, not physical coins). |
vendor.buy |
who, vendor, item, itemSerial, amount, perUnit, total, committed:false |
NPC vendor purchase (validation stage). |
vendor.sell |
who, vendor, item, itemSerial, amount, perUnit, total, committed:false |
NPC vendor sale. |
vendor.sale |
buyerSerial, buyerAcct, ownerSerial, ownerAcct, vendorSerial, itemType, itemSerial, itemId, amount, price, commission, committed:true |
Player vendor sale, at the committed transaction. Carries both buyer and owner accounts — the pair that flags laundering when they match. |
vendor.placed |
owner, vendor |
A player vendor was placed. |
{"kind":"vendor.sale","committed":true,"buyerAcct":"wttest","buyerSerial":"0x2E0",
"ownerAcct":"seed_000","ownerSerial":"0x1F5","vendorSerial":"0x2E1",
"itemType":"Longsword","itemSerial":"0x40015218","itemId":3937,"amount":1,
"price":100,"commission":0,"t":1783720195626}
Character progression & vitals
| kind | fields | notes |
|---|---|---|
char.vitals |
serial, hits,hitsMax, mana,manaMax, stam,stamMax, str,dex,int, map, x,y |
Periodic snapshot of each online player (~every 30s; configurable). Diff successive snapshots to detect change. |
skill.gain |
who, skill, gained, base, cap |
Player skill gains only (NPC gains are filtered out). |
fame.change / karma.change |
who, old, new |
Player only. |
quest.complete |
who, quest |
Death & PvP
| kind | fields |
|---|---|
player.death |
who, killer |
player.murdered |
victim, murderer |
mob.killed |
killed, killer — only kills that involve a player |
Housing / IDOC
| kind | fields |
|---|---|
house.decay |
serial, from, to, map, x,y,z, region, name, ownerSerial, ownerAcct, ban:{x,y,z}, builtOn, lastRefreshed |
from/to are decay stages (LikeNew, Slightly, Somewhat, Fairly, Greatly, IDOC, Collapsed, …). Emitted only on a transition, so watch for to == "IDOC". ban is where a player would stand to see the sign.
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
"builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"}
Economy supply (periodic)
| kind | fields |
|---|---|
economy.supply |
accounts, gold — total money supply across all accounts (~every 5 min; configurable) |
Cheat detection & staff audit
| kind | fields | notes |
|---|---|---|
cheat.fastwalk |
who, ip |
The shard's own speed-hack detector fired. |
audit.set |
staff, prop, target, targetSerial, old, new |
A staff member used [set to change a property. staff may be null. |
audit.command |
staff, command, args |
A staff command was invoked. |
Account linking
| 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. |
5. REST — read queries
These fetch live state from the shard (correlated round-trip). Typical latency is a few milliseconds; the sidecar waits up to 10s for the shard before returning 504.
Character profile
GET /char/{account}/{slot} # by account + character slot (0-based)
GET /char/serial/{serial} # by serial, e.g. /char/serial/0x24C
Full character sheet: stats, all trained skills, worn equipment with flattened item mods. Works for offline characters too. GET /char/serial/... falls back to the last cached profile if the shard is unreachable (so a page still renders during a shard restart).
{
"kind": "char.profile", "serial": "0x24C", "name": "Darrow", "title": null,
"body": 400, "hue": 33770, "online": false, "acct": "whitlocktech",
"stats": { "str":120,"dex":120,"int":123, "hits":110,"hitsMax":110,
"mana":123,"manaMax":123, "stam":120,"stamMax":120,
"fame":0,"karma":0,"luck":0,
"resist": {"phys":44,"fire":44,"cold":44,"pois":44,"energy":44} },
"skills": [ {"n":"Swords","base":120.0,"value":120.0,"cap":120.0,"lock":"Up"}, "..." ],
"equipment": [
{ "serial":"0x40013AAD","layer":"Shirt","itemId":7933,"hue":33,
"cliloc":1027933,"mods":{} },
{ "serial":"0x4002B3","layer":"OneHanded","itemId":5046,"hue":0,"cliloc":1023721,
"weapon":{"minDamage":16,"maxDamage":18},
"mods":{"WeaponDamage":50,"HitLightning":40} }
]
}
Field notes:
skills[].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. - 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.)
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.
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":...}, ... ] }
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) |
| 409 | Protocol version mismatch (you sent X-UOLink-Version and it disagreed) |
| 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": "1" };
// 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.