# @bitskiff/cli

`bitskiff` is the management surface for a Bitskiff project. Install it, point it at a project, and
do everything the console does: create and rotate keys, read and change limits and settings, list
and close sessions, and read usage and the audit log.

It is a management tool, not a host: it calls the service over HTTP and exits.

This page is the reference. Every verb here wraps a route, and the
[REST API](/docs/reference/rest/) is the whole surface. The guides cover the rest:
[quickstart](/docs/quickstart/) ·
[node hosts](/docs/guides/node-hosts/) ·
[lifecycle](/docs/guides/lifecycle/)

## Install

`bitskiff` is a command, so it installs globally. Take the tarball URL from the
[downloads page](/docs/downloads/) -- `npm install` takes a URL as well as a name -- and install it
on your path:

```
npm i -g https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz
```

To check a download, compare `sha256sum bitskiff-cli.tgz` with the release's line at `https://api.dev.bitskiff.com/v1/releases/<version>/digests`, which the api serves apart from the downloads.

Every command on this page is written as a bare `bitskiff ...`, which is what the global install
gives you.

`bitskiff --version`, `bitskiff -v` and `bitskiff version` all print the version you installed and
its build, as `bitskiff 0.3.0 (build 1a2b3c4)`, and `bitskiff --help` shows the version in its first
line. The build is the commit the tarball was packed from, so two installs of one version tell
themselves apart.

If `--version` is refused, or `bitskiff init` is an unknown command, the
`bitskiff` on your path is an older build: install the current tarball again.

A tarball from an environment's docs site already knows that environment's endpoint, so it needs no
setup. To talk to another one, set it once with `bitskiff config set endpoint <url>`, or pass
`--endpoint <url>` on a command; either wins over the built-in one. Each environment has its own,
and this is the one answering today:

| Environment | Endpoint |
|---|---|
| dev | `https://api.dev.bitskiff.com` |

```
$ bitskiff config set endpoint https://api.dev.bitskiff.com
```

A key, a module or a CLI set to one environment talks only to that one. The
[REST reference](/docs/reference/rest/) carries the same endpoint under Base URL, and
`bitskiff whoami` prints the one this CLI is using.

## Get started

New here? `bitskiff init` signs in to an organization you already belong to, picks or makes the
project, and writes its host key into `bitskiff.json` as `key`, minting one only when the project
has none. It writes a host page too when the directory is empty:

```
$ npx https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz init
```

One console URL to approve, then it is done. `--join-url <url>` also sets the project's join URL,
for a client page you serve yourself, and mints the client key that page joins with, for its
origin, into `bitskiff.json` as `clientKey`:

```json
{
  "key": "bsk_pk_...",
  "project": "<slug>",
  "endpoint": "<api origin>",
  "joinUrl": "<url>",
  "clientKey": "bsk_pk_..."
}
```

`--new-key` mints a host key even when the project holds one.

Already set up? `bitskiff login`, below, is the fallback.

## Signing in

The credential is an organization key, `bsk_ak_...`, scoped to an organization with an optional
project allowlist. Pipe it into `bitskiff login`, which also prompts without echo on a TTY. Never
pass it as an argument: a key in `--key` is a key in the shell history and in `ps`.

```
$ echo "$BITSKIFF_ORG_KEY" | bitskiff login
stored the organization key in the config file and verified it against proj_z1227rqkpwypmjx947tb
```

With no project resolved yet, `bitskiff login` stores the key and says on stderr that it did not
verify it:

```
stored the organization key in the config file; it was not verified, because no project is resolved
```

Set `BITSKIFF_ORG_KEY` to override the stored credential, which is how CI runs without a login
step. It is the only environment variable that carries this key.

### Sign in on another machine

On a laptop of your own, sign in as yourself instead of pasting a key:

```
$ bitskiff login --device-code
bitskiff: open https://console.dev.bitskiff.com/auth/device?code=BSKF-4QT9 and approve the code BSKF-4QT9
bitskiff: waiting for that approval; the code is good for 10 minutes
bitskiff: signed in; stored the console session in the config file
bitskiff: using the organization org_xedd48megcvaedxa93yx
```

Open the page, sign in to the console and approve the code. The CLI keeps the console session in the
config file, `consoleRefresh` in the file, under the same owner-only permissions as a key. It never
prints it. Every command then acts as you, with your roles, and each run refreshes the sign-in, so
the machine stays signed in for up to 12 hours. After that, a command prints
`Your sign-in has ended. Run bitskiff login --device-code.`

A config target holds at most one key and at most one console sign-in. `bitskiff init` on a
machine signed in this way mints its organization key with the stored sign-in, asking for no second
approval, and stores the key beside it. From then on the key carries every command except
`bitskiff keys create|rotate|revoke --scope org`, which run on the console session while it lasts, so you sign in again only when the session ends. `bitskiff whoami` shows both.
`bitskiff logout` removes both; `--key` removes only the key and `--session` only the console
session. `BITSKIFF_ORG_KEY` wins over a stored key, and no variable carries a console session, so CI
never uses one.

Some acts need a second factor passed in the last five minutes, such as inviting an admin. When the
service asks for one, the CLI prints a console page and a fresh code. Approve it with your second
factor and the CLI tries the command once more. Off a terminal it prints `This needs a fresh second
factor. Run bitskiff login --device-code, then try again.` and exits 4. Under `--format json`,
stdout still carries only the answer.

### Switch organizations

You may belong to several organizations. `bitskiff login --device-code` stores one only when you
belong to exactly one.

```
$ bitskiff orgs list
org_xedd48megcvaedxa93yx  Acme  (in use)
org_fbqdyz3hbzq0e08neqhr  Arcade

$ bitskiff orgs use org_fbqdyz3hbzq0e08neqhr
bitskiff: now using the organization org_fbqdyz3hbzq0e08neqhr; stored in the config file
```

`orgs use` checks the id against `GET /v1/orgs`, then writes `org` beside the credential, and
`bitskiff whoami` shows it. An id the list does not hold is refused, exit 2, and nothing is written.
`--org` and `BITSKIFF_ORG` still outrank it for one command or one shell.

Four ranks resolve every setting, highest first: the flag, then the environment variable, then the
named profile, then the top level of the config file.

| Setting | Flag | Environment | Then |
|---|---|---|---|
| project | `--project acme-demo` | `BITSKIFF_PROJECT` | profile, then file |
| organization | `--org org_xedd48megcvaedxa93yx` | `BITSKIFF_ORG` | profile, then file |
| endpoint | `--endpoint <url>` | `BITSKIFF_ENDPOINT` | profile, then file |
| credential | none, by design | `BITSKIFF_ORG_KEY` | profile, then file |

The credential has no flag: a key in an argument is a key in the shell history and in `ps`.

`--project`, `--org`, `--endpoint`, `--profile`, `--format` and `--retry` are global, so every
command takes them, wherever you put them on the line. These three are the same command:

```
$ bitskiff --format json sessions list
$ bitskiff sessions --format json list
$ bitskiff sessions list --format json
```

A command with no organization in it takes `--org` and does nothing with it.

`--profile prod` picks a named profile out of the config file's
`profiles` block, which holds the same four settings; `BITSKIFF_PROFILE` picks one for the whole
shell, and `bitskiff config get profiles` prints the ones you have. `--retry` honours `Retry-After`
on a limited call and tries again, bounded; it is off by default, because a management CLI that
retries quietly hides the limit it hit.

A project is named by its slug, the same
value the `X-Bitskiff-Project` header takes; a `proj_` id still works where one is already in use.

```
bitskiff config set project proj_z1227rqkpwypmjx947tb
```

`bitskiff config set endpoint <url>` and `bitskiff config get [field]` work the same way, and mask
the stored organization key -- `orgKey` in the file -- like everywhere else. There is no
`bitskiff config set` for that field: storing a credential is always `bitskiff login`.

A file that still calls the field `adminKey` is read where it is, with one `notice:` line on stderr;
the next write of the file carries `orgKey`.

The file is `%APPDATA%\bitskiff\config.json` on Windows, `~/Library/Application
Support/bitskiff/config.json` on macOS and `$XDG_CONFIG_HOME/bitskiff/config.json` on Linux,
falling back to `~/.config`. It is created `0600`.

The organization key itself rests in the OS credential store where one answers (Windows
Credential Manager, the macOS Keychain, the Secret Service on Linux), and the file carries
`"orgKeyStore": "os"` in its place. Where no store answers, such as a headless Linux box or a
container, the key stays in the file. `bitskiff logout` deletes it from wherever it rests.

A shell that stored its config under an older layout prints one `notice:` line on stderr and keeps
working; run `bitskiff login` again to move it.

## First commands

Ten verbs cover most of an organization's and a project's day-to-day management.

Under JSON the service's own body is printed byte for byte. The samples below therefore carry the
wire's words, and a few of them are not the words the rest of these docs use. This table maps them:

| On the wire | In these docs |
|---|---|
| *participant* | a client |
| `seatClass` | what a client's place lets it do: `controller`, `viewer` or `opener` |
| `seatDeadlineMs` | the instant that place is gone |

### `bitskiff whoami`

Prints which endpoint, project, organization and credential this shell resolved, and where each one
came from, then the credential's key id and this CLI's version and build. No part of the key is
printed: the credential is its kind. A `credential` row that names a key means a key is stored:
the row is local state, and it reads the same for a key that has expired or been revoked.

If a command is then refused `invalid_key`, run `bitskiff logout`, then `bitskiff init`.

If it is refused `forbidden` with `details.reason` of `missing_permission`, the key lacks the
permission `details.permission` names: widen it in the console, or run `bitskiff logout`, then
`bitskiff init` to sign in again. A `forbidden` without it means the key may not use that project: pass `--project`
of a project the key may use.

```
$ bitskiff whoami
endpoint        https://api.dev.bitskiff.com  (BITSKIFF_ENDPOINT)
project         proj_z1227rqkpwypmjx947tb  (BITSKIFF_PROJECT)
org             org_xedd48megcvaedxa93yx  (BITSKIFF_ORG)
credential      organization key  (BITSKIFF_ORG_KEY)
keyId           key_2q9z3v5w6t1ynk8m
consoleSession  (not set)
cli             0.3.0 (build 1a2b3c4)
```

The key id is looked up in the organization's key list, best effort: without `keys:manage` it
reads `(unknown)`.

What `GET /v1/whoami` answers about the service follows those rows. That half is best effort: an
unset endpoint or a call that fails prints one line saying so instead, and the command still exits
`0`.

With a key stored and an organization resolved, the organization's plan comes last: its name, and
when it ends as a UTC time with the time left, or `no expiry`. Once that time has passed it reads
`expired`, and a line says that opening sessions and joining are refused until the plan is
renewed. This part is best effort too.

```
plan     experimental
expires  2026-12-31T00:00:00Z  (in 94d 12h)
```

Every part is on stdout under `--format json`, one JSON document per line: the resolved rows first,
then the route's body byte for byte, then the plan's body when there is one.

```
$ bitskiff whoami --format json
{"endpoint":"https://api.dev.bitskiff.com  (BITSKIFF_ENDPOINT)","project":"proj_z1227rqkpwypmjx947tb  (BITSKIFF_PROJECT)","org":"org_xedd48megcvaedxa93yx  (BITSKIFF_ORG)","credential":"organization key  (BITSKIFF_ORG_KEY)","keyId":"key_2q9z3v5w6t1ynk8m","cli":"0.3.0 (build 1a2b3c4)"}
{"engineUrl":"https://rtc.dev.bitskiff.com","region":"us-central1","env":"dev","apiId":"a_us-central1"}
```

With no endpoint resolved, or a route the service does not answer:

```
$ bitskiff whoami
endpoint        (not set)
project         (not set)
org             (not set)
credential      (not set)
keyId           (not set)
consoleSession  (not set)
cli             0.3.0 (build 1a2b3c4)

bitskiff: no endpoint resolved, so there was nothing to call
```

`whoami` does not echo the caller's own identity, meaning the organization or roles behind the key.

### `bitskiff sessions list`

```
$ bitskiff sessions list
{"sessions":[{"sessionId":"s_x-3FoH9VbpRMvuc70LAvs_","projectId":"proj_z1227rqkpwypmjx947tb","state":"open","label":"lobby 14","openedAt":"1774000000000","controllersHeld":1,"viewersHeld":0}],"nextCursor":"c2"}
cursor: c2
```

The next page's cursor prints on stderr, never mixed into the answer on stdout. It comes from the
body's `nextCursor` field, which is what every paging route here answers with: `cursor` goes up and
`nextCursor` comes back. Pass `--cursor c2`
to start there, or `--all` to follow every page to the end. The route is `GET /v1/sessions`, and
`bitskiff sessions close <sessionId>` is `POST /v1/sessions/{sessionId}/close`.

`--host attached|away` keeps only the sessions whose host is attached, or only those whose host is
away and its host grace running; `--all` carries it on every page.

Two more read the same sessions:

- `bitskiff sessions get <sessionId>` reads one session with its clients in it
  (`GET /v1/sessions/{sessionId}`), which the list does not carry.
- `bitskiff sessions tail` polls `GET /v1/sessions` and prints one line per change. `--interval
  <seconds>` sets how often it polls. It runs until you stop it, and Ctrl-C exits 130.

### `bitskiff keys create`

```
$ bitskiff keys create --type publishable --label demo --origin https://a.example \
    --origin https://b.example --limit concurrentSessions=10 \
    --limit sessionOpensPerMinute=5
{"keyId":"key_2z9fe38503dwxejz","type":"publishable","orgId":"org_xedd48megcvaedxa93yx","projectId":"proj_z1227rqkpwypmjx947tb",
 "secret":"bsk_pk_a7k2m9xqw4tz8bn3p6rs5vd1fg0hjy2k",
 "masked":"bsk_pk_a7k2m9xqw4tz8bn3p6rs5vd1fg0hjy2k","status":"active","label":"demo",
 "origins":["https://a.example","https://b.example"],
 "limits":{"concurrentSessions":10,"sessionOpensPerMinute":5},"createdAt":"1774000000000"}
bitskiff: this key string will not be shown again
```

Reformatted here for legibility: the CLI prints one line. `origins` comes back as you passed it. A
publishable key carries no `permissions` field, because its list is fixed at `sessions:open` and
cannot be edited, and its `masked` is the key itself: a publishable key is stored in clear and
readable in full in the console.

A **secret or organization** key is the one to copy now. Its full string is on stdout once, in the
create and rotate responses, and every other verb prints no part of any key: it names a key by its
`keyId`.

The console shows the masked form: the prefix, the first eight characters, `...`, and the
last four, as in `bsk_sk_9c21f7be...4m8p`. The stderr notice prints on every create, publishable
keys included.

Give a secret key a permission list or a preset, never both. Use `bitskiff keys create --type
secret --preset host-only` for a device that only hosts, or repeat `--permission sessions:open
--permission usage:read` to name the list yourself.

Any key takes `--limit <name>=<value>`, repeatable, and `--expires 720h` -- thirty days -- which
takes a duration string, a time or epoch ms. The name is the one the service uses,
`concurrentSessions`. A limit you do not name inherits the project's value, and no `--expires` takes
the route's default.

`bitskiff keys list` reads the project's keys, each one by its `keyId` and with no key string:

```
$ bitskiff keys list
{"keys":[{"keyId":"key_2z9fe38503dwxejz","type":"publishable","projectId":"proj_z1227rqkpwypmjx947tb","status":"active","label":"demo","origins":["https://a.example"],"createdAt":"1774000000000"}]}
```

`--scope org` reads and lists an organization's own keys (`bitskiff keys list --scope org --org
org_xedd48megcvaedxa93yx`). `bitskiff keys create --scope org` works under a stored organization key
that holds `keys:manage` and names no project allowlist: the new key holds only permissions yours
holds, never outlives it, and is revoked with it, and anything else is refused `forbidden` with a
line saying why. On a machine signed in with `bitskiff login --device-code` it sends on the console
session instead. `bitskiff keys revoke --scope org` and `bitskiff keys rotate --scope org` under
an organization key act only on the organization keys that key made, or that those made: any other
is refused `forbidden` with a line saying why. `create --scope org` takes `--permission`,
repeatable, and no `--type` or `--preset`.

The routes are `POST /v1/project/keys` to create, `PATCH /v1/project/keys/{keyId}` to change one,
`POST /v1/project/keys/{keyId}/rotate` to rotate and `DELETE /v1/project/keys/{keyId}` to revoke.

`bitskiff keys revoke <keyId>` requires `--reason manual` or `--reason compromise`, with no
default. `manual` drains: every session the key opened keeps running until it ends on its own.
`compromise` cuts: every session it opened ends now. On a TTY, leaving `--reason` off asks instead
of refusing.

### `bitskiff tokens`

```
$ BITSKIFF_SECRET_KEY=bsk_sk_... bitskiff tokens mint --subject user_42 --ttl 10m
{"tokenId":"tok_Y66ZBr5mNO3-A9rM","token":"bsk_at_a7k2m9xqw4tz8bn3p6rs5vd1fg0hjy2k","expiresAt":"1789000000000"}
bitskiff: this token will not be shown again

$ BITSKIFF_SECRET_KEY=bsk_sk_... bitskiff tokens list --subject user_42
{"tokens":[{"tokenId":"tok_Y66ZBr5mNO3-A9rM","subject":"user_42","permissions":["sessions:open"]}]}

$ BITSKIFF_SECRET_KEY=bsk_sk_... bitskiff tokens revoke tok_Y66ZBr5mNO3-A9rM
bitskiff: the sessions this token opened keep running
```

`mint` takes `--permission`, `--limit <name>=<value>` repeated, `--ttl <duration>` (a duration string
such as `10m`, never a bare number), and, on a `sessions:join`
token, `--session <sessionId>` to bind it to one session. The permissions are `sessions:open`,
`sessions:read`, `sessions:manage` and `sessions:join`, and the default is `sessions:open`.

`list` filters by `--subject` or `--key` and never prints a clear string. `revoke` ends one token
by id, and the sessions it opened keep running: ending those is `bitskiff sessions` work.

The routes are `POST /v1/tokens`, `GET /v1/tokens` and `DELETE /v1/tokens/{tokenId}`.

This is the one verb family that does not send the stored organization key. These routes take a
secret key holding `tokens:mint`, so they read `BITSKIFF_SECRET_KEY` from the environment and name
it when it is not set. Use them as a smoke test of the mint. The integration is the same REST call
made from your own backend.

### `bitskiff limits`

`bitskiff limits` with no subcommand reads the limits, which is `GET /v1/project/limits`. There is
no `bitskiff limits get`:

```
$ bitskiff limits
{"maxConcurrentSessions":5,"concurrentSessions":1,"bytesPerSecond":16384}
```

Trimmed here: the real body also carries `maxConcurrentClients`, `concurrentClients`,
`sessionOpensPerMinute`, `joinsPerMinuteSession`, `clientResumesPerMinute`,
`statsReportsPerMinuteSession`, a `ceilings` object (`SessionConfig`), four `LimitQuota`
objects -- `clientMinutesPerDay`, `monthlySpendCap`, `turnBytesPerMonth` and
`ticketMintsPerMinute` -- the two duration rows `tokensMaxTtl` and `bootCooldown` as duration
strings, and `requireToken` as its word (`off` until the project sets it).

`bitskiff limits set <limit> <value> [--key key_]` raises or lowers a project limit (a key's own,
with `--key`) via `POST /v1/project/limits/raise`, applying at once; it takes `{ limit, value,
keyId? }`, while `PUT /v1/project/limits` writes the whole block instead. There is no lowering
route or approval step: a value under your plan's ceiling applies now, and one above it is filed
pending.

The limit's name matches `details.limit` on a refusal and `keys create --limit`; an
unknown one is refused with the list.

`tokensMaxTtl` and `bootCooldown` are the two rows whose value is a duration, so each takes a
duration string -- `bitskiff limits set tokensMaxTtl 720h`, `bitskiff limits set bootCooldown 5m`
-- and a bare number is refused before anything is sent.

`requireToken` is the one row whose value is a word: `off`, `token` or `token_bound_to_session` --
`bitskiff limits set requireToken token` -- and any other word is refused with those three before
anything is sent. The word travels in the route's `word` field
(`{ limit, word, keyId? }`) and reads back as `value`. `keys create` and `keys update` read
`--limit requireToken=token` by the same rule.

```
$ bitskiff limits set concurrentSessions 10
{"limit":"concurrentSessions","value":"10","applied":true,"pending":false}
bitskiff: applied
```

### `bitskiff usage`

`bitskiff usage --month 2026-02` asks `GET /v1/project/usage` for that whole UTC month. With no
`--month`, no `--from` and no `--to`, the window is the current UTC month. `--from` and `--to`,
with `--by day|month`, work the same way as `--month`.

```
$ bitskiff usage --month 2026-02 --by month
{"rows":[{"bucketStart":"1769904000000","meters":{"peak.participants":4,"session_ms.attached":600000}}]}
```

The route serves frozen daily and monthly rollups, so a day appears once its rollup job has run. An
empty answer means nothing is frozen for that range yet. Pass `--live` to poll
`GET /v1/project/limits` every 5 seconds for the current counters instead.

Pass `--capacity` to read what the project can open now, before you open it. Describe the sessions
you would open with `--max-clients`, `--lossy-hz`, `--reliable-hz` and `--max-bytes`. The answer
is the project's capacity standing, how many more of those sessions would be placed, and how many
clients they hold. It is a reading and holds nothing, so an open can still be refused.

```
$ bitskiff usage --capacity --max-clients 4 --lossy-hz 30 --format json
{"standing":{"status":"CAPACITY_STATUS_OK","at":"1769904000000"},"sessions":3,"clients":12,"maxClients":4}
```

### `bitskiff projects`

```
$ bitskiff projects list
{"projects":[{"projectId":"proj_z1227rqkpwypmjx947tb","slug":"demo-k8m2p","orgId":"org_xedd48megcvaedxa93yx","name":"demo"}]}

$ bitskiff projects create "arcade wall"
{"projectId":"proj_fbqdyz3hbzq0e08neqhr","slug":"arcade-wall","orgId":"org_xedd48megcvaedxa93yx","name":"arcade wall"}
```

`create` takes the name as its one argument, 1 to 64 characters, and derives the slug from it.
`--id <slug>` claims a slug instead, and a slug another project already holds is refused with the
field named. A new project starts with the default settings and the default limits.

A shell with no project configured yet takes the new one as its project. A shell that already has
one keeps it, and is told to pass `--project` rather than switched under.

Neither verb is project-scoped, so neither sends a project header. The organization comes from
`--org`, `BITSKIFF_ORG` or the config file, and `bitskiff login` stores the organization its key
belongs to, so `--org` is an override rather than something to look up. With none resolved the
call is refused before anything is sent, exit 2. The routes are `GET /v1/orgs/{orgId}/projects`
and `POST /v1/orgs/{orgId}/projects`.

```
$ bitskiff projects get --project arcade-wall
$ bitskiff projects rename "arcade lobby" --project arcade-wall
$ bitskiff projects archive --project arcade-wall
$ bitskiff projects unarchive --project arcade-wall
```

`get`, `rename`, `archive` and `unarchive` act on the project `--project`, `BITSKIFF_PROJECT` or
the config file names, sent in the path, and each prints the project. `rename` changes the name
and never the slug. `archive` never deletes: the project refuses new sessions, new keys and token
mints, keeps its keys, ledgers and usage, and shows `archivedAt`. An owner can `unarchive` it for
30 days. The routes are `GET` and `PATCH /v1/projects/{projectId}` and
`POST /v1/projects/{projectId}/archive` and `/unarchive`.

### `bitskiff members`

```
$ bitskiff members list
{"members":[{"userId":"user_4f8a2c","email":"owner@example.test","role":"org_owner","relations":["owner"],"joinedAt":"1767225600000"}]}

$ bitskiff members update user_9k2m4p --role org_admin
{"userId":"user_9k2m4p","email":"member@example.test","role":"org_admin","relations":["admin"],"joinedAt":"1767312000000"}

$ bitskiff members remove user_9k2m4p
{"userId":"user_9k2m4p","email":"member@example.test","role":"org_admin","relations":["admin"],"joinedAt":"1767312000000"}

$ bitskiff members invite newbie@example.test --role org_member
{"invitationId":"inv_8wcd7g4kq2xm9pbz3tvn","orgId":"org_xedd48megcvaedxa93yx","email":"newbie@example.test","roles":["org_member"],"state":"open"}

$ bitskiff members invitations
{"invitations":[{"invitationId":"inv_8wcd7g4kq2xm9pbz3tvn","email":"newbie@example.test","roles":["org_member"],"state":"open"}]}

$ bitskiff members revoke inv_8wcd7g4kq2xm9pbz3tvn
{"invitationId":"inv_8wcd7g4kq2xm9pbz3tvn","state":"revoked"}
```

`list` shows the organization's members, owners first, each with their role. `update` changes one
member's organization role with `--role`, one of `org_owner`, `org_admin`, `billing_admin` and
`org_member`; their project roles stay as they are. `remove` ends a membership: the member's roles
on the organization and its projects go, and so do their console sessions. You cannot change your
own role, and the last owner can be neither removed nor demoted.

`invite` mails an invitation to the address and needs at least one `--role`. The flag repeats, one
per role, and the seven roles are `org_owner`, `org_admin`, `org_member`, `billing_admin`,
`project_admin`, `project_developer` and `project_viewer`. The last three name a project, so pass
`--project <projectId>` beside them.

`invitations` shows the organization's invitations, newest first, each with its state. `revoke`
ends one open invitation by its id, and the link in it stops working from the next request. No
answer ever carries the link itself.

There is no `bitskiff members accept`: accepting takes a person in a browser, which is what the
[console](/docs/guides/console/) is for. These verbs are organization-scoped the same way
`projects` is. The routes are `GET /v1/orgs/{orgId}/members`, `PATCH` and `DELETE
/v1/orgs/{orgId}/members/{userId}`, `POST /v1/orgs/{orgId}/invitations`, `GET
/v1/orgs/{orgId}/invitations` and `DELETE /v1/orgs/{orgId}/invitations/{invitationId}`.

### `bitskiff settings`

```
$ bitskiff settings get
{"joinUrl":"https://example.com/join","e2ee":"off"}

$ bitskiff settings set joinUrl https://game.example/join
{"joinUrl":"https://game.example/join","e2ee":"off"}

$ bitskiff settings set restrictedMode on
{"joinUrl":"https://game.example/join","e2ee":"off","restrictedMode":true}
```

Three fields are writable: `joinUrl`, `e2ee` and `restrictedMode`, which is written `on` or `off`.
A field at its zero value is left out of the body, which is why the first two answers above carry
no `restrictedMode`.

`set` takes one field and its value, and sends that field alone, so nothing else is disturbed.
`bitskiff settings set --unset <field>` clears one by sending the empty string, and takes no value
beside the name.

A settings body can carry `warnings`, and each one prints as a `warning:` line on stderr while
stdout stays the service's body byte for byte. The onboarding notice -- this project is still on
the sample client page -- is one of them. The routes are `GET /v1/project/settings` and
`PATCH /v1/project/settings`.

### `bitskiff audit`

```
$ bitskiff audit --action key.revoked
{"events":[{"id":"evt_dqsxe7csf4kq6ptsrct7","at":"1700000000000","projectId":"proj_z1227rqkpwypmjx947tb","actor":{"type":"admin_key","id":"key_j2qw9xb5x26knq5j"},"action":"key.revoked","target":{"type":"key","id":"key_znwytxg25n88cdw5"},"requestId":"6b9f2c81-0d54-4a7e-9c33-2e85af71b0d6"}]}
```

The project's audit log: one event per key, limit and webhook operation. `--action`,
`--actor`, `--since`, `--until` and `--cursor` become query parameters on the route, so the service
filters and pages rather than the CLI. The next page's cursor prints on stderr, from the body's
`nextCursor` field. The route is `GET /v1/project/audit`.

### `bitskiff ledger`

```
$ bitskiff ledger --month 2026-09 --subject cust_1042
{"sessions":[{"id":"xkybmicycvmqluiol6oulow4we","sessionId":"s_jrvtZUd_CTOfMqnI2zyumX","subject":"cust_1042","endedAt":"1790000000000","seatMs":"5400000"}],"frozenThrough":"1789000000000"}
```

The usage records a developer bills its own customers on: one row per ended session (`--slice
session`, the default), per session per UTC day (`--slice day`), or per ended client (`--slice
client`). `--subject <subject>` narrows to one customer, `--from` and `--to` or `--month YYYY-MM`
bound the range to 31 days at once, and `--cursor` and `--limit` page it; `--all` follows the
cursor to the end.

`--csv` prints the route's CSV byte for byte, paged off the
`X-Bitskiff-Next-Cursor` header rather than a body field, and `--all --csv` is one file with the
header row printed once.

`frozenThrough` is the instant before which no row in the answer changes again; a range that
reaches past it is not final yet. The route is `GET /v1/project/ledger`. See
[Usage-based billing](/docs/guides/usage-based-billing/) for how to
read the records into monthly totals.

### `bitskiff webhooks`

```
$ bitskiff webhooks set --url https://example.com/hooks/bitskiff --kind session.ended --kind client.gone
{"url":"https://example.com/hooks/bitskiff","secret":"<64 hex characters>","kid":"a1b2c3d4","kinds":["session.ended","client.gone"]}

$ bitskiff webhooks get
{"url":"https://example.com/hooks/bitskiff","kid":"a1b2c3d4","kinds":["session.ended","client.gone"]}
```

Where the project's events are delivered. `set --url` sets the delivery URL and `set --clear`
clears it. `--kind` repeats, one per opt-in kind, and replaces the subscription: `session.ended`
and `client.gone` add the usage records to the limit events every webhook gets. `--clear-kinds`
subscribes to none. A `set` that names only the kinds keeps the URL it already has.

The `set` answer is the one place the signing secret is shown, so store it where your receiver
reads it; `get` never carries it. The key needs `webhooks:manage`. The routes are `GET
/v1/project/webhooks` and `PUT /v1/project/webhooks`.

A billing account's invoice events have a webhook of their own: `bitskiff billing webhooks get
<billingAccountId>` reads it, and `bitskiff billing webhooks set <billingAccountId> --url <url>`
(or `--clear`) with `--kind invoice.issued`, `invoice.paid` or `invoice.voided` writes it. `set` runs
on the console sign-in from `bitskiff login --device-code`, and its answer is the one place that
webhook's signing secret is shown.

### `bitskiff events`

```
$ bitskiff events --limit 1
{"events":[{"id":"m4tq2ow7zkc3xbv5ernh6jyd2a","type":"session.ended","at":"1774915200000","data":"{\"sessionId\":\"s_jrvtZUd_CTOfMqnI2zyumX\"}"}],"nextCursor":"c_m4tq2ow7"}
```

The project's event stream, oldest first: the same objects the webhook delivers, kept for seven
days or 10,000 events, so a receiver that was down catches up here. `--cursor` and `--limit` page
it, and the next page's cursor prints on stderr; `--all` follows the cursor to the end. When the
cursor is older than the oldest event kept, a warning on stderr says so, and what fell between is
in `bitskiff ledger`. The key needs `usage:read`. The route is `GET /v1/project/events`.

## Output and exit codes

stdout is the answer and nothing else. Progress, notices and every error go to stderr. The format
follows the destination, a table on a TTY and JSON otherwise, and `--format=json|table` overrides
either way. Under JSON, the service's response body is printed byte for byte, with no envelope and
no renamed fields.

A body with a list in it always carries that list: every repeated field is present, and an empty
one is `[]`. A project with nothing open answers `{"sessions":[]}`. That holds for `sessions list`,
`keys list`, `projects list`, `members list`, `members invitations`, `tokens list`, `audit`, `events` and `usage`, so reading a list
off any of them is safe without a guard on the first day.

A list that matched nothing prints `Listed 0 items.` in a table.

The exit code follows the HTTP status, condensed to one class per code:

| Exit | Class |
|---|---|
| 0 | success |
| 1 | unclassified failure, the CLI's own fault (`sdk_internal`) among them |
| 2 | usage error: a bad flag, or an unresolved project or endpoint. Nothing was sent |
| 3 | unauthenticated (401) |
| 4 | forbidden (403) |
| 5 | not found (404) |
| 6 | conflict or gone (409, 410) |
| 7 | limited (429) |
| 8 | invalid request (400, 426) |
| 9 | the service or the network failed (500, 503, transport) |

The exit class does not say whether to retry. Exits `6`, `7` and `9` each cover codes that can
carry `retryable: true` -- `idempotency_in_flight` at `6`, the limit codes at `7`, `unavailable`,
`network` and `timeout` at `9` -- and the body's own `retryable` field is the signal. Branch on
that, and wait the `retryAfterMs` or `Retry-After` beside it when there is one.

Anything above 9 comes from the shell as `128 + signal`. `130` is SIGINT, which is how
`sessions tail` and `usage --live` exit when a person stops them.

A limit refusal prints which limit it was and how to raise it:

```
bitskiff: Refused: concurrentSessions (project proj_z1227rqkpwypmjx947tb) is at 5 of 5.
  retry after 12s  (Retry-After: 12)
  raise it with: bitskiff limits set concurrentSessions <value> --project proj_z1227rqkpwypmjx947tb
  requestId dcea9d32-21c0-4aac-8040-d9f90646ecc3
```

## Full verb list

Run `bitskiff --help` for every command, and `bitskiff <command> --help` for one command's flags,
its output shape and its paging. A script or an agent reads the same table as data with
`bitskiff commands --format json`: every verb with its arguments, flags, routes, permissions, output
shape, paging and sign-in.

<!-- commands: begin -->
| Command | What it does | Routes | Permission |
|---|---|---|---|
| `bitskiff init` | sign in, make this directory's project (or use the one bitskiff.json or --project names), mint a publishable key into bitskiff.json, and write a hello world and its headless proof (bitskiff-prove.mjs) into an empty directory | `POST /v1/auth/device/code`<br>`POST /v1/auth/device/token`<br>`GET /v1/orgs`<br>`POST /v1/orgs/{orgId}/admin-keys`<br>`GET /v1/orgs/{orgId}/projects`<br>`POST /v1/orgs/{orgId}/projects`<br>`GET /v1/project/keys`<br>`POST /v1/project/keys`<br>`PATCH /v1/project/settings` | `keys:manage`, `projects:read`, `projects:create`, `project:manage` |
| `bitskiff commands` | every verb as data: arguments, flags, routes, permissions and output shape | local | - |
| `bitskiff mcp` | serve every verb as a tool to an MCP client over stdio, under the stored credential | local | - |
| `bitskiff login` | store an organization key, from stdin or an unechoed prompt, and verify it; or sign in with --device-code | `GET /v1/project/settings`<br>`POST /v1/auth/device/code`<br>`POST /v1/auth/device/token`<br>`GET /v1/orgs` | `project:read` |
| `bitskiff logout` | delete the stored key and console session, and nothing else | local | - |
| `bitskiff config get` | print the config file, or one field of it | local | - |
| `bitskiff config set` | set one of project, endpoint, org in the config file | local | - |
| `bitskiff whoami` | endpoint discovery, and which endpoint, project, organization and credential this shell resolves | `GET /v1/whoami`<br>`GET /v1/orgs/{orgId}/admin-keys`<br>`GET /v1/orgs/{orgId}/plan` | `keys:manage`, `projects:read` |
| `bitskiff doctor` | check the build, credential, project, host key origins, join URL and api, one line each, and say what to do | `GET /v1/whoami`<br>`GET /v1/orgs`<br>`GET /v1/orgs/{orgId}/admin-keys`<br>`GET /v1/projects/{projectId}`<br>`GET /v1/project/keys` | `keys:manage`, `project:read` |
| `bitskiff orgs list` | the organizations this credential belongs to, the one in use marked | `GET /v1/orgs` | - |
| `bitskiff orgs use` | act in this organization from now on, stored beside the credential | `GET /v1/orgs` | - |
| `bitskiff orgs show` | the organization in use: its id, name and creation time | `GET /v1/orgs/{orgId}` | `projects:read` |
| `bitskiff orgs policies` | read the organization's policies, or set them (owners only, under a console sign-in) | `GET /v1/orgs/{orgId}/policies`<br>`PUT /v1/orgs/{orgId}/policies` | `projects:read` |
| `bitskiff limits` | the project's limits, their ceilings and the current counters; set raises or lowers one | `GET /v1/project/limits` | `project:read` |
| `bitskiff limits set` | raise or lower a project limit, or (--key) a key's own; it applies at once | `POST /v1/project/limits/raise` | `project:manage` |
| `bitskiff limits raises` | the organization's pending limit raises: raises list, approve or deny | `GET /v1/orgs/{orgId}/limit-raises`<br>`POST /v1/orgs/{orgId}/limit-raises/approve`<br>`POST /v1/orgs/{orgId}/limit-raises/deny` | `projects:read`, `project:manage` |
| `bitskiff settings get` | the project's own settings | `GET /v1/project/settings` | `project:read` |
| `bitskiff settings set` | set one of joinUrl, e2ee, restrictedMode, terminationRules, registeredApps, maxClients, reconnectBudget, backgroundBudget, hostResumeWindow, idleTimeout, maxSessionTime, joinCodeRotation, warningLead; presence is the patch | `PATCH /v1/project/settings` | `project:manage` |
| `bitskiff webhooks get` | where the project's events are delivered; never the signing secret | `GET /v1/project/webhooks` | `webhooks:manage` |
| `bitskiff webhooks set` | set or clear the delivery URL and the opt-in kinds; prints the signing secret once | `PUT /v1/project/webhooks`<br>`GET /v1/project/webhooks` | `webhooks:manage` |
| `bitskiff sessions list` | the project's live sessions, paginated with cursor | `GET /v1/sessions` | `sessions:read` |
| `bitskiff sessions get` | one session, including its clients (SessionView) | `GET /v1/sessions/{sessionId}` | `sessions:read` |
| `bitskiff sessions close` | end a session, or every session of --subject; every client gets close code 4000 | `POST /v1/sessions/{sessionId}/close`<br>`GET /v1/sessions` | `sessions:manage`, `sessions:read` |
| `bitskiff sessions boot` | free every place --subject holds as a client, in every open session | `POST /v1/sessions/boot` | `sessions:manage` |
| `bitskiff sessions tail` | poll GET /v1/sessions and print one line per change | `GET /v1/sessions` | `sessions:read` |
| `bitskiff usage` | meters by day or by month over a range; --live polls the current counters; --capacity reads what can be placed now | `GET /v1/project/usage`<br>`GET /v1/project/limits`<br>`GET /v1/project/capacity` | `usage:read`, `project:read` |
| `bitskiff ledger` | usage records to bill your customers on: per session, per session-day or per client | `GET /v1/project/ledger` | `usage:read` |
| `bitskiff events` | the project's event stream: what the webhook delivers, kept seven days, oldest first | `GET /v1/project/events` | `usage:read` |
| `bitskiff projects list` | the organization's projects, filtered to what the caller can see | `GET /v1/orgs/{orgId}/projects` | `projects:read` |
| `bitskiff projects create` | create a project with default settings and default limits | `POST /v1/orgs/{orgId}/projects` | `projects:create` |
| `bitskiff projects get` | one project by slug or id, with archivedAt when it is archived | `GET /v1/projects/{projectId}` | `project:read` |
| `bitskiff projects rename` | set the project's display name; the slug never changes | `PATCH /v1/projects/{projectId}` | `project:manage` |
| `bitskiff projects archive` | archive the project, never delete it: new sessions, keys and tokens are refused, ledgers and usage stay, and an owner can unarchive it for 30 days | `POST /v1/projects/{projectId}/archive` | `project:manage` |
| `bitskiff projects unarchive` | take an archive back within 30 days, so the project takes new work again | `POST /v1/projects/{projectId}/unarchive` | `project:manage` |
| `bitskiff members list` | the organization's members, owners first, each with their role | `GET /v1/orgs/{orgId}/members` | `projects:read` |
| `bitskiff members update` | change a member's organization role; their project roles stay as they are | `PATCH /v1/orgs/{orgId}/members/{userId}` | `members:manage` |
| `bitskiff members remove` | remove a member: their relations and their console sessions end | `DELETE /v1/orgs/{orgId}/members/{userId}` | `members:manage` |
| `bitskiff members invite` | mail an invitation to an address, with the roles accepting it writes | `POST /v1/orgs/{orgId}/invitations` | `members:invite` |
| `bitskiff members invitations` | the organization's invitations, newest first, each with its state | `GET /v1/orgs/{orgId}/invitations` | `members:invite` |
| `bitskiff members revoke` | revoke an open invitation; its link stops working from the next request | `DELETE /v1/orgs/{orgId}/invitations/{invitationId}` | `members:invite` |
| `bitskiff billing accounts list` | the organization billing accounts this credential may view, the default first | `GET /v1/orgs/{orgId}/billing-accounts` | `billing:read` |
| `bitskiff billing accounts create` | create a billing account in the organization; never the default | `POST /v1/orgs/{orgId}/billing-accounts` | - |
| `bitskiff billing accounts update` | change a billing account's name or details, or make it the organization's default | `PATCH /v1/orgs/{orgId}/billing-accounts/{billingAccountId}` | - |
| `bitskiff billing attach` | attach a project to another billing account; its usage rolls up there from the start of that UTC day | `POST /v1/projects/{projectId}/billing-account` | - |
| `bitskiff billing usage` | a billing account's month roll-up: each attached project's owned meters and their totals | `GET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/usage`<br>`GET /v1/orgs/{orgId}/billing-accounts` | `billing:read` |
| `bitskiff billing webhooks get` | where a billing account's invoice events are delivered; never the signing secret | `GET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/webhooks` | `billing:read` |
| `bitskiff billing webhooks set` | set or clear a billing account's invoice delivery URL and kinds; prints the signing secret once | `PUT /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/webhooks` | - |
| `bitskiff invoices list` | a billing account's invoices, newest month first: number, period, status, total, currency | `GET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/invoices`<br>`GET /v1/orgs/{orgId}/billing-accounts` | `billing:read` |
| `bitskiff invoices show` | one invoice with its line items and the rate card version it was rated on | `GET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/invoices/{invoiceId}`<br>`GET /v1/orgs/{orgId}/billing-accounts` | `billing:read` |
| `bitskiff invoices pdf` | write the PDF archived when the invoice was issued | `GET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/invoices/{invoiceId}/pdf`<br>`GET /v1/orgs/{orgId}/billing-accounts` | `billing:read` |
| `bitskiff keys list` | the project's keys, by id, or (--scope org) the organization's keys | `GET /v1/project/keys`<br>`GET /v1/orgs/{orgId}/admin-keys` | `keys:manage` |
| `bitskiff keys create` | create a publishable or secret key, or (--scope org) an organization key no wider than the stored credential -- the full string prints once | `POST /v1/project/keys`<br>`POST /v1/orgs/{orgId}/admin-keys` | `keys:manage` |
| `bitskiff keys rotate` | start rotation; overlap defaults to keys.rotationOverlap when not given | `POST /v1/project/keys/{keyId}/rotate` | `keys:manage` |
| `bitskiff keys update` | patch label, origins, permissions, hostKeys, subjects, limits or retireAt; presence is the patch, at least one flag required | `PATCH /v1/project/keys/{keyId}` | `keys:manage` |
| `bitskiff keys revoke` | revoke a key; --reason manual leaves the sessions it opened running, --reason compromise ends them now | `DELETE /v1/project/keys/{keyId}`<br>`DELETE /v1/orgs/{orgId}/admin-keys/{keyId}` | `keys:manage` |
| `bitskiff tokens mint` | mint one token for one subject; prints the token once | `POST /v1/tokens` | `tokens:mint` |
| `bitskiff tokens list` | the live tokens of this project, by id; no listing carries a clear string | `GET /v1/tokens` | `tokens:mint` |
| `bitskiff tokens revoke` | end one token by id; the sessions it opened keep running | `DELETE /v1/tokens/{tokenId}` | `tokens:mint` |
| `bitskiff fixed-codes list` | the project's fixed codes, by id; never a code | `GET /v1/project/fixed-codes` | `project:read` |
| `bitskiff fixed-codes create` | make a fixed code for an NFC tag or a printed QR; prints the code once | `POST /v1/project/fixed-codes` | `project:manage` |
| `bitskiff fixed-codes replace` | draw a new code under the same id, so the old NFC tag stops joining; prints the code once | `POST /v1/project/fixed-codes/{fixedCodeId}/replace` | `project:manage` |
| `bitskiff fixed-codes relabel` | change a fixed code's label; the code stays as it is and is not shown | `PATCH /v1/project/fixed-codes/{fixedCodeId}` | `project:manage` |
| `bitskiff fixed-codes delete` | delete a fixed code, so its NFC tag stops joining; the session holding it keeps its clients | `DELETE /v1/project/fixed-codes/{fixedCodeId}` | `project:manage` |
| `bitskiff audit` | the project's audit log: one event per key, limit and webhook operation | `GET /v1/project/audit` | `audit:read` |
<!-- commands: end -->

## Next steps

- [The console](/docs/guides/console/)
- [Credentials](/docs/guides/credentials/)
- [Limits and capacity](/docs/guides/limits/)
- [REST API](/docs/reference/rest/)

## For coding agents

Bitskiff lets a person control a host -- a page, an app, a signage player -- from their own device.

- Setting Bitskiff up for a developer? Read and follow [https://docs.dev.bitskiff.com/setup.md](https://docs.dev.bitskiff.com/setup.md): it asks the person two questions and runs the rest of the setup under the CLI's stored credential.
- First: `npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz whoami` (Node 22 or newer). If its `credential` row names a key, a key is stored, and the next lines need no person.
- A stale copy: npx keeps one per URL and runs it again after a new publish at the same URL. Delete this CLI's copy, and nothing else in npm's cache, with `node -e "const c=child_process.execSync('npm config get cache',{encoding:'utf8'}).trim(),n=path.join(c,'_npx');for(const d of fs.existsSync(n)?fs.readdirSync(n):[]){let u;try{u=JSON.parse(fs.readFileSync(path.join(n,d,'package.json'),'utf8')).dependencies['@bitskiff/cli']}catch{}if(!u)continue;fs.rmSync(path.join(n,d),{recursive:true,force:true});const h=crypto.createHash('sha256').update('make-fetch-happen:request-cache:'+u).digest('hex');fs.rmSync(path.join(c,'_cacache','index-v5',h.slice(0,2),h.slice(2,4),h.slice(4)),{force:true})}"`, then run whoami again.
- Key stored: the whole setup is one command, run in the project directory, because it leaves `bitskiff.json` for your code to read. Pass no `--project`, and do not take one from whoami's `project` row: outside an app directory it can name another app's project. `init` uses the project the directory's `bitskiff.json` names, else makes a new project named for the directory, so a second app gets a second project:

```sh
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz init
```

- Serve your own client page? Add `--join-url <url>` with the page's absolute URL: init sets the project's join URL, mints a client key for that page's origin that serves this host key, and writes it to `bitskiff.json` as `clientKey`. The sample client page needs no key.
- It writes the host key into `bitskiff.json` (`key`, `project`, `endpoint`, and with `--join-url`, `joinUrl` and `clientKey`) and prints no key string. Its `done` step names the file as `keyFile` and carries the host key's id as `hostKeyId`, the `<hostKeyId>` below. It writes a scaffold (`index.html`, `client.html`, `README.md`) only into an empty directory, or with `--scaffold`, so in an existing project `bitskiff.json` is the one file it adds.
- A `bitskiff.json` of the same project is kept, and no key is minted. With no `bitskiff.json`, it takes the project's newest active host key, and mints a host key only when the project has none or you pass `--new-key`. One of another project is refused: pass that project's `--project` to keep it, or add `--force` to replace it, which reuses the new project's host key when it has one.
- A host key `init` mints has no origin set, so it allows `http://localhost` and `http://127.0.0.1` on any port. A key `init` took keeps the origins it already had; check them with `keys` before adding yours. For a deployed page, name its origin and keep localhost, because `--origin` replaces the list:

```sh
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys update <hostKeyId> --origin <your origin> --origin 'http://localhost:*'
```

- Prove the project works, with no host code and no device: the CLI is not a host, so the scaffold carries `bitskiff-prove.mjs`. Run `npm install && node bitskiff-prove.mjs` (the `prove` value of `done`) in the directory `init` wrote the scaffold into. It opens a session on `@bitskiff/host-node`, joins one client, sends a message each way, then prints one JSON object per step on stdout, each transport's record and the session's usage record among them, and closes. A step that fails prints `{"step":"failed","failed":"<step>",...}` and exits 1. It reads two keys from the environment: `BITSKIFF_SECRET_KEY`, a secret key holding `sessions:open` and `sessions:read`, and `BITSKIFF_CLIENT_KEY`, a client key naming no host key (the `clientKey` from `--join-url` serves the page's host key only). `--only secret` prints a new key alone: redirect each into a file outside the project, and export the variable from there:

```sh
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys create --type secret --label prove --permission sessions:open --permission sessions:read --only secret
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys create --type publishable --preset client-page --label prove --only secret
```

- Refused `invalid_key`: the stored key no longer works. Run `npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz logout`, then the steps below.
- Refused `forbidden` with `details.reason` of `missing_permission`: the key lacks the permission `details.permission` names. Run `logout`, then `init` to sign in again.
- Refused `forbidden` without it: the key may not use that project. Pass `--project` of a project the key may use.
- If the key comes from `BITSKIFF_ORG_KEY`, `logout` does not remove it: unset the variable instead. If whoami's `org` row is then `(not set)`, add `--org <orgId>` to `init`, with the id of the organization the key belongs to.
- No key stored (`credential` is `(not set)`), and only then: the steps below, which wait on a person once.
- Run: `npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz init --format=json` (Node 22 or newer; the flag is what puts one JSON object per step on stdout). The CLI already knows the api it was published beside. It leaves the same `bitskiff.json`, and takes the same `--project` and `--join-url`.
- It blocks until a person approves it in the console, for up to ten minutes: run it in the background, or with a timeout at least that long.
- Or add `--no-wait`, and it returns at once: it prints one JSON object, `{ verificationUri, userCode, expiresAt, next }`, and exits 0. Show your developer the URL and the code, then run `next` through the same `npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz` in place of `bitskiff`: it waits for the approval and finishes the setup.
- **[HUMAN]** tell your developer to open the URL it prints and press Approve. Do not open it yourself: only a signed-in person can approve.
- Working through an MCP client instead of a shell? Add the CLI as a stdio MCP server, `{ "mcpServers": { "bitskiff": { "command": "npx", "args": ["--yes", "https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz", "mcp"] } } }`: every verb is a tool under the same stored key, `init` runs as `--no-wait`, and a refusal is an error result carrying the same `code` and `details.reason`.
- Sign in: [https://console.dev.bitskiff.com](https://console.dev.bitskiff.com)
- Quickstart: [https://docs.dev.bitskiff.com/docs/quickstart/](/docs/quickstart/)

The whole host SDK, in a page that needs no build step:

```html
<script type="importmap">
{
  "imports": { "@bitskiff/host": "https://docs.dev.bitskiff.com/sdk/host@0.3.0.js" },
  "integrity": { "https://docs.dev.bitskiff.com/sdk/host@0.3.0.js": "sha384-t4tevb6IaP3UssGRlkmHoQscYfON1BN2JyGY3zsoEGm3KmG2W3Mbe4icXcXQ7uGA" }
}
</script>

<script type="module">
  import { BitskiffHost, qrSvg } from '@bitskiff/host';

  const host = new BitskiffHost({ key: 'bsk_pk_...' });   // your host key: the key from bitskiff.json
  const press = host.channel('press');                    // declare every channel before open
  press.on('message', (m) => press.send(m.client, `got ${m.data}`));   // m.client is the client that sent it
  const session = await host.open();                      // open last: it seals the channels
  document.body.innerHTML = qrSvg(session.joinCode.url);
</script>
```

The `.d.ts` files in this package are the contract for calling it as a library, and
`bitskiff <command> --help` is the contract for the command line; where either and this page
disagree, they are right.

Read `bitskiff commands --format json` before anything else, and never parse `--help`. It prints
the whole command table as one JSON document: every verb with its arguments, flags, the routes it
calls, the permissions they need, its output shape, whether it pages and whether it needs a
sign-in. It needs no sign-in and sends no request.

Every refusal is the service's own error body, printed as it arrived under
`--format=json` with `code`, `retryable`, `retryAfterMs` and `details` on it, so read `retryable` and
not the exit class to decide whether to try again.
