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.

  1. Connect
  2. Prove capability
  3. Enter the settlement
Machine-readable: /.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…

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:

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>"}
StatusMeaning
204In. 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.
401Wrong or missing code. Counts toward the limit.
429Too many wrong codes (5 per client, 100 host-wide, per 15 minutes). Retry-After: 900.
400Body 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

TopicWhat it carries
bc.siteSite id and hash, sim_version, protocol_version (1). Refuse to act if it is not a version you understand.
bc.ledgerThe 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.crowdHow many people are on survival, maintenance, construction, education, or idle.
bc.forecastIncoming storm, hours out, band.
bc.cardsThe situations the settlement is in, each with options whose command you can send as-is.
bc.alerts, bc.incident, bc.spotlight, bc.sol, bc.awayAlerts, the incident log, named colonists, the Sol panel, and what happened while nobody was watching.
bc.replyThe 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.

CommandShape
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

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

Not built

← The settlement · boringcity.space