@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 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:
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 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:
{
"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 tailpollsGET /v1/sessionsand 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 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 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.
| 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 |
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 itscredentialrow 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.jsonfor your code to read. Pass no--project, and do not take one from whoami'sprojectrow: outside an app directory it can name another app's project.inituses the project the directory'sbitskiff.jsonnames, else makes a new project named for the directory, so a second app gets a second project:
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 tobitskiff.jsonasclientKey. The sample client page needs no key. - It writes the host key into
bitskiff.json(key,project,endpoint, and with--join-url,joinUrlandclientKey) and prints no key string. Itsdonestep names the file askeyFileand carries the host key's id ashostKeyId, 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 projectbitskiff.jsonis the one file it adds. - A
bitskiff.jsonof the same project is kept, and no key is minted. With nobitskiff.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--projectto keep it, or add--forceto replace it, which reuses the new project's host key when it has one. - A host key
initmints has no origin set, so it allowshttp://localhostandhttp://127.0.0.1on any port. A keyinittook keeps the origins it already had; check them withkeysbefore adding yours. For a deployed page, name its origin and keep localhost, because--originreplaces the list:
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. Runnpm install && node bitskiff-prove.mjs(theprovevalue ofdone) in the directoryinitwrote 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 holdingsessions:openandsessions:read, andBITSKIFF_CLIENT_KEY, a client key naming no host key (theclientKeyfrom--join-urlserves the page's host key only).--only secretprints a new key alone: redirect each into a file outside the project, and export the variable from there:
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. Runnpx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz logout, then the steps below. - Refused
forbiddenwithdetails.reasonofmissing_permission: the key lacks the permissiondetails.permissionnames. Runlogout, theninitto sign in again. - Refused
forbiddenwithout it: the key may not use that project. Pass--projectof a project the key may use. - If the key comes from
BITSKIFF_ORG_KEY,logoutdoes not remove it: unset the variable instead. If whoami'sorgrow is then(not set), add--org <orgId>toinit, with the id of the organization the key belongs to. - No key stored (
credentialis(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 samebitskiff.json, and takes the same--projectand--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 runnextthrough the samenpx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgzin place ofbitskiff: 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,initruns as--no-wait, and a refusal is an error result carrying the samecodeanddetails.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:
<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.