Skip to content

Hosting Studio for a team

Studio's default is one developer's laptop, and the quickstart covers that. This page is the other shape: a shared instance that other people sign in to.

Until 0.2.1 this page did not exist, and neither KNEO_STUDIO_USERS_FILE nor KNEO_STUDIO_OIDC_PROVIDER_FILE appeared in any published document — the login modes were 0.2.0's headline feature and could not be configured from anything a user could read (audit M6). Everything below is transcribed from the loaders that enforce it.

Running the published image

docker run \
  -p 8091:8091 \
  -v kneo-state:/var/lib/kneo-studio/state \
  -v kneo-default:/var/lib/kneo-studio/default \
  -v kneo-workspace:/var/lib/kneo-studio/workspace \
  -e KNEO_STUDIO_AUTH_MODE=static \
  -e KNEO_STUDIO_USERS_FILE=/etc/kneo-studio/users.json \
  -e KNEO_STUDIO_DEPLOYMENT_CAP=53687091200 \
  -e KNEO_STUDIO_ALLOWED_HOSTS=studio.example.com \
  -v kneo-secrets:/etc/kneo-studio:ro \
  ghcr.io/kneo-agent/kneo-studio:<version>

It will refuse to start without a login mode and a cap, deliberately. The image binds 0.0.0.0 so it is reachable at all, and Studio refuses an unauthenticated routable bind — every request would be the local owner with full rights over the project tree. It also refuses a shared deployment with no cap of its own, because the built-in one is a laptop number chosen by one person for themselves. Both refusals print one line and exit; neither is a bug to work around.

Port 8091, inside the container and by default outside it
State /var/lib/kneo-studio/state — the store, sessions and the analysis keyring
Default project /var/lib/kneo-studio/default — the attached project bootstrap creates
Workspace /var/lib/kneo-studio/workspace — where members' own projects are allocated

The state path doubles, and that surprises people. XDG_STATE_HOME is /var/lib/kneo-studio/state, and Studio puts its own directory inside whatever that names — so the database is at /var/lib/kneo-studio/state/kneo-studio/studio.db, with kneo-studio appearing twice. That is the XDG convention working as intended (the variable names a base, not an application directory), but it means a backup script pointed at the shorter path copies an empty directory.

The three volumes may not nest. The state store inside a project root would make a project directory contain the database describing it, and Studio refuses to start. workspace inside default makes every managed allocation land in the default project's tree, and creation answers 409 project_root_overlap for every caller. The image's own paths are already separate; keep them so if you remap.

Bind mounts need chown

The container runs as uid 10001. A Docker volume is created with the right owner and needs nothing; a bind-mounted host directory arrives owned by whoever owns it on the host, and Studio then cannot create or secure its state directory.

chown -R 10001:10001 /srv/kneo-studio/state

Studio refuses with a short message naming that command rather than a traceback — but it is still the commonest reason a first docker run stops.

PostgreSQL in the image

The image installs the postgres extra, so KNEO_STUDIO_DB_URL takes a postgresql+psycopg:// URL directly:

-e KNEO_STUDIO_DB_URL=postgresql+psycopg://kneo:...@db.internal/kneo_studio

Before 0.7.0 it did not, and the failure was 47 lines of driver traceback with no Studio refusal in it — the one configuration the "more than one replica" advice below requires.

Health

GET /api/readyz answers {"status": "ready", "store": true} with 200, or 503 and "degraded" when the store cannot be reached. It is unauthenticated and exempt from the Host check so a container health check can reach it; it reports nothing about the deployment beyond those two fields.

It deliberately does not check the platform. A Studio whose kneo-serv is unreachable is degraded, not unready — taking it out of rotation would remove the surface that explains the problem, and restarting it fixes nothing.

The image carries a HEALTHCHECK pointed at it already.

Choose a login mode

KNEO_STUDIO_AUTH_MODE takes one of three values.

Mode Who it is for What it needs
none (default) one person, on their own machine nothing — but it is refused on a non-loopback bind
static a small team with a file of accounts a users file
oidc an organisation with an identity provider a provider file

none is not a weaker password; it is no authentication — every request is the local owner. Studio therefore refuses to serve none on a routable address rather than trusting you meant it. A shared instance also has to declare its storage cap.

static — a users file

export KNEO_STUDIO_AUTH_MODE=static
export KNEO_STUDIO_USERS_FILE=/etc/kneo-studio/users.json
export KNEO_STUDIO_ALLOWED_HOSTS=studio.example.com   # the name people type
export KNEO_STUDIO_DEPLOYMENT_CAP=53687091200         # shared: declare it

The users file is owner-only, or Studio refuses to start. It holds password hashes, and since 0.5.0 it is held to the same rule as the analysis keyring: a file with any group or other permission bit set — read, write or execute — is refused at startup, naming the path, the mode and the fix — chmod 600. Owner-only means owned by the account Studio runs as; in the published image that is uid 10001, so a bind-mounted file needs chown 10001 as well as the mode (a 0600 file the process does not own is refused as unreadable instead). The image can do the hand-over itself, which needs no root on the host:

docker run --rm --user 0 -v kneo-secrets:/etc/kneo-studio \
  -v "$PWD/users.json:/tmp/users.json:ro" --entrypoint sh IMAGE -c \
  'cp /tmp/users.json /etc/kneo-studio/ && chown 10001:10001 /etc/kneo-studio/users.json && chmod 600 /etc/kneo-studio/users.json'
# then mount the volume read-only: -v kneo-secrets:/etc/kneo-studio:ro

KNEO_STUDIO_ALLOWED_HOSTS is not optional here, and it is the first thing that goes wrong: a request whose Host is not on the list is refused with 403 host_not_allowed before it reaches the login route, so a mistyped or missing entry looks like a broken sign-in rather than a configuration error. Studio's Host allowlist is a DNS-rebinding defence and does not read your proxy's mind. (Found by running this page's own instructions — the first draft omitted it, and every login 403'd.)

The file is JSON: either a list of users, or an object with a users list.

{
  "users": [
    {
      "username": "ada",
      "password_hash": "$argon2id$v=19$m=65536,t=3,p=4$…",
      "display_name": "Ada Lovelace"
    }
  ]
}
  • username and password_hash are required; display_name defaults to the username.
  • password_hash must be an argon2id hash. A plaintext password is refused, not accepted with a warning — "the failure mode is a file of plaintext passwords in a backup".
  • A username appearing twice is refused, because which password worked would otherwise depend on file order.
  • An empty list is refused: static with no users authenticates nobody.

All of these are checked at startup, not at first login. An instance that cannot authenticate anyone should say so before someone tries to sign in.

What startup checks of the hash is its $argon2id$ prefix, and nothing more [0.9.0 Phase-5 docs audit]. A hash that starts correctly but is truncated or mangled — a copy-paste that lost its tail — loads without a word, and that account then answers 401 invalid_credentials to its correct password, which looks exactly like a wrong one. If one user cannot sign in and the others can, re-mint that user's hash before suspecting the password.

Failed sign-ins are throttled per account and per address, and password checks run a bounded number at a time; the numbers, the codes and the settings are in the quickstart's Login attempts.

Producing a hash

argon2-cffi is already installed as a dependency, so the interpreter running Studio can mint one:

python -c 'from argon2 import PasswordHasher; import getpass; \
print(PasswordHasher().hash(getpass.getpass("password: ")))'

getpass rather than an argument, so the password does not land in your shell history.

Hash every password with the same parameters — mint them all with the same command, as above. A sign-in for an unknown username is checked against a decoy hash, so that its timing does not reveal which usernames exist. Since 0.9.0, that decoy is built with the configured users' argon2 parameters; when the file holds more than one parameter set it takes the costliest and logs a warning, because login time can then tell an account with a cheaper hash from an unknown username. 0.8.0 and earlier always build the decoy with argon2-cffi's defaults, which the command above also uses — one more reason to mint every hash with it.

oidc — a provider file

export KNEO_STUDIO_AUTH_MODE=oidc
export KNEO_STUDIO_OIDC_PROVIDER_FILE=/etc/kneo-studio/provider.json

The provider file holds the client secret, and the rule stated for the users file above applies unchanged: owner-only (chmod 600, owned by the account Studio runs as) or Studio refuses to start, since 0.5.0.

{
  "provider_id": "corp-idp",
  "issuer": "https://login.example.com/tenant-id/v2.0",
  "client_id": "…",
  "client_secret": "…",
  "callback_origin": "https://studio.example.com",
  "authorization_endpoint": "https://login.example.com/tenant-id/oauth2/v2.0/authorize",
  "token_endpoint": "https://login.example.com/tenant-id/oauth2/v2.0/token",
  "jwks_uri": "https://login.example.com/tenant-id/discovery/v2.0/keys",
  "redirect_allowlist": [
    "https://studio.example.com/api/session/oidc/callback"
  ],
  "admission": {
    "mode": "allowlist",
    "email_domains": ["example.com"]
  },
  "bootstrap": {
    "owner": {"kind": "oidc", "issuer": "https://login.example.com/tenant-id/v2.0", "subject": "…"}
  }
}

Required: provider_id, issuer, client_id, client_secret, callback_origin, the three endpoints authorization_endpoint, token_endpoint and jwks_uri, a non-empty redirect_allowlist, and an admission block.

This example was missing the last four until 2026-08-15, so an operator who copied it got ProviderConfigError and no bound port. The page claimed to have been written by running it; that was true of the static path and not of this one. The block above now loads — checked by feeding it to load_provider(), not by reading the loader.

There is no discovery shortcut. This release does not fetch .well-known/openid-configuration; naming a discovery key is refused outright rather than ignored, because a metadata document silently overriding what you configured is worse than being told to write three URLs. Copy them from your provider's published metadata.

redirect_allowlist must contain the derived callback URI exactly — <callback_origin>/api/session/oidc/callback. Registering it with your provider is necessary and not sufficient: Studio builds its own redirect from callback_origin, checks that it is in this list, and refuses to start if it is not. That catches a callback_origin typo at startup instead of at somebody's first sign-in, where it surfaces as the provider's opaque redirect_uri_mismatch and sends you to the wrong console.

It is a startup cross-check and nothing else. Studio never accepts a redirect target from a request, so any entry beyond the derived URI is inert — listing three does not permit three. Nothing here is what stops an open redirect; the post-login return path is bounded separately, by safe_return_to, which admits only same-origin paths.

  • issuer is the exact iss your provider stamps into tokens, and it is required because it cannot be derived. Providers that scope the issuer per tenant (Azure AD among them) publish an issuer that is not the URL you configured discovery with, and a mismatch is refused at verification.
  • callback_origin is where the browser lands, and Studio builds the redirect URI as <callback_origin>/api/session/oidc/callback. Register that exact URI with the provider.
  • Every configured URL is checked for transport before it is used, and a configured endpoint whose own query would shadow a protocol parameter (state, nonce, code_challenge, grant_type, …) is refused at load rather than at the first login.
  • Signing keys are cached for five minutes and fetched with a 15-second timeout, in a worker thread, so a slow key endpoint delays the sign-in that needs it and nobody else. Rotating a key at the provider needs no restart: a token signed with a new kid triggers a fresh read at once; a key you remove from the set keeps verifying for at most those five minutes.
  • The ID token must be signed with an asymmetric algorithm: RS256, RS384, RS512, ES256, ES384 or ES512, and, since 0.9.0, PS256, PS384 or PS512. none and the HS* family are refused, since an HMAC "signed" with the provider's published key would verify. Any other algorithm fails the sign-in as provider_error, with the reason in Studio's log; on 0.8.0 and earlier that includes a provider signing with RSA-PSS.

admission — who may exist

A valid token is not permission to have an account, so there is no default; the block is required.

mode Meaning
allowlist only identities matching a rule may become principals; a matching identity becomes an active principal at its first sign-in
provision any identity the provider authenticates becomes a pending principal, which an operator approves with kneo-studio principal approve

Allowlist rules — hosted_domains, email_domains, groups, subjects — are lists, and an identity matching any one rule is admitted. Each reads one claim of the verified ID token [0.9.0 Phase-5 docs audit]:

Rule Reads
hosted_domains hd
email_domains the domain of email, only when the token also carries email_verified: true; an unverified or absent flag never matches
groups groups, or roles when there is no groups claim
subjects sub

hosted_domains, email_domains and groups are matched case-insensitively. subjects is matched exactly, because an OIDC sub is a case-sensitive opaque identifier: Ada and ada are different accounts, and Studio must not fold one into the other.

The allowlist is checked at every sign-in, not only the first. An active principal whose claims no longer match — removed from the group at the IdP, or outside a rule you narrowed — is refused its next login.

Two refusals are worth knowing before you hit them: allowlist with no rules is refused (it admits nobody, and "empty means allow everything" is the other defensible reading, which is exactly why you may not have to guess), and provision with rules is refused rather than ignoring them, because an operator who wrote both believes they apply.

Signing in is two steps, and the first one fails

Authenticating is not the same as being allowed to exist. In static mode, and in oidc under provision, the first sign-in registers the identity as pending, and an operator admits it [0.9.0 Phase-5 docs audit — this said "oidcunderallowlist", which is backwards]. Under static a correct password gets you:

403 not_admitted
this account is awaiting admission; an operator approves it with
`kneo-studio principal approve`

Under oidc the sign-in is a browser redirect, so there is no 403: the browser is sent back to the sign-in page with ?error=not_admitted, which reads "Your account is not admitted to this instance yet." Under oidc allowlist there is no pending step: an identity that matches a rule is an active principal at its first sign-in, and one that matches none is sent back with the same not_admitted and no principal is created (the refusal is audited) — there is nothing to approve, and the fix is the allowlist.

That is the design, not a fault. Walked through, for static:

$ kneo-studio principal list
active    operator     host
pending   ada          kneo-studio:static
active    local-owner  local

$ kneo-studio principal approve --subject ada --as "your name"
approved ada (kneo-studio:static)

Now the same credentials return 200. --as is a label recorded on the audit row for the human running the command; it is never treated as identity. --issuer is needed only when one subject exists under more than one issuer.

(This section exists because running the page's own instructions produced a 403 where the draft promised a login. A hosting guide that has not been executed is a guess.)

Becoming the owner

A fresh shared instance has no owner, and approving a principal does not make one. One way per login mode [0.9.0 Phase-5 docs audit]:

  • oidc: bootstrap.owner in the provider file names {kind, issuer, subject} in advance; that login becomes the owner. It is the only way an oidc instance gains an owner, because the OIDC callback carries no claim token. All three fields are required and kind must be "oidc"; a partial or mistyped block is refused at startup (ProviderConfigError), not left to match nothing. subject is taken exactly as written, case and surrounding whitespace included: it must equal the sub your provider signs, byte for byte. A non-string value is refused rather than converted. Under allowlist the owner adopts at their first sign-in; under provision that first sign-in is pending, and they adopt at the next one, after you approve them.
  • static: kneo-studio bootstrap-token mints a one-time claim. It is printed once; paste it into the sign-in form's I have an adoption token field on the sign-in that should become the owner, before that person signs in for the first time — a sign-in without it registers them as pending, and a pending account cannot adopt. The command refuses under oidc.

Both principal and bootstrap-token are audited host commands: they run on the machine, never through the API.

Adoption is one-way. It disables the local owner, which is the identity every request is under auth_mode = none, so an adopted instance restarted under none has nobody to act as. GET /api/me answers 503 ("no active principal"), and, since 0.9.0, so do GET and POST /api/projects. On 0.8.0 and earlier those two routes still act for the disabled local owner: listing its projects, and creating new ones owned by and charged to it. Keep an adopted instance on static or oidc.

Set KNEO_STUDIO_PROJECT_ROOT before the first host command

The default project is attached to a directory the first process to open the store picks, and host commands open the store. With the variable unset that directory is whatever shell you happened to run the command from — and whoever adopts the instance becomes the owner of that tree and can read files out of it.

Export it alongside the other settings, in the same shell you run bootstrap-token from and in the service's own environment:

$ export KNEO_STUDIO_PROJECT_ROOT=/srv/kneo/projects

A shared instance refuses to start without it rather than guessing, and refuses to move an existing project to a different tree — the people granted access were granted it against the old one. To work with a second tree, attach it as its own project with project attach.

Removing somebody

Deleting someone's entry from users.json is not enough. Studio reads that file only when somebody signs in. Removing an entry refuses their next login, and a session they already hold keeps working until it expires. To end their access now, disable them as below, which also ends their sessions.

Under oidc the same holds for the identity provider. Removing somebody's account at the IdP refuses their next login, and so, under allowlist, does removing them from the group or domain it admits, because the allowlist is checked at every sign-in. Neither touches a session they already hold: Studio receives no event from the IdP. principal disable is what ends a live session.

New in 0.3.0. Disabling a principal ends every session it holds and refuses it a login, in one transaction:

$ kneo-studio principal disable --subject ada --as "your name"
disabled ada; 1 session(s) ended, 0 pending invitation(s) revoked

It also revokes every pending invitation that principal issued, and says how many.

If that person is the only active owner of a project, or the project's storage is charged to them, the command refuses and names what is blocking it — because a project with no active owner has nobody who can add one, and a charge on a disabled principal is a bill nobody can move. Name who inherits and the whole thing lands together:

$ kneo-studio principal disable --subject ada
error [sole_owner]: 'p-ada' is the only active owner of proj-7; name a principal to inherit ownership
$ kneo-studio principal disable --subject ada --transfer-to grace --as "your name"
disabled ada; 1 session(s) ended, 0 pending invitation(s) revoked
  ownership of proj-7 -> grace
  charge for proj-7 -> grace (48213 bytes)

--transfer-to covers both moves, which is nearly always what you want: whoever created a project is usually both its owner and the account it is charged to. Use --owner-to and --quota-to when they should go to different people.

Who can inherit: an active member of that project. This is deliberate — the command overrides who may decide, never who is involved, and handing a project to a stranger gives them something they cannot see. Add them as a member first.

If a write is in progress, the charge will not move and you will see [quota_transfer_busy]. Wait and retry: the writer's lease expires and the reservation is reconciled, which is bounded. Forcing the move would land the bytes on whichever side finished last.

When the owner is already gone

Two break-glass commands for the case where there is nobody to ask — no session, no password, no cooperation:

$ kneo-studio project reassign-owner --project proj-7 --to grace --as "your name"
$ kneo-studio project reassign-quota --project proj-7 --to grace --as "your name"

Both are operator-only and audited, like every host command. reassign-owner promotes without removing anyone: guaranteeing the project has an active owner is one decision, and removing the old one is another that principal disable handles. reassign-quota is the only path that moves a charge without the target agreeing and without disabling anyone — principal disable --quota-to or --transfer-to also moves one without asking, as part of the disable.

Behind a reverse proxy

Four settings [0.9.0 Phase-5 docs audit — this said "three"]. A login mode is required behind a proxy (from 0.3.0), and KNEO_STUDIO_TRUSTED_PROXIES is what lets sign-in through a forwarding proxy succeed (from 0.7.0):

export KNEO_STUDIO_ALLOWED_HOSTS=studio.example.com
export KNEO_STUDIO_HOST=127.0.0.1        # proxy talks to loopback
export KNEO_STUDIO_TRUSTED_PROXIES=127.0.0.1   # the address the proxy connects FROM
export KNEO_STUDIO_AUTH_MODE=static      # or oidc — see above

KNEO_STUDIO_TRUSTED_PROXIES takes IP addresses and CIDR blocks, not hostnames. Set it to the address Studio sees your proxy coming from — 127.0.0.1 for a same-host proxy, the container-network address for a sidecar. Studio believes X-Forwarded-* headers, or an RFC 7239 Forwarded header, only from a peer on this list, which is why the setting exists: without it any caller could claim to be a proxy. If your proxy sends both and they disagree, Studio believes neither.

With it set, the session cookie is marked Secure when your proxy reports X-Forwarded-Proto: https (or Forwarded: proto=https), and only then: arriving on a routable interface is not evidence that anything was encrypted, and 0.7.0 stopped treating it as such. Keep Strict-Transport-Security at the proxy — Studio does not send it.

Forgetting it stops being silent in 0.7.0

Until 0.7.0 an authenticated instance behind a proxy with no policy started, served and issued session cookies without Secure — silently, on a connection that was HTTPS all the way to the browser. From 0.7.0 that configuration is refused at sign-in: any request carrying forwarding headers from a peer the policy does not name answers 403 untrusted_forwarding and says which variable to set.

Signing in is refused; nothing else is. Sessions already established keep working, and every other route keeps exactly the checks it had.

The variable stays optional, and unset, blank and empty are one state — no proxy is trusted:

KNEO_STUDIO_TRUSTED_PROXIES=            # same as not setting it at all

There is no keyword for "no proxy", deliberately. This field takes IP addresses and CIDR blocks — a hostname is refused, because it resolves to something else at some later moment — and a magic word inside a field of addresses is a word that could one day collide with a host somebody named. If nothing forwards to your instance, leave the variable out; a container needs no new setting to run.

If you set the variable correctly and sign-in is still refused, the peer Studio sees is not your proxy — check the launch command below.

Start Studio with kneo-studio, as the container does. The console script starts the server so that Studio, not the server, reads the forwarding headers. Launching the application with a bare uvicorn kneo_studio.app:app command line turns uvicorn's own header handling back on: it rewrites the peer address from X-Forwarded-For before Studio sees it, KNEO_STUDIO_TRUSTED_PROXIES then never matches, the cookie loses Secure, and a caller on the same host can pick its own login-throttle key with one header. That launch is not supported.

If your proxy setup stopped working when you upgraded

auth_mode = none behind a proxy is refused from 0.3.0, and this is the one upgrade in this release that can stop a working deployment. Two shapes:

  • Studio will not start: KNEO_STUDIO_TRUSTED_PROXIES is set while KNEO_STUDIO_AUTH_MODE is none (or unset, which means none).
  • Requests come back 403 unauthenticated_proxy: no policy is configured, but requests are arriving with forwarding headers, so something is proxying them.

Both mean the same thing. Under auth_mode = none every request is the local owner with no credential required, so a proxy in front of Studio published the owner's API — including writes into the project tree — to everyone who could reach the proxy. It was reported as a security defect against 0.2.1 and fixed rather than warned about, because the guarantee on the security page is written unconditionally.

The fix is to choose one: set KNEO_STUDIO_AUTH_MODE=static or oidc and keep the proxy, or take the proxy out of the path and reach Studio directly on loopback. If you are sure no proxy is involved, something in front of Studio is adding X-Forwarded-* or Forwarded headers; stop it sending them.

The full rules are the transport trust policy.

When an owner asks you to raise a taint cap

Studio bounds how much analysis provenance a project may hold. An owner who runs into one of those bounds sees their usage and headroom on the project's limits screen and then has to ask you, because a cap an owner could raise themselves is not a cap.

kneo-studio quota set-taint-cap --project alpha --project-cap 20000
kneo-studio quota set-taint-cap --project alpha --per-principal 2000 --retained-max 200000

Any one flag alone, or several together. There are three bounds, and they answer different questions:

flag bounds notes
--project-cap live associations the project holds superseded ones excluded, so repairing is free
--per-principal live associations any one member holds so one editor exhausts their own share, not the project's
--retained-max every association ever recorded cannot go below the rows already retained, and those are never evicted, so in practice it only rises

Each is refused below what the project already holds, with that number in the message, so a refusal tells you what you can set. --per-principal is checked against the busiest member rather than the total, since a value that is fine on average can already be violated for one person.

--retained-max has its own flag rather than following --project-cap automatically: a bound that moved as a side effect of changing a different bound is the kind of coupling you would discover by surprise. If a project keeps hitting it, that is churn — a repair loop or a heavily upgraded analyzer — not a project holding too much, and the refusal code says which.

Every change is audited with the old and new values for all three.

Attaching a directory that already exists

Members can create projects, and when they do Studio chooses the path — beneath KNEO_STUDIO_WORKSPACE_BASE, named after the allocated project id. The client sends a name and never a path, which is what makes it safe for anyone to call: there is no input to traverse with, and collisions cannot happen.

Set KNEO_STUDIO_WORKSPACE_BASE, or that capability does not exist. There is no default — on a laptop the single project is the developer's own attached checkout, so nothing needs one — and an unset value is not a half-configured state but a disabled feature: POST /api/projects refuses with 409 project_creation_disabled, and a member who tries gets a refusal about server configuration they cannot change.

-e KNEO_STUDIO_WORKSPACE_BASE=/var/lib/kneo-studio/workspace

The published image sets it already (and gives it its own volume). This paragraph used to describe the allocation as though the variable were always configured, which is true of the image and of nothing else.

Not the same directory as KNEO_STUDIO_PROJECT_ROOT. Every managed allocation would then land inside the default project's tree, and creation would answer 409 project_root_overlap for every caller, always.

Pointing Studio at a tree that already exists is a different proposition. An arbitrary directory may hold credentials, another team's work, or your /etc, and a project root is a directory Studio then reads, writes, exposes to the project's members and hashes into an exposure digest. So it is a host command, never an API route, and it works only against a list you declare:

export KNEO_STUDIO_ATTACHABLE_ROOTS=/srv/teams,/srv/archive
kneo-studio project attach --path /srv/teams/payments --owner ada
  • Comma-separated absolute directories. Any subtree of a listed directory may be attached, and a listed directory may be attached itself. A relative entry is refused rather than resolved against whatever directory Studio was started from, and a path containing a comma cannot be expressed.
  • Unset means the command is disabled, and says so. There is no default, for the same reason KNEO_STUDIO_WORKSPACE_BASE has none: a guess here would be a guess about which of your files Studio may read and hand to somebody else.
  • The allowlist is about the filesystem, not the string you typed. A symlink inside a listed directory pointing somewhere outside it is refused.
  • --owner takes a subject who already exists — they get the owner role and the project's storage charge. Overlap with any existing project's root is refused in both directions: one tree belongs to one project.

Every attach writes an audit row naming the resolved path, because which tree was attached is the question anyone reviewing it later will have.

Registering a file when nobody can

Registering a pre-existing file is an owner action, and it does not happen in the browser: Studio's UI offers no register control on purpose. An owner registers through the API route, and the host command below covers the cases the route cannot — because adding a manifest entry widens what sharing the project exposes, and the flow makes the owner accept the whole aggregate rather than one file at a time.

That leaves the case where there is no owner able to ask — an attached tree whose owner has gone, or one you registered before inviting anybody. The host fallback is deliberately two commands:

kneo-studio draft register --project alpha --path agent.yaml
# prints the root identity, the current exposure digest, what is already
# registered, and exits without changing anything

kneo-studio draft register --project alpha --path agent.yaml \
    --exposure-digest 4f3c…

The second run refuses if the digest has moved since you were shown it, which is what stops your registration slipping past somebody else's confirmation. The entry is attributed to the operator, never to a Studio user — a host command has no session, and the manifest is not a table to put a guess in.

What a shared instance still owes you

  • A declared storage cap — KNEO_STUDIO_DEPLOYMENT_CAP; Studio refuses to start shared without one (why).
  • A project-count bound per principal — KNEO_STUDIO_PROJECT_LIMIT, which defaults to 100 live projects and may be set to any positive whole number, higher or lower; zero, a negative or a non-number refuses to start. Unlike the storage cap this does not refuse to start when unset: a byte cap is a resource one principal can exhaust for everyone, while a project count is per-principal, so an unset limit degrades to a generous default rather than a shared cliff.

Which projects count: those where the principal is the quota principal — the one charged for the bytes — not the ones they created and not the ones they are a member of. A project has no singular owner, so counting by membership would multiply-count a project with two owners. A charge moves by accepted transfer or by operator reassignment, and the count follows it.

What is refused at the bound: creating a project, accepting a transfer into a full principal, an operator reassignment or a bulk principal disable that would move an estate into one, and project attach. The refusal is 409 project_limit_reached and it names both the count and the limit, because the remedy depends on which of the two is wrong.

What is never refused: an estate that is already over the limit. A bootstrapped first project and an adopted estate are grandfathered — an inherited over-limit principal starts and signs in normally, and only its next acquisition is refused. Raising the limit is always safe; lowering it below what principals already hold does not retroactively break them. - A state store outside every project root, which Studio also enforces. - Backups of three things, not two. The project directories, the database, and $XDG_STATE_HOME/kneo-studio/analysis-keyring.json.

An artifact's governance history lives in the database, so project directories alone are not a backup. But the analysis payloads in that database are sealed with AES-256-GCM, and the key is not in it — load_keyring(state_home()) resolves the keyring from the state home independently of where the database lives. On the default SQLite deployment they sit side by side and copying the directory gets both. Under the PostgreSQL option below they are in different places by construction, and an operator who backs up Postgres plus the project directories has everything except the key.

Restoring without it is not a degraded instance, it is a stopped one: the keyring is validated against the live rows before serving, so Studio refuses to start and the sealed payloads are unrecoverable. This bullet named two things until 2026-08-15.

What refuses to start, and why

One rule, stated once: when configuration is missing, contradictory or unreadable, Studio refuses to start rather than guess. Every such refusal is a StartupRefusal; kneo-studio serve prints its message as one line — what is wrong, and what to set — and exits non-zero, with no traceback. Nothing below is a degraded mode. The causes, by the setting that fixes them:

Refusal Setting
A shared instance with no project root, while no default project has been attached yet; or one asked to move an existing default project to a different tree. Shared means any of: a login mode other than none, a non-loopback KNEO_STUDIO_HOST, KNEO_STUDIO_WORKSPACE_BASE set, or a principal other than the local owner in the store KNEO_STUDIO_PROJECT_ROOT
A shared instance (as above) with no storage cap, or a cap that is not a whole number greater than zero KNEO_STUDIO_DEPLOYMENT_CAP
A project limit that is not a whole number greater than zero KNEO_STUDIO_PROJECT_LIMIT
A login mode other than none, static or oidc KNEO_STUDIO_AUTH_MODE
Login mode none on a non-loopback bind KNEO_STUDIO_HOST, KNEO_STUDIO_AUTH_MODE
A port that is not a number KNEO_STUDIO_PORT
KNEO_STUDIO_TRUSTED_PROXIES set while the login mode is none KNEO_STUDIO_AUTH_MODE
A KNEO_STUDIO_TRUSTED_PROXIES entry that is not an IP address or CIDR block, such as a hostname KNEO_STUDIO_TRUSTED_PROXIES
A state directory Studio cannot create or secure — the bind-mount case in Bind mounts need chown, whose message runs to a few lines because it carries the command XDG_STATE_HOME, or the directory's owner
A state store inside the project root XDG_STATE_HOME, KNEO_STUDIO_PROJECT_ROOT
A static users file or oidc provider file that is missing, unreadable, malformed, or has any group or other permission bit set (chmod 600, owned by the account Studio runs as — uid 10001 in the image) KNEO_STUDIO_USERS_FILE, KNEO_STUDIO_OIDC_PROVIDER_FILE
A recovery interval or lease that is zero, negative or unreadable KNEO_STUDIO_RECOVERY_INTERVAL_SECONDS, KNEO_STUDIO_RECOVERY_LEASE_SECONDS
A key id that cannot be read back, no active key id whenever KNEO_STUDIO_ANALYSIS_KEYS is set (even with a single key), an active key id that names none of the configured keys, or a keyring that is world- or group-readable or does not open the live rows KNEO_STUDIO_ANALYSIS_KEYS, KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_ID
A store whose schema the migrations cannot read, or an analysis-key rotation left in a state the code cannot continue from none — the message names the recovery command

The sections that describe each setting mention its refusal where it matters to the reader; this table is the definition, and the rule does not vary by row. (Until 0.5.0 four of these were described one at a time, each as if it were its own kind of failure — the 0.4.0 audit's deferral "one statement would cover them", pulled forward.)

Test-run recovery

A test run that Studio dispatched but never heard back about — the process died mid-call, or the response was lost — is settled by a background task the server owns: one pass at startup, then one every KNEO_STUDIO_RECOVERY_INTERVAL_SECONDS (default 60). It asks the platform whether a run with the dispatch's session exists and adopts exactly one match; every other answer settles the run as unknown. It never re-issues a run — a duplicate LLM run is worse than an unresolved status line.

KNEO_STUDIO_RECOVERY_LEASE_SECONDS (default 300) is how long one worker's claim on a run lasts. Two Studio processes over one PostgreSQL store will both see a stale run; the lease is what stops them both settling it, and a worker that dies mid-recovery parks its run for this long before another may take it over. Set it longer than a correlation should ever take and shorter than you would wait for a run's status.

Both must be positive numbers of seconds. A zero, negative or unreadable value refuses to start rather than silently falling back to the default — a lease of 0 is a fence any second worker walks straight through, and an operator who typed it meant something.

PostgreSQL

KNEO_STUDIO_DB_URL points the state store at PostgreSQL instead of the default SQLite file — the escape hatch ADR-013 exists for, and the reason a shared instance can outgrow a single file. Install the extra and set the URL:

pip install 'kneo-studio[postgres]'
export KNEO_STUDIO_DB_URL=postgresql+psycopg://user:pass@host/kneo_studio

The schema is created and migrated by Studio itself on first start, the same migrations the SQLite default runs. Remember the keyring: see the bullet above — this is exactly the deployment where the database and the key stop being in the same place.

More than one replica

Read this first. Single-instance shared hosting is the supported baseline — one Studio process, any number of people signing in to it. Running several replicas needs everything below and one condition this page did not state until 0.8.0: every replica's project mounts must report the same device and inode, not merely the same contents.

A confirmed exposure is pinned to the project root's device number, inode and canonical path. A replica whose mount disagrees — which is the ordinary case for two hosts mounting one NFS export — refuses content reads on every project whose exposure was confirmed elsewhere, saying "this project's root has changed since its exposure was confirmed". Nothing has changed; that replica is simply somewhere else, and the message cannot tell the difference.

So: a shared database and shared storage are necessary and are not sufficient. Several processes on one host see the same identity and are fine. Across hosts, verify it before relying on it — stat -c '%d %i' on the project root from each replica must agree — and treat a disagreement as unsupported rather than as something to work around. Loosening the check is not the workaround: it is what stops a re-pointed root inheriting someone else's confirmation.

A single instance generates its analysis key on first run and keeps it beside the state store with owner-only permissions. Two replicas cannot each do that — they would write payloads the other cannot read — so the key comes from the environment instead, and both replicas must be given the same one:

export KNEO_STUDIO_ANALYSIS_KEYS='k1:<base64 32 bytes>'
export KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_ID=k1
  • KNEO_STUDIO_ANALYSIS_KEYS is a comma-separated list of <key-id>:<base64 material> entries. Setting it replaces the on-disk keyring entirely; the file is not consulted. A key id is 1–64 characters and may not contain . (it delimits the stored envelope's fields), , or : (they delimit this variable's entries) or control characters; anything else — tenant@prod, k/1 — is fine. Studio refuses to start on an id it could not read back rather than sealing payloads under it.
  • KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_ID names the key used for writes, and is required rather than defaulting to "the only one" — a deployment that later added a second key would otherwise silently change which key writes.
  • Startup refuses, rather than degrading, when the keyring is incomplete: if a stored payload names a key id the keyring does not carry, Studio does not start. That is deliberate — reaching a per-row decision with an incomplete keyring is the one thing that must not happen, since a missing key is a configuration fault and corrupt ciphertext under a complete keyring is a data fault, and they need different answers.

The login bounds are per process, not per deployment. The failed-attempt throttle, the OIDC start limit and the number of password checks run at once (Login attempts) are each held in one process's memory, so N replicas allow N times each limit. Size them per replica, or put a rate limit at the load balancer if you need one bound across the estate.

Upgrading Studio — stop the estate first

A schema upgrade requires a quiescent estate. Stop every Studio process before upgrading, and start them again only on the new version.

This is a precondition, not advice. Studio already refuses to start against a store newer than the running version — "state store is at schema version N, but this Studio supports at most M" — so a replica you forget to upgrade fails closed on its next restart. What that refusal cannot do is stop a process already running when the migration lands: it holds an open connection and never re-checks, so it can keep writing under the old release's assumptions.

Two replicas starting the new version at the same moment is safe, though [0.6.0]. Their first starts serialize on a database lock: one applies each pending migration and seeds the default rows, the other waits, sees the work already done, and comes up clean. Before this, a simultaneous first start could crash the loser on a duplicate-key error and leave you restarting it by hand. You still stop the old processes first; you no longer have to stagger the new ones.

For most migrations the cost of that is a lost write. For the analysis keyring it is worse and permanent: an old process can create a payload under a key the upgrade is retiring, and retiring the key then makes that payload unreadable. So the requirement is stated here rather than left to inference.

One part of it is now enforced rather than requested [0.4.0]: a schema migration refuses to run while an analysis-key rotation session is open, naming the session and telling you to complete or cancel it first. Nothing is applied and the store is unchanged. That closes the worst case — a migration rebuilding the very table that holds the rotation's ownership record, so that afterwards nothing could tell a completed rotation from an interrupted one. It does not replace stopping the estate: a running old process is still invisible to the store, which is why the paragraphs above are a precondition and not a suggestion.

If you run a single instance on a laptop, stopping it is what you were going to do anyway. If you run replicas, take them all down together — a rolling upgrade is not supported for schema changes.

What the 0.6.0 upgrade changes in a 0.5.0 store, and why [0.6.0]. One data migration runs alongside the schema changes: it reclaims the gap detail of every 0.5.0 analysis whose parameters not carried card could quote a credential. That card listed the model options an import could not carry — temperature, maxTokens and the rest — and in 0.5.0 it quoted their values verbatim, including an apiKey the importer had already identified as a secret and refused to carry anywhere else. So a 0.5.0 store can hold a credential in review content, encrypted at rest and readable by any viewer of the project. After the upgrade, such an analysis keeps its blocking and manual counts and its acknowledgements, and opening its detail answers gap detail was reclaimed; re-import to see it again — the repair the product already names for a reclaimed payload. Re-importing the same export produces a fresh analysis whose card shows the option's name and <redacted>. Analyses of workflows with no model options, and every analysis written by 0.6.0, are untouched.

A credential a pre-0.6.0 import may have written into a draft [0.6.0]. Before 0.6.0, if a workflow repeated one of its own credentials in an agent's system prompt, its model identifier, or a node name, Studio carried that value into the generated spec — so it was returned by the import API and written into a plaintext draft under the project root, beside a gap card stating the value had not been carried. 0.6.0 does not carry it, cards it as a blocking gap, and refuses the import outright if any other position still would.

No migration can undo the ones already written, and none is attempted: a generated draft is an artifact's governed base, and rewriting it would move bytes an artifact's revision pins. What to do, in this order:

  1. Search your project roots for the secret, not for the workflow: grep -rIl '<the credential>' /path/to/projects. Drafts are plaintext YAML.
  2. Treat any hit as a disclosed credential and rotate it. It was written to disk unencrypted, so it is in your filesystem backups and in anything that read them; deleting the file does not undo that.
  3. Delete the affected draft and re-import the workflow. 0.6.0's analyzer produces the blocking card, so the re-import tells you which position to fix in the source.
  4. Artifacts built on such a draft keep pointing at it. Retract or recreate them after the re-import, or the artifact's own snapshot still carries the value.

One thing the upgrade cannot repair for you [0.6.0]. Before 0.6.0, an agent that carried both a system message and a per-run prompt (promptType: define with text) was analysed with its system prompt and without the per-run prompt becomes run input card, so such an artifact could be handed off with no acknowledgement and no word about the request it dropped. No migration can find those analyses — the defect is a card that is absent, inside an encrypted payload — so an artifact created before the upgrade keeps the analysis it has. Re-import the workflow: 0.6.0's analyzer produces the card, hand-off then waits for the acknowledgement, and the exported spec carries the system prompt as before.

Disable supervisors and autoscalers for the whole window, not just until the last old process exits. The refusal above can only fire once the schema version has actually advanced, so anything that restarts a process during the migration can bring an old binary back against a store that has not finished moving. systemd Restart=, a container orchestrator's replica count, or an autoscaler will all do exactly that. Turn them off, upgrade, then turn them back on.

Added 2026-08-24 by maintainer decision, after an external design review found that this page documented no upgrade precondition while the design assumed one.

Rotating the analysis key

You can, from 0.4.0 — and the route is a sequence of deliberate steps rather than an environment variable, because a rotation that begins by changing configuration is one that can be half-applied across a fleet. Configuration still has to follow the store, though, and when is the whole runbook [corrected 2026-09-05, 0.6.0 S4].

The rule the sequence is built around. Every process is configured with a preferred write key, KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_ID, and the store checks it for intent at every startup and every admission refresh — it never uses it; the store chooses the write identity. What the check accepts depends on the rotation phase:

Store phase KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_ID may name
stable (no session) only the active id
prepared, or rewriting the source or the target
any phase never a retired id

A process whose configured id is outside that set refuses: it exits non-zero at startup, and a running one fails its next analysis access with 503. So the moment to move the configured id is while the session is open — prepared or rewriting — when both answers are accepted and each replica can be rolled one at a time. Move it after complete and the store is stable on the new id while every replica still names the old, retired one: the whole estate refuses at once.

$ kneo-studio keys status          # phase, active id, and what live rows still name
$ kneo-studio keys prepare --target new-key
prepared. session <id>
Every process must now hold BOTH keys. Writes stay on the old id until `keys cut-over`.
#   1. put BOTH keys in KNEO_STUDIO_ANALYSIS_KEYS on every replica (restart each)
$ kneo-studio keys cut-over --session <id>
#   2. now roll KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_ID=new-key across the replicas, one
#      at a time — the store is in a phase that accepts either id, so nothing refuses
$ kneo-studio keys rewrite --session <id>   # repeat until it reports nothing left to move
$ kneo-studio keys complete --session <id>  # LAST: only once every replica names new-key
retired the source. …
#   3. only now remove old-key from KNEO_STUDIO_ANALYSIS_KEYS

The four-step form of this runbook — with no mention of the configured id — was executed verbatim on a two-replica deployment during 0.6.0's Phase-1 audit: after complete, every import and every analysis read answered 503 and every restart exited non-zero, until the operator worked out which variable to change. The same rotation with the configured id rolled during the rewrite window was measured at zero interruption, both replicas serving 201 through cut-over and rewrite. Nothing in the product changed; the runbook had left out the step that decides the outcome.

There is no initialization step: a store upgraded from an earlier release adopts the key it is already using automatically at startup (keys init exists as the idempotent operator surface, and a fresh store initializes itself the same way). The one store startup will not adopt is a mixed-id predecessor — live rows under more than one key — which is refused unchanged with the way out named.

Keep the session id. cut-over, complete and cancel all require it, and none of them infers which rotation you meant from anything else — that inference is what made an earlier attempt unable to tell "released" from "still held".

Both keys must be in every process's keyring between prepare and complete. That is what prepare is for: it makes the new key mandatory before anything writes under it, so a process that has not been given the key fails its next analysis access rather than discovering the gap halfway through.

complete refuses until every live payload names the new key and retires the old one in the same transaction — there is no step that proves the old key is unreferenced and then separately releases it, because that gap is a window in which the proof stops being true. Quarantined analyses are counted and excluded: they carry no live key dependency, so a store that has had a corrupt analysis can still retire a key.

Cancelling is safe only when the configured id agrees with what stays active. Rows already rewritten stay rewritten, nothing is retired, and keys resume --source old-key picks the rotation back up — that part holds in either phase. But a cancel ends the session, and a stable store accepts only its active id: cancel while prepared and the old key stays active, so a replica already rolled to the new id refuses; cancel while rewriting and the new key stays active, so a replica still on the old id refuses. Before cancelling, set every replica's KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_ID to the id the cancel will leave active — or resume instead of cancelling. This paragraph said "safe at any point" until 2026-09-05; it was false in both cancellable phases.

Before 0.4.0 this section said "you cannot, in this release." Changing KNEO_STUDIO_ANALYSIS_ACTIVE_KEY_ID on a store holding sealed payloads made the service refuse to start. That refusal still exists and is still right — configuration is not how a rotation is performed — but it is no longer the end of the road.

Why configuration alone was never going to be enough. Reads work across a mixed store already — every payload records the key that sealed it — so the problem was never reading. It was that writes would begin under the new key while old rows kept naming the old one, with no way to rewrite them, so the keyring had to keep holding a key you rotated away from because it leaked, indefinitely, with nothing in the product saying so. The startup refusal existed to stop you reaching that state. Rotation is what finally gets you out of it.

Where each key lives is unchanged:

  • on a single-instance install the first key is generated on first run and kept in the state home;
  • on a hosted deployment it is whatever KNEO_STUDIO_ANALYSIS_KEYS names, and during a rotation that variable must name both keys — until complete has committed, never earlier: between a cancel and a resume the old key is still authorized and still named by live rows, so admission requires it, and removing it refuses the estate with the missing id named.

This capability was built for 0.3.0 and withdrawn from it, after five rounds of external implementation review each found defects in the previous round's fixes and the fifth recommended withdrawal rather than a sixth attempt. Nothing from that attempt was ever released, so no store carries a trace of it. What ships in 0.4.0 was rebuilt against a written state-transition contract — the thing the withdrawn attempt lacked, and the reviewer's actual diagnosis: there was no model, so each round repaired the instance in front of it.

One thing you should know about that contract: it was reviewed extensively but never confirmed by a party with no stake in it, because this project has no such party available. That is recorded as an explicit waiver rather than glossed, and what stands in for the missing read is executable evidence — the acceptance controls the release lane runs.