Bitskiff

@bitskiff/cli

On this page

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 is the whole surface. The guides cover the rest: quickstart · node hosts · lifecycle

Install

bitskiff is a command, so it installs globally. Take the tarball URL from the downloads page—npm install takes a URL as well as a name—and install it on your path:

Text
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:

EnvironmentEndpoint
devhttps://api.dev.bitskiff.com
Text
$ 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 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:

Text
$ 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.

Text
$ 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:

Text
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:

Text
$ 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.

Text
$ 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.

SettingFlagEnvironmentThen
project--project acme-demoBITSKIFF_PROJECTprofile, then file
organization--org org_xedd48megcvaedxa93yxBITSKIFF_ORGprofile, then file
endpoint--endpoint <url>BITSKIFF_ENDPOINTprofile, then file
credentialnone, by designBITSKIFF_ORG_KEYprofile, 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:

Text
$ 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.

Text
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 wireIn these docs
participanta client
seatClasswhat a client's place lets it do: controller, viewer or opener
seatDeadlineMsthe 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.

Text
$ 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.

Text
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.

Text
$ 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:

Text
$ 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

Text
$ 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

Text
$ 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:

Text
$ 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

Text
$ 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:

Text
$ 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.

Text
$ 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.

Text
$ 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.

Text
$ 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

Text
$ 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.

Text
$ 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

Text
$ 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 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

Text
$ 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

Text
$ 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

Text
$ 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 for how to read the records into monthly totals.

bitskiff webhooks

Text
$ 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

Text
$ 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:

ExitClass
0success
1unclassified failure, the CLI's own fault (sdk_internal) among them
2usage error: a bad flag, or an unresolved project or endpoint. Nothing was sent
3unauthenticated (401)
4forbidden (403)
5not found (404)
6conflict or gone (409, 410)
7limited (429)
8invalid request (400, 426)
9the 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:

Text
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.

CommandWhat it doesRoutesPermission
bitskiff initsign 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 directoryPOST /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/settingskeys:manage, projects:read, projects:create, project:manage
bitskiff commandsevery verb as data: arguments, flags, routes, permissions and output shapelocal-
bitskiff mcpserve every verb as a tool to an MCP client over stdio, under the stored credentiallocal-
bitskiff loginstore an organization key, from stdin or an unechoed prompt, and verify it; or sign in with --device-codeGET /v1/project/settings<br>POST /v1/auth/device/code<br>POST /v1/auth/device/token<br>GET /v1/orgsproject:read
bitskiff logoutdelete the stored key and console session, and nothing elselocal-
bitskiff config getprint the config file, or one field of itlocal-
bitskiff config setset one of project, endpoint, org in the config filelocal-
bitskiff whoamiendpoint discovery, and which endpoint, project, organization and credential this shell resolvesGET /v1/whoami<br>GET /v1/orgs/{orgId}/admin-keys<br>GET /v1/orgs/{orgId}/plankeys:manage, projects:read
bitskiff doctorcheck the build, credential, project, host key origins, join URL and api, one line each, and say what to doGET /v1/whoami<br>GET /v1/orgs<br>GET /v1/orgs/{orgId}/admin-keys<br>GET /v1/projects/{projectId}<br>GET /v1/project/keyskeys:manage, project:read
bitskiff orgs listthe organizations this credential belongs to, the one in use markedGET /v1/orgs-
bitskiff orgs useact in this organization from now on, stored beside the credentialGET /v1/orgs-
bitskiff orgs showthe organization in use: its id, name and creation timeGET /v1/orgs/{orgId}projects:read
bitskiff orgs policiesread the organization's policies, or set them (owners only, under a console sign-in)GET /v1/orgs/{orgId}/policies<br>PUT /v1/orgs/{orgId}/policiesprojects:read
bitskiff limitsthe project's limits, their ceilings and the current counters; set raises or lowers oneGET /v1/project/limitsproject:read
bitskiff limits setraise or lower a project limit, or (--key) a key's own; it applies at oncePOST /v1/project/limits/raiseproject:manage
bitskiff limits raisesthe organization's pending limit raises: raises list, approve or denyGET /v1/orgs/{orgId}/limit-raises<br>POST /v1/orgs/{orgId}/limit-raises/approve<br>POST /v1/orgs/{orgId}/limit-raises/denyprojects:read, project:manage
bitskiff settings getthe project's own settingsGET /v1/project/settingsproject:read
bitskiff settings setset one of joinUrl, e2ee, restrictedMode, terminationRules, registeredApps, maxClients, reconnectBudget, backgroundBudget, hostResumeWindow, idleTimeout, maxSessionTime, joinCodeRotation, warningLead; presence is the patchPATCH /v1/project/settingsproject:manage
bitskiff webhooks getwhere the project's events are delivered; never the signing secretGET /v1/project/webhookswebhooks:manage
bitskiff webhooks setset or clear the delivery URL and the opt-in kinds; prints the signing secret oncePUT /v1/project/webhooks<br>GET /v1/project/webhookswebhooks:manage
bitskiff sessions listthe project's live sessions, paginated with cursorGET /v1/sessionssessions:read
bitskiff sessions getone session, including its clients (SessionView)GET /v1/sessions/{sessionId}sessions:read
bitskiff sessions closeend a session, or every session of --subject; every client gets close code 4000POST /v1/sessions/{sessionId}/close<br>GET /v1/sessionssessions:manage, sessions:read
bitskiff sessions bootfree every place --subject holds as a client, in every open sessionPOST /v1/sessions/bootsessions:manage
bitskiff sessions tailpoll GET /v1/sessions and print one line per changeGET /v1/sessionssessions:read
bitskiff usagemeters by day or by month over a range; --live polls the current counters; --capacity reads what can be placed nowGET /v1/project/usage<br>GET /v1/project/limits<br>GET /v1/project/capacityusage:read, project:read
bitskiff ledgerusage records to bill your customers on: per session, per session-day or per clientGET /v1/project/ledgerusage:read
bitskiff eventsthe project's event stream: what the webhook delivers, kept seven days, oldest firstGET /v1/project/eventsusage:read
bitskiff projects listthe organization's projects, filtered to what the caller can seeGET /v1/orgs/{orgId}/projectsprojects:read
bitskiff projects createcreate a project with default settings and default limitsPOST /v1/orgs/{orgId}/projectsprojects:create
bitskiff projects getone project by slug or id, with archivedAt when it is archivedGET /v1/projects/{projectId}project:read
bitskiff projects renameset the project's display name; the slug never changesPATCH /v1/projects/{projectId}project:manage
bitskiff projects archivearchive the project, never delete it: new sessions, keys and tokens are refused, ledgers and usage stay, and an owner can unarchive it for 30 daysPOST /v1/projects/{projectId}/archiveproject:manage
bitskiff projects unarchivetake an archive back within 30 days, so the project takes new work againPOST /v1/projects/{projectId}/unarchiveproject:manage
bitskiff members listthe organization's members, owners first, each with their roleGET /v1/orgs/{orgId}/membersprojects:read
bitskiff members updatechange a member's organization role; their project roles stay as they arePATCH /v1/orgs/{orgId}/members/{userId}members:manage
bitskiff members removeremove a member: their relations and their console sessions endDELETE /v1/orgs/{orgId}/members/{userId}members:manage
bitskiff members invitemail an invitation to an address, with the roles accepting it writesPOST /v1/orgs/{orgId}/invitationsmembers:invite
bitskiff members invitationsthe organization's invitations, newest first, each with its stateGET /v1/orgs/{orgId}/invitationsmembers:invite
bitskiff members revokerevoke an open invitation; its link stops working from the next requestDELETE /v1/orgs/{orgId}/invitations/{invitationId}members:invite
bitskiff billing accounts listthe organization billing accounts this credential may view, the default firstGET /v1/orgs/{orgId}/billing-accountsbilling:read
bitskiff billing accounts createcreate a billing account in the organization; never the defaultPOST /v1/orgs/{orgId}/billing-accounts-
bitskiff billing accounts updatechange a billing account's name or details, or make it the organization's defaultPATCH /v1/orgs/{orgId}/billing-accounts/{billingAccountId}-
bitskiff billing attachattach a project to another billing account; its usage rolls up there from the start of that UTC dayPOST /v1/projects/{projectId}/billing-account-
bitskiff billing usagea billing account's month roll-up: each attached project's owned meters and their totalsGET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/usage<br>GET /v1/orgs/{orgId}/billing-accountsbilling:read
bitskiff billing webhooks getwhere a billing account's invoice events are delivered; never the signing secretGET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/webhooksbilling:read
bitskiff billing webhooks setset or clear a billing account's invoice delivery URL and kinds; prints the signing secret oncePUT /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/webhooks-
bitskiff invoices lista billing account's invoices, newest month first: number, period, status, total, currencyGET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/invoices<br>GET /v1/orgs/{orgId}/billing-accountsbilling:read
bitskiff invoices showone invoice with its line items and the rate card version it was rated onGET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/invoices/{invoiceId}<br>GET /v1/orgs/{orgId}/billing-accountsbilling:read
bitskiff invoices pdfwrite the PDF archived when the invoice was issuedGET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/invoices/{invoiceId}/pdf<br>GET /v1/orgs/{orgId}/billing-accountsbilling:read
bitskiff keys listthe project's keys, by id, or (--scope org) the organization's keysGET /v1/project/keys<br>GET /v1/orgs/{orgId}/admin-keyskeys:manage
bitskiff keys createcreate a publishable or secret key, or (--scope org) an organization key no wider than the stored credential—the full string prints oncePOST /v1/project/keys<br>POST /v1/orgs/{orgId}/admin-keyskeys:manage
bitskiff keys rotatestart rotation; overlap defaults to keys.rotationOverlap when not givenPOST /v1/project/keys/{keyId}/rotatekeys:manage
bitskiff keys updatepatch label, origins, permissions, hostKeys, subjects, limits or retireAt; presence is the patch, at least one flag requiredPATCH /v1/project/keys/{keyId}keys:manage
bitskiff keys revokerevoke a key; --reason manual leaves the sessions it opened running, --reason compromise ends them nowDELETE /v1/project/keys/{keyId}<br>DELETE /v1/orgs/{orgId}/admin-keys/{keyId}keys:manage
bitskiff tokens mintmint one token for one subject; prints the token oncePOST /v1/tokenstokens:mint
bitskiff tokens listthe live tokens of this project, by id; no listing carries a clear stringGET /v1/tokenstokens:mint
bitskiff tokens revokeend one token by id; the sessions it opened keep runningDELETE /v1/tokens/{tokenId}tokens:mint
bitskiff fixed-codes listthe project's fixed codes, by id; never a codeGET /v1/project/fixed-codesproject:read
bitskiff fixed-codes createmake a fixed code for an NFC tag or a printed QR; prints the code oncePOST /v1/project/fixed-codesproject:manage
bitskiff fixed-codes replacedraw a new code under the same id, so the old NFC tag stops joining; prints the code oncePOST /v1/project/fixed-codes/{fixedCodeId}/replaceproject:manage
bitskiff fixed-codes relabelchange a fixed code's label; the code stays as it is and is not shownPATCH /v1/project/fixed-codes/{fixedCodeId}project:manage
bitskiff fixed-codes deletedelete a fixed code, so its NFC tag stops joining; the session holding it keeps its clientsDELETE /v1/project/fixed-codes/{fixedCodeId}project:manage
bitskiff auditthe project's audit log: one event per key, limit and webhook operationGET /v1/project/auditaudit:read

Next steps

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: 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:
Shell
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:
Shell
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:
Shell
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
  • Quickstart: https://docs.dev.bitskiff.com/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.