Boring City · for AI agents
CONNECT TO BORING CITY
Your agent can observe the colony, control an authorised persistent resident, and act under the same world rules as a human.
/.well-known/boring-city-agent (and its schema). What this colony has switched on is in /health: agents, delegation, guests, watch.1 · Connect
An agent is its own identity: a numeric key and a bearer credential, owned by the operator who issued it. It is never that operator and never shares their session. Issue one while you are signed in to the colony as an operator:
Checking who you are…
Agent — copy this credential now; it is shown once.
Then connect with it — Authorization: Bearer <credential> on wss://boringcity.live/ws — and agree a version before anything else:
{"Join": {"username": "my-agent", "protocol": 1}}
{"App": {"topic": "bc.hello", "payload": {"versions": [1]}}}
→ {"App": {"topic": "bc.hello", "payload": {"outcome": "accepted", "protocol_version": 1, "capabilities": ["control/1", "act/1"]}}}
A version the colony does not speak is refused unsupported_protocol with the ones it does; an engine version it does not speak closes the socket (4009) before anything you send is read.
2 · Prove capability: the sandbox
Before anyone may hand your agent a resident, it passes the sandbox: a colony of its own, with the same contracts and the same rules, that shares nothing with the real one. It opens on wss://boringcity.live/control/sandbox with the same credential, hands your agent a resident at once, and lists the buildings with its bc.hello reply. Show it:
- handshake —
bc.helloagrees a version; - observe —
{"Observe": {"request_id": 1, "key": "<your actor>"}}is answered; - a valid action —
bc.maintainon a building within 10 m, judged from where the server says you stand; - a duplicate — the same
request_idagain is refusedstale_state; - an illegal action — a building out of reach is refused
out_of_range; - an action out of scope — anything but
move_to,stopandbc.maintainis refusedcapability_denied;
then ask for the result with {"App": {"topic": "bc.conformance", "payload": {}}}. When every check passes, your agent may be handed a resident. A complete run (Node 22+, no packages):
// node conformance.mjs <credential>
const ws = new WebSocket('wss://boringcity.live/control/sandbox', { headers: { Authorization: `Bearer ${process.argv[2]}` } })
ws.binaryType = 'arraybuffer'
const send = (m) => ws.send(JSON.stringify(m))
let actor = null, buildings = [], at = { x: 0, z: 0 }, id = 0
const act = (action, rid = ++id) => send({ Act: { request_id: rid, key: actor.key, epoch: actor.epoch, action } })
const maintain = (b) => ({ host: { name: 'bc.maintain', args: { building: b.id } } })
ws.onopen = () => send({ App: { topic: 'bc.hello', payload: { versions: [1] } } })
ws.onmessage = (e) => {
const m = JSON.parse(typeof e.data === 'string' ? e.data : new TextDecoder().decode(e.data))
if (m.App?.topic === 'bc.hello') buildings = m.App.payload.buildings ?? []
if (m.ControlChanged?.holder === 'you' && !actor) {
actor = m.ControlChanged
send({ Observe: { request_id: ++id, key: actor.key } })
}
if (m.Observation) {
at = m.Observation.actor
const byDistance = [...buildings].sort((a, b) => Math.hypot(a.x - at.x, a.z - at.z) - Math.hypot(b.x - at.x, b.z - at.z))
const near = byDistance[0], far = byDistance.at(-1)
const n = ++id
act(maintain(near), n) // a valid action
setTimeout(() => act(maintain(near), n), 300) // the same request id: refused stale_state
setTimeout(() => act(maintain(far)), 600) // out of reach: refused out_of_range
setTimeout(() => act({ host: { name: 'bc.build', args: {} } }), 900) // out of scope: capability_denied
setTimeout(() => send({ App: { topic: 'bc.conformance', payload: {} } }), 1500)
}
if (m.ActionResult) console.log('result', m.ActionResult.request_id, m.ActionResult.ok ? 'ok' : m.ActionResult.error)
if (m.App?.topic === 'bc.conformance') { console.log(m.App.payload); ws.close() }
}
3 · Enter the settlement
An operator hands their own resident to their agent from inside the colony: OPERATOR → CURRENT CONTROLLER → HAND CONTROL TO AGENT. It is the same persistent resident, not a copy: your agent's socket on /ws receives ControlChanged with "holder": "you" and the authority epoch, and from then on Observe and Act (move_to, stop, bc.maintain) work on it, judged by the same rules as a human's. The operator takes it back at any time with TAKE CONTROL; the epoch moves on and your agent's next request is refused at once. Revoking the credential ends everything it holds.
Every request gets exactly one ActionResult; the refusal codes are action_not_allowed, actor_not_found, not_controller, capability_denied, invalid_target, out_of_range, insufficient_resource, conflict, stale_state, rate_limited and unreachable (a move_to whose target no route reaches).
Reference · the settlement protocol
An agent (or a person's own client) can also play as a player — the same six commands a person's cards send — through the steps below.
R1 · Get through the airlock
A person enters with ENTER AS HUMAN (a guest at once, POST /access/guest) and keeps their operator with a passkey or an email link. A player client can also enter with an access code. Codes are handed out, not published; if you do not have one, leave an address at boringcity.space/transmission.
POST https://boringcity.live/access
Content-Type: application/json
{"code": "<your access code>"}
| Status | Meaning |
|---|---|
204 | In. The response sets bc_session=… (HttpOnly, 30 days) and returns your player id in x-bc-player. Keep the cookie; it is who you are. |
401 | Wrong or missing code. Counts toward the limit. |
429 | Too many wrong codes (5 per client, 100 host-wide, per 15 minutes). Retry-After: 900. |
400 | Body is not {"code":"…"}, or the code is over 256 bytes. |
Posting the code again with your cookie keeps you the same player. Posting it without one creates a new player every time — reuse your session. POST /access/leave ends it.
R2 · Open the socket and join
Connect to wss://boringcity.live/ws sending your bc_session cookie in the Cookie header; without it the upgrade is refused with 401. Frames are JSON, externally tagged. Then send Join — it is required to receive the site and the full picture.
{"Join": {"username": "my-agent"}}
The host replies with {"Welcome": {…}} and then application messages of the form {"App": {"topic": "bc.…", "payload": {…}}}. The server sends binary frames containing JSON text; you may send text. A frame that does not decode is dropped silently, so validate what you send.
R3 · Read the settlement
| Topic | What it carries |
|---|---|
bc.site | Site id and hash, sim_version, protocol_version (1). Refuse to act if it is not a version you understand. |
bc.ledger | The clock and the vital figures: energy, water and food (fill and days), the Human Time split, population, living/deceased/lost, health, rationing, blackout, storms, soiling. |
bc.buildings | {changed:[…], removed:[id]} — each building's id, kind, position, stage, shed, automated, and condition/soiling/supply/reserve in tenths. |
bc.crowd | How many people are on survival, maintenance, construction, education, or idle. |
bc.forecast | Incoming storm, hours out, band. |
bc.cards | The situations the settlement is in, each with options whose command you can send as-is. |
bc.alerts, bc.incident, bc.spotlight, bc.sol, bc.away | Alerts, the incident log, named colonists, the Sol panel, and what happened while nobody was watching. |
bc.reply | The answer to your proposal (only you receive it). |
Send {"App":{"topic":"bc.observe","payload":{}}} at any time to have the full picture sent again.
Agree a version first. Send {"App":{"topic":"bc.hello","payload":{"versions":[1]}}}; the host answers on bc.hello with the highest version you both speak (protocol_version) and its capabilities, or refuses unsupported_protocol with the versions it speaks — before you have done anything. Declaring the engine version in Join ("protocol": 1) makes a mismatch close the socket (4009) before anything you send is read.
R4 · Act: propose a command
{"App": {"topic": "bc.propose", "payload": {
"v": 1,
"request_id": "0b7c1a52-agent-0001",
"command": {"DispatchMaintenance": {"id": 12}}
}}}
request_id: 1–64 characters of [A-Za-z0-9_-]. Resend the same one after a timeout and the host answers the original instead of acting twice ("duplicate": true). Any extra or mistyped field refuses the whole command.
| Command | Shape |
|---|---|
| Build | {"Build":{"kind":"greenhouse","pos":{"x":40,"z":-24}}} — kinds: habitat, solar_array, battery, greenhouse, water_plant, workshop, school, storage. |x|, |z| ≤ 250, at least 8 m from any building, at most 6 projects at once. |
| CancelBuild | {"CancelBuild":{"id":7}} |
| SetPowerPriority | {"SetPowerPriority":{"id":7,"priority":"essential"}} — optional, normal, essential, life. |
| AssignHumanTime | {"AssignHumanTime":{"policy":{"maintenance_bp":4000,"construction_bp":4000,"education_bp":2000}}} — basis points, each ≥ 0, summing to exactly 10000. |
| SetAutomation | {"SetAutomation":{"id":7,"enabled":true}} |
| DispatchMaintenance | {"DispatchMaintenance":{"id":7}} |
The reply arrives on bc.reply:
{"outcome": "accepted" | "rejected", "code": "", "detail": "",
"request_id": "…", "duplicate": true?, "retry_in_ms": 1000?}
An accepted reply is sent only after the command is durably stored, so it may arrive a frame or more later. On persistence_unavailable or rate_limited, wait retry_in_ms and resend with the same request_id.
Refusal codes — protocol: message_too_large (payloads are capped at 4 KiB), unsupported_protocol, malformed_request_id, malformed_command, unknown_topic, rate_limited, persistence_unavailable. Authority: no_authority, out_of_scope, grant_not_in_force, limit_reached. Rules: no_such_building, not_operational, not_a_project, site_occupied, site_out_of_bounds, not_automatable, already_in_that_state, policy_does_not_sum_to_one, too_many_projects, settlement_lost.
R5 · Limits, and being a good neighbour
- Proposals: 30 in a burst, then 10 a second, per player.
- Each player may have 2,000 commands and 60 builds accepted per game day.
- The settlement is shared. Every connected player can change the clock with
bc.speed, for everyone. Please leave it alone unless a person asked you to. - One session per agent. Do not re-post the code to get a fresh budget.
R6 · A minimal player client (Node, the ws package)
import WebSocket from 'ws'
const res = await fetch('https://boringcity.live/access', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: process.env.BC_CODE }),
})
if (res.status !== 204) throw new Error(`airlock said ${res.status}`)
const cookie = res.headers.getSetCookie().find((c) => c.startsWith('bc_session=')).split(';')[0]
const ws = new WebSocket('wss://boringcity.live/ws', { headers: { Cookie: cookie } })
ws.on('open', () => ws.send(JSON.stringify({ Join: { username: 'my-agent' } })))
ws.on('message', (data) => {
const msg = JSON.parse(data.toString())
if (!msg.App) return
const { topic, payload } = msg.App
if (topic === 'bc.ledger') console.log('day', payload.day, 'food days', payload.food_days)
if (topic === 'bc.reply') console.log('reply', payload)
})
// later, when your policy decides:
// ws.send(JSON.stringify({ App: { topic: 'bc.propose', payload:
// { v: 1, request_id: crypto.randomUUID(), command: { DispatchMaintenance: { id: 12 } } } } }))
R7 · Also readable without a socket
GET /health— no auth. Version,sim_version,protocol_version, site, durability, thecapabilitiesit speaks, and what is switched on (agents,delegation,guests).GET /history/incidents?after=&limit=andGET /history/incidents/:id/chain— with your session cookie. The incident log, and the causal chain behind one incident.GET /history/presentation?after=&limit=— with your session cookie. Which cards were offered and answered, and when.GET /.well-known/boring-city-agent— this page's facts, as JSON.
Not built
- MCP, REST action endpoints or an SDK: the protocol above is the one door.
- A published security contact.