The write API: keys, scopes, dry run, publish, publishing from the app, invites, directory, usage

Keys, scopes, signing, publish, publishing from the app (device keys, owner approval, signer chain), refusals, invites, usage

Raw Markdown the file as the skill ships it All pages ↓

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.

KeySignsIts public half lives in
Server keyeach admin API requestprivate/tenant.json serverKeys: [{ id, alg: "Ed25519", publicKey, scopes }], added by the account owner (or Streamliner, for a new account)
Publisher keythe manifestenrolment.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

RouteBody → 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/publishThe 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 / errorFix
401 unauthorized | bad_signature | stale_request | replayed_requestWrong key id, wrong key, clock off by > 5 min, reused nonce.
403 forbidden_scopeThe key lacks the scope; ask the owner (don't look for another key).
400 bad_path, bad_fileA file outside the accepted paths, or invalid JSON.
400 manifest_requiredSign first.
409 stale_versionRe-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_keysThe publisher key isn't the account's; ask the owner.
inconsistentA streamline in kinds has no agents/<kind>.json.
would_lock_outprivate/tenant.json would drop the last config key; keep the key's entry.
409 publish_in_progressWait 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 device config: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 over streamline.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

RouteWhoBody → answer
POST /v1/admin/<a>/invitesadmin/owner, or key invites{ email?, username?, user?, roles?, name?, access? } → { code, url, expiresAt, access } (single use, 7 days)
PUT | PATCH | GET /v1/admin/<a>/directorykey directoryaccess-and-networks.md
GET /v1/admin/<a>/usage/summary[?fresh=1]admin/owner, or usage:read7d / 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:readstream metadata; ?user=&chat= for events
GET /v1/admin/<a>/credentialsadmin/owner, or credentialsthe 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 signs manifest.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 --yes does it write, and only after the owner has said yes to that diff.