CLI

The irtio CLI does two separate jobs. Three commands run entirely on your machine and need no account: init scaffolds a project, dev runs your room locally, and simulate points bots at it. The rest talk to irtio over the network, so they need a login: deploy, migrate, rollback, keys, logs, rooms, login and whoami.

The same surface is available as MCP tools, for a coding agent doing these things on your behalf. See MCP server. It reads the credential irtio login writes, so sign in here first either way.

Nothing needs a global install.

npx irtio <command>

-c, —config

Every command that reads irtio.json takes -c <file> (or --config <file>) to read a different one: dev, deploy, deploy --static, migrate create, rollback, logs, rooms and keys. That is how one checkout deploys to two projects.

npx irtio deploy -c prod.irtio.json
npx irtio logs -c test.irtio.json --follow

The path is relative to the directory you run in, and a file named this way must exist. It is never silently swapped for irtio.json. init is the exception: it always writes irtio.json, and you copy that file to make the second one. See irtio.json.

Running irtio with no command, or with help, --help or -h, prints the same summary. An unknown command prints it too and exits 1.

Building a project runs its code

irtio dev, irtio deploy, and irtio simulate load your project’s code to read its schema and configuration. The code and its dependencies run with your user privileges, including file and network access. Review unfamiliar projects before building them, or use an isolated environment. Deployed room code runs in irtio’s isolated guest.

Commands at a glance

CommandWhat it does
init [dir]Scaffold a relay or coded room, irtio.json and package.json
skill installInstall the irt.io agent skill into .claude/skills/irtio/
devBundle the room file and run it locally with an inspector page
simulateDrive N real clients at a running room and check the built-in invariants
loginSign in through the browser
whoamiPrint who the stored credential resolves to
projectsList the projects your org owns
deployBundle, classify the schema change, and upload
deploy --static <dir>Upload a built client directory to the project’s static site
stamp [<dir>]Record which schema a client build was made against, so a deploy can check it
static release <label>Release a site label so any project can claim it
migrate create <name>Scaffold a migration file for a breaking schema change
rollback <version>Re-promote an older deployment, restoring rooms that migrated past it
keys jwt-secretMint or retire a project’s JWT signing secret
keys jwt-mintSign a test player token locally with a secret you already have
logsTail a project’s logs
roomsList a project’s rooms
rooms saves <room>List a room’s save generations
rooms restore <room>Bring a room back from a save
rooms delete <room>Delete a room’s stored state. Permanent
restartStop the project’s tenant everywhere. The next join starts it again
statusShow a project’s latest metrics and per-room tick health
usageShow what a project used this billing period, per meter
leaderboard <board>Print a project’s leaderboard. Reads are public, so no login is needed
delete-project <id> --yesDelete a project and every control-plane record of it. Permanent

There is no test command (room tests run under your own test runner with @irtio/testing), no releases, and no secrets (JWT signing secrets are keys jwt-secret).

irtio init

npx irtio init [dir] [options]

Scaffolds a project into dir (the current directory by default). It never overwrites a file that already exists, and it lists anything it skipped.

Questions

In a terminal, init asks these in order. A flag answers its question, and a question that does not apply is skipped.

QuestionAsked whenFlag
Relay room?Always--relay, --relay-state, --tick, --event
Should the relay keep shared state?You answered yes to relay--relay (no), --relay-state (yes)
How does the room change state? tick or eventYou answered no to relay--tick, --event
Physics?The room ticks--physics, --no-physics
2D or 3D?You answered yes to physics--physics=2d, --physics=3d
Host your game page on irtio? Then: which directory does your build write to?Always--static <dir>, --no-static
Run the dev server on a port other than 7070? Then: which port?The project has a dev server (not a plain relay)--port <n>
Add scripts to package.json?At least one script applies--scripts dev,deploy,test or --scripts none
Run the install now?Always--install, --no-install

A yes/no question takes only y, Y, n or N, and asks again on anything else. A list question takes the number of a choice or its exact name. The scripts question is a checkbox list: arrow keys move, space toggles, a toggles all, enter confirms. All three scripts start ticked.

When stdin is not a terminal (CI, a pipeline), or with -y / --yes, init asks nothing and every unanswered question takes its default: a tick room, no physics, no static hosting, port 7070, no scripts, and no install.

Room kinds

KindRoom codeFiles
--relayNone. Clients join by code and message each other with joinRelayirtio.json, irtio/AGENT.md
--relay-stateNone. A schema gives the relay shared state and ownership rulesirtio/schema.ts with a perPlayer players entity, irtio.json, irtio/AGENT.md
--tickA fixed-rate loop runs whether or not anyone is doing anythingThe coded-room files below
--eventThe room applies frames on arrival and hibernates when it goes quietThe coded-room files below

A relay-with-state project has no room file on purpose: a deploy with no room file stays a relay room. irtio dev runs it from the schema alone. A plain relay project has nothing for irtio dev to run, so init offers it neither a dev script nor a port.

Files written for a coded room:

FileWhat is in it
irtio/schema.tsA players entity, roles: ['player', 'spectator'], and a project option that reads the id from irtio.json
irtio/rpc.tsexport const rpc = {} with a commented example of each direction
irtio/room.tsdefineRoom with an onJoin spawn block, onLeave and a permissive validate
irtio/room.test.tsA testRoom smoke test: two clients join, one writes, both views converge
irtio/AGENT.mdThe agent guide
irtio.jsonname, plus static when you chose hosting. init does not create a project id: your first irtio deploy gets one from irt.io and writes it here as project. If the file already has a project and name, init keeps both

With physics it also writes irtio/world.ts, the world builder both sides import. --physics=3d (or bare --physics) scaffolds a rapier3d room, and --physics=2d a rapier2d room whose world.ts exports the physics2d block the client passes to joinRoom. Physics needs a ticking room, so --physics with --event or a relay is an error.

package.json

init creates package.json when there is none, and otherwise only adds what is missing:

KeyAdded
dependencies@irtio/client, plus @irtio/schema for a relay with state, plus @irtio/server, @irtio/testing and the Rapier build for a coded room
devDependenciesirtio, plus vitest for a coded room
scripts.devirtio dev, with --port <n> when you chose a port
scripts.deployirtio deploy
scripts.testvitest run, coded rooms only

A script name that is already taken by a different command is added as irtio:dev, irtio:deploy or irtio:test instead, and nothing existing is replaced.

The package manager comes from the nearest packageManager field, then the nearest lockfile (pnpm-lock.yaml, yarn.lock, bun.lock, package-lock.json), then the manager that launched init, then npm. Every command init prints and runs uses it.

Static hosting and ports

Choosing static hosting writes "static": { "dir": "<dir>", "label": "<name>" } to irtio.json, with the label made from the project name. See static.

A client finds irtio dev on its own only at port 7070. With another port, pass url: 'ws://localhost:<port>' to joinRoom while developing locally, and remove it for a deployed page. init prints the line with your port.

irtio skill install

npx irtio skill install [--global]

Installs the irt.io agent skill at .claude/skills/irtio/SKILL.md, so a coding agent working in this project has the schema rules, the room file shape, the four commands, and the verification step before a room counts as working. The skill ships inside the irtio package, so this command copies a file that is already on disk. It needs no login and no network.

OptionWhat it does
--globalInstall into your home directory, ~/.claude/skills/irtio/SKILL.md, for every project

The skill is generated from the same guide the site serves at https://irt.io/llms.txt and that irtio init writes to irtio/AGENT.md. Installing over an existing copy replaces it, which is how you pick up a newer guide after upgrading the CLI. Running it again with the same CLI version reports that the file is already current and writes nothing.

irtio dev

npx irtio dev [--room <file>] [--port <n>] [--host <address>] [--serve <dir> | --proxy <url>] [--no-watch] [-c <file>] [--profile] [--trace-rpc] [--record [file]]

Bundles the room file and runs it locally on the real runtime. It prints these lines, plus a client line when it is also serving your page:

ws://localhost:7070
http://localhost:7070/__irt/
share links: http://localhost:7070/?room=CODE (codes appear when a client joins)
FlagDefaultMeaning
--room <file>first of irtio/room.ts, irtio/room.js, room.tsThe room entry
--port <n>7070Port for both the WebSocket endpoint and the inspector. 0 binds an ephemeral port. Without the flag, a refused 7070 falls back through 7171, 7272, 7373 and 7474, and the port it settled on is printed and written to .irtio/dev.json
--host <address>127.0.0.1Bind address. 0.0.0.0 exposes the server on your LAN for playtests and prints the LAN URLs. The room socket accepts localhost pages, plus this machine’s LAN URLs on the dev port when bound off loopback; IRT_ORIGINS replaces that list
--serve <dir>the static directory in irtio.json, when it has an index.htmlServe your game page from <dir> on the same port
--no-serveDo not serve the static directory from irtio.json
--proxy <url>offForward page requests to your own dev server, for example http://localhost:5173
--no-watchwatching is onStop rebuilding when files change
--reset-stateoffDelete .irtio/snapshots before booting, so every room starts fresh
-c, --config <file>irtio.jsonThe project file to read
--profileoffPrint a bandwidth breakdown per room once a second. See the profiler
--profile-top <n>12Rows per table. Implies --profile
--trace-rpcoffRecord every RPC per room and show it in the inspector. See Tracing RPCs
--record [file]offRecord each room’s authoritative timeline and RPC trace to one JSON file on exit. Implies --trace-rpc

/__irt/ is a live state inspector. /__irt/state.json is the same data as JSON. /__irt/ticks.json is the per-room tick counters alone (no room state, no logs), which is what irtio simulate reads for tick-health.

State on disk

irtio dev persists room snapshots and save generations to .irtio/snapshots, under the directory you ran the command in, so hibernation, room.save() and irtio rooms restore all behave locally the way they do deployed, and state survives restarting the server. The directory is not keyed by project: one working directory is one set of rooms. Add .irtio/ to your .gitignore, and pass --reset-state when you want a clean boot. See Saves and restore.

room.kv is served locally too, from .irtio/player-kv.json in the same directory, so player storage survives a restart and another room in the same working directory reads back what this one wrote. The local store enforces the production limits and answers with the production error codes, so a key or value that is rejected deployed is rejected here. --reset-state clears it along with the snapshots. See Player storage.

Leaderboards and skill ratings are not served locally. room.leaderboard and room.ratings calls reject with E_LB_UNAVAILABLE and E_RATING_UNAVAILABLE, and the rejection lands on the room log.

Breaking changes against your last deploy

Every successful irtio deploy saves the schema it shipped to .irtio/deployed-schema.json. When that file exists and names the project in irtio.json, irtio dev compares the schema against it as the room loads, and again after each edit that changes the schema, and prints what irtio deploy would refuse, with the concrete fix for each field:

irtio dev: 1 breaking change against deployed v3 — irtio deploy will refuse this:
  game.botsWanted: new required field without a default (breaking) — add `.default(0)` to the u8, or make it .opt
  make it additive, or run: irtio migrate create <name>, then deploy with --allow-breaking

It is a warning: the server keeps running the new schema. A project with "preRelease": true gets a dim note instead, because deploy allows the change. The file is per machine, so a fresh clone or a deploy from CI or another machine leaves it missing or out of date, and a rollback does not rewrite it. irtio deploy still classifies against the control plane’s record and is the one that refuses.

Room logs

room.log(...) output does not go to the terminal. It is per room, so it appears on the inspector page at /__irt/ and in /__irt/state.json, alongside the room it came from. For a deployed room, read the same output with irtio logs.

Tracing RPCs

irtio dev --trace-rpc

Every client-to-server RPC gets one line: the client id, the server tick it was applied at, the client stamp it carried (ctx.clientTick), how it ended, and a truncated view of what it returned. The inspector shows them newest first, and /__irt/rpc-trace.json?room=CODE serves the same data.

OutcomeMeans
okThe handler returned and the client got its result
deny:<reason>The handler called ctx.deny(reason)
errorThe handler threw, or the call was rejected before it ran

The client table gains a stamp lag column: ctx.tick - ctx.clientTick as of that client’s last call, which is the number a room using lag compensation decides on. A dash means that client has not called anything yet, or called without a stamp.

Tracing is a development tool. It is off unless you pass the flag, and a deployed room never records one.

Recording a session

irtio dev --record            # writes .irtio/records/record-<timestamp>.json
irtio dev --record run.json   # writes run.json

Records the authoritative timeline of every room this server runs — the same per-tick state irtio simulate captures — together with that room’s RPC trace, and writes one JSON file when the server exits. Read /__irt/recording.json to write it without stopping; the response names the file.

{
  "version": 1,
  "project": "my-game",
  "startedAt": 1725900000000,
  "writtenAt": 1725900120000,
  "rooms": [
    { "room": "ABCD", "timeline": { "frames": [] }, "rpcTrace": { "entries": [], "stampLag": [] } }
  ]
}

Each timeline is the same shape irtio simulate writes to its <trace>-timeline.json sidecar, so anything that reads one reads the other.

Serving your game page

irtio dev serves your game page and the room on one port, so one URL is a whole playtest.

irtio dev --serve . --host 0.0.0.0

--serve <dir> serves plain files: index.html at /, everything else by path. --proxy <url> forwards page requests to a dev server you run yourself instead. The two are exclusive, and both leave /__irt/ and /healthz alone. --serve never exposes dotfiles or dot-directories (.env, .git, .irtio; .well-known is served) or node_modules, and it does not follow symlinks at all: a served path whose final component is a link is a 404, and so is one reached through a linked directory that resolves outside the served root.

Binding off loopback

--host 0.0.0.0 is how you play a build on a phone or a second machine, and it changes three things.

The page and its whole directory become LAN-visible. Anything under --serve <dir> that is not a dotfile, a dot-directory or node_modules is readable by anyone who can reach the port, source maps and stray notes included. Serve a build directory rather than a repository root.

The inspector starts asking for a token. The /__irt/ routes carry the authoritative state of every room, including data your room keeps hidden from clients. Requests whose Host is a loopback address answer without ceremony, whichever address you bound. Requests from anywhere else need the token the dev server mints for that run and prints once at startup:

inspector token (LAN only): 5f3a...
  e.g. http://localhost:7070/__irt/?token=5f3a...

Pass it as ?token=... or an x-irt-dev-token header. The inspector page served by this same server carries it for you, so the printed line matters when you reach the routes with curl from another machine. A request whose Host is a hostname rather than an IP address is refused outright, token or not, so a name pointed back at your dev server cannot read your rooms.

The room endpoint checks the page’s origin, and nothing else. Which browser pages may join depends on the bind:

  • On the default loopback bind, pages served from localhost or a loopback address, on any port.
  • With --host, the same, plus this machine’s LAN URLs on the dev port (http://<lan-ip>:<port>), which is where --serve and --proxy pages come from.
  • IRT_ORIGINS replaces that list with your own, comma-separated (IRT_ORIGINS=http://192.168.1.20:5173 irtio dev --host 0.0.0.0). Localhost pages stay allowed whatever it says, and * allows every origin.

Clients that send no Origin at all (bots, scripts, native clients) always connect. There is still no rate limit, so anyone who can reach the port can attempt to join. That is fine on a home network and wrong on a shared one. Nothing about a bound address changes what a deployed project does: origins there come from your project’s origin list.

Every HTML page served either way gets one injected script. It points the client at the page’s own address, so a page opened from a LAN URL connects to your dev server, not the hosted endpoint. A page that sets window.IRT_URL itself keeps its value.

The dev server refuses every HTTP request addressed to a hostname rather than localhost or an IP address, served pages and proxied ones included, as protection against DNS rebinding. That covers forwarded hostnames too (a tunnel, Codespaces, MagicDNS, *.local), so open it by localhost or its IP address. Only /healthz answers whatever the Host, since it says nothing but ok.

--proxy forwards HTTP only, not WebSocket upgrades. A Vite dev server’s hot reload needs its own socket, so run vite --host and skip --proxy; Vite already serves your LAN. Its LAN URL is then not one the dev server allows by default, so name it in IRT_ORIGINS (IRT_ORIGINS=http://<lan-ip>:5173).

The project id comes from irtio.json, defaulting to dev. A malformed irtio.json is a hard error rather than a silent fallback.

Watching covers the room file’s directory with a 150 ms debounce, and rebuilds .ts, .js, .mts, .mjs, .tsx and .jsx files. A build error is logged and the previous bundle keeps serving, so saving mid-thought does not take the dev server down. A successful rebuild resets the rooms.

The resume secret

irtio dev prints this once at startup:

no resumeSecret configured: resume tokens die with this process

A resume token is what lets a client reconnect as the same clientId after a dropped socket. The token is signed, and the signing key is the resume secret. irtio dev has no key to sign with, so it makes a random one at boot: resume works normally while the dev server runs, and every outstanding token stops verifying when you restart it. A client that reconnects across a restart gets a new id instead of its old one.

The warning needs no action. There is no flag or irtio.json key for it, and deployed rooms are given a stable key by irtio, so the warning does not appear in production and reconnects survive a deployment. Set IRT_RESUME_SECRET only when you run the room server yourself. See Identity for what a resumed id gets you.

What room code may import

dev and deploy build your room file with the same bundler, and that bundler accepts a fixed list of bare imports. Everything else is rejected at build time, including Node built-ins.

ImportUse
@irtio/serverthe room definition, defineRoom and the room API
@irtio/schemathe schema builder
@dimforge/rapier3d-compatthe rapier3d engine
matter-jsthe matter2d engine
@dimforge/rapier2d-compatthe rapier2d engine
relative paths (./, ../)your own files

Subpaths of an allowed package are allowed. A rejected import fails the build:

irtio: room code may import only @irtio/server, @irtio/schema, @dimforge/rapier3d-compat, matter-js, @dimforge/rapier2d-compat and local files (got "lodash")

Sharing code with the rest of a monorepo

A bare workspace specifier is rejected like any other bare import. In a pnpm workspace, import { TILE } from '@yourgame/shared' fails the build even though the package resolves for the rest of your repo.

Import the sibling package’s source by relative path instead:

// irtio/room.ts
import { TILE } from '../../shared/src/constants.js'; // not '@yourgame/shared'

The bundler follows relative paths out of the room directory and inlines what it finds, so the shared file ends up in the room bundle. Keep the shared file free of bare imports of its own, for the same reason.

irtio simulate

npx irtio simulate [--bots <n>] [--seconds <n>] [--room <code>] [--url <ws://...>]
                   [--cheat] [--cheat-bot <index>] [--key <projectKey>] [--trace <path>]
                   [--scenario <file>] [--truth]
                   [--conditions <json>] [--conditions-bot <index>:<json>]
                   [--misprediction-max <units>] [--snaps-max <n>]
                   [--corrections-max <perSec>] [--overruns-max <n>] [--profile]
                   [--queue <name>] [--control <https://...>] [--party <n>]

Points real clients at a running room, plays it randomly, and prints a pass or fail line per built-in invariant.

A run always ends, and says how. It ends when every bot’s script finishes, when --seconds elapses, or on a fatal run condition: the room going away (every bot disconnected and none rejoined), or a hard wall-clock ceiling of seconds x 3 + 30s. A fatal end still prints the report and still writes the trace.

Exit codeMeaning
0Every invariant held
1Something was measured and failed
2The run was not performed: nobody joined, or it passed its wall-clock ceiling

That split makes it usable unattended. 1 is a verdict on the room. 2 says there is no verdict, which is a different thing to act on.

FlagDefaultMeaning
--bots <n>5How many clients to spawn. Must be a whole number
--seconds <n>10How long to run
--room <code>a new roomJoin a specific room code
--url <ws://...>ws://localhost:7070Endpoint to connect to
--key <projectKey>from the schemaPublic project key
--cheatoffEvery bot also sends out-of-range and teleport writes, and expects corrections
--cheat-bot <index>noneOnly this bot cheats. Repeatable
--conditions <json>noneInject network conditions into every bot’s socket: rttMs, jitterMs, loss, duplicate, reorder, reorderMs. Loss, duplication and reorder touch state frames only, so a join always completes
--conditions-bot <i>:<json>noneConditions for one bot, overriding --conditions. Repeatable
--truthoffSave the room at the end of the run and diff it against what each client received, within that client’s visibility
--trace <path>offDump every frame in and out per bot, with timestamps, as JSON
--scenario <file>offRun a scenario and assert against the server’s recorded timeline. The scenario decides the bot count, the duration and the seed
--misprediction-max <units>unlimitedFail the misprediction invariant once a correction snaps a prediction further than this, in your world’s numeric units
--snaps-max <n>unlimitedFail the snaps invariant once more than <n> corrections outrun the client’s resim window
--corrections-max <perSec>5Raise the correction-storm invariant’s threshold above its default of 5 corrections/s per bot
--overruns-max <n>0Raise the tick-health invariant’s threshold above zero server tick overruns in the run window
--denial-rate-max <0..1>0.9Raise the denial-rate ceiling: the fraction of the run’s calls the room may refuse with ctx.deny, judged once the run has made 20 calls
--profileoffPrint where the run’s bytes went, by collection and field, per bot per second. See the profiler
--profile-top <n>12Rows in that table. Implies --profile
--queue <name>offQueue for rooms through the matchmaker instead of creating one: every bot calls the real match API and joins the room its ticket names. Needs --control
--control <https://...>noneThe control plane the queue lives on
--party <n>offGroup each n consecutive bots into one party, so they queue together and land in one room. Needs --queue
-h, --helpPrint usage and exit

simulate loads irtio/schema.ts and derives its behaviour from it: random writes to owned instances within their declared ranges, random void RPC calls with valid params. With no schema module it falls back to a relay simulation and says so in the report, which is the right answer for a relay room.

--cheat expects the room to push back. If a cheating bot draws no corrections at all, the report says which bot and what to do: your room accepted every illegal write, and you want rules in the room file’s validate handlers.

Random bots and a turn-gated game

Generic bots generate valid RPC parameters without following turn order or game rules. Use --scenario for games that require a particular sequence of actions.

Use ctx.deny(reason) for expected refusals. Denials are reported separately from handler-error, but a high refusal rate can fail the denial-rate check. Reserve thrown errors for faults in room logic. See testing in CI before using simulation exit codes as a CI check.

--conditions and --truth both need a local irtio dev room for their reports to mean anything. Hit registration reads the recorded authoritative timeline, and the truth seam takes a room save and reads it back. Per-bot conditions past one or two bots belong in a scenario, where conditions is a function of the bot index.

A truth-seam difference fails the run like any other measured violation. A run that could not take a save at all does not: the section says why instead of reporting an unread number as a pass.

misprediction and snaps report only until you pass their flag, because magnitude is in your world’s units and a shipped default would be tuned for someone else’s game. correction-storm is the exception, since it fails on its own default of 5/s per bot. A physics room where players shove each other can trip that legitimately, and --corrections-max is how you raise it.

With --scenario, the file’s own bots, seconds and seed decide the shape of the run, and its assertions are reported beside the invariants. The invariants still run and still gate the exit code. A scenario that will not load is exit 2, because nothing was measured.

All three engines predict. If your project has a shared world-builder module, the bots simulate physics locally the way a browser client does, and body-field corrections are judged as real disagreements rather than counted as authority arriving. The engine comes from the room’s own physics.engine, so a rapier3d, rapier2d or matter2d world is recognised for what it declares. A project whose room will not load falls back to the shape of the exported gravity, where a { x, y, z } means rapier3d and a { x, y } means matter2d. The builder’s settle (matter2d only), epsilon, timestep, maxPredictedBodies and smoothingHalfLifeMs exports cross too. Either way the builder is built twice and compared before any socket opens, so a world that reads Math.random() fails the run instead of showing up later as mispredictions blamed on netcode. A 2D world with no bodies, or with no steering hook and no gravity, is recognised and run without prediction, and the run says in one sentence what it would have to export.

--queue needs --control, and is refused without it before any socket opens: quick-match is a control-plane surface and irtio dev runs none, so there is no queue at a dev server to join. A queued run adds a matchmaking section to the report (tickets, rooms, wait p50/p95, timeouts, parties intact) and the party-integrity invariant. A bot that never got a ticket, including one that timed out waiting for a queue to fill, fails the run and is named.

If it cannot connect, it asks whether irtio dev is running. See simulated players for what the invariants mean, invariants and load runs for prediction and matchmaking, and scenarios for writing your own assertions.

irtio login

npx irtio login [--url <control>] [--token-file <file>]

Signs in through the browser. The CLI binds a loopback port, opens <control>/cli-auth, and waits up to three minutes for the page to post a token back. The token is written to the credentials file with mode 0600 and never printed.

--url defaults to https://irt.io.

Credentials live at ~/.config/irtio/credentials.json, or %APPDATA%\irtio\credentials.json on Windows. IRT_CREDENTIALS_FILE overrides the location outright, and every command also takes --token-file <file>, which means the same thing.

--token-file exists for coding agents and sandboxes that cannot read or write your home directory. Sign in once, storing the credential somewhere the agent can reach:

npx irtio login --token-file ./.claude/irtio-token.json

Then the agent passes the same flag on every command it runs on your behalf:

npx irtio deploy --token-file ./.claude/irtio-token.json

Keep the file out of version control. It holds a bearer token, and it is written mode 0600 where the filesystem honours that.

The file is keyed by URL, so one machine can hold several logins. Whatever you signed in to is remembered, and later commands use it without needing --url again. An explicit --url or the IRT_CONTROL_URL environment variable still wins. A file holding live credentials for more than one URL stays ambiguous and falls back to the default rather than guessing.

Tokens live 30 days from their last use: every authenticated command slides the expiry forward another 30 days, up to 90 days after login. Past that, run irtio login again, however often you use the CLI. A token that sits unused for a month expires on its own sooner. Active tokens are listed on the dashboard’s account page with where and when each was created and when it was last used, and any of them can be revoked there.

There is no non-interactive login. For CI, place a credentials file yourself and set IRT_CONTROL_URL.

irtio whoami

npx irtio whoami [--url <control>]

Prints the identity your stored credential resolves to, and the URL it asked, so you can tell which login the next command will act as.

you@example.com
org: org_7f21c4
control plane: https://irt.io

With no usable credential it prints not logged in, points at irtio login, and exits 1.

irtio projects

npx irtio projects [--url <control>]

Lists the projects your org owns, with the id irtio.json points at and delete-project takes.

p_7f21c4a90b3e5d18  arena-brawl   2026-08-02
p_0c9d31be77a24f60  putt-lounge   2026-09-11
p_44ab0e5cd1936f27  trivia-night  2026-09-14

3 projects of 3 on the free plan
to create another, delete one of these (irtio delete-project <id> --yes), or add a card in the billing page

The count line states your plan’s real project limit, read from the control plane. At or over the free plan’s cap, it says how many projects must go before another fits (the count minus 3, plus one), so an org holding five is told to delete three, not one. On a card or comped plan there is no project limit and the line reads 5 projects; your plan has no project limit. See pricing for what each plan allows. With no usable credential the command prints not logged in, points at irtio login, and exits 1.

irtio deploy

npx irtio deploy [--allow-breaking] [--project <id>] [--url <control>] [--room <file>]
                 [--strategy drain|migrate] [--name <name>] [-c <file>] [--no-static]
                 [--no-reuse] [--include-dotfiles]
FlagMeaning
--allow-breakingProceed with breaking schema changes, provided a migration covers them
--project <id>Project id. Otherwise irtio.json, then the schema’s own project
--name <name>The project’s name. Otherwise the project file’s name, then the directory name. On a first deploy this is what the project is created as; after that, a name that no longer matches renames the project
-c, --config <file>The project file to read. Default irtio.json
--url <control>The irtio URL to deploy to. Otherwise your stored login
--room <file>Room entry. Otherwise the same candidates dev uses
--strategy drain\|migrateWhat happens to rooms already running. drain (the default) leaves them on their old version until they end. migrate moves them onto the new version now
--no-staticDeploy the room only, leaving a configured static site as it is
--no-reuseUpload every static file, instead of reusing unchanged ones. See unchanged files
--include-dotfilesAlso upload hidden static files. .git, .env* and node_modules are never uploaded. See what it skips
--allow-stale-staticUpload the client build even when its stamp names a different schema than this deploy ships

What it does, in order:

  1. Bundles the room file and verifies it by loading it.
  2. Resolves the project id from --project, then the project file’s project, then the schema.
  3. With no id anywhere, this is a first deploy: it creates the project, named from --name, then the project file’s name, then the directory, and writes the id irt.io assigned into the project file as project. With an id that is not a project on your account, the deploy stops; irt.io assigns every id, so an id cannot be claimed by writing it into the file. If the declared name has changed, it renames the project. Neither step asks.
  4. Classifies the schema change against the last deployment.
  5. Bundles the migration, if one applies.
  6. Uploads the bundle and creates the deployment.
  7. On a first deploy, asks for a production origin. localhost is always allowed.
  8. Ships the static site, if the project file has a "static" key. On a first deploy it skips this step, because your page was built before the project had an id. Rebuild the page and deploy again to publish it.

Room and site in one deploy

A game is a room and the page that joins it, so with "static" in your project file one command ships both:

{ "project": "p_a5da84132e8d6c2e", "name": "dive", "static": { "dir": "dist", "label": "dive" } }
bundled room.4f2c8a10b3e9d7b1.mjs (91c04ade77f2)
deployed v3
reachable at wss://eu.irt.io/p_a5da84132e8d6c2e
uploaded 34 files (2841219 bytes) as v7
live: https://dive.irt-serve.com

The room goes first. The page usually talks to the schema this deploy just shipped, and a page live against a version that was refused is the wrong half to have running. --no-static deploys the room alone.

A project with no room file at all and a "static" key is a valid project: a page that joins someone else’s room, or a site that is not a game yet. irtio deploy there ships the site and says so, rather than reporting a missing room file:

no room file, so deploying the static site only

With no "static" key, a missing room file is still the error it always was.

Additive changes ship without ceremony:

1 additive change(s):
  players.emoji: new field added (has a default/opt, safe for existing snapshots)
deployed v2
rooms already running finish on v1

Breaking changes are refused, with every reason and the next command:

deploy refused: 6 breaking changes
  players.pos: new required field without a default (breaking) — make it .opt, or add `.default({ ... })` with every field filled
  players.x: field was removed (breaking)
--allow-breaking needs a migration for v2. Run: irtio migrate create <name>, then re-run with: irtio deploy --allow-breaking

--allow-breaking with no matching migration file is still refused.

Which version a room runs

  • A new room is created under the newest deployment.
  • A room that is already running finishes on the version it started under, under the default drain strategy.
  • A hibernated room wakes under the newest deployment and runs the migration chain once.
  • A room that never idles never migrates under drain. The alternative is changing code under a live game. --strategy migrate is the opt-in exception.

--strategy migrate

drain is the default and is right for almost every deploy: new rooms start on the new version, and rooms already running finish on their old one, untouched. --strategy migrate is the opt-in alternative for when a fix cannot wait for every room to end. It moves every currently running room onto the new version now, one room at a time, because your project runs on a single vCPU and moving a fleet of rooms at once would overload it. For each room, in order:

  1. Its current state is written as a retained pre-migration save (what irtio rollback restores from).
  2. The room’s worker restarts under the new bundle.
  3. The migration chain runs over the stored state.
  4. Still-connected sockets are rejoined.

What connected clients see at that last step depends on whether a client on the old schema can still read the new one. irtio deploy prints that classification before it uploads anything, so you know which case you are in before rooms move.

  • Code-only deploy, schema unchanged: connected clients see a short gap, then a resync WELCOME on the same socket. No disconnect.
  • Additive schema change: the same short gap and resync on the same socket. The room hands each connected client the new schema first, so new fields read as their defaults and can be written straight away. Appending a field with a .default(...) or .opt is the everyday case. Adding a collection counts only if its name sorts after every existing one, adding or removing an RPC never counts, and neither does any change touching a collection with physics:. See deploying: what connected clients see.
  • Breaking schema change: every connected client is disconnected with E_SCHEMA_MISMATCH and must reload. Shipping the new client bundle first does not help, because a client built against the new schema cannot join a room still serving the old version either. The order that works is the room deploy first, then the client, with connected players reloading in between.

See deploying: strategies for the fuller treatment.

Scripting it

deploy asks one question, and only on a first deploy:

production origin (localhost is always allowed) - leave blank to skip:

It accepts an empty line, and when stdin is not a TTY (CI, a pipeline) it takes the empty answer rather than hanging. Nothing else is asked. The name a new project is created under comes from irtio.json or --name, never from a prompt, so the same command works in CI on a project that does not exist yet.

See deploying for the fuller picture.

irtio deploy --static

npx irtio deploy --static [<dir>] [--label <label>] [--project <id>] [--url <control>]
                 [--name <name>] [-c <file>] [--no-reuse] [--include-dotfiles]

A different deploy entirely: <dir> is a built client directory, not a room bundle. There is no build step here, so run your own build first and point this at its output. With "static" in your project file, the directory and the label live there and the whole command is npx irtio deploy --static.

Use this when you want the site and nothing else: a copy change, an asset swap, anything that does not touch the room. With "static" configured, a plain npx irtio deploy already ships the site alongside the room. --static branches before the room deploy’s argument parser, so the room flags (--room, --strategy, --allow-breaking) are rejected as unknown options rather than quietly ignored.

FlagMeaning
--static [<dir>]The built directory to upload. Otherwise the project file’s static
--label <label>The site’s subdomain label. Otherwise the project file’s static.label, then irtio assigns one
--project <id>Project id. Otherwise irtio.json
--name <name>Same as the room deploy: creates or renames
-c, --config <file>The project file to read. Default irtio.json
--url <control>The irtio URL to deploy to. Otherwise your stored login
--no-reuseUpload every file, instead of reusing unchanged ones. See unchanged files
--include-dotfilesAlso upload hidden files. .git, .env* and node_modules are never uploaded. See what it skips
uploaded 3 files (48211 bytes) as v4; reused 31 unchanged (2793008 bytes) already on the site
live: https://my-game.irt-serve.com
allowed origin: https://my-game.irt-serve.com (localhost is always allowed)

Like the room deploy, this creates the project when the id has never been deployed, named from --name, then irtio.json’s name, then the directory. A static-only project never runs a room, so this is a legitimate first deploy, and it asks nothing.

The upload goes to a fresh version prefix and nothing serves it until the pointer flips at the end, so an interrupted upload never leaves half a site live. Static versions are numbered separately from room deployments: v4 here has nothing to do with the v4 in irtio rooms saves, and irtio rollback does not touch a static site.

Activating a site adds its own origin to the project’s allow list, and the CLI says so, because that is a change to your security policy made on your behalf.

A label is a DNS label: lowercase letters, digits and hyphens, 1 to 63 characters, no leading or trailing hyphen. Names like api, www, docs, staging and admin are reserved, and a label already taken by another project is refused. Each refusal comes back as a named error (E_STATIC_LABEL_INVALID, E_STATIC_LABEL_RESERVED, E_STATIC_LABEL_TAKEN).

Unchanged files are not uploaded again

Static deploys check if any of your files are unchanged from the last deploy. It skips those duplicates, copying them from the last deploy instead.

uploaded 3 files (48211 bytes) as v4; reused 31 unchanged (2793008 bytes) already on the site

Pass --no-reuse to skip the check and upload every file.

What it skips, and what it refuses

Nothing is dropped silently. Every skipped file is printed with its reason:

skipped assets/link.js: symlink (not followed)

Symlinks are skipped by name rather than followed, so a link cannot pull bytes in from outside the directory you named. Anything that is not a regular file is skipped, as is any path the server side validator would refuse: a backslash separator, a control character, a .. segment, a path over 512 characters.

A static site is public, so a few things are never uploaded, at any depth:

skipped .env: environment file (never published)
skipped .git/: version control (never published)
skipped node_modules/: dependency directory (never published)
skipped .DS_Store: hidden (pass --include-dotfiles to upload)
  • .env and every .env.* file, .git (a directory, or the file a git worktree has in its place) and node_modules/ are always skipped. No flag uploads them.
  • Every other hidden file or directory (a name starting with .) is skipped unless you pass --include-dotfiles. That is a command-line flag only, not a project-file key, so a template or a cloned repository cannot turn it on for you.
  • A top-level .well-known/ directory is uploaded as usual, for files like security.txt and assetlinks.json. The files inside it follow the same rules.

A skipped directory is reported once and not walked. The deploy also warns, without refusing, when the directory you named is the project directory itself, or holds package.json, .git or your project file. Your sources, lockfile and source maps would all be public, so point "static" at your build output, such as dist.

Three limits are checked before the first byte is uploaded, so a run that would fail fails immediately:

LimitValueError
Bytes per file32 MBE_STATIC_FILE_TOO_LARGE
Files per deploy2,000E_STATIC_TOO_MANY_FILES
Total bytes per deploy256 MBE_STATIC_TOTAL_TOO_LARGE

An empty directory is an error. A directory with no top-level index.html is not, but it gets a warning, since the link the command prints will 404 until one exists.

Your client bundle and your room bundle are separate artifacts with separate versions. Rolling the server back does not move the client, which is the two-step problem under “Rollback and your client bundle” below.

When rooms can’t start

A hosted page keeps serving even when the project behind it cannot start rooms, so the page loads and joining then fails. Two things make that visible.

A banner in the page. When irtio knows the project’s placements are failing, every HTML document the site serves gets a fixed banner at the top saying the game’s server is unavailable and that rooms cannot start right now. It is a plain element with inline styles and no script, it is added only while the condition holds, and the rest of the document is untouched. Run npx irtio tenant status to see why.

A health endpoint your client shell can check. Every site answers:

GET /.irtio/health.json  ->  {"roomsAvailable": true}

roomsAvailable is false while placements are failing. The response is no-store, so it is always current, and it costs nothing against your data-out meter. Fetch it before you connect if you want your own message instead of the banner:

const { roomsAvailable } = await fetch('/.irtio/health.json').then((r) => r.json());
if (!roomsAvailable) showYourOwnNotice();

The .irtio/ path prefix belongs to the platform. A deploy that contains a file under it is refused with E_STATIC_BAD_PATH, so nothing you upload can shadow this answer.

irtio stamp

npx irtio stamp [<dir>] [-c <file>]

Writes irtio.build.json into a built client directory, recording the schema that build was made against. Run it as the last step of your client build:

// package.json
"scripts": {
  "build": "vite build && irtio stamp dist"
}
FlagMeaning
[<dir>]The built client directory to stamp. Otherwise the project file’s static
-c, --config <file>The project file to read. Default irtio.json

The page a player loads carries a copy of your schema, and so does the room. If the page was built before a schema edit and the deploy ships both, every join fails with E_SCHEMA_MISMATCH while your local checks all pass. irtio deploy compares the stamp against the schema it is shipping and refuses when they disagree, naming both:

irtio deploy: the client in dist was built against a different schema (built 2026-09-23T09:14:02.881Z)
  it carries   6f1c2b9d4a77
  this ships   0b83ef5512ac
  every join on that page would fail E_SCHEMA_MISMATCH
  rebuild the client (its build should end in "irtio stamp"), then re-run
  or pass --no-static to ship the room alone, or --allow-stale-static to upload it anyway

The hash is read by building this project the way a deploy builds it, so the stamp records the schema your code actually produces. Stamp at the end of a build, not later: stamping a directory that has been sitting there for a week records what the sources say today, which is the claim the file makes.

A project that never stamps loses nothing it had. The deploy then falls back to comparing file dates — if every file in the directory is older than your newest irtio/*.ts, it warns. That comparison is a heuristic (a checkout or a copy moves a timestamp), so it only ever warns, while a stamp is exact and may refuse.

irtio.build.json is uploaded with the rest of the site, so a live site can say which build it is serving.

irtio static release

npx irtio static release <label> [--url <control>]

Releases a site label. <label>.irt-serve.com stops answering immediately, the label goes back in the pool for any project to claim, and the site’s origin is removed from the project’s allowed origins.

The command takes a label, not a project, so it needs no project file and can be run from anywhere. The label’s project must belong to your account; a label you do not hold, and a label nobody holds, are both a 404.

released gundive from p_a5da84132e8d6c2e
gundive.irt-serve.com now serves nothing; any project may claim the label

Use it when a game moves to a new project and you want its hostname back without deploying from the old one. The uploaded files are not deleted: they stop being reachable, and they are swept by that project’s next static deploy or when the project is deleted. Releasing a label a live game is using takes that game’s page down, so release the old label first and deploy the new project second.

irtio migrate

npx irtio migrate create <name> [-c <file>]

create is the only subcommand. It scaffolds irtio/migrations/<version>_<name>.ts with the live breaking changes quoted verbatim in a header comment, the old schema’s shape where it could be computed, and a typed up/down pair.

next version: v3 (from the control plane)
created irtio/migrations/3_pos.ts
quoted 6 breaking change(s) in the header comment

The version number is the latest deployment’s plus one. Offline, or not logged in, it falls back to scanning irtio/migrations/ for the highest version, and says which source it used, so the number is never a guess presented as fact. It never overwrites an existing file.

irtio rollback

npx irtio rollback <version> [--project <id>] [--url <control>] [-c <file>]

Re-promotes an older deployment. Any room that migrated past <version> (via --strategy migrate or a wake-time migration) is brought back to the pre-migration state it had right before that migration, from the retained save migrate wrote. It discards everything those rooms wrote since. The CLI names each room it touched (restored) or skipped, and a skip always carries a reason. The common one is a room with no pre-migration state, because it never migrated past the target version at all.

rolling back to v2 - rooms that migrated past it discard everything written since their migration
v3 rolled back; v2 serves again
  ABCD: restores its pre-migration state on its next start
  EF12: untouched - no pre-migration state at v2
the tenant was stopped; the next join brings rooms back on the older version

Refused, by name, rather than half-done:

  • a version that does not exist,
  • a version that is itself already rolled back,
  • a version that is already the one serving.

A drain deploy never touched running rooms, so rollback has nothing to undo on them. Rollback only matters for what migrate moved.

Rollback and your client bundle

irtio rollback re-promotes the room bundle and the schema it was built with. Your client bundle is a separate artifact, built against whatever schema was current when you built it, and served from wherever you serve it: irtio deploy --static, a CDN, an itch page. Rolling the server back does not touch it.

So after a rollback that crosses a schema change, every deployed client still carries the newer schema. The hashes no longer match, and every join is refused with E_SCHEMA_MISMATCH until the client catches up. A rollback across a schema change is two steps, not one:

  1. irtio rollback <version>.
  2. Rebuild the client from the schema that version deployed, and redeploy it (irtio deploy --static dist/, or however you ship your bundle).

Keep the old schema recoverable (a tag, a branch) so step 2 is a rebuild rather than an archaeology exercise. A rollback of a code-only deploy, same schema and same hash, needs no second step.

irtio keys

npx irtio keys jwt-secret [retire] [--issuer <label>] [--project <id>] [--url <control>]
                          [-c <file>]
npx irtio keys jwt-mint --sub <player> [--secret - | --secret <s>] [--iss <label>] [--room <id>]
                        [--role <r>] [--ttl <seconds>] [--project <id>] [-c <file>]

keys jwt-secret mints, or with retire retires, a project’s JWT signing secret. That is the credential your own server uses to sign player tokens. The secret is printed exactly once and never stored on this machine. At most two secrets are live per issuer at a time, which is what makes rotation safe: mint the new one, move your server to it, then retire the old. Tokens signed with either verify during the overlap. --issuer names a second token issuer; most projects only ever use the default, main.

keys jwt-mint signs a test token locally with the documented recipe and prints it. It is a development tool for trying out a JWT join without standing up a signing server. The secret never leaves your machine, and your real server still holds the secret and mints for real players.

The secret comes from IRT_JWT_SECRET, or from stdin with --secret -. --secret <s> also works, but it puts the secret in your shell history and in the process list other users can read:

IRT_JWT_SECRET=… npx irtio keys jwt-mint --sub user_8123
printf %s "$SECRET" | npx irtio keys jwt-mint --secret - --sub user_8123

irtio logs

npx irtio logs [--follow] [--since <cursor>] [--room <id>] [--project <id>] [--url <control>]
               [-c <file>]
FlagMeaning
--followPoll every 2 s, threading the cursor forward so nothing repeats
--since <cursor>Start after this cursor. A cursor is a previous row’s timestamp, not a line count
--room <id>Narrow to one room’s stream
--project <id>Project id. Otherwise the project file
-c, --config <file>The project file to read. Default irtio.json
--url <control>The irtio URL to read from. Otherwise your stored login
2026-08-24T09:12:41Z  INFO   FV7A          party-quiz: sleeping (idle 10000ms) - round 2, phase reveal

Lines come oldest first, so a tail reads top to bottom.

--room is applied on the server, not by filtering the page locally, so a quiet room still fills a page when a busy neighbour is logging hard.

room.log(...) in your room file is what lands here. Each room buffers its last 500 lines, and they are kept for 7 days.

Not everything a project logs belongs to a room. Boot, wake, deploy and storage events belong to the project’s server, and they arrive under the reserved room id __tenant:

npx irtio logs --room __tenant

That is where to look when rooms are not the problem: a deploy that did not take, a server that keeps restarting, a snapshot write that failed. __tenant cannot be used as a real room id, so the stream is only ever the project’s own.

irtio rooms

npx irtio rooms [--active|--idle] [--idle-longer-than <d>]
               [--project <id>] [--url <control>] [-c <file>]
ROOM  STATUS      RETAIN  DELETES        LAST SEEN
FV7A  running     10m     in 9m          2026-08-24T09:12:41Z
NNGQ  hibernated  forever never          2026-08-24T08:55:02Z
PQ3M  hibernated  30d*    in 29d         2026-08-24T08:40:11Z
* set on this room, not declared by its type

STATUS is the room’s own lifecycle state, verbatim. no rooms when there are none.

RETAIN is the retention window in force: how long the room’s stored state outlives its last activity. forever is the default and means nothing deletes it. A * means the window was set on this room with irtio rooms set rather than declared by its type. See room types for the declaration.

DELETES is when that window runs out. It is approximate here, because it is measured from LAST SEEN rather than from the storage timestamp the sweep actually reads; irtio rooms get prints the exact time. next sweep means the window has already lapsed and the room goes on the next pass.

Two filters, and they combine:

npx irtio rooms --idle --idle-longer-than 7d

--active shows only rooms awake in a live server, --idle only those that are not, and --idle-longer-than <d> only rooms last reported longer ago than a duration like 30m, 6h or 7d. A SLOT column appears when any listed room has a slot greater than 0.

These commands also read IRT_API_KEY, so a cron job can run them with a scoped API key and no stored login. The key is sent only to https://irt.io, to localhost, to a control plane you have logged in to, or to one you name with --url. A host that comes only from IRT_CONTROL_URL is refused.

LAST SEEN is when the room was last reported, which is not the same as now. Rooms are reported on a poll interval, and a room that goes away simply stops being reported: its row stays with a frozen LAST SEEN rather than vanishing. Read it as “last confirmed alive”. Rows not reported for 30 days (by default) are pruned from this listing. That prunes the row and nothing else. The room’s stored state is untouched by it. Only a declared retention window or irtio rooms delete removes that.

irtio rooms get

npx irtio rooms get <room> [--project <id>] [--url <control>] [-c <file>]

One room in full. Costs one storage listing on the control plane, which is why the save count, the state size and the exact deadline are here rather than on the listing.

room        arena-7
status      hibernated
type        arena
class       medium
retention   7d
deletes     in 6d
saves       3
state       40.2 KB
last seen   2026-08-30T11:02:41.000Z

state says unknown while the room is awake for a live room, because the stored copy is then whatever the last hibernation wrote. Read it after the room sleeps.

irtio rooms set

npx irtio rooms set <room> --retention <d|forever|clear>
                   [--project <id>] [--url <control>] [-c <file>]

Overrides the retention window for this room, whatever its type declares. A duration like 7d keeps it that long past its last activity, forever keeps it indefinitely, and clear removes the override and goes back to the type’s window.

arena-7: retention set to 30d
room        arena-7
retention   30d
            set on this room (30d), not by its type
deletes     in 30d

It works in both directions: a long override keeps a room its type would have deleted, and a short one disposes of a room its type would have kept. Nothing else about a room is settable from here; see the rooms API for why.

irtio rooms saves

npx irtio rooms saves <room> [--project <id>] [--url <control>] [-c <file>]

Save generations for one room, newest first. A room writes one when its code calls room.save().

SAVE ID                 AGE         SIZE      DEPLOY
00001756209600000-a3f1  30m ago     4.2 KB    v2
00001756206000000-77c1  1h ago      4.1 KB    v2
00001756123200000-0001  1d ago      512 B     v1

DEPLOY is the deployment version the generation was written under. A room that slept across a deploy has generations under both versions, and restoring the wrong one is an easy mistake to make. Only the newest 10 generations are kept; older ones are pruned as new saves are written. no saves when there are none.

irtio rooms restore

npx irtio rooms restore <room> --save <id> [--project <id>] [--url <control>] [-c <file>]

Brings a room back from a named generation. This discards the room’s current state. --save is required, and a --save that names nothing is refused with nothing recorded.

Two things can happen, and the output tells you which. If the room is running, its server is stopped so the restore takes effect now:

discarding the current state of room FV7A and restoring save 00001756209600000-a3f1
restored: FV7A was live, so its tenant was stopped

If nothing was running, nothing is stopped and the restore waits for the next start:

discarding the current state of room NNGQ and restoring save 00001756123200000-0001
queued: NNGQ was not running

Note the second-order effect in the first case. Stopping is per project, so the other rooms in that project are stopped too. They come straight back from their own snapshots on the next join, unchanged, which is the same disruption a redeploy causes.

See saves and restore for what a save contains.

irtio rooms delete

npx irtio rooms delete <room> [--force] [--project <id>] [--url <control>] [-c <file>]

Deletes one room’s stored state: its snapshot across every deployment version it slept under, every save generation it wrote, and its durable alarms. This is permanent and there is no undo.

deleting room NNGQ: its snapshot, its saves and its alarms
deleted NNGQ: 4 stored object(s) removed
player data and leaderboard scores are keyed to the player and are untouched

A room that is awake is refused, with E_ROOM_IN_USE and a 409:

room FV7A is running in a live tenant; wait for it to hibernate, then delete again, or pass
?force=true to disconnect its clients and delete it now

--force does the disconnecting for you. It says so before it acts, because by the time the command returns the players are already gone:

deleting room FV7A: its snapshot, its saves and its alarms
forced: if the room is awake, every client in it is disconnected first and sees E_ROOM_DELETED.
They cannot rejoin the same room; a fresh one starts empty
deleted FV7A: 4 stored object(s) removed

If the server holding the room cannot be reached, nothing is deleted and the command fails with E_EVICT_FAILED naming the server. Relay projects are refused: a relay room ends when its last client leaves, so there is nothing to evict.

No other room is affected, including one whose id begins with the same characters. Player storage and leaderboard scores are keyed to the player rather than to the room and are never touched.

This is the same deletion a retention window performs on a schedule. Use the command when you want one room gone now, and the declaration when a whole type’s rooms should go on their own.

irtio api-keys

npx irtio api-keys mint --scopes <list> [--label <text>] [--project <id>] [--url <control>]
npx irtio api-keys list [--project <id>] [--url <control>]
npx irtio api-keys revoke <id> [--project <id>] [--url <control>]

Scoped API keys let a script you run yourself reach one project’s rooms over HTTP and nothing else. A key is not a login: it cannot deploy, read your usage or billing, see your other projects, or mint keys, including itself. The full contract is the rooms API.

Not to be confused with irtio keys, which manages the JWT signing secret your game uses to prove things to irtio. This one is a credential your backend uses to ask irtio to do things.

$ npx irtio api-keys mint --scopes rooms:read --label "nightly cleanup"

irk_qX3mR8pL2vN7kT1yB6cD4wF9sH0jA5gZ8eU3iO7nY2Q

Copy this now. It will not be shown again, here or anywhere else.
id key_9f2c1a4b8d3e5f60, scopes rooms:read
use it as: Authorization: Bearer <key>, or set IRT_API_KEY for the rooms commands
revoke it with: irtio api-keys revoke key_9f2c1a4b8d3e5f60

--scopes is comma-separated. rooms:read lists and reads rooms and their saves; rooms:write also sets retention, restores and deletes. Write implies read, so you never need both.

$ npx irtio api-keys list
ID                    SCOPES                  LAST USED             LABEL
key_9f2c1a4b8d3e5f60  rooms:read              2026-08-30T04:00:11Z  nightly cleanup
key_1b8e4d7a2c6f3095  rooms:read,rooms:write  never                 support tool (revoked)

Revoking keeps the row, with the time it was revoked, because “what was this key and who made it” outlives the key. LAST USED tells you whether a key you are about to revoke is still in use.

irtio status

npx irtio status [--room <id>] [--project <id>] [--url <control>] [-c <file>]
METRIC              VALUE       AT
egressBytes         48213       2026-08-24 09:12:41Z
sockets             3           2026-08-24 09:12:41Z

ROOM  CONN  TICKS       MAXTICKMS   OVERRUNS  ELU     IN        OUT       SAMPLE AGE
FV7A  2     18400       9.5         0         0.12    41.2k     3.9M      12s ago
NNGQ  0     -           -           -         0.01    118       2.1k      31s ago

The tables report project metrics and room health; they omit the server count. See servers and room placement.

The first table is the newest value of every project-wide metric. The second is one row per room: connections, tick counters, event-loop utilisation, the bytes that room has received and sent since it started, and how old the sample is. Per-room samples are taken at most every 30 seconds and kept for 7 days, so a room that died recently still shows here until its rows age out.

IN and OUT are totals, not rates: one sample cannot carry a rate. For a breakdown of what those bytes were, by collection and field, run irtio dev --profile against the same room locally.

A - is not a zero. It means that counter has never been read for that room, which usually means an old guest image. Zero overruns is a healthy room; - is a room nobody has measured.

--room <id> switches to that one room’s recent history, newest first, one line per sample:

2026-08-24 09:12:41Z  ticks=18400 maxTickMs=9.5 overruns=0 connections=2
2026-08-24 09:12:11Z  ticks=17800 maxTickMs=8.9 overruns=0 connections=2

Watch overruns between two samples. A growing count means the room’s tick handler is not finishing inside its own tick, and the fix is less per-tick work or a lower tick rate.

irtio usage

npx irtio usage [--windows] [--project <id>] [--url <control>] [-c <file>]

Reports room hours, data out, voice participant-minutes, and storage for the current UTC billing month. --windows shows hourly source rows. A missing reading means the meter has not reported for this project.

Data out includes game traffic and hosted static files. Storage is a high-water reading. See pricing for rates and limits for enforcement.

irtio leaderboard

npx irtio leaderboard <board> [--limit <n>] [--around <player>] [--project <id>]
                              [--url <control>] [-c <file>]
board scores  ·  higher is better

RANK  PLAYER                    SCORE
1     irt:9tW2rQxK4mB8vN1pLc0   1200
2     irt:3kF7hJ2sD9wQ5xR6tYu    900

Reads a board over the public HTTP surface, with no credential attached, which is the same read a project’s own website makes. That means it works against any project’s board, which is why --project matters here and a login does not.

--around <player> prints that player and their neighbours instead of the top of the board, with absolute ranks and the player highlighted. That is the read most players care about, since most players are not in the top ten.

There is no irtio leaderboard submit, and there will not be one. A score reaches a board only from room code, through a credential no client and no CLI holds. Adding a writer here would put a forgeable path next to the one thing the feature exists to make unforgeable. See leaderboards.

irtio restart

npx irtio restart [--project <id>] [--url <control>] [-c <file>]

Stops the project’s tenant on every instance it is placed on. Nothing starts it again until the next join, which starts it cold. State is preserved: rooms resume from their saves, so the disruption is a redeploy’s rather than a restore’s. The reply says how many slots a live box was told to stop.

It is also the repair for a tenant record control believes is running on a box that is not actually running it. The stop path leaves every row truthful whatever the box says, so a project stuck in that state comes back with one restart.

irtio delete-project

npx irtio delete-project <id> --yes [--url <control>]

Deletes a project and every record the control plane holds about it: origins, deployments, signing secrets, rooms, player storage, leaderboard rows, logs and metrics. This cannot be undone.

The project id is a required argument and is never read from irtio.json, so being in the wrong directory cannot pick the victim. Without --yes the command only prints what it would delete.

Three things outlast the delete:

  • The id is retired permanently. No project, on your account or any other, can use it again.
  • Usage the project has already counted stays on your bill and keeps counting toward your account’s allowances for the rest of the billing period. Deleting a project does not reset them.
  • Stored objects (bundles, saves, static files, clips) are no longer served or readable, and are removed about 30 days after the delete.

It is refused while the tenant is live

A project with any instance in a running or starting state is refused with 409 E_PROJECT_IN_USE:

project p_a5da84132e8d6c2e still has a live tenant serving rooms; wait for its rooms to empty and
the tenant to stop, then delete again

Deleting the row out from under a placed VM would leave a guest serving rooms that control can no longer name. So the delete waits for the tenant to be gone, and a project that is idle enough to delete usually already is.

You do not have to wait it out. irtio restart stops the tenant on every instance, and once it returns the delete goes through:

irtio restart --project p_a5da84132e8d6c2e
irtio delete-project p_a5da84132e8d6c2e --yes

Two things to know about that pair. A join arriving between the two commands starts the tenant again and re-arms the refusal, so take the project’s origins down or point players away first if it is live enough to matter. And restart clears instances that are running; one caught mid-boot in starting still blocks the delete, so run the delete again a moment later.

irtio.json

{
  "project": "p_a5da84132e8d6c2e",
  "client": "src/main.ts"
}

Written by init, which sets project only. Read by dev, deploy (both kinds), rollback, migrate create, keys, logs and rooms as the default project, and by dev and deploy for the optional client entry, which is the path they check for accidental imports of the room file.

The project id is the public key. It is domain locked, never secret, and safe in client code.

Every command reads ./irtio.json in the directory you ran it in. There is no search up the tree. See the irtio.json reference for both keys, the per-command resolution order, and what happens when the file is malformed.

Environment variables

VariableRead byMeaning
IRT_CONTROL_URLevery command that needs a loginOverrides the URL the CLI talks to, ahead of your stored login
IRT_CREDENTIALS_FILEevery command that needs a loginOverrides the credentials file location (the --token-file flag means the same)
IRT_URL@irtio/client in NodeOverrides the endpoint a client connects to
IRT_RESUME_SECRETa self-hosted room serverThe signing key for resume tokens. See the resume secret