---
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.