# Deploying with the CLI

> Take a project from login to live with irt login, init, dev, test, build, deploy and logs.

The `irt` CLI covers the whole loop: log in, create a project, run it
locally, test it, deploy it and read its logs.

## Install

The CLI is the npm package `@irt/cli` (the name `irt` on npm belongs to
someone else). It needs Node 24 or later. Run it without installing:

```sh
npx @irt/cli <command>
```

or install it, globally or as a project devDependency, for the `irt`
command:

```sh
npm install -g @irt/cli
irt <command>
```

Every error is one line that says what to do, and the CLI exits with a
non-zero status. `irt help` lists every command, and `irt <command>
--help` one command's arguments and flags.

## Global options

| Option or variable | Meaning |
|---|---|
| `--project <slug>` | The project to act on; overrides `project` in `irt.config.ts` |
| `IRT_TOKEN` | A token to use instead of the stored login; set it to a project API key (`irt_key_...`) in CI |
| `IRT_API_URL` | The API base URL; default `https://api.irt.io/v1` |

## `irt.config.ts`

A project declares its config and settings in code, next to its room:

```ts
// irt.config.ts
import { defineConfig } from "@irt/cli/config";

export default defineConfig({
  project: "space-race",
  room: "src/room.ts",
  site: { dir: "dist", build: "npm run build:web", crossOriginIsolated: false },
  settings: {
    players: { guests: true, createRooms: true, listPublic: true },
    voice: { enabled: true, maxSpeakers: 8 },
    rooms: { size: "standard", retention: "7d", buildUpdate: "hibernate" },
  },
});
```

| Field | Meaning |
|---|---|
| `project` | The project's slug: a-z, 0-9 and -, its name in URLs and in `<slug>.irt-serve.com` |
| `room` | The room module; its default export is the result of `defineRoom`. It's one [room type](#room-types), named `default`. Give `room` or `rooms` |
| `rooms` | The project's room types by name; see [Room types](#room-types) |
| `site.dir` | Optional: the directory of built site files that's deployed |
| `site.build` | Optional: a shell command that builds the site into `site.dir`, run by `irt build`, `irt deploy` and `irt dev` |
| `site.crossOriginIsolated` | Optional, default `false`: serve the site with COOP and COEP |
| `settings` | Optional: the project's settings, below |

The CLI bundles the file with esbuild, loads it, and checks it before
building, so a bad value fails in one line naming the field and what it
allows, before anything is uploaded:

```
irt: irt.config.ts: settings.rooms.retention is "2y"; it must be "none", a number of hours or days such as "12h" or "7d" up to 365 days, or "forever"
```

### Room types

A project whose rooms run different code, such as a lobby and a match,
declares each as a [room type](/docs/concepts#rooms) in `rooms`
instead of `room`:

```ts
// irt.config.ts
import { defineConfig } from "@irt/cli/config";

export default defineConfig({
  project: "space-race",
  rooms: {
    default: "src/lobby.ts",
    match: { room: "src/match.ts", size: "large", maxPlayers: 8, retention: "1d" },
    chat: { native: true, size: "small" },
    world: { native: true, schema: "src/world.ts", tickRate: 20, size: "small" },
  },
});
```

| Entry | Meaning |
|---|---|
| `"path"` | The type's room module, whose default export is the result of `defineRoom` |
| `{room, size?, maxPlayers?, retention?}` | The module, with the type's own room size, cap on connections and retention, over the project's `rooms.size`, none and `rooms.retention` |
| `{native: true, schema?, tickRate?, lobby?, size?, maxPlayers?, retention?}` | A [native room](/docs/concepts#rooms), which runs no code of yours: it sends each message on to the other players and keeps presence and a leader. With `schema`, players also spawn, own and write the entities of the schema module's default export, under the rules each `entity()` declares, and it ticks `tickRate` times a second, 1 to 60, 20 by default. With `lobby`, it gathers players and hands them off to a room of another type; see [Lobbies and parties](/docs/guides/lobbies) |

A project has 1 to 32 types, each named with 1 to 32 characters of
`a-z`, `0-9` and `-`, starting with a letter. A standard type can't be
`small` until small has a price; a native type can. `maxPlayers` is 1
to its size's connection limit. A create, quick join or connection that
names no type gets the type named `default`; without one, every one of
them must name a type. Clients pass the type to `connect`,
`createRoom`, `quickJoin` and `listPublic` as `type`.

### Settings

`settings` has the same names as the project's settings in the
[HTTP API](/docs/http-api#project-settings) and the dashboard:

| Setting | Default | Allowed | Meaning |
|---|---|---|---|
| `players.guests` | `true` | boolean | Clients may join with no player token |
| `players.createRooms` | `true` | boolean | Clients may create rooms and use join codes |
| `players.listPublic` | `true` | boolean | Clients may list active public rooms |
| `players.quickJoin` | `false` | boolean | Clients may join any public room with space, or a new one |
| `players.listPublicWithoutToken` | `false` | boolean | Anyone may list public rooms with no token, even with `players.guests` off |
| `voice.enabled` | `false` | boolean | Connections get a voice token |
| `voice.maxSpeakers` | `64` | integer 1 to 64 | A ceiling on each room's own `voice.maxSpeakers`, applied from a room's next wake; see [Proximity voice](/docs/guides/proximity-voice#turning-it-on) |
| `rooms.size` | `"standard"` | `"standard"`, `"large"`, `"extra-large"` | Room size for new rooms |
| `rooms.retention` | `"7d"` | `"none"`, `"<n>h"`, `"<n>d"` up to 365 days, `"forever"` | How long a room is kept after it hibernates; `"none"` deletes it when it empties |
| `rooms.buildUpdate` | `"hibernate"` | `"restart"`, `"hibernate"` | Running rooms move to a new build at once, or on their next hibernate |
| `rooms.rollbackOnCrashLoop` | `true` | boolean | When a newly live build crash-loops, make the build it replaced live again, once per build; see [Builds](/docs/builds#automatic-rollback) |
| `data.player.clientWrite` | `[]` | player data keys | The keys a client may write for its own player |
| `data.collections.<name>` | none | 1 to 32 of `a-z 0-9 _ -`, from a letter; at most 32 | A collection, with `clientRead` (boolean, default `false`), `clientWrite` (`"own"`, default none), `maxOwn` (1 to 10,000, default 100) and `leaderboard` (`order` `"desc"` or `"asc"`, `keep` `"best"`, `"latest"` or `"sum"`, `periods` of `"all"`, `"day"`, `"week"`, `"month"`, default `["all"]`); see [Player data](/docs/guides/player-data) |

`irt deploy` sends the settings in the build. When the build goes live,
the settings it declares become the project's, and rolling back to an
older build rolls them back too. A setting the live build declares is
read-only in the dashboard. A setting the config leaves out keeps its
current value. `limits` and `webhookUrl` never come from code: they're
the account owner's, so a deploy can't raise a spending cap, and
`defineConfig` rejects them.

## `irt login` and `irt logout`

```sh
irt login
irt logout
```

`irt login` logs the CLI in with a device code:

1. The CLI shows a short code and opens `https://app.irt.io/device`.
2. You sign in to the dashboard with GitHub, if you aren't already, and
   enter the code.
3. The CLI receives a personal token and stores it in
   `~/.config/irt/credentials.json`, readable only by you (mode 600). The
   token acts for your tenant.

`irt logout` deletes the stored token. In CI, don't log in: set
`IRT_TOKEN` to an API key from the dashboard, which acts for one project
only.

## `irt init [dir] [--project <slug>]`

```sh
irt init my-game --project space-race
```

Creates a directory (default: the current one) with a minimal working
game for the project `--project` names. Without `--project`, the slug is
made from the directory's name: lowercased, with runs of other
characters turned into `-`. `init` prints the slug it used and what to
run next:

```text
created /home/you/my-game for the project space-race
the slug is `project` in irt.config.ts, which web/main.ts imports
next: cd my-game, npm install, npx @irt/cli projects create space-race, then npx @irt/cli dev
```

When you're logged in, `init` asks the API whether the project exists
and leaves `projects create` out of the next steps if it does. Logged
out, or when the API can't be reached, the step reads `npx @irt/cli
projects create space-race if it doesn't exist yet`.

To change the slug later, edit `project` in `irt.config.ts`; the
scaffolded `web/main.ts` imports it from there. Create the project itself
with `irt projects create <slug>`.

| File | Contents |
|---|---|
| `irt.config.ts` | The project's slug, entry points and settings |
| `build-web.mjs` | Bundles and minifies `web/main.ts` into `dist/` with esbuild, for `site.build`, with each dynamic `import()` in a chunk of its own, and hashed file names that it writes into `dist/index.html` |
| `src/schema.ts` | The schema, shared by the room and the client |
| `src/room.ts` | The room code |
| `src/room.test.ts` | A test with [`@irt/testing`](/docs/reference/testing) |
| `web/index.html`, `web/main.ts` | A plain TypeScript web client with no framework |

The generated `irt.config.ts` declares every setting at its default.
`irt dev` applies the settings locally: guests, room creation and public
listing as declared, and `rooms.retention: "none"` deletes a room when it
empties. Voice is off locally unless `IRT_LIVEKIT_URL` names a LiveKit
server; see [Proximity voice](/docs/guides/proximity-voice#turning-it-on).

## `irt dev [--port 7350]`

Runs the project on your machine: `irt-room-host` in local mode with each
room type's bundled room code, native types included, serving the client protocol and the site on one
origin, `http://127.0.0.1:<port>`, with the same headers the hosted site
gets. The scaffolded client uses `location.origin` as its router address
there, and `wss://eu.irt.io` on `irt-serve.com`. That includes COOP and COEP
when the site is cross-origin isolated, so `SharedArrayBuffer` behaves
locally as it does on `irt-serve.com`. It rebuilds and restarts the room
host when a file in the project changes, so connected clients reconnect,
and says which file it was. Without a `site.build` command, only the
room's own files and `site.dir` count; with one, any file can, since the
command may read it. It doesn't watch dot files and directories,
such as `.irt` and `.git`, `node_modules`, the site build's `site.dir`,
`*.log` files or editors' swap files, so `irt dev > dev.log` inside the
project doesn't rebuild it over and over. Ctrl-C, SIGTERM and SIGHUP
stop the room host with it; if the port is taken, as by a room host left
over from an `irt dev` that was killed with SIGKILL, it says so and
exits.
Room state is kept in `.irt/data` between runs. The inspector at
`/v1/dev/` shows each room's connections, state, entities, recent
messages and calls, and tick profile; see [Dev tools](/docs/dev-tools).

| Flag | Default | Meaning |
|---|---|---|
| `--port` | `7350` | Port of the local room host and site |

In local mode, as deployed, a room is created when a client first
connects to it, if `players.createRooms` is on.

## `irt test`

Runs the project's tests with Vitest. Tests use `@irt/testing`, which
runs rooms in process with test clients and a controlled clock; its
failure messages say what was expected, what happened, and in which
room, connection and tick.

`irt test` doesn't typecheck, since Vitest strips types without checking
them, so a test run stays fast. `irt build` and `irt deploy` do; to check
types while you work, run `npm run typecheck`, the scaffold's `tsc
--noEmit`.

## `irt simulate`

Runs the project's room locally, in process, with simulated players:
each a bot with a real `@irt/client` connection, driven by the built-in
behaviours or by the project's `irt.sim.ts`. After every tick it checks
invariants: no client sees an entity its interest excludes, predictions
aren't corrected too often, owned writes aren't silently lost, the room
logs no errors, room state stays within limits, reconnects restore the
room's state, and the script's own. It prints a line per invariant with
traffic per player and time per tick, writes a trace of JSON lines, and
exits with 1 when an invariant fails. It never runs against a deployed
project. See [Simulating players](/docs/guides/simulate).

| Flag | Default | Meaning |
|---|---|---|
| `--players` | 8 | Bots that join before the first tick |
| `--ticks`, `--seconds` | 30 seconds | How long to run, in ticks or seconds of room time |
| `--seed` | 1 | Seeds the bots, the network and `Math.random` in room code |
| `--script` | `irt.sim.ts` if it exists | The simulation script |
| `--behaviour` | `walk` | Built-in behaviours for every bot: `walk`, `chat`, `idle`, or several joined by commas |
| `--rtt`, `--jitter`, `--loss` | none | A simulated network for every bot: round trip and jitter in milliseconds, and the fraction of unreliable messages lost |
| `--drops` | 0 | Connections to drop at random ticks, to check reconnects |
| `--trace` | `.irt/simulate/trace.jsonl` | Where to write the trace |
| `--type` | `default` | The [room type](#room-types) to simulate, without a script; a script names its own room |

Flags override the script's own settings.

## `irt build [--out dir] [--no-typecheck]`

Typechecks the project, bundles the room module with esbuild into one
file, runs the site's `site.build` command if `irt.config.ts` has one,
and writes the build tarball. It prints each room type's schema hash:

```text
built .irt/build/build.tar.gz (9 files)
  room type default: schema 0x1f2e3d4c
  room type match: schema 0x5a6b7c8d, large, max 8 players, retention 1d
  room type chat: native, schema 0x9437de34, small
```

The typecheck is `tsc --noEmit` with the project's `tsconfig.json`, so it
covers whatever that file includes: the scaffold's covers `src`, `web` and
`irt.config.ts`. Any error fails the build after printing tsc's errors. A
project without `tsconfig.json` isn't checked, and one with it but no
`typescript` installed fails until you install it. `--no-typecheck` skips
the check, such as to deploy a fix while an unrelated file has errors.

The tarball:

| Path | Contents |
|---|---|
| `irt-build.json` | `{format: 2, sdk, site: {crossOriginIsolated}, settings, types}`, with each room type's `{entry, schemaHash, schema, stateVersion}` or `{native, schemaHash, schema, stateVersion, tickRate, lobby?}`, `lobby` with every default filled in, and its own `size`, `maxPlayers` and `retention`; `schemaHash` is `0x` and 8 hex digits, `stateVersion` is the type's `version`. See [Builds](/docs/builds#whats-in-a-build) |
| `room/` | Each standard room type's bundled room code, as `{type}.mjs` |
| `site/` | The site's files, if the project has a site |

| Flag | Default | Meaning |
|---|---|---|
| `--out` | `.irt/build` | Directory to write `build.tar.gz` to |
| `--no-typecheck` | off | Build without running `tsc` |

The unpacked files stay in `stage/` under that directory, which
`irt-room-host` runs directly; see [Self-hosting](/docs/self-hosting).

## `irt deploy [--no-live] [--no-typecheck]`

Builds as `irt build` does, typecheck included unless `--no-typecheck`
is given, uploads the build to the project, and makes it live unless
`--no-live` is given. Then it prints where the site is served and the
router address its clients pass to `connect`, from the project's client
config:

```text
deployed build b_01J9Z3 to space-race; it's live
room state of type default: compatible, at version 1; rooms wake from the live build's room state without a migration:
  added player.score (u32); room state without it loads with its default value
  site:   https://space-race.irt-serve.com
  router: wss://eu.irt.io (pass it to connect() as `router`)
```

The `room state of type` lines are the control server's
[schema check](/docs/builds#the-schema-check-at-deploy): how each room
type's schema and `version` compare with the same type in the build it
replaces, and each change.
A build whose changes old room state can't load, at the same `version`,
is refused. It stays uploaded, the live build stays live, and the CLI
lists each change and exits with 1:

```text
build b_01J9Z4 isn't live; these changes to room type default's room state need a version bump and a migration:
  removed player.hp (u8), so room state that has it won't load
irt: build b_01J9Z4 can't replace the live build b_01J9Z3: it changes room state without a version bump, both builds being at version 1: removed player.hp (u8), so room state that has it won't load. Bump `version` in defineRoom to 2 and add a migration, `migrations: { 2: (data) => ... }`, that converts room state from version 1
```

There's no flag to skip the check: rooms would fail to wake on the build.
A build that drops a room type while rooms of it have records is refused
the same way, naming the type; see
[Adding and removing room types](/docs/builds#adding-and-removing-room-types).

Making a build live does two things at once:

- the site at `<slug>.irt-serve.com` switches to the build's site, and
- rooms move to the build's room code according to the project's
  `buildUpdate` setting: on their next hibernate, or with `restart`
  straight away, a few a second on each host. See
  [New builds](/docs/hibernation#new-builds).

Because the site and room code go live together, a client always finds
the room build it was built against. A client loaded from an older build
that connects to a room on a newer one gets close code 4004 and should
reload. A room still on an older build refuses newer clients with 4004
too, until it hibernates and moves. See [Builds and deploys](/docs/builds) for the schema hash rule,
migrations and rollbacks.

A site marked cross-origin isolated in `irt-build.json` is served with
COOP and COEP. `SharedArrayBuffer` needs them, and they stop the site
loading cross-origin resources that don't opt in. See [Sites](/docs/sites).

The dashboard lists builds and can make an earlier build live again,
as `irt rollback` does.

## `irt rollback [build-id]`

```sh
irt rollback
irt rollback b_01J9Z3
```

Makes the build before the live one live again, or the build you name.
The site and the build's declared settings switch at once, and running
rooms move to it as `rooms.buildUpdate` says. The CLI prints which:

```text
rolled space-race back to build b_01J9Y8; it's live, with its site and its declared settings
  room type default: schema 0x1f2e3d4c
running rooms stay on build b_01J9Z3 until they next hibernate, then wake on build b_01J9Y8 (rooms.buildUpdate is hibernate)
```

When the older build has an older room state `version`, rooms that
migrated past it can't run on it, and the CLI prints a `room state of
type default: version 2 to 1, a rollback` line saying so. Each such room wakes from
the room state kept from before its migration, losing progress since
then, and your backend gets a [`room.restored`](/docs/webhooks) webhook.
Room state from before a migration is kept for 7 days. A room with none
kept, such as one created on the newer build, fails to wake until a
build at its version is live again; `irt logs` says why.

A rollback goes through the same schema check as a deploy. Rolling back
to a build at the same `version` that lacks a field or kind the live
build added is refused, since rooms that woke on the live build saved
room state with it. Deploy a new build instead.

## `irt restart [room-id]`

```sh
irt restart
irt restart forest-7
```

Moves the project's running rooms, or the one named, onto the live
build now, whatever `rooms.buildUpdate` says. Each room hibernates,
saving its room state, and wakes on the live build, even when it runs
that build already, so a restart also clears whatever a room host held
in memory. Clients close with 4008 and reconnect at once. Rooms move 20
a second on each host. Hibernated rooms aren't touched: they wake on the
live build anyway. Naming a room that isn't running is an error.

## `irt logs [--follow]`

Prints the output of the project's room hosts; with `--follow`, keeps
printing as new lines arrive. The same stream is in the dashboard. It
holds what room code prints, errors the room SDK logs, such as a hook
that threw, and the host daemon's notes about a room, such as why it
can't wake or that its room state nears 32 MB. A room that runs without
printing or failing writes nothing, and `irt logs` then says it found no
lines. Each host keeps the last 1 MB of a project's lines, in memory.

## `irt rooms`

```sh
irt rooms list [--public] [--all]
irt rooms show <id>
irt rooms profile <id>
irt rooms create [id] [--type <room type>] [--public] [--join-code] [--retention <value>]
irt rooms delete <id>
irt rooms send <id> '<json>'
```

`profile` prints an active room's tick profile over the last second, as
its room host measured it: per phase, such as `stepper`, `onTick`,
`interest` and `flush`, how many ran and their p50, p95 and max in
milliseconds. It doesn't wake a hibernated room.

`list` prints each room's ID, status and players. It lists only active
rooms, so a room that emptied and hibernated drops out of it until it
wakes; use `--all` to list every room the project keeps, with
hibernated ones shown as `hibernated`. `--public` lists only public
rooms. `show` prints one room's status, players, build, size and room
state size as last saved to object storage, which for a running room
trails by up to 30 seconds. `create --type` sets the new room's
[room type](#room-types), default `default`. `create --retention` gives the room a
retention of its own, such as `forever` or `30d`, which wins over the
project's `rooms.retention`; see [Retention](/docs/hibernation#retention).
`send` delivers a server API message to the room's `onRequest` and prints
the reply. A message to a hibernated room wakes it.

## `irt secrets`

```sh
irt secrets list
irt secrets set <name> <value>
irt secrets unset <name>
```

Secrets reach room code as environment variables, read with
`process.env`. A room host reads them when it starts, so a changed
secret reaches rooms on room hosts started after the change: `irt deploy`
starts new ones for the new build, and rooms move to them as the
project's `rooms.buildUpdate` setting says. Their values are never shown again
after they are set; `list` shows names only. Names are 1 to 64 letters,
digits and underscores; `PATH`, `NODE_OPTIONS`, `HTTPS_PROXY`,
`NODE_USE_ENV_PROXY` and names starting with `IRT_` are reserved for
IRT.

## `irt bans`

```sh
irt bans list
irt bans add <player> [--reason <text>] [--for <30m|12h|7d>]
irt bans remove <player>
```

`add` bans a player from every room of the project, permanently or for
`--for` minutes, hours or days. Their connections close with 4013 within
a few seconds. See [Moderation](/docs/moderation).

## `irt projects`

```sh
irt projects list
irt projects create <slug> [name]
irt projects delete <slug> --yes
```

Deleting a project stops its site and rooms for good. Its slug is never
reused, not even by you, so `delete` asks for `--yes`.

## `irt api-keys`

```sh
irt api-keys list
irt api-keys create <name>
irt api-keys delete <id>
```

API keys act for one project, for backends and CI. `create` prints the
key once; it can't be shown again. `list` shows each key's ID, name and
first characters.

## `irt signing-keys`

```sh
irt signing-keys list
irt signing-keys create
irt signing-keys delete <id>
```

Signing keys sign [player tokens](/docs/player-tokens). `create` prints
the new key's ID and its secret once; it can't be shown again, so store
it on your backend. `list` shows each key's ID and when it was created.
A project has at most 5. Deleting a key stops the player tokens it
signed from connecting; to rotate, create a new key, move your backend
to it, then delete the old one. These commands need `irt login`: an API
key can't manage signing keys.

## `irt webhooks`

```sh
irt webhooks show
irt webhooks set <url>
irt webhooks unset
irt webhooks rotate-secret
```

`set` and `unset` change the project's `webhookUrl`, where
[webhook](/docs/webhooks) events go. `rotate-secret` makes a new signing
secret and prints it once; it signs every event from then on, so update
your backend straight away. Events are sent only once the project has
both a URL and a secret. These commands need `irt login`: an API key
can't change where events go.

## `irt whoami`, `irt status` and `irt usage`

```sh
irt whoami
irt status
irt usage [--from 2026-11-01] [--to 2026-11-30]
```

`whoami` shows the user, tenant and credit balance the login acts for.
`status` shows the project, its live build with its room types, the latest build that was
[rolled back](/docs/builds#automatic-rollback) for crash-looping and
why, and its active rooms and players. `usage` prints room-hours by room size, traffic and cost by
day, and the total; dates are UTC and inclusive, and default to the last
30 days.

## `irt stats`

```sh
irt stats [--from 2026-11-03T00:00:00Z] [--to 2026-11-04T00:00:00Z]
irt stats --build <id> [--from <time>] [--to <time>]
```

Prints each build's peak players and rooms, reconnects, and the errors,
ticks and average and longest tick that its rooms report, over a range
of at most 14 days, by default the last 24 hours. With `--build`, it
prints that build's numbers minute by minute, over at most 24 hours, by
default the last hour. Times are RFC 3339, in UTC. These are the
numbers of [Stats and usage alerts](/docs/stats). Tick cost there is per
build; per room and per hook it's only in the
[`irt dev` inspector](/docs/dev-tools).

## `irt skill install [--dir <path>] [--force]`

```sh
irt skill install
```

Writes the IRT skill for coding agents, the integration rules, common
mistakes and which `irt` commands to run, to `.claude/skills/irt/SKILL.md`
in the project: the nearest directory with `irt.config.ts`, or the
current one. Claude Code loads it when it works on the project. `--dir`
writes `SKILL.md` into that directory instead, such as
`~/.claude/skills/irt` for every project.

A file that's already the same is left alone. One that differs, because
you edited it or an older CLI wrote it, isn't replaced unless you pass
`--force`. The skill points agents to the
[guide for coding agents](/docs/guides/agents).

## `irt mcp [--allow-writes]`

```sh
claude mcp add irt -- npx -y @irt/cli mcp
```

Runs an MCP server on stdio for coding agents, with tools that search
and read the IRT docs and read the project's status, builds, rooms, room
state, logs, usage and bans with your login. `--allow-writes` adds tools
that create and delete rooms, send rooms messages, make builds live and
ban players. See [MCP server](/docs/reference/mcp) for setup in other
clients and the full tool list.

## Without the CLI

Everything the CLI does goes through the [HTTP API](/docs/http-api), so a
CI pipeline can upload builds and make them live with an API key.
