Keys, scopes, signing, publish, publishing from the app (device keys, owner approval, signer chain), refusals, invites, usage
Everything that changes an organization account goes through the signed admin API on the back end (tenant.json gateway.url's host). Personal accounts have no write API: the person imports the file in the app.
Contents
- Keys
- Signing a request
- Scopes
- Publishing config
- Publishing from the app
- Refusals you will see
- Invites, directory, usage, transcripts
- What the skill's scripts do
Keys
Two Ed25519 key pairs, both made on the publisher's own machine. Their private halves never leave it: kept in files with mode 0600 (or the Keychain), passed to scripts by path, never printed, pasted into chat, or committed.
| Key | Signs | Its public half lives in |
|---|---|---|
| Server key | each admin API request | private/tenant.json serverKeys: [{ id, alg: "Ed25519", publicKey, scopes }], added by the account owner (or Streamliner, for a new account) |
| Publisher key | the manifest | enrolment.json keys[] (or the live manifest's nextKeys) |
Make a key pair (prints only the public half):
node -e 'const fs=require("fs"),c=require("crypto");const k=c.generateKeyPairSync("ed25519").privateKey.export({format:"jwk"});k.kid=process.argv[1];fs.writeFileSync(process.argv[2],JSON.stringify(k),{mode:0o600});console.log(k.kid,k.x)' claude-setup-oct ./server-key.jwk
Ask the owner to register the public half with the narrowest scopes and an expiry. Never ask for, accept or use an employee's sign-in, session or password for automation: server credentials belong to the account.
Signing a request
Headers: X-Streamline-Key: <key id>, X-Streamline-Timestamp: <unix seconds> (±300 s), X-Streamline-Nonce: <16–64 of [A-Za-z0-9_-], once per key>, X-Streamline-Signature: base64url Ed25519 over
streamline.admin.v1|<account>|<METHOD>|<path?query>|<sha256hex(body)>|<timestamp>|<nonce>
Scopes
Built: config (publish anything), config:streamlines (read config and publish streamline files only: agents/, templates/, forms/, assets/, private/agents/, private/briefs/, manifest.json, and tenant.json when only kinds changes; anything else is 403 scope_denied; single-file PUT/DELETE need config), directory, invites, members, usage:read, transcripts:read, credentials (the organization's model keys). Designed, coming: config:check (dry run only), config:keys (owners only), per-key expiresAt (7 days default for agent keys), rate limits and source CIDRs, and an email to the owners on every publish by a key. Ask for the least that does the job: a dry-run-only key to draft, a streamlines-only key to publish streamlines.
Publishing config
| Route | Body → answer |
|---|---|
GET /v1/admin/<a>/config | → { tenant, manifest: { version, published, keyId } | null, files: [{ path, sha256, bytes }] } (hashes, never contents) |
POST /v1/admin/<a>/config/publish?dryRun=1 | { files: { <path>: <base64> }, delete: [<path>] }, the signed manifest.json among the files → { ok, dryRun, changed, version, put, unchanged, deleted }. Writes nothing. |
POST /v1/admin/<a>/config/publish | The same, for real. ≤ 32 MB. All or nothing; the manifest is written last. |
GET /v1/admin/<a>/config/<path> | One stored file's contents, private/ included (no-store). A server key or an approved device key; never a session. |
PUT /v1/admin/<a>/config/<path>[?dryRun=1] | One file, raw bytes (≤ 2 MB), live at once (for big assets). |
DELETE /v1/admin/<a>/config/<path>[?dryRun=1] | A file the live manifest doesn't list. |
Everything is checked before anything is written: JSON parses, tenant.json id is the account, private/tenant.json holds public keys only, the manifest is for this account with a higher version, every listed file exists with that hash and size, the signature verifies with the account's publisher keys, every streamline in kinds has its file, and an enabled config key remains.
Refusals you will see
| Status / error | Fix |
|---|---|
401 unauthorized | bad_signature | stale_request | replayed_request | Wrong key id, wrong key, clock off by > 5 min, reused nonce. |
403 forbidden_scope | The key lacks the scope; ask the owner (don't look for another key). |
400 bad_path, bad_file | A file outside the accepted paths, or invalid JSON. |
400 manifest_required | Sign first. |
409 stale_version | Re-sign with --min-version <published + 1>. |
409 manifest_mismatch (missing, mismatched) | A file changed after signing, or is missing: re-sign. |
bad_manifest_signature, no_publisher_keys | The publisher key isn't the account's; ask the owner. |
inconsistent | A streamline in kinds has no agents/<kind>.json. |
would_lock_out | private/tenant.json would drop the last config key; keep the key's entry. |
409 publish_in_progress | Wait and retry. |
403 scope_denied (path) | A config:streamlines key touched a file outside streamlines; leave that file out or ask the owner. |
Publishing from the app
Owners and admins can also publish from the Streamliner app itself, on an iPhone, iPad or Mac, without key files:
- Device keys. The app makes one Ed25519 key per organization on that device (kept in its Keychain, unlocked by Face ID / Touch ID / the passcode, never synced) and registers the public half with the person's sign-in. It does nothing until an owner approves it, comparing a fingerprint shown on both screens. Nothing is approved automatically: approval is an owner's signature (an endorsement) over the device's key. The account's first owner device is endorsed by whoever holds the account's publisher key; after that, owners approve devices in the app.
- Scopes. An owner's device gets
config+config:streamlines; an admin's deviceconfig:streamlines. Device requests are signed exactly like server-key requests (X-Streamline-Key: dev-…), and every one re-checks that the person is still an admin or owner. Revoking a device (an owner: any; anyone: their own) is final. - The signer chain. A manifest published from the app is signed by the device's own key and carries
signers: [{ keyId, pub, endorsedBy, endorsement, label }]: each entry endorsed (Ed25519 overstreamline.signer.v1|<account>|<keyId>|<pub>) by a publisher key or by the next entry, at most four links. Members' apps accept it only when the chain reaches a publisher key they pinned; the back end also requires every device in it to be approved and not revoked. Versions still only go up (409 stale_version). - In the app: Library → Staged changes → Publish vN…: the app re-reads the live config, merges staged edits three ways (a field changed on both sides is a conflict the person resolves), signs, shows the back end's dry run and publishes after a tap and Face ID.
Which to use. The app, when a person is making the change: it holds no key files, shows the diff and the dry run, and catches conflicts with other admins. scripts/publish.mjs (this skill), for automation, CI and bulk edits from a folder, with a server key the owner registered (prefer config:streamlines) and the publisher key; or edit the files and hand them to an owner or admin to stage and publish in the app. Never ask a person for their device's key: it can't leave the device.
Invites, directory, usage, transcripts
| Route | Who | Body → answer |
|---|---|---|
POST /v1/admin/<a>/invites | admin/owner, or key invites | { email?, username?, user?, roles?, name?, access? } → { code, url, expiresAt, access } (single use, 7 days) |
PUT | PATCH | GET /v1/admin/<a>/directory | key directory | access-and-networks.md |
GET /v1/admin/<a>/usage/summary[?fresh=1] | admin/owner, or usage:read | 7d / 30d / month-to-date tokens and estimated USD, by user, role, streamline, model |
GET /v1/admin/<a>/transcripts?user=&kind=&since= | admin/owner, or transcripts:read | stream metadata; ?user=&chat= for events |
GET /v1/admin/<a>/credentials | admin/owner, or credentials | the model keys set (ref, provider, last4, set at/by) and the refs the streamlines need; never a key |
PUT | DELETE /v1/admin/<a>/credentials/<ref> | admin/owner session, or credentials | { provider: "anthropic", key }: checked with the provider (422 key_rejected on its 401), stored sealed, never readable back |
Model keys are the organization's (decision 34). A streamline's private model.credentialRef names one (absent = default); without it an organization's chats answer 503 no_model_key. You never handle the key: tell the owner to add it in the app's Vault (Organization model key) or with their server key and scripts/set-model-key.mjs (the key from stdin or the Keychain, never an argument). Never ask for, paste or store a key yourself.
An invite link is sensitive (it lets someone join): give it only to the person it is for.
What the skill's scripts do
scripts/sign-config.mjs <folder> --publisher-key <jwk>writes and signsmanifest.json.scripts/publish.mjs <folder> --host <base> --key <server-key.jwk>reads the published index, sends the changed files with?dryRun=1, and prints a readable diff (--baseline <folder>for field-level JSON changes). Only with--yesdoes it write, and only after the owner has said yes to that diff.