Kaiten

Self-Hosting

Platform API

The operator's internal surface — a second listener on its own port, platform credentials (ksm_) that carry no organization context, the operations they reach, and how an operator acts inside an organization without becoming a member of it.

The Platform API is an internal API, exposed only to operators running their own self-hosted Kaiten. If you use Kaiten Cloud, it is not reachable for you and nothing on this page applies: the operator of that installation is Kaiten. If you run the Community Edition, you are the operator, and this is the surface for the handful of actions that sit above any single organization — creating, reading or deleting an organization, deleting a user or a membership, and minting an ordinary credential inside an organization you do not belong to.

Where it sits

The Platform API is a second listener in the same process, on its own port. The one process has two sockets: the Core listener on KAITEN_PORT, public and behind Envoy, and the Platform listener on KAITEN_PLATFORM_PORT — 6001 locally, api.service.platformPort in a cluster — which is on no Envoy route and no HTTPRoute. The API refuses to start if the two ports are equal, and either listener stopping brings the other down with it.

Core APIPlatform API
ListenerKAITEN_PORT, fronted by EnvoyKAITEN_PLATFORM_PORT, fronted by nothing
Prefix/api/** except /api/platform/api/platform/**
Accepted credentialAn identity-provider JWT, or a ksh_… access tokenA ksm_… platform credential, only
IdentityA user or a service account, inside an organizationThe platform identity system:kaiten, without one
OpenAPI document/api/openapi.yaml, through Envoy/api/platform-openapi.yaml, on the Platform port
Security schemebearerAuthplatformAuth

/api/platform/** on the public listener is an unknown route — a 404, not a refusal. In the other direction, a ksm_ credential presented to the Core API is refused as a JWT that does not verify, and a ksh_ credential or JWT presented to the Platform API is refused by its prefix, before routing, with 403 Auth.WrongCredentialKind on every path, so holding the wrong class of credential teaches a caller nothing about which paths exist. Each listener serves its own OpenAPI document, and its reference page at /api/docs, without a credential — and only its own: /api/platform-openapi.yaml is a 404 on the public entry point.

Keeping this listener off the public entry point is what keeps it internal. Publish it on a host port for local work, reach it by cluster DNS in Kubernetes, and never route it through your gateway.

Platform credentials

A platform credential is ksm_ followed by 32 random bytes, base64url. The prefix diverges from ksh_ at the third character, before any random content, so the two families can never be confused; an architecture test locks their equal length as well.

What the credential means:

  • It authenticates system:kaiten — the platform identity created by a migration, a member of every organization from the moment the organization exists, never listed among an organization's service accounts.
  • It carries no organization execution context. Any handler that asks "which organization am I in?" answers 403 for a platform principal. An operation that acts inside an organization names it in the path — {orgId} — and never inherits one from the credential. The actor and the target are two different notions here, kept in two different places.
  • The Platform listener authenticates it itself. There is no gateway exchange and no signed internal JWT. The listener reads the Authorization header, refuses any prefix other than ksm_, and validates the credential against the database: a lookup by deterministic hash, a bcrypt comparison of the secret, and a shared cache that revocation empties by hash — on every replica, through PostgreSQL LISTEN/NOTIFY. The cost is a cache hit per request, a bcrypt on a miss. What it buys is that no gateway ever authenticates a platform credential in any sense, and that no signed JWT can outlive a revocation.
  • It is minted by the admin tooling, and nothing else: kaiten-admin-tools platform-token create. Neither a Core API operation nor the seeder mints one.
  • It is shown once. The value is stored as a bcrypt hash; platform-token list shows names, scopes, and expiry, never the secret. A lost credential is replaced (create --replace), never recovered.
  • It carries scopes, and each operation below declares the one it requires. write:* covers everything.
kaiten-admin-tools platform-token create --name <name> --scopes <scope,scope,…>
                                         (--ttl <duration> | --no-expiry)
                                         [--replace] [--output-file <path>]
kaiten-admin-tools platform-token list              names, scopes, expiry, retired ones included — never the secret
kaiten-admin-tools platform-token revoke <name>     the credential, and every token it issued

create has no default to fall back on. --name and --scopes are required, and so is exactly one of --ttl (a duration such as 720h) and --no-expiry: what a platform credential may do and how long it lives are decisions the operator takes. The value is printed to stdout, alone, unless --output-file writes it to a file at 0600 and leaves stdout empty. A name that is already active is refused; --replace revokes that credential first.

Bootstrapping the first one

The first ksm_ cannot come from this API: a credential minted on the Platform API is the child of another ksm_, and the first one has no parent. It is minted database-side, by tooling that talks to PostgreSQL only and can therefore run before anything listens.

How the first credential is minted
LocallyThe bootstrap service runs scripts/local-credentials.sh, which composes exactly one primitive: kaiten-admin-tools platform-token create --name local --scopes "$PLATFORM_SCOPES" --no-expiry --replace --output-file dev/platform-token. It stays a script because what it decides — the name, the scope list, the non-expiry, the destination — is policy, which the binary leaves to its caller. The file is 0600 and git-ignored; nothing reaches stdout, so docker compose logs bootstrap never contains a secret.
In KubernetesA Helm post-install hook Job (bootstrap.platformCredential) mints it and publishes the plaintext into a Secret from an in-memory emptyDir, with no shell or interpreter in the image. The Job is opt-in — enabled is false, because nothing in the chart calls the Platform API — and it has no default either: name, scopes, and one of ttl and noExpiry are yours to set. Everything else is then created over HTTP.

What the local credential can do

The credential in dev/platform-token is narrow. scripts/local-credentials.sh gives it six scopes:

ScopeOperations it reaches
read:organizationsRead an organization
delete:organizationsDelete an organization
delete:membershipsRemove a membership
delete:usersDelete a user
read:tokensDescribe itself; list the tokens it minted
write:tokensMint and revoke an organization token — whose scopes must be among these six

It holds no write:organizations, so it cannot ensure an organization — the call answers 403 Auth.MissingScope — and a token it mints cannot carry a scope such as read:customers (403 MintOrganizationToken.ScopesExceedCredential). The examples below that need more use a second credential, minted with the scopes they name. The migrate service wraps the admin tooling with the database connection already set:

export KAITEN_PLATFORM_TOKEN=$(KAITEN_CONTAINER_PREFIX=kaiten-oss docker compose -p kaiten-oss run --rm -T migrate \
  platform-token create --name docs-examples \
  --scopes "write:organizations,write:tokens,read:customers,write:licenses" --ttl 24h)

write:organizations and write:tokens imply their read: counterparts. The credential lives for a day, survives a re-run of bootstrap — which replaces the credential named local and no other — and is retired earlier with platform-token revoke docs-examples.

--replace revokes by name, and the revocation cascades. Re-running it retires the previous credential and every organization credential that credential minted. Locally that is the intended behavior — task up twice rewrites dev/platform-token, and anything holding the old value gets a 401, which is the correct outcome since a plaintext cannot be recovered. In a cluster it is a write outage on every gated create, which is why the chart's replace value is false and the bootstrap Job is post-install only, never run on upgrade. Rotating a deployed credential means minting a second one under a new name, re-minting its children, and revoking the first — never a helm upgrade.

The operations

Base URL: http://localhost:6001/api on a local stack — the Platform port, not Envoy's. Every error response body is an RFC 9457 problem.

MethodPathScopeEffect
GET/platform/meread:tokensDescribe the calling credential: name, slug, scopes, expiry, creation time, and the identity it authenticates. Never the value.
POST/platform/organizationswrite:organizationsEnsure an organization exists. Idempotent: the internal id is derived from the external id, so a re-run returns the same organization rather than creating a second one. 200.
GET/platform/organizations/{orgId}read:organizationsRead an organization: id, external_id, name
DELETE/platform/organizations/{orgId}delete:organizationsHard delete of the organization and everything it owns — customers, licenses, flags, entitlements, instances, releases, memberships, service accounts and their tokens, its audit trail. Irreversible; the usage history cannot be rebuilt. 204.
DELETE/platform/organizations/{orgId}/memberships/{userId}delete:membershipsSoft-delete one membership. system:kaiten's membership cannot be removed — 403.
DELETE/platform/users/{id}delete:usersSoft-delete a user. system:kaiten cannot be deleted — the database refuses, and this answers 403.
GET/platform/organizations/{orgId}/tokensread:tokensName the credentials this platform credential minted in that organization and that are still active. No secret values.
POST/platform/organizations/{orgId}/tokenswrite:tokensMint an organization credential (ksh_…) owned by system:kaiten inside the named organization. 201 with the value, once.
DELETE/platform/organizations/{orgId}/tokens/{tokenSlug}write:tokensRevoke a credential this platform credential minted in that organization. 204.

The three deletions authorize on holding the scope, not on owning the target — which is why they are on this surface and not on the Core API. memberships exists as a scope module only for delete:memberships: removing one person's membership is a narrower, separately grantable privilege than deleting the organization or the user.

State-changing calls (POST, PUT, PATCH, DELETE) are logged as structured audit lines by the API process, including the ones the surface refuses for the wrong credential class or a missing scope. A refused mutation on a privileged surface is what an operator wants to see. Reads are not logged: GET /platform/me is what an SDK's source credential calls to introspect itself, and logging it would bury the calls that changed something. The log is deferred, so an operation that panics still leaves a line, with status: 0 as its own signal.

These are structured log lines, not audit_trail rows. An audit row needs an organization, and three of these operations have none to attribute: delete-organization hard-deletes the row the foreign key would point at, delete-membership emits nothing today, and delete-users is global.

Describe the calling credential

curl http://localhost:6001/api/platform/me \
  -H "Authorization: Bearer $(cat dev/platform-token)"
{
  "id": "2c4a…",
  "name": "local",
  "slug": "local",
  "subject": "system:kaiten",
  "credentialKind": "platform",
  "scopes": [
    "read:organizations", "delete:organizations", "delete:memberships",
    "delete:users", "read:tokens", "write:tokens"
  ],
  "createdAt": "2026-09-08T09:12:44Z"
}

expiresAt is absent when the credential does not expire. Your name, slug, and scopes are whatever platform-token create was given.

Ensure an organization exists

Just-in-time provisioning creates an organization the first time someone signs in as a member of it. This operation is how a provisioning system creates it before that — when onboarding runs ahead of the first sign-in, or when a name should be set from the start. It requires write:organizations, which the local credential does not carry: the call uses the second credential.

curl -X POST http://localhost:6001/api/platform/organizations \
  -H "Authorization: Bearer $KAITEN_PLATFORM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "externalId": "org_2abcDEF", "name": "Kaiten Sushi Shop" }'
{
  "id": "b7a2…",
  "external_id": "org_2abcDEF",
  "name": "Kaiten Sushi Shop"
}

externalId is required and is the identity provider's id for the organization; the internal UUID is derived from it, which is what makes the call idempotent. name sets the display name: on an organization that already exists it replaces the current name, the way a sign-in carrying kaiten_org_name does, and omitting it names a new organization after its external id and leaves an existing one's name alone. An organization that already exists, including one provisioned on a first login, otherwise comes back unchanged.

Acting inside an organization: minting a credential

This is the operation the Platform API exists for. When the operator needs to act inside an organization — bootstrap it, run a migration script against it, feed an integration — the credential does not become organization-scoped. It mints a separate, ordinary ksh_ credential, derived from system:kaiten's membership there, bounded in scope, and uses that. The scopes asked for here are not among the local credential's six, so this call too uses the second credential:

curl -X POST http://localhost:6001/api/platform/organizations/$ORG_ID/tokens \
  -H "Authorization: Bearer $KAITEN_PLATFORM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "sushi-shop-bootstrap", "scopes": ["read:customers", "write:licenses"], "ttl": "15m" }'
{
  "id": "9b0e…",
  "name": "sushi-shop-bootstrap",
  "slug": "system-kaiten-a1b2c3",
  "scopes": ["read:customers", "write:licenses"],
  "expiresAt": "2026-09-08T09:30:12Z",
  "createdAt": "2026-09-08T09:15:12Z",
  "createdBy": { "id": "00000000-0000-0000-0000-000000000001" },
  "token": "ksh_…"
}

The rules, all of them enforced server-side:

FieldRule
nameRequired. A label, not an identifier — the slug is generated from a fixed base (system-kaiten-…), never from the name, so a caller can neither choose one nor probe for one the organization already uses. A name already in use is 409.
scopesMust be a subset of the calling platform credential's scopes; omitted inherits all of them. Asking for more is 403 — the rule is reduce, never widen.
ttlA Go duration ("15m", "24h"). Omitted mints a non-expiring credential, whose bound is revocation. There is no server-side ceiling.
tokenReturned once, here.
RevocationDELETE …/tokens/{tokenSlug} retires it early; revoking the platform credential that minted it revokes it too — the cascade is the lifetime bound of a non-expiring credential.

Minting also emits SYSTEM_ORGANIZATION_TOKEN_ISSUED (com.kaiten.identity.v1.system_token_issued) into the target organization's event stream — the organization is told that a credential now exists in its name. The credential and the event are written in one transaction: no committed credential nobody was informed of, no announced credential that was rolled back. Expect this event in bursts from long-running integrations that refresh a short-lived credential: one per lifetime, so a 15-minute TTL means 96 events a day per process per organization (24 h ÷ 15 min), more if the integration refreshes before expiry. That is the normal shape, not an incident.

Finding a credential you minted

Revocation addresses a credential by slug, and the slug is server-generated with a random suffix so it cannot be guessed. A client that minted a credential holds only the name it chose. Without this operation a credential was creatable and not retirable by its own creator, and re-minting under the same name is refused by the name constraint.

curl http://localhost:6001/api/platform/organizations/$ORG_ID/tokens \
  -H "Authorization: Bearer $KAITEN_PLATFORM_TOKEN"

The list is narrow in the same way revocation is: the named organization, still-active credentials, and only the ones this platform credential minted. One platform credential cannot enumerate another's children, and no organization credential reaches it at all — which is what makes it safe to expose. The operation requires read:tokens, not write:tokens, because naming what you already hold is not a write. It carries no hash of any kind, because these rows identify a credential for revocation and do not authenticate it. It is a list rather than a lookup by name, unpaginated, so that a name which resolves to nothing and a name that belongs to someone else stay indistinguishable.

Kaiten Cloud's own hosted layer is a consumer of this API: it uses exactly these operations to relay identity-provider deletions and to obtain the organization-scoped credentials its services run with. The surface documented here is the one Kaiten operates itself.

Reference

The generated reference for the Platform API — every operation, request body, and response schema — is in the API reference, Platform API section, produced from platform-openapi.yaml. The specification itself is served by your stack on the Platform port — http://localhost:6001/api/platform-openapi.yaml locally, with its reference page at http://localhost:6001/api/docs.

Where to go next

The other two credential families: authentication for service accounts and their ksh_ tokens, identity provider for human sign-in. How organizations come to exist, and what deleting one removes: organizations. The scope grammar: scopes.

On this page