--- title: API reference description: All HTTP endpoints with request and response shapes. --- # API reference The kobe HTTP API is served by the operator. All endpoints except `/v1/status`, `/healthz`, and `/metrics` require an `Authorization` header using a method the service advertises: a Bearer token for OIDC/static-token auth, or an SSH request signature. ## Quick reference | Method | Endpoint | Auth | Description | |---|---|---|---| | `POST` | `/v1/leases` | Required | Create a lease from a pool (any kind) | | `GET` | `/v1/leases` | Required | List your active leases of every kind | | `GET` | `/v1/leases/:id` | Required | Get a specific lease | | `DELETE` | `/v1/leases/:id` | Required | Release a lease | | `PATCH` | `/v1/leases/:id` | Required | Extend a lease TTL | | `GET` | `/v1/leases/:id/diagnostics` | Required | Get diagnostics bundle URL (Cluster) | | `GET` | `/v1/pools` | Required | List available pools | | `GET` | `/v1/pools/:name` | Required | Get a specific pool's status | | `GET` | `/v1/pools/:name/leases` | Required | List leases for a specific pool | | `POST` | `/v1/sandbox-leases` | Required | Deprecated alias for `POST /v1/leases` | | `GET` | `/v1/sandbox-leases` | Required | List your Sandbox leases | | `GET` | `/v1/sandbox-leases/:id` | Required | Deprecated alias for `GET /v1/leases/:id` | | `PATCH` | `/v1/sandbox-leases/:id` | Required | Deprecated alias for `PATCH /v1/leases/:id` | | `DELETE` | `/v1/sandbox-leases/:id` | Required | Deprecated alias for `DELETE /v1/leases/:id` | | `GET` | `/v1/sandbox-leases/:id/logs` | Required | Read the Sandbox log tail | | `POST` | `/v1/sandbox-leases/:id/executions` | Required | Run or reserve an idempotent execution | | `GET` | `/v1/sandbox-leases/:id/executions/:execution` | Required | Read execution state | | `DELETE` | `/v1/sandbox-leases/:id/executions/:execution` | Required | Cancel an execution | | `GET` | `/v1/sandbox-leases/:id/executions/:execution/logs` | Required | Read retained stdout/stderr windows | | `GET` | `/v1/sandbox-leases/:id/attach` | Required | Upgrade to an attach WebSocket | | `GET` | `/v1/sandbox-leases/:id/port-forward` | Required | Upgrade to a declared-port WebSocket | | `GET` | `/v1/status` | Optional | Endpoint status, auth methods, pool summary | | `GET` | `/healthz` | None | Liveness probe | | `GET` | `/metrics` | None | Prometheus metrics | The `:id` in `/v1/leases/:id` is a lease id or one of your aliases, for both lease kinds. See [Addressing a lease](#addressing-a-lease). --- ## Errors Every error from a `/v1` lease or pool route has one JSON shape: ```json { "error": "Concurrent lease limit (5) reached", "detail": "You have 5 active leases", "reason": "quota_exhausted" } ``` `error` is for people. `detail` is optional, client-actionable text. `reason` is a stable machine-readable code; branch on it, not on `error`. It is omitted where no bounded reason exists, so treat a missing `reason` as "unclassified". The same condition reports the same code for Cluster and Sandbox leases. Every `429` carries a `reason`, which is how a client tells a hard limit from throttling: | `reason` | Status | Retry | |---|---|---| | `quota_exhausted` | 429 | No. Release a lease first. No `Retry-After`. | | `rate_limited` | 429 | Yes, after `Retry-After`. Per-principal admission budget. | | `server_busy` | 429 | Yes, after `Retry-After`. The API replica is at its request ceiling. | The other codes: | `reason` | Status | Retry | |---|---|---| | `not_found` | 404 | No. Also returned for another caller's lease. | | `invalid_request` | 400 | No. Fix the request. | | `alias_taken` | 409 | No. Release or extend the lease holding the alias. | | `idempotency_conflict` | 409 | No. The key was used for a different request. | | `ambiguous_alias` | 409 | No. Address the lease by id. | | `wrong_resource_kind` | 409 | No. The pool name exists as both kinds. | | `not_ready` | 409 | Yes, once the lease is `Bound` or `Ready`. | | `extension_budget_exhausted` | 409 | No. | | `max_ttl_ceiling` | 409 | No. | | `max_idle_ceiling` | 409 | No (Sandbox pools with `maxIdle`). | | `expiry_derivation_mismatch` | 409 | No. | | `policy_unresolvable` | 409 | No. The lease's AccessPolicy no longer resolves. | | `conflict_retryable` | 409 | Yes. The lease changed under the request; read it and retry. | | `teardown_quarantined` | 409 | No. An operator must resolve cleanup. | | `admission_busy` | 503 | Yes, after `Retry-After`. Nothing was created. | | `pool_exhausted`, `capacity_blocked`, `degraded` | 503 | Only after the pool recovers. | | `warming` | 202 | Not an error: the lease is queued. | | `expired` | 410 or 409 | No. Request a new lease. | | `target_unresolved` | 409 | Yes, after placement completes. | | `provenance_incomplete`, `pool_unresolvable` | 503 | Operator attention. | | `not_declared`, `port_name_covers_range` | 400 | No. | | `backend_error` | 503 | Yes, transient. | Execution and stream routes add `execution_limit_exhausted`, `concurrency_limit`, `lease_ended`, `iroh_transport`, `direct_transport`, `runner_missing`, `runner_unreachable`, `runner_unreadable`, `runner_forgot_execution`, `runner_id_conflict`, `runner_rejected_request` and `runner_internal_error`. Servers before this release sent some of these errors with no `reason`, answered some `404`s with an empty body, and answered a refused Cluster extend with `400`. A client that must support older servers should fall back to the status code when `reason` is missing. ## Addressing a lease `GET`, `PATCH` and `DELETE /v1/leases/:id`, and the Sandbox operations under `/v1/leases/:id/...`, accept either: - a lease id (`lease-…` for Cluster, `sandbox-…` for Sandbox), or - the `alias` of one of your active leases, of either kind. Aliases are scoped to the caller. An alias that matches none of your active leases returns `404 not_found`. One that matches an active lease of each kind returns `409 ambiguous_alias`; use the id. ## Field naming Existing field names are kept so current clients keep working. The rule is per response: | Response | Field style | |---|---| | Lease objects: `POST`/`GET /v1/leases`, `GET /v1/leases/:id`, the `/v1/sandbox-leases` equivalents | snake_case (`expires_at`, `queue_position`, `effective_ttl`, `cluster_name`, `provisioning_deadline`), except `resourceKind` | | Admission handoff fields on a Sandbox create: `statusUrl` | camelCase, next to the snake_case lease fields | | Extend: `PATCH /v1/leases/:id` | Cluster: `expires_at`. Sandbox: `expiresAt`, plus `extensionsCount`, `maxExtensions` and `runningExecutions`. Each answer carries one spelling only. | | Pools, executions, `/v1/status` | camelCase | | Errors | `error`, `detail`, `reason` | Request bodies accept both spellings where they differ: `extend_ttl` or `extendTtl`, `profile` or `pool`. --- ## `POST /v1/leases` Creates a lease from the named pool. The server looks up the pool and admits the matching resource kind. Clients do not choose Cluster vs Sandbox URLs. Returns `202 Accepted` with the lease id, `resourceKind`, and `Pending` phase. Poll `GET /v1/leases/:id` until the lease is usable: `Bound` with `kubeconfig` for Cluster, `Ready` for Sandbox. **Request body** ```json { "profile": "ci-small", "ttl": "30m", "alias": "pr-3835", "metadata": { "github": { "actor": "lenij", "pullRequest": 3835, "runId": 123456789 }, "purpose": "e2e" } } ``` | Field | Type | Required | Description | |---|---|---|---| | `profile` | string | One of `profile` or `pool` | Pool name. `pool` is an alias used by Sandbox clients. | | `pool` | string | One of `profile` or `pool` | Same as `profile`. If both are set they must match. | | `ttl` | string | No | Requested TTL (e.g. `"30m"`, `"1h"`). Defaults to the pool's `spec.ttl`. Capped by AccessPolicy `maxTtl`. | | `alias` | string | No | DNS-label name unique among the requester's active leases. | | `metadata` | object | No | Opaque caller context. Cluster leases only. Maximum 8 KiB encoded JSON, 32 top-level keys, four nesting levels, and 128 JSON values. | | `idempotencyKey` | string | No | Create recovery key, 1–253 bytes. Repeating a create with the same key and body returns the lease the first request created. | `metadata` is descriptive, untrusted, and creation-time only: the lease API does not expose an update operation. It never affects authorization, quota, priority, scheduling, or lifecycle behavior. Do not include credentials or secrets: the lease owner and cluster administrators can read it. Shared pool listings redact another tenant's metadata together with its requester identity and alias. **Response** — `202 Accepted` ```json { "id": "lease-a1b2c3d4e5f6", "resourceKind": "Cluster", "phase": "Pending", "profile": "ci-small", "queue_position": 0, "effective_ttl": "30m", "alias": "pr-3835", "metadata": { "github": { "actor": "lenij", "pullRequest": 3835, "runId": 123456789 }, "purpose": "e2e" } } ``` `effective_ttl` reports the TTL the server accepted after policy clamping. If the response to a Cluster create is lost, repeat the request with the same `idempotencyKey`. A matching lease that still exists comes back as `200 OK` with its current phase instead of a second lease being created. Reusing a key with a different pool, TTL, alias or metadata returns `409`. Creates for one caller are admitted one at a time across every API replica, so `maxConcurrentLeases` and alias uniqueness hold even when a CI matrix fires many creates at once. A caller is the full authenticated principal: provider, token issuer and identity. All jobs of one CI workflow run usually share a principal, so their creates queue behind each other. Each admission takes well under a second, and a waiting request keeps trying for up to 25 seconds, backing off from 50 ms to 1 s with jitter. A request still waiting after 25 seconds gets `503` with `reason: admission_busy` and `Retry-After: 1`. Nothing was created. Retry it with the same `idempotencyKey`, after the `Retry-After` delay plus some random jitter so a matrix does not retry in lockstep. `kobe lease` and `kobe with-lease` do this for you. A create that the Kubernetes API server does not confirm within 10 seconds gets `503` with `Retry-After: 1` unless the lease can be found afterwards. Retry it with the same `idempotencyKey`: if the first attempt did land, the retry returns that lease. A keyed retry is matched against existing leases before the pool pre-flight runs, so it gets its lease back even if the pool has since gone into `Backoff`. The pre-flight refuses a pool that has quarantined members and no Ready member with `503` and `reason: capacity_blocked`, whatever phase the pool reports. Quarantined members hold their capacity until their teardown evidence is repaired, so such a lease could not be served. The lease's `status.message` says the same thing if the pool becomes blocked after the lease was queued. A pool in phase `Exhausted` (every cluster leased, leases queued) is not refused. The lease is queued with `202` and binds when a lease ends; its `Satisfiable` condition reads `AtCapacity` meanwhile. Once bound, `GET /v1/leases/:id` returns: ```json { "id": "lease-a1b2c3d4e5f6", "resourceKind": "Cluster", "phase": "Bound", "profile": "ci-small", "kubeconfig": "apiVersion: v1\nclusters:\n...", "expires_at": "2026-04-09T15:00:00Z" } ``` **Errors** | Status | Meaning | |---|---| | `400` | Invalid profile name, alias, TTL, or metadata | | `403` | Profile not allowed for your identity | | `404` | No pool with that name | | `409` | The name exists as both a Cluster pool and a Sandbox pool (`reason: wrong_resource_kind`), the alias is held by another active lease (`alias_taken`), or the `idempotencyKey` was used for a different request (`idempotency_conflict`) | | `429` | Concurrent lease limit reached (`quota_exhausted`, no `Retry-After`), admission rate limit hit (`rate_limited`, `Retry-After`), or server overloaded (`server_busy`, `Retry-After: 1`) | | `503` | The pool cannot satisfy a lease now, another create for the same caller is still being admitted (`reason: admission_busy`; retry), or the create timed out (retry with the same `idempotencyKey`) | > **Lease ownership after upgrading.** Cluster leases now record the provider > and token issuer that created them, and only a caller with the same provider, > issuer and identity can read, extend or release them. Leases created by an > earlier Kobe version have no provider or issuer recorded and stay owned by > identity alone until they end. Cluster creates are rate limited per principal with the same budget as Sandbox creates: a burst of 10, then one attempt every two seconds, per API replica. Every attempt that passes validation spends a token, including one that is later refused for quota. A throttled attempt gets `429`, `reason: rate_limited` and `Retry-After`, and costs the Kubernetes API nothing. `kobe lease` and `kobe with-lease` wait and retry it with the same `idempotencyKey`. `POST /v1/sandbox-leases` is a deprecated alias for this same create. Prefer `POST /v1/leases`. Kind-specific verbs (`exec`, `logs`, `attach`, `port-forward`, `executions`) are also served under `/v1/leases/:id/...`. --- ## `GET /v1/leases` Lists your active Cluster and Sandbox leases. Each item includes `resourceKind` and `capabilities`. **Response** — `200 OK` ```json [ { "id": "lease-a1b2c3d4e5f6", "phase": "Bound", "profile": "ci-small", "expires_at": "2026-04-09T15:00:00Z" } ] ``` --- ## `GET /v1/leases/:id` Returns the full lease, including `kubeconfig` once the phase is `Bound`. **Response** — `200 OK` ```json { "id": "lease-a1b2c3d4e5f6", "phase": "Bound", "profile": "ci-small", "kubeconfig": "apiVersion: v1\n...", "expires_at": "2026-04-09T15:00:00Z" } ``` Possible values for `phase`: `Pending`, `Bound`, `Released`, `Expired`, `Recycling`, `Quarantined`. A Sandbox id or alias returns the Sandbox lease shape described under [`GET /v1/sandbox-leases/:id`](#get-v1sandbox-leasesid). An unknown id, an alias that matches nothing, and another caller's lease all return the same `404`: ```json { "error": "Lease not found", "reason": "not_found" } ``` --- ## `DELETE /v1/leases/:id` Releases a lease immediately. The cluster is destroyed and a fresh replacement is queued. Returns `204 No Content`. A lease already in `Quarantined` returns `409 Conflict` with reason `teardown_quarantined`; release cannot downgrade or discard incomplete cleanup evidence. --- ## `PATCH /v1/leases/:id` Extends a lease TTL. Only allowed up to `maxExtensions` times per lease (configured in AccessPolicy, default: 2). **Request body** ```json { "extend_ttl": "30m" } ``` **Response** — `200 OK` ```json { "expires_at": "2026-04-09T16:00:00Z" } ``` A Cluster lease answers with `expires_at`; a Sandbox lease answers with `expiresAt`. Each answer carries one spelling only. A Sandbox id or alias is extended as described under [`PATCH /v1/sandbox-leases/:id`](#patch-v1sandbox-leasesid). **Errors** | Status | `reason` | Meaning | |---|---|---| | `400` | `invalid_request` | The duration does not parse | | `404` | `not_found` | No such lease for this caller | | `409` | `not_ready` | The lease is not `Bound` | | `409` | `extension_budget_exhausted` | `maxExtensions` already used | | `409` | `max_ttl_ceiling` | The new expiry would pass `boundAt + maxTtl` | | `409` | `policy_unresolvable` | No AccessPolicy resolves the lease's requester type | | `409` | `conflict_retryable` | Another write changed the lease first; read it and retry | Before this release, the refusals above returned `400` with no `reason`, and a lost concurrent write returned `500`. --- ## `GET /v1/leases/:id/diagnostics` Returns a pre-signed S3 URL to the diagnostics bundle for the lease, if diagnostics are configured on the pool and a bundle was captured. Returns `404` if no bundle exists. --- ## `GET /v1/pools` Lists every pool accessible to your identity, regardless of resource kind. `resourceKind` identifies what a lease allocates and `capabilities` identifies the operations available to the caller. **Response** — `200 OK` ```json [ { "name": "ci-small", "resourceKind": "Cluster", "capabilities": ["lease", "kubeconfig", "extend", "release"], "phase": "Healthy", "ready": 3, "leased": 1, "creating": 0, "recycling": 0, "unhealthy": 0, "quarantined": 0, "queueDepth": 0, "policy": { "mode": "fixed", "ttl": "2h", "warmTarget": 3 } } ] ``` --- Possible values for a pool `phase`: `Healthy`, `Quarantined`, `ScalingUp`, `Exhausted`, `ScalingDown`, `Backoff`, `Failing`, `Idle`. See [Pool status](/docs/how-it-works/pool-lifecycle#pool-status) for what each means. A phase is omitted when the operator has not written one yet; treat an unknown value the same way. --- ## `GET /v1/pools/:name` Returns status for a single pool. **Response** — `200 OK` ```json { "name": "ci-small", "resourceKind": "Cluster", "capabilities": ["lease", "kubeconfig", "extend", "release"], "phase": "Healthy", "ready": 3, "leased": 1, "creating": 0, "recycling": 0, "unhealthy": 0, "quarantined": 0, "queueDepth": 0, "policy": { "mode": "fixed", "ttl": "2h", "warmTarget": 3 } } ``` --- ## `GET /v1/pools/:name/leases` Lists leases currently associated with one pool. This is useful for operational visibility when debugging queueing, stuck leases, or tenant usage. **Response** - `200 OK` ```json [ { "id": "lease-a1b2c3d4e5f6", "phase": "Bound", "profile": "ci-small", "cluster_name": "pool-ci-small-0", "requester": "github:org/repo", "expires_at": "2026-04-09T15:00:00Z" } ] ``` --- ## Sandbox leases `SandboxPool` and `SandboxLease` are served at `v1alpha1`, so the object shape can still change between releases. Sandbox HTTP endpoints are mounted when `agentSandbox.mode=external` or `managed` and Agent Sandbox v1.0.0 passes the mode's compatibility and ownership checks. Managed child composition also completes an exact create/delete canary before creating the caller's upstream Claim. Disabled mode serves no Sandbox API, so it cannot admit a lease that no controller will reconcile. Sandbox callers select an administrator-owned `SandboxPool`; they cannot send a Pod spec, namespace, RuntimeClass, environment values, mounts, PVCs, or target credentials. Responses never contain a management or child-cluster kubeconfig. ### Sandbox error reasons Sandbox routes use the shared [error body](#errors). The codes most specific to Sandbox leases: | `reason` | Retryable | |---|---| | `not_found` | no | | `not_ready` | yes, once Ready or placed | | `expired` | no — request a new lease | | `target_unresolved` | yes, after placement completes | | `provenance_incomplete` | needs operator attention | | `pool_unresolvable` | needs operator attention | | `not_declared` | no | | `ambiguous_alias` | no — disambiguate first | | `backend_error` | yes, transient | | `extension_budget_exhausted` | no | | `max_ttl_ceiling` | no | | `expiry_derivation_mismatch` | no | | `conflict_retryable` | yes, against current state | | `teardown_quarantined` | no — resolve cleanup first | | `wrong_resource_kind` | no — the pool name is ambiguous across kinds | ### `POST /v1/sandbox-leases` Deprecated alias for `POST /v1/leases`. Prefer `/v1/leases`. This path still accepts the Sandbox body (`pool`, `idempotencyKey`) and still returns `Location: /v1/sandbox-leases/:id`. A Cluster pool name is admitted as a Cluster lease rather than rejected. ```json { "pool": "agent-small", "ttl": "30m", "alias": "review-142", "idempotencyKey": "one-random-key-per-deliberate-create" } ``` `ttl` defaults to the pool's `defaultTtl` and is capped by both the pool and the caller's Sandbox policy. `alias` is optional and must be unique among that identity's active Sandbox leases. Current clients should generate one random `idempotencyKey` (1–253 bytes) for each deliberate create and reuse it only while recovering that request. It remains optional for older clients. ```json { "id": "sandbox-a1b2c3d4e5f60718293a4b5c", "resourceKind": "Sandbox", "phase": "Pending", "pool": "agent-small", "ttl": "30m", "alias": "review-142", "provisioning_deadline": "2026-08-20T12:10:00Z" } ``` When a requested TTL is reduced, `effective_ttl` reports the accepted value. `provisioning_deadline` is anchored to the API-server creation time and already includes queueing or controller downtime; retrying reconciliation never resets it. If Kobe is shutting down or cannot safely classify a lost Kubernetes response, it preserves the same legacy fields and adds an explicit non-retry handoff: ```json { "id": "sandbox-a1b2c3d4e5f60718293a4b5c", "phase": "Pending", "pool": "agent-small", "ttl": "30m", "provisioning_deadline": "2026-08-20T12:10:00Z", "status": "admission_pending", "retry": false, "statusUrl": "/v1/sandbox-leases/sandbox-a1b2c3d4e5f60718293a4b5c" } ``` This response is not confirmation that placement was accepted. Do not repeat the create: retain `id` and poll `Location`/`statusUrl`. The admission arbiter will either admit that exact lease or cancel it; a later `404` means cancellation won. `phase: "Pending"` remains present so older clients also keep polling the handle instead of treating it as placeable. A keyed retry returns the same durable lease and `Location`. Reusing a key with different pool, TTL, or alias intent returns `409`. If response headers arrived but the body was lost, recover with `GET Location`; do not repeat the POST. If the connection failed before headers, the same keyed POST may be repeated. | Status | Meaning | |---|---| | `400` | Invalid pool name, alias, or TTL | | `403` | Missing Sandbox `lease` permission or resource ceiling exceeded | | `404` | An allowed SandboxPool does not exist | | `409` | Alias is already active (`alias_taken`), an idempotency key is bound to different intent, or the pool name is ambiguous across kinds (`wrong_resource_kind`) | | `429` | Sandbox concurrency limit reached (`quota_exhausted`), or the per-principal admission rate limit was hit (`rate_limited`) | | `503` | Pool configuration or Kubernetes admission cannot be verified | Admission is rate limited per principal, independently of the concurrency limit. Each attempt that passes the limiter spends one token before admission work starts. An already-throttled request is refused without another charge or admission mutation. A keyed retry may perform one exact-name GET solely to recover an already committed lease; a miss remains `429`. Rate-limit `429` includes `Retry-After` in seconds, while concurrency `429` does not. The limit is per API replica, and `kobe_sandbox_admission_rate_limited_total` counts every refusal. ### `GET /v1/sandbox-leases` Lists only leases owned by the authenticated identity and still permitted by its Sandbox pool scope. Requester identity and target credentials are omitted. ### `GET /v1/sandbox-leases/:id` Deprecated alias for `GET /v1/leases/:id`, which also accepts an alias. Returns the typed lifecycle state for an owned lease. A lease belonging to another identity returns the same `404 not_found` body as a missing one. Possible phases are `Pending`, `Provisioning`, `Ready`, `Releasing`, `Released`, `Expired`, and `Quarantined`. `Quarantined` means cleanup is uncertain and the capacity remains unavailable; it is not a successful terminal state. When teardown starts, `release_cause` is recorded once as `Requested`, `RuntimeTtl`, or `ProvisioningDeadline`. The first cause is immutable: a later release request cannot turn an already-expired lease into a caller-requested `Released` outcome. After cleanup is verified, both automatic causes finish as `Expired` and `Requested` finishes as `Released`; unverifiable cleanup finishes as `Quarantined` without returning capacity. `WorkloadLost` means the Sandbox Pod ended on its own, for example because kubelet evicted it for exceeding its ephemeral-storage limit. Pods are not restarted, so the lease is released instead of staying `Ready` until its TTL, and finishes as `Released`. The `WorkloadLost` entry in `conditions` carries the Pod's own reason and message (such as `Evicted` and the storage limit it crossed). Anything written inside the Sandbox is gone; lease a new one. Kobe releases only while it can still see the ended Pod; if that Pod was already deleted, the lease is not marked `WorkloadLost` and ends at its TTL as before. When resolved, the response may include non-secret placement and target object references with exact Kubernetes UIDs. It never includes bearer tokens or kubeconfigs. ### `PATCH /v1/sandbox-leases/:id` Deprecated alias for `PATCH /v1/leases/:id`. Extends the runtime TTL of a `Ready` lease. The body takes `extendTtl` (or `extend_ttl`, so one body shape works against the cluster endpoint too): ```json { "extendTtl": "30m" } ``` The response reports the new expiry and the remaining budget: ```json { "expiresAt": "2026-08-10T01:32:00Z", "expires_at": "2026-08-10T01:32:00Z", "extensionsCount": 1, "maxExtensions": 2 } ``` When the lease has running executions that still stop before the new expiry, the response lists them in `runningExecutions` (`id`, `deadline`). An execution's deadline is fixed when it starts, so extending the lease does not give it more time. Authorized by the `lease` verb rather than one of its own: an extension can never move expiry past `maxTtl` measured from readiness, which is the same ceiling the caller could have requested at creation. Set `maxExtensions: 0` on the Sandbox grant to forbid extension and require callers to commit up front. Refusals are all `409 Conflict` except a malformed or non-positive duration, which is `400 Bad Request`: a lease that is not `Ready`, an exhausted extension budget, an extension that would exceed the ceiling, or a lease that changed concurrently — the last is safe to retry against the current lease. ### `DELETE /v1/sandbox-leases/:id` Deprecated alias for `DELETE /v1/leases/:id`. Requires the independent Sandbox `release` verb. For an admitted active lease, the API records server-owned release intent and returns `204 No Content`; it does not write lifecycle status. The placement controller observes that intent, moves the lease to `Releasing`, revokes access, and verifies cleanup. Repeated release of an already requested, `Releasing`, `Released`, or `Expired` lease returns `204 No Content`. A `Quarantined` lease returns `409 Conflict`: cleanup is still uncertain, so its capacity remains unavailable rather than being reported as released. The intent write is fenced on the lease as read, so a controller write that lands in between makes the API re-read and try again. If the lease is still changing after a few attempts, the API answers `409 Conflict` with reason `conflict_retryable`; retry the release. ### `POST /v1/sandbox-leases/:id/executions` Reserves the execution before starting its process, so retrying the same request cannot run a command twice. `idempotencyKey` is required, contains 1–253 bytes, and must be new for each deliberate execution. Reusing it with different command, working directory, timeout, container, stdin, or detach intent returns `409 Conflict`. ```json { "command": ["/agent", "analyse", "./repo"], "cwd": "/workspace", "timeout": "5m", "container": "agent", "idempotencyKey": "execution-018f8f4f", "detach": false, "stdin": "Z2hwX2V4YW1wbGV0b2tlbg==" } ``` `command` is an argv array and is never interpreted by a shell. `cwd` and `container` are optional. `timeout` defaults to the time left on the lease. A longer value is cut to the lease expiry. `stdin` is optional, base64-encoded, and written to the command's standard input before it is closed. **It is how you pass a secret to a Sandbox.** The exec request Kobe makes against the target cluster puts the command line in a URL that the target apiserver's audit log records verbatim, so a token in `command` is a token in somebody's log. A token in `stdin` is not: it is forwarded to the process and hashed into the execution's request digest, and no part of it is stored in the execution record, its status, or etcd. ```sh # The token never appears in an argument. printf %s "$GITHUB_TOKEN" | base64 | \ jq -Rn --argjson cmd '["gh","auth","login","--with-token"]' \ '{command: $cmd, idempotencyKey: "execution-018f8f50", stdin: input}' ``` The command's stdin is **closed** once the bytes are delivered, so a tool that reads until EOF — `gh auth login --with-token` does — completes rather than waiting for its timeout. Omitting `stdin` leaves the process reading `/dev/null`, exactly as before; an empty string gives it a pipe that is immediately closed. There is no input byte ceiling by default. An operator can set `KOBE_SANDBOX_STDIN_MAX_BYTES`; larger decoded inputs are refused with `400` before reserving an execution. Stdin is never truncated. Wait mode (`detach: false`) returns `200 OK` after the supervised command ends: ```json { "id": "sbxe-a1b2c3d4e5f6", "state": "Succeeded", "exitCode": 0, "startedAt": "2026-08-20T12:01:00Z", "finishedAt": "2026-08-20T12:01:12Z", "deadline": "2026-08-20T13:01:00Z", "deadlineSource": "timeout", "stdout": "analysis complete\n", "stderr": "", "truncated": false } ``` Detached mode returns `202 Accepted` once the durable reservation exists. Poll the execution URL; a keyed retry returns the same execution rather than starting another process. Execution states are `Queued`, `Running`, `Succeeded`, `Failed`, `Cancelled`, `TimedOut`, and `Unknown`. `exitCode` is present only when the remote process reported one. `deadline` is when the runner stops the command, fixed at start; `deadlineSource` is `timeout` when the caller's timeout set it and `lease` when the lease expiry cut it. `Unknown` means Kobe could not establish the outcome and must not be treated as permission to rerun effects automatically. Common failures are `400` for an invalid command, timeout, cwd, stdin, or idempotency key; `403`/`404` for unavailable lease access; `409` for key reuse with changed intent and for the per-lifetime execution bound (`reason: execution_limit_exhausted` — retrying cannot succeed, request a new lease); `501` when the pool has no Kobe runner; and `503` when the execution store or target cannot be verified. ### `GET /v1/sandbox-leases/:id/executions/:execution` Returns the current execution object in the same response shape, without inline stdout/stderr. It returns `404` unless both the lease and execution belong to the authenticated caller and exact lease instance. ### `GET /v1/sandbox-leases/:id/executions/:execution/logs` Reads retained output with independent resume positions: ```text ?stdoutOffset=0&stderrOffset=0 ``` ```json { "id": "sbxe-a1b2c3d4e5f6", "state": "Running", "stdout": { "data": "building\n", "nextOffset": 9, "more": true, "truncated": false }, "stderr": { "data": "", "nextOffset": 0, "more": false, "truncated": false } } ``` Pass each returned `nextOffset` unchanged on the next request. Offsets count the runner's retained raw bytes and are opaque resume tokens; do not derive them from the length of `data`. The API represents each returned window as a string, replacing invalid UTF-8 when necessary. `more` means another window is already available. `truncated` means earlier or excess output was irreversibly dropped at the retention cap. A follower is drained only when the execution state is terminal and both windows report `more: false`. Transport/body retry uses the same offsets so it cannot duplicate an already acknowledged window. `401` and `403` are terminal authorization results, not retry signals. ### `DELETE /v1/sandbox-leases/:id/executions/:execution` Requests cancellation and returns `200 OK` with the state that actually applies. Runner-managed execution cancellation signals the whole process group. An execution already terminal remains in that terminal state; the response does not claim it was cancelled after the fact. Repeating cancellation is safe. --- ## `GET /v1/status` Public endpoint — no auth required, but providing a token enriches the response with accessible pools. **Response** — `200 OK` ```json { "version": "0.1.0", "auth": { "methods": ["oidc", "ssh"], "sessions": [] }, "pools": [ { "name": "ci-small", "ready": 3, "leased": 1, "creating": 0 } ] } ``` This is the endpoint `kobe status` queries.