@bitskiff/cli
bitskiff is the management surface for a Bitskiff project (docs/design/cli.md 1). Install it, point
it at a project, and do everything the console will eventually do: create and rotate keys, read
and change limits and settings, list and close sessions, and read usage and the audit log. It is
not a host—it opens no session, joins none, holds no seat, and speaks no transport; it calls
the control plane over HTTP and exits.
Install
npm i -g @bitskiff/cli
The verbs below work against a running api—point bitskiff at any endpoint with --endpoint.
Sign in
The credential is an admin key, mrc_ak_..., organization-scoped with an optional project
allowlist (cli.md 2.1). bitskiff login reads it from stdin, or prompts without echo on a TTY—never as an argument, because a key in --key is a key in the shell history and in ps.
$ bitskiff login <<< "$MRC_ADMIN_KEY"
bitskiff: stored mrc_ak_a7k2m9xq...jy2k in the config file and verified it against proj_7f3
With no project resolved yet, bitskiff login stores the key and says plainly that it did not verify
it, on stderr:
bitskiff: stored mrc_ak_a7k2m9xq...jy2k in the config file; it was not verified, because no project is resolved
MRC_ADMIN_KEY overrides the stored credential, which is what lets CI run without a login step
(cli.md 2.2, D-780). Project scope resolves the same way, highest rank first: --project
proj_... on the command, MRC_PROJECT, or the config file --
bitskiff config set project proj_7f3
bitskiff config set endpoint <url> and bitskiff config get [field] work the same way and mask
adminKey like everywhere else; there is no bitskiff config set admin-key—storing a credential is
always bitskiff login.
First commands
bitskiff whoami
Prints which endpoint, project and credential this shell resolved and from which rank, then what
GET /v1/whoami answers (cli.md 2.4). The rows are the answer this verb promises, so they print
first and always; the api half is best effort—an unset endpoint or a call that fails prints one
line saying so instead of the body, and the command still exits 0:
$ bitskiff whoami
endpoint https://api.example.test (MRC_ENDPOINT)
project proj_7f3 (MRC_PROJECT)
org org_1 (MRC_ORG)
credential mrc_ak_a7k2m9xq...jy2k (MRC_ADMIN_KEY)
engineUrl wss://engine.example
region eu
env production
apiId a_eu
With no endpoint resolved, or a route the api side has not built yet:
$ bitskiff whoami
endpoint (not set)
project (not set)
org (not set)
credential (not set)
bitskiff: no endpoint resolved, so there was nothing to call
whoami does not yet echo the caller's own identity (the organization or roles behind the key):
that block waits on api-identity-routes and is not built.
bitskiff sessions list
$ bitskiff sessions list
{"sessions":[{"sessionId":"s_1","state":"live","participants":[]}],"cursor":"c2"}
bitskiff: cursor: c2
The next page's cursor prints on stderr, never mixed into the answer on stdout; --cursor c2
starts there, and --all follows every page to the end.
bitskiff keys create
$ bitskiff keys create --type publishable --label demo --origin https://a.example \
--origin https://b.example --quota concurrent_sessions=10 \
--quota session_opens_per_minute=5
{"keyId":"key_1","type":"publishable","orgId":"org_1","projectId":"proj_7f3","projects":[],
"masked":"mrc_pk_a7k2m9xq...jy2k","secret":"mrc_pk_a7k2m9xqw4tz8bn3p6rs5vd1fg0hjy2k",
"status":"active","label":"demo","origins":[],"permissions":[], ...}
bitskiff: this key string will not be shown again (credentials.md 3.1)
(Reformatted here for legibility—the CLI prints one line.) The full key string is on stdout
exactly once, in the create and rotate responses; every other verb prints the masked form,
mrc_pk_a7k2m9xq...jy2k.
A secret key takes a permission list or a preset, never both: bitskiff keys create --type secret
--preset host-only for a device that only hosts, or repeated --permission sessions:open
--permission usage:read to name the list yourself (cli.md 3.2). Any key takes --limit
<row>=<value> repeated and --expires 90d (a duration, a time, or epoch ms); a blank row
inherits the project's value and no --expires takes the route's default (credentials.md 3).
bitskiff keys revoke <keyId>
--reason manual|compromise is required and there is no default—manual drains (every session
it opened keeps running until it ends on its own) and compromise cuts (every session it opened
ends now); on a TTY, leaving --reason off asks instead of refusing.
bitskiff tokens
$ BITSKIFF_SECRET_KEY=mrc_sk_... bitskiff tokens mint --subject user_42 --ttl 120
{"tokenId":"tok_9f2","token":"mrc_at_a7k2m9xqw4tz8bn3p6rs5vd1fg0hjy2k","expiresAt":1789000000000}
bitskiff: this token will not be shown again (credentials.md 4)
$ BITSKIFF_SECRET_KEY=mrc_sk_... bitskiff tokens list --subject user_42
{"tokens":[{"tokenId":"tok_9f2","subject":"user_42","permissions":["sessions:open"]}]}
$ BITSKIFF_SECRET_KEY=mrc_sk_... bitskiff tokens revoke tok_9f2
bitskiff: the sessions this token opened keep running (credentials.md 4)
mint takes --permission (the session words: sessions:open, sessions:read,
sessions:manage, sessions:join; the default is sessions:open), --limit <row>=<value>
repeated, --ttl and, on a sessions:join token, --session <sessionId> to bind it to one
session. list filters by --subject or --key and never carries a clear string. revoke ends
one token by id; the sessions it opened keep running, which is bitskiff sessions work.
This is the one verb family that does not send the stored organization key: the routes take a
secret key holding tokens:mint, so they read BITSKIFF_SECRET_KEY from the environment and refuse
naming it when that is not set. It is a developer's smoke test of the mint (credentials.md 4),
not the integration, which is the same REST call made from the developer's own backend.
bitskiff limits
The bare noun is the read verb—there is no bitskiff limits get (cli.md 3.3):
$ bitskiff limits
{"maxConcurrentSessions":5,"concurrentSessions":1,"bytesPerSecond":16384}
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 defaults to the current UTC month. --from/--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 the frozen usage_daily and usage_monthly tables (metering.md 4.2, 8), so a
day appears once its usage-daily job has run and an empty answer means nothing is frozen for that
range yet. --live polls GET /v1/project/limits every 5 seconds for the current counters instead
of a historical query.
Output and exit codes
stdout is the answer and nothing else; progress, notices and every error go to stderr. Format
follows the destination—a table on a TTY, JSON otherwise—and --format=json|table overrides
either way. Under JSON, the api's response body is printed byte for byte: no envelope, no renamed
fields (cli.md 4).
The exit code is a function of the error registry's http column, condensed to one class per
code (cli.md 5):
| Exit | Class |
|---|---|
| 0 | success |
| 1 | unclassified failure |
| 2 | usage error (bad flag, 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)—the only class worth retrying, and only when the body's retryable is true |
| 8 | invalid request (400, 426) |
| 9 | the platform or the network failed (500, 503, transport) |
Above 9 is the shell's: 128 + signal, and 130 for SIGINT, which is how sessions tail and
usage --live exit when a person stops them. A limit refusal prints the catalogue id and how to
raise it:
bitskiff: Refused: concurrent_sessions (project proj_7f3) is at 25 of 25.
retry after 12s (Retry-After: 12)
raise it with: bitskiff limits set concurrent_sessions <value> --project proj_7f3
requestId req_9k2m4x
Everything else
The full verb table, the config file's precedence rules, paging, id and key formatting, and
bitskiff sessions tail's poll contract are docs/design/cli.md.