---
title: Sessions from code
description: "The session handle: create, wait for readiness, send and steer prompts, stream events, read transcripts, handle errors, and start sessions from your own backend."
---

A session is one agent run in its own sandbox, on its own git branch. This page drives a session from TypeScript with `@kortix/sdk`, from a script or from a server that starts sessions for its own customers. For the concept, see [Sessions](/docs/work/sessions); to set up the client, see [SDK](/docs/sdk).

The shortest complete run:

```ts
import { createKortix } from '@kortix/sdk';

const kortix = createKortix({
  backendUrl: 'https://api.kortix.com/v1',
  getToken: async () => process.env.KORTIX_API_KEY!,
});

const created = await kortix.project(projectId).sessions.create({ agent_name: 'my-agent' });
const s = kortix.session(projectId, created.session_id);

const { runtimeSessionId } = await s.ensureReady();
let finish!: () => void;
const turnDone = new Promise<void>((resolve) => (finish = resolve));

const stream = await s.stream({
  onEvent: (event) => {
    if (event.type === 'session.idle' && event.properties.sessionID === runtimeSessionId) finish();
  },
});

await s.send('List the open TODO comments in this repository.');
await turnDone;
stream.close();

const { messages } = await s.messages({ limit: 1 }); // the agent's last message: { info, parts }
```

## The session handle

```ts
const s = kortix.session(projectId, sessionId);
// the same handle: kortix.project(projectId).session(sessionId)
```

- A handle resolves only its own sandbox. It never falls back to the runtime of another session, so two handles in one process never cross.
- The `session_id` is also the name of the session's git branch.
- `ensureReady()` returns `sandboxId`. That is the sandbox provider's id, a different value from `session_id`.
- Creating a handle sends no request.

## Create a session

```ts
const created = await kortix.project(projectId).sessions.create({
  agent_name: 'my-agent',
  model: 'kimi-k3',
  initial_prompt: 'Triage the open issues labeled bug.',
  labels: ['triage'],
  metadata: { app_ticket: 'T-142' },
});
created.session_id;
```

Every field is optional. With no input, the session runs the project's default agent and model and waits for a prompt. A create starts provisioning the sandbox at once. If the session needs a new secret value or a new connector credential, write it before the create.

| Field | Meaning |
| --- | --- |
| `initial_prompt` | The first prompt. The server delivers it when the runtime is up, so you need no `send()`. |
| `agent_name` | The agent to run. Default: the project's default agent. |
| `model` | The model id (see [Models](/docs/project/models)). With the LLM gateway off, use the native `provider/model` form; a bare id returns `400` `INVALID_SESSION_MODEL`. A model the account cannot use returns `400`. |
| `name` | The session title. Without it, the server makes a title from the first prompt. |
| `labels` | Up to 20 labels, each 1 to 64 characters after trimming. Duplicates drop. Matching is exact and case-sensitive. |
| `metadata` | A JSON object of your own keys, at most 16,384 characters of JSON per write. Server-managed keys, such as `source`, `trigger_kind` and `trigger_slug`, return `400`. Prefix your keys, for example `app_`. |
| `session_id` | A v4 UUID you generate, so you know the id before the create returns. A second create with the same id returns `409`. |
| `base_ref` | The branch or ref the session branch starts from. Default: the project's default branch. |
| `sandbox_slug` | The sandbox to boot, by its slug in `kortix.yaml`. Default: the agent's sandbox, then the project default, then `default`. |
| `provider` | The sandbox provider: `daytona`, `platinum` or `e2b`. Default: the project's pinned provider, else the platform's choice. |
| `persistent_machine` | `true` keeps the whole root disk across stops. The session then runs on Platinum. Only for an account with Volumes on; without Volumes, Kortix ignores the field and returns no error. See [Persistent and ephemeral machines](/docs/work/runtime#persistent-and-ephemeral-machines). |
| `runtime_context` | Non-secret values for the agent. See [Start sessions from your server](#start-sessions-from-your-server). |
| `connector_bindings` | Pins one connector account per connector alias: `{ gmail: { connection_id } }`. |
| `inherit_unbound` | With `connector_bindings` set, a connector you did not bind gets no account. `true` lets it use the project default. Default `false`. |
| `secrets` | Narrows which project secrets the sandbox receives, by identifier. A personal access key or service account key only. |
| `provider_secret_pools` | HTTP body only. Selects up to 10 account secret ids per model provider, for up to 20 providers. Needs the `pooled_provider_secrets` flag and the LLM gateway. |

`opencode_model` is the deprecated name of `model`; `model` wins when you send both. `require_connectors` is accepted and ignored.

Update a session later:

```ts
await s.update({ name: 'Bug triage', labels: ['triage', 'done'], metadata: { app_priority: null } });
const { items } = await kortix.project(projectId).sessions.listPage({ labels: ['triage', 'done'] });
```

`labels` replaces the list. `metadata` merges keys, and a `null` value removes a key. `listPage({ labels })` returns only sessions that carry every label. For a pre-booted session that is ready before the first prompt, see [Warm sessions](/docs/work/sessions#warm-sessions).

## Wait until ready

```ts
const { runtimeSessionId, runtimeUrl, sandboxId } = await s.ensureReady({ readyTimeoutMs: 300_000 });
```

`ensureReady()` provisions or resumes the sandbox and waits for its runtime.

- It asks the API to start the session in long polls of up to 30 s, with a 1 s pause, while the answer is `provisioning` or `starting` with `retriable: true`.
- The default deadline is 180 s. Pass `readyTimeoutMs` to wait longer.
- It throws an `ApiError` with `code: 'RUNTIME_UNAVAILABLE'` at the deadline, or at once on a terminal `failed` or `stopped` answer.
- Concurrent calls for one session share one start request. A second call on a ready handle returns at once.

Which methods ready the session for you:

| Behavior | Methods |
| --- | --- |
| Call `ensureReady()` themselves | `send`, `abort`, `rewind`, `restoreRewind`, `stream`, `messages`, `pending`, `answerPermission`, `answerQuestion`, `compact`, every `files.*` method |
| Throw `SessionNotReadyError` before a ready | `previewUrl`, `proxyUrl`, `sandboxPortUrl` |
| Read only the Kortix API, never the sandbox | `get`, `update`, `cost`, `scope`, `transcript`, `transcriptSync`, `messageAuthors`, `modelUsage`, `turn`, `attachments.read` |

`s.health()` never throws `SessionNotReadyError`. Before this handle has a runtime it returns `{ status: 0, ok: false }`.

A failed start is a cooldown, not a final verdict. A start after the cooldown tries again. The cooldown grows from 2 to 5 to 10 minutes. After 5 consecutive failures, `ensureReady()` throws `RUNTIME_UNAVAILABLE` at once. That verdict expires 30 minutes after the last failure. `s.restart()` clears it at once.

## Send prompts

```ts
await s.send('Refactor the auth module.');
await s.send('Write the tests.', { agent: 'tester' }); // this call only

s.setModel({ providerID, modelID }); // later send() calls on this handle
s.setAgent('reviewer');

await s.abort(); // end the running turn; the session stays
```

- `send()` puts the prompt in the session's durable prompt inbox, the same inbox the web app and the CLI use. It resolves when the prompt is stored, with `{ prompt_id, state, message_id }`. It does not wait for the turn. Read the reply from `stream()` or the transcript.
- `send()` throws an `ApiError` with `code: 'PROMPT_FAILED'` when the inbox row already failed.
- Model and agent precedence: the `send()` option, then `setModel()` / `setAgent()` on this handle, then the session's saved default.
- `setModel()` stays on the handle. To change the session's saved model, call `changeModel()`:

```ts
const { applied_live } = await s.changeModel('kimi-k3');
```

The same model-id rule applies as for `model` at create. `changeModel()` restarts the runtime when the model changes on a running session, so a running turn ends. `applied_live` is `true` when a running session took the model now, and `false` when it applies at the next start. `push_failed: true` means the model is stored, but the running runtime still uses the old one. On a `stopped`, `failed` or `completed` session it returns `409` `SESSION_NOT_RUNNING`. Only the session owner or a project manager may call it. Anyone else gets `403`.

### Steer, queue or interrupt

`send()` queues: the prompt waits for the running turn to end. To choose how a prompt reaches a running turn, write it with `s.prompts.create()`:

```ts
import { mintWireMessageId } from '@kortix/sdk';

const messageId = mintWireMessageId();
await s.prompts.create({
  clientMessageId: messageId,
  messageId,
  remintOnDelivery: true,
  delivery: 'steer',
  parts: [{ type: 'text', text: 'Use the staging database.' }],
});
```

| `delivery` | While a turn runs |
| --- | --- |
| `steer` | The running turn reads the prompt at its next step. The turn does not stop. |
| `queue` | The prompt waits for the turn to end, then runs as its own turn. |
| `interrupt` | The turn ends after the running tool. Then the prompt runs as its own turn. |

With no turn running, all three start a turn. A `steer` needs the runtime capability `session.steer` and must come from the member who started the turn. Otherwise the server delivers it as `queue`, and the inbox row shows `steer_fallback`:

- `unsupported`: the runtime does not list `session.steer`.
- `not_prompter`: another member started the running turn.
- `turn_ended`: the turn ended before the prompt reached it.

The inbox lists only prompts not yet delivered:

| Method | What it does |
| --- | --- |
| `s.prompts.list()` | Prompts the session still owes, oldest first, with `state`, `reason` and `steer_fallback` |
| `s.prompts.edit(promptId, text)` | Replaces the text of a waiting prompt. `409` when the prompt is already on its way to the runtime |
| `s.prompts.interrupt(promptId)` | Turns a waiting prompt into an `interrupt` |
| `s.prompts.retry(promptId)` | Retries a `failed` prompt |
| `s.prompts.remove(promptId)` | Removes a prompt and returns it. `409` when the agent already reads it |
| `s.prompts.hold(true \| false)` | Holds or releases the whole queue |
| `s.turn()` | Running turns (`turns`, empty when idle), how the last turn ended (`last_ended`), and recent failures (`recent_failures`) |

### Attach files

Upload a file to the project, then send its id in a file part:

```ts
const file = await kortix.project(projectId).attachments.upload(blobFile);
const fileMessageId = mintWireMessageId();
await s.prompts.create({
  clientMessageId: fileMessageId,
  messageId: fileMessageId,
  remintOnDelivery: true,
  parts: [
    { type: 'text', text: 'Summarize this report.' },
    { type: 'file', attachment_id: file.attachment_id, filename: file.filename, mime: file.mime },
  ],
});
```

- Limits: 50 MiB per file, 100 MiB per message, 20 files per message. A file must not be empty.
- An upload needs no running sandbox. Before the prompt runs, the server writes each file to `/workspace/uploads/.kortix-inbox/`.
- An upload that no prompt uses expires after 24 hours.
- A browser composer uses `usePromptAttachments`. See [React](/docs/sdk/react).

## Stream events

```ts
const stream = await s.stream({
  onEvent: (event) => { /* one runtime event */ },
  onGapRehydrate: (gapMs) => { /* re-read state you cache */ },
  signal: abortController.signal,
});
stream.close();
```

`stream()` readies the session, then opens the session runtime's event stream through the Kortix API. It is server-sent events over a streaming `fetch`. There is no WebSocket. The same stream serves both harnesses.

1. Open the stream before you send, so you do not miss the first events.
2. Send the prompt.
3. Close the stream on `session.idle` for your runtime session id. A subagent's conversation sends its own `session.idle` with its own id.

The SDK keeps the connection alive for you:

- It reconnects at once (250 ms) after a healthy stream drops, else with backoff from 1 s up to 30 s plus up to 50% jitter, and resumes with `Last-Event-ID`.
- The sandbox sends a keepalive every 20 s. After 60 s with no frame, the SDK drops the connection and reconnects.
- `onGapRehydrate(gapMs)` runs after a reconnect when the dropped stream had delivered events, or when the gap was longer than 5 s.
- After 8 consecutive failed connects (a stopped or deleted sandbox), the stream stops. Open a new one after the session runs again.
- Events arrive in batches every 16 ms. Consecutive `message.part.delta` events for one part field arrive as one event: `properties.delta` holds the joined text, `id` is the last event's id, and `coalesced` lists the events it replaced. Append `delta` as for a single event.

The events a chat needs:

| `type` | When it fires |
| --- | --- |
| `message.updated` / `message.removed` | A message changed or was deleted. |
| `message.part.updated` / `message.part.removed` | A part (text, tool call, file) changed or was removed. |
| `message.part.delta` | Text was appended to a part field. |
| `session.status` | A conversation's busy state changed. |
| `session.idle` | A turn finished. |
| `session.error` | A turn failed. The event carries the error. |
| `permission.asked` / `permission.replied` | The agent asked for a permission, and the answer. |
| `question.asked` / `question.replied` / `question.rejected` | The agent asked a question, and the answer. |
| `todo.updated` | The agent's todo list changed. |

Each event has `type` and `properties`, for example `event.properties.sessionID`. The stream carries more event types than these. `narrowChatEvent(event)` from `@kortix/sdk` maps the events above, except `message.part.delta`, to typed chat events, maps `server.connected` to a `connection` event, and returns `null` for every other event. It merges `question.replied` and `question.rejected` into `question.answered`.

A host whose `fetch` cannot stream a response body, such as React Native, passes its own transport. The SDK keeps reconnect, resume, backoff and the keepalive check:

```ts
import { configureKortix, type RuntimeEventTransport } from '@kortix/sdk';

const transport: RuntimeEventTransport = async function* ({ url, headers, signal }) {
  // Yield { data, id?, retry? } for each server-sent message of ONE connection.
  // Return when the server ends it. Throw (with `status` for a non-2xx answer) when it fails.
};
configureKortix({ backendUrl, getToken, eventStreamTransport: transport });
```

A host without `visibilitychange` and `online` events calls `notifyHostSignal('visible' | 'online' | 'retry')`. The stream that `useSession` holds reconnects on it. A `stream()` handle does not listen to it.

A React app uses `useSession` instead of `stream()`. See [React](/docs/sdk/react).

## Read the conversation

| Method | Returns | Wakes the sandbox |
| --- | --- | --- |
| `s.messages({ limit?, before?, conversationId? })` | A page of messages, oldest first, as `{ info, parts }` (`kortix.transcript.v1`), and `hasMore`. `conversationId` reads a subagent's conversation. | Yes |
| `s.transcriptSync({ limit?, before?, child? })` | The copy the server saves after every turn: every part, with tool inputs and outputs. `limit` at most 500. Pass `next_cursor` as `before` for the older window. `child` reads a subagent's saved transcript. | No |
| `s.transcript({ limit?, chars? })` | A short digest: text and tool calls without inputs and outputs. `limit` 1 to 500 (default 40), `chars` 80 to 5,000 per message (default 700). | No |
| `s.get()` | The session record: status, agent, model, labels, metadata. | No |
| `s.messageAuthors()` | The member or session that sent each message, keyed by message id. | No |
| `s.modelUsage()` | The model that answered each turn, and `fallback_from` when a fallback model answered. | No |

A `transcriptSync` window also stops at 1,000,000 characters of JSON. It keeps its newest messages, and `next_cursor` then names the oldest one it kept.

### Answer the agent

```ts
const { statuses, permissions, questions } = await s.pending();
await s.answerPermission(permissions[0].id, 'once'); // 'once' | 'always' | 'reject'
await s.answerQuestion(questions[0].id, [['Yes']]); // one answer list per question; null dismisses
```

### Control the session

| Method | What it does |
| --- | --- |
| `s.abort()` | Ends the running turn. |
| `s.rewind(messageId)` / `s.restoreRewind()` | Rolls the conversation back to a user message, and undoes that before the next prompt. Needs `session.rewind`. |
| `s.compact(model?)` | Summarizes the conversation into a shorter context. Needs `session.compact`. |
| `s.stop()` | Stops the sandbox. The disk, the branch and the conversation stay. |
| `s.restart()` | Stops and starts the sandbox in place. Clears a failed-start verdict. |
| `s.delete()` | Deletes the session and its sandbox. This cannot be undone. |
| `s.configState()` / `s.reloadConfig({ force? })` | Reads whether the session runs the current config from the base branch, and loads that config into the running session. The reload restarts the runtime. While a turn runs it returns `409` `SESSION_BUSY`; `force: true` ends the turn. |
| `s.setSharing(intent)` | Sets who can open the session. See [Who can open a session](/docs/work/sessions#who-can-open-a-session). |
| `s.publicShares.create({ transcript: true })` | Makes one read-only link to the conversation. See [Public links](/docs/work/sessions#public-links). |
| `s.reminders.create(input)` | Schedules a prompt into this session. See [Triggers & reminders](/docs/connect/triggers). |

The full method list is in [SDK reference](/docs/sdk/reference).

## Files and previews

`s.files` reads and writes the session's live sandbox. Every call readies the session first.

| Method | What it does |
| --- | --- |
| `list(dir)`, `read(path)`, `readBlob(path)` | Lists a directory and reads a file as text or as a `Blob`. |
| `status()` | The git status of changed files. |
| `findFiles(query, { type?, limit? })`, `findText(pattern)` | Finds files by name and by content. |
| `write(path, blob)` | Writes a file and overwrites it in place. |
| `upload(file, targetPath?, filename?)` | Writes a new file. It never overwrites: a taken name gets a unique name. |
| `create`, `copy`, `remove`, `mkdir`, `rename` | Edits the file tree. |

Previews need a ready handle:

```ts
await s.ensureReady();
const url = s.previewUrl(3000, '/docs'); // a URL a browser tab opens
const fixed = s.proxyUrl('http://localhost:3000/'); // rewrite a localhost URL the agent printed
const direct = s.sandboxPortUrl(3000); // the authenticated API proxy URL, for a bearer-token client
const ports = await s.previews(); // ports the runtime exposes
```

See [Ports and previews](/docs/work/runtime#ports-and-previews).

## Runtime capabilities

A session runs on one of two harnesses, OpenCode or pi. `s.health()` lists what the runtime supports in `health.capabilities`. Hide a control when its capability is absent:

```ts
import { runtimeSupports } from '@kortix/sdk';

await s.ensureReady();
const { health } = await s.health();
if (!runtimeSupports(health?.capabilities, 'session.compact')) hideCompactButton();
```

| Capability | Feature |
| --- | --- |
| `session.rewind` | Roll back to a message, and restore it |
| `session.compact` | Summarize the conversation on demand |
| `session.commands` | Project slash commands |
| `session.fork` | Fork a session at a message |
| `session.subagents` | Subagent child sessions |
| `session.mcp` | MCP servers the runtime connects itself |
| `session.todo` | The runtime's todo list |
| `session.shell` | A shell command run as a turn |
| `session.attach` | Attach the harness's own terminal client |
| `session.config` | A runtime config document a client reads and patches |
| `session.steer` | A prompt read by the running turn at its next step |
| `session.log.v2` | The runtime event stream serves the session log as `log.*` frames (`?schema=v2`) |

- OpenCode lists all 12. It lists `session.steer` only on OpenCode 1.18.15 or later.
- pi lists `session.subagents`, `session.compact`, `session.commands`, `session.steer` and `session.log.v2`.
- A route for an unlisted feature answers `501 feature_not_supported`.
- `runtimeSupports` returns `true` before a health answer arrives, and for a runtime that lists no `session.*` capability.

See [Harnesses: OpenCode and pi](/docs/project/agents#harnesses-opencode-and-pi).

## Start sessions from your server

Your server holds one Kortix credential and starts sessions for its own customers. Your customers never sign in to Kortix.

1. Get a credential. A personal access key (`kortix_pat_…`) acts as you. A service account key (`kortix_sa_…`) acts as itself and needs a project role first. See [API keys and tokens](/docs/accounts/identity#api-keys-and-tokens).
2. Create the client with `createScopedKortix` from `@kortix/sdk/server`. It never writes the process-wide config, so one request never reads another request's config or sandbox. See [Servers that act for many users](/docs/sdk/auth#servers-that-act-for-many-users).

Both credentials give the session the origin `backend`. The request body cannot set the origin. A token from inside a session sandbox is never `backend`.

```ts
import { createScopedKortix } from '@kortix/sdk/server';

const kortix = createScopedKortix({
  backendUrl: process.env.KORTIX_API_URL!,
  getToken: async () => process.env.KORTIX_API_KEY!,
});

const created = await kortix.project(projectId).sessions.create({
  agent_name: 'support',
  initial_prompt: 'Answer ticket ' + ticket.id + '.',
  labels: ['support', 'customer:' + customer.id],
  metadata: { app_customer_id: customer.id, app_ticket_id: ticket.id },
  runtime_context: { customer_id: customer.id, plan_tier: customer.tier },
  connector_bindings: { gmail: { connection_id: supportInboxConnectionId } },
  secrets: ['HELPDESK_API_KEY'],
});
```

On a scoped client, `kortix.runtime()` throws. Reach a session through `kortix.session(projectId, sessionId)`.

The same kind of create over HTTP, with an idempotency key:

```bash
curl -X POST "https://api.kortix.com/v1/projects/<project_id>/sessions" \
  -H "Authorization: Bearer kortix_pat_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: my-app:ticket-123:reply" \
  -d '{
    "agent_name": "support",
    "initial_prompt": "Answer ticket 123.",
    "runtime_context": { "customer_id": "c-42" },
    "secrets": ["HELPDESK_API_KEY"]
  }'
```

The API answers `201` with the session. The full route reference is at https://api.kortix.com/v1/docs.

### Retry a create safely

- Send one `Idempotency-Key` per logical create: 1 to 255 characters of `A-Z a-z 0-9 . _ : + / = -`. A replay with the same key returns the same session.
- A key is unique across all of Kortix. A key that another project or account already used returns `409` `IDEMPOTENCY_KEY_CONFLICT`. Put your own unique ids in the key.
- A replay while the first create still runs answers `202` with `status` and `session_id`.
- A replay with different `connector_bindings`, `provider_secret_pools`, `secrets` or `runtime_context` returns `409` `IDEMPOTENCY_BINDING_CONFLICT`, `IDEMPOTENCY_PROVIDER_POOL_CONFLICT`, `IDEMPOTENCY_SECRETS_CONFLICT` or `IDEMPOTENCY_CONTEXT_CONFLICT`.
- A key whose session was deleted returns `409` `IDEMPOTENCY_KEY_SESSION_DELETED`. Use a new key.
- `sessions.create()` in the SDK sends no `Idempotency-Key`. To retry an SDK create, generate `session_id` yourself. A retry that answers `409` `session_already_exists` means the first create succeeded.

### Pass context, accounts and secrets

`runtime_context` gives the agent non-secret values, such as your customer id.

- Values are strings (at most 4,096 characters), numbers, booleans or `null`.
- Keys are 1 to 64 characters: a lowercase letter, then lowercase letters, digits, `.`, `_` or `-`. A key with a segment such as `token`, `secret`, `password`, `credential`, `api_key`, `private_key`, `authorization` or `cookie` is rejected.
- At most 64 keys and 16 KiB of JSON.
- The sandbox receives the object as JSON in the environment variable `KORTIX_SESSION_CONTEXT`.
- You set it at create. Scope changes do not touch it.

`connector_bindings` pins the connector account each alias uses, as `{ alias: { connection_id } }`, at most 64 entries.

- When you set it, an alias you did not bind gets no account. Pass `inherit_unbound: true` to let unbound aliases use the project default.
- A shared (`project`) account works for a personal access key and a service account key.
- A private (`member`) account works only for its owner, and only in a private session. A new session is private. Binding a private account to a shared session returns `409`. A service account never uses a private account.
- The credential never enters the sandbox. Kortix resolves it on every connector call. See [Connectors, connections and accounts](/docs/connect/connectors#connectors-connections-and-accounts).

`secrets` narrows the project secrets the sandbox receives, from the agent's grant down to the identifiers you list. It never widens the grant. `[]` delivers no project secrets. Only a `backend`-origin caller may set it; any other caller gets `403` `origin_override_forbidden`. See [Secrets](/docs/project/secrets).

Tag each session with your own ids in `labels` and `metadata`. Find a customer's sessions with `listPage({ labels: ['customer:c-42'] })`.

## Scope and cost

Read and change a running session's secrets and connector accounts:

```ts
const scope = await s.scope();
scope.secrets_allowlist; // null: the agent's grant applies
scope.connector_bindings_configured; // false: the session uses the project defaults

const next = await s.rescope({
  secrets: ['HELPDESK_API_KEY'],
  connector_bindings: { gmail: { connection_id: otherConnectionId } },
});
next.applied_live; // true when the sandbox took the new secrets now
```

Each field you send replaces its whole previous value. A field you leave out stays as it is.

| Value | `secrets` | `connector_bindings` |
| --- | --- | --- |
| `null` | Use the agent's secret grant | Remove the override and use the project defaults |
| `[]` / `{}` | No project secrets | No connector account at all, project defaults included |
| A list / a map | Exactly these secrets, inside the agent's grant | Exactly these accounts |

- Sending `{}` where you meant `null` blocks every connector.
- A connector change applies on the next connector call.
- A secret change restarts the runtime when the delivered set changes. `applied_live` says whether it applied now or applies at the next start. `push_failed: true` means the change is stored, but the running runtime still has the old secrets.
- A removed secret stops reaching the sandbox. It cannot leave a model context or a process that already read it. Rotate the secret when that matters.
- A rescope cannot widen past the agent's grant.
- Only the session owner or a project manager may change the scope.

Read usage without waking the sandbox:

```ts
const record = await s.cost(); // LLM and compute totals, tokens, model usage, compute time, ledger entries

const page = await kortix.billing.sessionCosts.list({ accountId, projectId, from, to, limit: 25 });
```

`sessionCosts.list` covers every session of the account in a half-open UTC window `[from, to)`. The default window is the last 30 days. Filter by `projectId` or `ownerId`. See [Track usage](/docs/credits#track-usage).

## Session errors

A session create returns these errors. The `code` field of the JSON body holds the code.

| Status | Code | Meaning |
| --- | --- | --- |
| `400` | `INVALID_SESSION_MODEL` | The model id is malformed or the account cannot use the model. |
| `400` | `INVALID_SESSION_CONNECTOR_BINDINGS` | The binding map is malformed. |
| `400` | `INVALID_SESSION_RUNTIME_CONTEXT` | The runtime context breaks a key, value, entry or size rule. |
| `400` | `INVALID_SESSION_SECRETS` | The secrets list is malformed or has more than 128 identifiers. |
| `400` | `INVALID_IDEMPOTENCY_KEY` | The Idempotency-Key header is longer than 255 characters or has a character outside the allowed set. |
| `400` | `UNKNOWN_SANDBOX_TEMPLATE` | The sandbox slug names no sandbox in the project. |
| `400` | `PERSISTENT_MACHINE_UNSUPPORTED` | A persistent machine needs the Platinum provider. |
| `402` | `subscription_required` / `insufficient_credits` | The account cannot start a session. |
| `403` | `origin_override_forbidden` | A caller that is not a backend origin sent a secrets list. |
| `403` | `CONNECTOR_NOT_ASSIGNED` | The agent is not granted a connector you bound. |
| `404` | `CONNECTOR_CONNECTION_NOT_FOUND` | The account does not exist for that connector in this project, or the caller may not use it. |
| `404` | `SECRET_IDENTIFIER_NOT_FOUND` | The secrets list names an unknown project secret. |
| `409` | `CONNECTOR_CONNECTION_INACTIVE` | The account or its connector is inactive, disabled or not connected. |
| `409` | `PERSONAL_CONNECTOR_CONNECTION_REQUIRES_PRIVATE_SESSION` | A private account is bound to a session that is not private. |
| `409` | `SECRET_IDENTIFIER_KEY_COLLISION` | Two listed secrets set the same environment variable. |
| `409` | `session_already_exists` | A session with this session id exists. |
| `409` | `IDEMPOTENCY_*_CONFLICT` | Another create already holds the key, or this key was used before with a different body. |
| `409` | `IDEMPOTENCY_KEY_SESSION_DELETED` | The key belongs to a deleted session. |
| `500` | `SESSION_CREATE_FAILED` | The create failed. The body has retry set to true: retry it. |
| `503` | `SESSION_MODEL_RESOLUTION_FAILED` | The server could not resolve the default model. Retry it. |

`s.rescope()` returns these errors:

| Status | Code | Meaning |
| --- | --- | --- |
| `400` | `INVALID_SESSION_SCOPE` | The body is malformed, or it has neither field. |
| `403` | `NOT_IN_AGENT_GRANT` | A secret is outside the agent's grant. |
| `403` | `NOT_GRANTED_CONNECTOR` | A connector is outside the agent's grant. |
| `403` | `SECRET_IDENTIFIER_NOT_AVAILABLE` | A secret does not exist or the session may not use it. |
| `403` | `CONNECTOR_CONNECTION_NOT_FOUND` / `CONNECTOR_CONNECTION_INACTIVE` | The account is unknown, unusable or inactive. |
| `409` | `AGENT_GRANT_UNRESOLVED` | The server could not read the agent's grant. Retry it. |
| `409` | `SECRET_IDENTIFIER_KEY_COLLISION` | Two listed secrets set the same environment variable. |
| `409` | `PERSONAL_CONNECTOR_CONNECTION_REQUIRES_PRIVATE_SESSION` | A private account is bound to a session that is not private. |

A rescope by anyone other than the session owner or a project manager returns `403` with no code.

## Errors and retries

Every SDK call rejects with an `Error` subclass. Check `instanceof`, then branch on `status` or `code`:

```ts
import { ApiError, BillingError, SessionNotReadyError, formatBillingErrorForUI } from '@kortix/sdk';

try {
  await kortix.project(projectId).sessions.create({ agent_name: 'my-agent' });
} catch (err) {
  if (err instanceof BillingError) {
    const ui = formatBillingErrorForUI(err); // { alertTitle, alertSubtitle }
  } else if (err instanceof ApiError) {
    console.error(err.status, err.code, err.detail);
  } else {
    throw err;
  }
}
```

| Class | When it throws | Key fields |
| --- | --- | --- |
| `ApiError` | Any failed request: an error status, a network failure, a timeout or an abort | `status`, `code`, `detail`, `response`, `url`, `endpoint`, `timeout` |
| `AuthError` (extends `ApiError`) | `getToken()` returned `null`. No request was sent | `code` is `NO_SESSION` |
| `BillingError` (extends `ApiError`) | Every `402` | `code`, for example `insufficient_credits`; `detail.message` |
| `RequestTooLargeError` (extends `ApiError`) | A `431`, usually too many files in one request | `detail.suggestion` |
| `SessionNotReadyError` | A preview method ran before `ensureReady()` | `name` is `SessionNotReadyError` |

- `code` is the API's error code, or the status as a string.
- An aborted request has `name: 'AbortError'` and `code: 'ABORTED'`. It is not a failure.
- A request that passes its own timeout has `code: 'TIMEOUT'`. The default timeout is 30 s per request.
- `formatBillingErrorForUI(err)` returns `null` for any error that is not a billing error.
- `isSandboxNotReadyError(err)` and `isRuntimeStartingError(err)` are true while the sandbox is starting, stopped or parked. Show a waiting state and retry.
- A `401` is sent once more with a fresh token from `getToken()`. See [getToken and 401 recovery](/docs/sdk/auth#gettoken-and-401-recovery).

The SDK retries some requests before your code sees an error:

| Method | Retried | Never retried |
| --- | --- | --- |
| `GET`, `HEAD` | Up to 2 times, after 250 ms and 500 ms, on `502`, `503`, `504` or a network failure | A `500` or any other status |
| `DELETE` | Up to 2 times on a network failure with no response | Any received status |
| `POST`, `PUT`, `PATCH` | Never | — |

To make a create retry-safe, see [Retry a create safely](#retry-a-create-safely).
