# Developers

Build on Kortix: the CLI, git and change requests, the kortix.yaml reference, the REST API, Apps, self-hosting and the @kortix/sdk.

Canonical page: https://kortix.com/docs/developers

Everything a developer needs to build on Kortix, on one page: the CLI and git workflow, the `kortix.yaml` reference, the REST API, Apps, self-hosting, and the TypeScript SDK with its React hooks and UI components. For what Kortix is and how people use it, read the [Guide](/docs/guide).

## Quickstart

Go from an empty terminal to a merged change request. Every step is a git operation on your project's repository:

| Step | Command | What happens in git |
| --- | --- | --- |
| Create a project | `kortix init`, `kortix ship` | `git init`, commit, push to the project's repository |
| Get a project | `kortix projects clone` | `git clone` through Kortix |
| Run a session | `kortix sessions new` | The agent works on its own branch, named `<session_id>` |
| Review and merge | `kortix cr merge` | The session branch merges into the default branch |

### Install the CLI

```sh
curl -fsSL https://kortix.com/install | bash
```

The script supports macOS and Linux on x86_64 and arm64. On Windows, run it inside WSL. To pin a version or use the dev channel, see [CLI → Install](/docs/developers#install).

### Sign in

```sh
kortix login
```

Your browser opens. Approve the CLI there. The CLI then picks your account and a default project.

With one project, the CLI picks that project. With several, it asks. With none, it skips this step.

On a machine without a browser, sign in with a token: `kortix login --token kortix_pat_…`. The token kinds are in [Use any git client](/docs/developers#use-any-git-client).

### Create or get a project

A project is a git repository with a `kortix.yaml` at its root. Create one, or clone one a teammate made.

**New project**

```sh
kortix init my-agent
cd my-agent
kortix ship
```

`kortix init` creates the `my-agent` directory from the starter and runs `git init -b main`. It asks which local coding tools to wire up: Claude Code, Codex, pi or Cursor. Codex is selected by default. It ends with a prompt to paste into your coding tool.

`kortix ship` validates `kortix.yaml`, commits, offers to set missing secrets, pushes the current branch and connects declared connectors. In a folder with no link and no default project, it creates the project.

With no `origin`, Kortix creates and manages the repository. With a GitHub `origin`, Kortix links that repository. Then plain `git push` and `git pull` work. Details: [Commit and push](/docs/developers#commit-and-push).

> **Warning**
> If `kortix login` saved a default project, `kortix ship` in an unlinked folder ships to that project, secrets included. Run `kortix projects unset` before the first `kortix ship` to create a new project.

**Existing project**

```sh
kortix projects ls --all
kortix projects clone <project_id> my-agent
cd my-agent
```

`kortix projects clone` clones the repository with your Kortix login. The directory name defaults to the project ID, so pass a name. The CLI writes `.kortix/link.json` in the clone and installs a credential helper, so plain `git pull` and `git push` work.

Cloning needs `project.gitops.push`, which the project **Manager** role holds. A **Member** cannot clone. A Member starts sessions in the web app, or from any folder after `kortix projects use <project_id>`. A Member also needs access to an agent: see [Teams](/docs/guide#teams).

Run the next commands from inside the clone. Outside it, commands use your default project.

**Web app**

Open **Create a project** in the web app (`/new`). Type a **Project name**. With more than one account, pick the **Account**. Choose the repository under **Git account**:

| Choice | Result |
| --- | --- |
| **Kortix managed** | Kortix creates and manages a private repository. |
| A GitHub account, **Create a new repository** | Kortix creates a private repository in that GitHub account. |
| A GitHub account, **Import an existing repository** | Kortix links a repository you already have. It keeps its own files. |

Click **Create project**. To work on it from a terminal, run `kortix projects clone`.

### What a new project contains

`kortix init` and a new repository from **Create project** start from the same starter:

```text
my-agent/
├── kortix.yaml        the manifest (kortix_version: 3): skills, agents, one trigger, secret names
├── agents/            the prompt of each agent: kortix.md, harness-reflector.md, session-reviewer.md
├── skills/            11 skills, one folder each, with a SKILL.md
├── memory/MEMORY.md   the project memory
├── README.md
└── .gitignore
```

- `kortix` is the default agent. Every grant it has is `all`.
- `harness-reflector` refines the agents' prompts, skills and tools from recent sessions, through one change request. Its daily trigger is off until you set `enabled: true`.
- `session-reviewer` is a subagent of `harness-reflector`.
- All three set `workspace: { project_repository: true }`, so each session gets a checkout of the repository on its own branch in `/workspace`.
- The Kortix skills (`kortix-*`) are always registered. The `skills` key in `kortix.yaml` lists the other 10.

`kortix init` also writes files for the coding tools you picked. Codex, pi and Cursor get `AGENTS.md`. Codex, Claude Code and pi get a link to `skills/` in `.agents/`, `.claude/` or `.pi/`. Every key is in the [kortix.yaml reference](/docs/developers#kortixyaml-reference).

### Run a session

A session is one unit of agent work. It runs in its own sandbox, on its own branch.

```sh
kortix sessions new --prompt "Add a skill that drafts the weekly status report"
```

The session runs the project's default agent, `kortix`. `--agent <name>` and `--model <id>` override the agent and the model. In a terminal, the CLI then prints the `kortix sessions chat` command for the new session. In the web app, type the prompt on the project page.

On the Credits plan or Enterprise, a session runs on the platform default model. On Free, connect your own provider key or a ChatGPT subscription first. See [Models](/docs/guide#models).

| Task | Command |
| --- | --- |
| Talk to the agent | `kortix chat` (picks your most recent running session) |
| Send one prompt | `kortix chat <session_id> --prompt "…"` |
| Open a shell in the session's sandbox | `kortix sessions shell <session_id>` |
| List sessions | `kortix sessions ls` |

### Review and merge

A change request proposes to merge the session branch into the default branch (`main` in a new project). Kortix does not open a change request by itself.

The Kortix skill `kortix-system` tells the agent to push, open one with `kortix cr open`, and wait. The starter agents may load it: their `skills` grant is `all`. If the agent did not open one, ask it to, or open it yourself:

```sh
kortix cr open --head <session_id> --session <session_id> --title "Weekly status report skill"
```

Then review and merge:

```sh
kortix cr ls          # open change requests
kortix cr diff 1      # the unified diff
kortix cr merge 1     # merge into the base branch
git pull              # bring the merged work into your clone
```

`1` is the change request number from `kortix cr ls`. To send the work back, run `kortix cr request-changes 1 --message "…"`. The note goes to the change request's session and wakes it if it sleeps. In the web app, use the project's **Review** page.

The starter `kortix` agent holds `kortix_permissions: all`, so the API lets it merge its own change request. The `kortix-system` skill tells it to wait until you ask. Who may merge: [Agents that merge](/docs/developers#agents-that-merge).

### Check your setup

```sh
kortix doctor
```

`kortix doctor` checks your login and the project. Then it starts a throwaway session, sends `ping`, waits for the agent's reply, and deletes the session. It exits `1` when a check fails. `--no-session` stops after the login and project checks.

### Next steps

- [Git](/docs/developers#git): Clone, push, session branches and change requests.
- [Agents](/docs/guide#agents): Write agents and decide what each one may do.
- [Connectors](/docs/guide#connectors): Give agents governed access to apps, APIs and MCP servers.
- [Automations](/docs/guide#automations): Start sessions or call your Apps on a schedule, a webhook or an App event.
## Git

Every Kortix project is one git repository. You clone it and push to it with any git client. You fetch the branch of each session, and land work on the base branch through change requests. This section is for everyone who works on project files outside the web app, and for admins who decide who may push.

The shortest path from a project to a reviewed change:

```bash
kortix projects clone <project_id> my-project
cd my-project
git checkout -b tighten-triage
# edit agents/, skills/ or kortix.yaml
git commit -am "Tighten the triage prompt"
git push origin tighten-triage
kortix cr open --head tighten-triage --title "Tighten the triage prompt"
```

### Your project's repository

A project has one of two kinds of repository. Both work the same way after the project exists.

| Kind | What it is | How you get it |
| --- | --- | --- |
| Managed | Kortix creates and manages a private repository. You need no GitHub account. | Pick **Kortix managed** under **Git account** when you create a project. Or run `kortix ship` in a folder with no `origin` remote, or `kortix ship --origin managed`. |
| GitHub | A repository in your GitHub account or organization. Kortix reaches it through the Kortix GitHub App, which you install under **Account settings → Git**, or through a GitHub token. | Pick a GitHub account under **Git account**, then **Create a new repository** or **Import an existing repository**. Or run `kortix ship` in a folder whose `origin` is on GitHub. |

On a self-hosted instance, a platform admin sets up managed repositories in the admin console. `GET /v1/projects/managed-git/status` returns `configured: false` until then, and the new-project form says "Kortix-managed repositories are not available on this instance." See [Self-hosting](/docs/developers#self-hosting).

#### The git origin

Every project has one clone URL, whatever its kind:

```text
https://<api-host>/v1/git/<project_id>.git
```

On Kortix Cloud this is `https://api.kortix.com/v1/git/<project_id>.git`. Find it in two places:

- `kortix projects info <project_id> --json`, field `git_origin_url`.
- **Customize → Settings → Git repo → Use your own Git client → Clone address**.

Clone `git_origin_url`, not `repo_url`. `repo_url` is the upstream repository that Kortix forwards to.

- The origin speaks git over HTTPS only.
- You authenticate with a Kortix token. Kortix adds the upstream credential on the server. No GitHub or other provider credential reaches your computer or a sandbox.
- The audit log records each clone and fetch as `git.clone` and each push as `git.push`. See [Audit log](/docs/guide#audit-log).

### Clone a project

### Install the CLI and sign in
Follow [CLI → Install](/docs/developers#install), then run `kortix login`.
### Clone
Run `kortix projects clone [<project_id>] [<dir>]`. `kortix projects ls` lists project ids. Without an id, the command clones the linked or default project. The directory defaults to the project id, not the project name.
### Finish setup in the new folder
Optional: run `kortix init --force` to configure your local coding agents in the checkout. Run `kortix env pull` to write a `.env` file with one line per project secret. It writes names only: secret values stay in Kortix.

**Customize → Settings → Git repo → Work on this locally** shows the same three steps with the commands filled in.

The clone does three things:

1. It runs `git clone` with your token in the process environment. The token is never in the remote URL or in `.git/config`.
2. It writes `.kortix/link.json`, which binds the folder to the project for `kortix` commands. It adds the file to `.git/info/exclude`, so the link stays on your computer.
3. It installs a credential helper for the origin, in the repository's own `.git/config`:

```ini
[credential "https://api.kortix.com/v1/git/<project_id>.git"]
	helper =
	helper = !kortix git-credential
[credential]
	useHttpPath = true
```

The empty `helper =` line turns off every helper you configured elsewhere for this URL, so your keychain never stores the Kortix token. `kortix git-credential` gives git the token of your stored login each time git asks. After the clone, plain `git pull` and `git push` work. If that login is gone, git stops with `Kortix Git needs login. Run: kortix login`, plus `--host <name>` when the link names a host.

The clone writes the link and the helper only when the repository root has `.kortix/`, `kortix.yaml` or `kortix.yml`. In any other repository, create a `.kortix/` folder at the root, then wire them by hand as in [Wire the helper by hand](#wire-the-helper-by-hand).

### Use any git client

The origin accepts HTTP Basic authentication. The username can be anything; the convention is `x-access-token`. The password is a Kortix token.

```bash
git clone https://api.kortix.com/v1/git/<project_id>.git
# Username: x-access-token
# Password: kortix_pat_…
```

Plain git saves the password in the credential store you configured. To keep the token out of every store, send it as a header on each command instead:

```bash
git -c http.extraHeader="Authorization: Bearer $KORTIX_GIT_TOKEN" clone https://api.kortix.com/v1/git/<project_id>.git
```

| Token | What it can do |
| --- | --- |
| Personal access key, `kortix_pat_…`, from `kortix login`, **Settings → Personal access keys** or `kortix tokens new` | Acts as you. Kortix checks it against your role on the project. Membership of the account alone gives no access. |
| Project CLI token, `kortix_pat_…`, from `kortix projects cli-tokens new` | Acts as you, on one project only. |
| Session token, `KORTIX_TOKEN` inside a sandbox | Reads the repository and pushes the session's own branch. See [Who can clone and push](#who-can-clone-and-push). |
| Service account key (`kortix_sa_…`), OAuth token, gateway key, browser sign-in token | Refused with `401`. |

[Security](/docs/guide#security) explains how to create and revoke keys.

#### Wire the helper by hand

Run these at the root of the checkout. `kortix projects link` refuses a folder without `.kortix/` or `kortix.yaml`. The helper answers only for the URL that equals the project's `git_origin_url`.

```bash
ORIGIN=https://api.kortix.com/v1/git/<project_id>.git
git config --local --replace-all credential.$ORIGIN.helper ''
git config --local --add credential.$ORIGIN.helper '!kortix git-credential'
git config --local credential.useHttpPath true
kortix projects link <project_id>
```

`kortix projects link` writes `.kortix/link.json`. The helper reads it to find the project and the host.

#### From CI

Create a project CLI token with `kortix projects cli-tokens new` and store it as a CI secret. The token acts as the person who created it, so that person needs push access to push.

```bash
AUTH="Authorization: Bearer $KORTIX_GIT_TOKEN"
git -c http.extraHeader="$AUTH" clone https://api.kortix.com/v1/git/$PROJECT_ID.git project
cd project
# change files, then commit
git -c http.extraHeader="$AUTH" push origin HEAD:refs/heads/ci-update
```

#### Errors from the origin

| Status | Message | Fix |
| --- | --- | --- |
| `400` | `invalid project identifier` | Use the project id, a UUID, in the URL. |
| `401` | `authentication required`, with `WWW-Authenticate: Basic realm="Kortix Git"` | Send a token. Git prompts for one. |
| `401` | `git proxy requires a Kortix token` | The password is not a Kortix token. Use a `kortix_pat_` token. |
| `403` | `token is not authorized for this project` | You have no role on the project that allows the operation. See [Who can clone and push](#who-can-clone-and-push). |
| `403` | `token is scoped to a different project` | The token is bound to another project. |
| `404` | `Not found` | The project does not exist or was deleted. |
| `409` | `Repository changed during authorization; retry the request` | Someone replaced the repository while you connected. Retry. |
| `409` | `Kortix cannot reach <owner>/<repo>: …`, printed as `remote: …` | The repository is disconnected. See [Repository disconnected](#repository-disconnected). |
| `502` | `No git upstream is configured for this project` or `git upstream unreachable` | Kortix cannot reach the upstream repository. Check the connection under **Git repo**. |
| `503` | `git_credential_unavailable` | Kortix could not get a credential for the upstream. Retry when the body says `retry: true`. Otherwise check the connection. |

### Commit and push

After a clone, commit and push with plain git. `kortix ship` runs these steps in one command:

1. It runs the `kortix validate` checks. `--no-verify` skips them.
2. It runs `git add -A` and commits, with the message from `-m`. The default message is `kortix: ship`. `--no-commit` fails when the working tree has changes.
3. It offers to set each `env` secret that has no value. `--no-env` skips this.
4. It pushes the current branch to the branch of the same name.
5. It connects each declared connector that still needs sign-in. `--no-connect` skips this.

`-n` / `--dry-run` prints the plan and changes nothing. `kortix deploy` is an alias.

#### The first ship

A folder with no `.kortix/link.json` gets a new project, unless the CLI has a default project. `kortix login` and `kortix projects use` set one. Ship then pushes to the default project instead. `kortix projects unset` clears the default.

For a new project, ship picks the repository from the folder's `origin`, without a prompt:

| `origin` of the folder | What ship creates |
| --- | --- |
| A GitHub URL | A GitHub project through the Kortix GitHub App. If the App is not installed, ship prints an install link and asks you to retry after you install it. With `-y` it stops instead. `--github-token <PAT>` links without the App; the token needs repository Contents read and write. |
| Another URL (GitLab, Bitbucket, plain git) | Nothing. Kortix links GitHub repositories only, so ship stops before any request and suggests `--origin managed`. |
| None, or `--origin managed` | A managed repository. Ship sets `origin` to the git origin and installs the credential helper. |

For a GitHub URL, the first ship pushes to your existing `origin` with your own git credentials.

`--origin <github-url>` sets `origin` to that GitHub URL first. `--account <id|slug>` picks the account, and `--name` the project name.

Ship writes `.kortix/link.json` the moment the project exists, and `git add -A` commits it. A teammate with push access (`project.gitops.push`, the Manager role) who clones the repository can then run `kortix ship` too. Later ships set `origin` to the project's git origin and install the credential helper.

### Who can clone and push

Five permissions control git:

| Permission | Allows |
| --- | --- |
| `project.gitops.read` | Read commits, diffs and change requests. For a session: clone and fetch. |
| `project.gitops.push` | Push. Open change requests. |
| `project.gitops.merge` | Merge change requests. |
| `project.gitops.ref.any` | For a session: push a branch other than its own. |
| `project.gitops.ref.delete` | Delete a branch. |

A project **Member** holds `project.gitops.read` only. A Member reads the branches, commits and change requests of the sessions they may open. A Member reads nothing of another member's private session.

A clone serves every branch, so a person needs `project.gitops.push` to clone or fetch. A Member cannot clone from their own git client. A Member's sessions still clone and push their own branch.

A **Manager** holds all five. Account owners and admins act as Managers on every project in the account. See [Teams](/docs/guide#teams).

**People** with push access:

- may push any branch, including the default branch;
- need `project.gitops.ref.delete` to delete a branch;
- can never delete the default branch. Nobody can.

IP allowlist and MFA policies of the account apply to git requests too.

**Sessions** push with the session token:

- A session pushes only its own branch, the branch named after its `session_id`.
- Any other branch needs `project.gitops.ref.any` in the agent's `kortix_permissions`. Deleting a branch needs `project.gitops.ref.delete`.
- A push to the default branch also needs `project.agent.write`, `project.trigger.create`, `project.trigger.update` and `project.trigger.delete`.
- The agent's `kortix_permissions` must list the permission, or be `all`, and the agent's ceiling role must also hold it. Neither widens the other. The role of the person who started the session is not an input. An agent with no `kortix_permissions` key holds no project permission. See [What an agent may do](/docs/guide#agents).

By default, agents land work through [change requests](#change-requests). [Agents](/docs/guide#agents) shows how to grant permissions.

Kortix does not block a force-push. On a GitHub repository, add a ruleset that blocks force-pushes and deletions on the default branch. The ruleset does not block change-request merges, because a merge is a fast-forward or a merge commit.

#### Read a refused push

Kortix refuses the whole push when it refuses one ref. Git prints the reason for each ref:

```text
 ! [remote rejected] HEAD -> feature-x (a session may only push its own branch (<session_id>); commit there and open a change request to land this elsewhere)
```

| Reason | Cause |
| --- | --- |
| `<branch> is the default branch and cannot be deleted` | A delete of the default branch. |
| `deleting <ref> requires git ref-delete authority` | A person without `project.gitops.ref.delete` deleted a branch. |
| `a session may only push its own branch (…)` | A session without `project.gitops.ref.any` pushed another branch. |
| `pushing <branch> skips change-request review and needs agent and trigger write permission; …` | A session pushed the default branch without `project.gitops.ref.any` and the manifest permissions. |
| `pushing <branch> could change agent grants without review, and an agent grants only what it holds; …` | A governed agent pushed the default branch but does not hold every permission it could grant. |
| `a session may not delete its own branch` | A session without `project.gitops.ref.delete` deleted its branch. |

### Session branches

Each session works on its own branch. The branch name is the `session_id`, with no prefix. Kortix cuts the branch from the session's `base_ref`, which is the project default branch unless the session was created with another one.

A session gets the branch and a checkout in `/workspace` only when its agent sets `workspace: { project_repository: true }`. The starter agents set it. Without it, repository calls answer `403` `session_repository_access_denied`.

Inside the sandbox, the branch is `$KORTIX_BRANCH_NAME`. The agent commits there and runs `git push origin HEAD`. [Inside a session sandbox](/docs/developers#inside-a-session-sandbox) lists the other variables.

A pushed branch stays in the repository after the session stops and after the session is deleted. `/workspace` holds this session's files, kept for the life of the session. Work the agent did not push is gone when the session is deleted. See [Computers](/docs/guide#computers).

Get an agent's work on your computer:

```bash
git fetch origin <session_id>
git checkout <session_id>
```

- `kortix files branches` lists the branches with commits ahead of and behind the default branch.
- `kortix sessions info <session_id>` shows `branch` and `base_ref`.
- To start a session on another branch, pass `base_ref` when you create it, in `POST /v1/projects/<project_id>/sessions` or in `sessions.create()` of the [SDK](/docs/developers#sessions-from-code). `kortix sessions new` has no flag for it.

When the base branch moves, open the command palette in the session and run **Ask Agent: Sync Branch & Reload**. The agent merges `origin/<base_ref>` into the session branch, resolves conflicts, runs the relevant tests, and commits. Its last command is `kortix sessions reload … --no-repo --force --yes`, which restarts the agent runtime and ends the turn. Send `continue` when the runtime is back.

### Change requests

A change request merges one branch, the head, into another, the base. Kortix runs the merge, the diff and the conflict check as git operations on the project repository. So change requests work on every kind of repository.

```text
open ──merge──▶ merged          (final)
open ──close──▶ closed ──reopen──▶ open
```

#### Open one from your computer

```bash
git push origin my-branch
kortix cr open --head my-branch --title "Add the triage agent" --description "Why and what changed"
```

- The base is the project default branch. `--base <branch>` targets another branch.
- A head with no commits ahead of the base is refused with `CR_HEAD_NOT_AHEAD`. Push first.
- Each later push to the head branch updates the same change request.

#### Open one from a session

An agent follows this contract:

1. Commit on `$KORTIX_BRANCH_NAME`.
2. Push: `git push origin HEAD`.
3. Open: `kortix cr open --title "…" --description "…"`. Inside a sandbox, `--head` and `--session` fill themselves from `$KORTIX_BRANCH_NAME` and `$KORTIX_SESSION_ID`.
4. Report the change request number to the person, and stop.

A change request from a session targets the session's `base_ref`. An agent cannot retarget it: `--base` with another branch returns `400` `CR_BASE_NOT_SESSION_BASE`. A person can open a change request for the same head with `--base`.

#### Review and merge

Review in the web app on the [Review page](#the-review-page), or from a terminal:

```bash
kortix cr ls                     # open change requests
kortix cr diff 3                 # the unified diff
kortix cr merge-preview 3        # can it merge? lists conflicts
kortix cr merge 3
kortix cr request-changes 3 --message "Rename the flag first"
```

A merge runs these steps:

1. Kortix validates `kortix.yaml` on the head branch, with its `imports:`. An invalid manifest stops the merge with `422` `MANIFEST_INVALID`. A head with no manifest passes.
2. Kortix fast-forwards the base when the head is strictly ahead.
3. Otherwise it creates a merge commit, `Merge CR #<n>: <title>`, authored by `Kortix <noreply@kortix.ai>`. `--message` sets another message.
4. A conflict stops the merge with `409` and the list of conflicting files.

A merge can carry the commits of other open change requests on the same base. Example: you merge the top request of a stack, and the requests below it land with it. Kortix marks each of those requests `merged`, with the same merge commit. The response lists them in `also_merged`, and `kortix cr merge` prints `Also merged #<n>`.

After the merge, running sessions on that base pick up the new config, and Kortix syncs the project's connectors from the new `kortix.yaml`. See [Config comes from the base branch](#config-comes-from-the-base-branch).

**Request changes** records the note on the change request and sends it to the session that opened it as a new prompt. A sleeping sandbox wakes.

#### Agents that merge

- An agent that declares `kortix_permissions` merges only when the grant includes `project.gitops.merge`. An agent without `kortix_permissions` cannot merge.
- A session cannot merge a change request it opened unless its grant in `kortix.yaml` includes `project.gitops.merge`. Without it, the merge returns `403` `CR_SELF_MERGE_REFUSED`.
- A merge that changes `agents`, `default_agent` or `triggers` also needs the permission the direct edit needs: `project.agent.write`, or `project.trigger.create`, `.update` or `.delete`. The merging agent cannot grant any permission it does not hold.

A grant that names `project.cr.open` or `project.cr.merge` counts as `project.gitops.push` or `project.gitops.merge`. Write the `gitops` name in new grants.

#### Permissions

| Action | Needs |
| --- | --- |
| List, show, diff, merge preview | Read access to the project. |
| Open | `project.gitops.push` |
| Edit, close, reopen | Write access to the project. An agent also needs `project.gitops.push`. |
| Merge | `project.gitops.merge`, plus the manifest permissions above for an agent. |
| Request changes | `project.review.act` |

#### `kortix cr` commands

`<cr>` is the change request number, such as `3`, or its UUID.

| Command | Does |
| --- | --- |
| `kortix cr ls [--status <s>]` | Lists change requests. `<s>` is `open` (default), `merged`, `closed` or `all`. |
| `kortix cr show <cr>` | Shows one change request. |
| `kortix cr diff <cr>` | Prints the unified diff. |
| `kortix cr open --head <branch> --title <t> [--base <branch>] [--description <t>]` | Opens a change request. Aliases: `--from`, `--into`, `--body`. |
| `kortix cr merge-preview <cr>` | Shows whether it can merge, and the conflicts. |
| `kortix cr merge <cr> [--message <t>]` | Merges it. |
| `kortix cr request-changes <cr> --message <t>` | Sends the note to the agent that opened it. |
| `kortix cr close <cr>` | Closes it without a merge. |
| `kortix cr reopen <cr>` | Reopens a closed one. |
| `kortix cr version-diff --from <head> --into <base>` | Summarizes a merge before you open a change request. |

Each command takes `--project <id>` and `--host <name>`. `ls`, `show`, `diff`, `merge-preview`, `request-changes` and `version-diff` also take `--json`. The REST routes are under `/v1/projects/<project_id>/change-requests` in the [API reference](https://api.kortix.com/v1/docs).

`GET /change-requests` returns one page, newest number first: 50 change requests by default, at most 500 with `limit` (a larger `limit` answers `400`). Pass `next_cursor` back as `cursor` for the next page; it is `null` on the last page. `kortix cr ls` follows it and lists every change request.

#### Errors

| Code | Status | Meaning |
| --- | --- | --- |
| `CR_HEAD_NOT_AHEAD` | `422` | The head has no commits ahead of the base. Commit your work on the head branch and push it. |
| `CR_BASE_NOT_SESSION_BASE` | `400` | An agent tried to target a branch other than its session's base. |
| `MANIFEST_INVALID` | `422` | The manifest on the head branch fails validation. The body lists the issues. Run `kortix validate`. |
| (conflict) | `409` | The merge conflicts. The body lists the files. |
| (not open) | `409` | The change request is merged or closed. |
| `CR_SELF_MERGE_REFUSED` | `403` | A session tried to merge its own change request without a merge grant. |
| `CR_GOVERNANCE_UNVERIFIED` | `503` | Kortix could not read the manifest to check an agent's merge. Nothing merged. Retry. |
| (manifest unreadable) | `502` | Kortix could not read the manifest from the head branch. |

### The Review page

The Review page is the project's inbox for everything that waits on a person. Open **Customize** → **Review** in the project sidebar, or go to `/projects/<project_id>/customize/review`.

| Item | Id | Comes from |
| --- | --- | --- |
| Change | `cr:<id>` | A change request. |
| Approval | `call:<id>` | A connector call that a policy holds for approval. See [Connectors](/docs/guide#connectors). |
| Output (`output`), question (`decision`), finished batch (`batch`) | its own id | An agent submits it with `kortix review submit`. |

The lists are **Needs you**, **Waiting** and **Done**. The filters are **All**, **Changes**, **Approvals**, **Outputs**, **Questions** and **Finished**. Keys: `j` and `k` move, `Enter` opens, `a` approves, `e` asks for changes, `d` dismisses, `1` `2` `3` switch lists, `/` searches, `?` shows help.

A change opens as a page, with its diff. Every other item opens in a dialog over the list, the way an approval does. `Esc` closes it.

An output shows the agent's note, then what the agent made: the first image, video or audio file under `/drives/` plays in the dialog, the `preview` text shows as its description, and each other file is a row that opens it. An output that names no file shows only its note.

Each list loads 50 items at a time, newest first. **Load more** shows the next 50. The filters and the search apply to the items loaded so far. The **Review** count in the sidebar, and the dot on a session that waits on you, count every item that needs you.

What a verdict does depends on the item:

| Item | `approve` | `reject` | `changes` |
| --- | --- | --- | --- |
| Change | Merges the change request. | Closes it. | Sends the note to the agent that opened it. A note is required. |
| Approval | Lets the call run. | Denies it. | Not available. |

An item an agent submitted takes every verdict: `approve`, `reject`, `changes`, `answer` and `dismiss`.

The Review page shows a verdict only to a role that holds its permissions. Every verdict on a change or on an agent's item needs write access to the project (`project.write`):

| Control | Needs |
| --- | --- |
| **Ship it** (web), **Merge** (mobile) | `project.write` + `project.gitops.merge` |
| **Solve with agent** (web), **Resolve conflicts** (mobile): it replaces the merge on a conflicted change | `project.write` + `project.gitops.merge` |
| Ask for changes, close, dismiss, answer, and every verdict on an agent's item | `project.write` + `project.review.act` |
| **Approve** and **Deny** on a connector approval | A project manager, or the person who launched the session. The API refuses anyone else. |
| **+** on mobile, to open a change request | `project.write` + `project.gitops.push` |

A built-in member holds none of these permissions. It sees changes and agent items without verdicts, and it decides a connector approval only in a session it launched.

The change request dialog on the Files page and on a session's change card follows the same rule. **Apply changes** and **Fix with agent** need `project.write` + `project.gitops.merge`. **Dismiss** and **Reopen** need `project.write`.

| Command | Does |
| --- | --- |
| `kortix review ls [--segment <s>] [--kind <k>]` | Lists items. Segments: `needs_you`, `waiting`, `done`. Kinds: `change`, `approval`, `output`, `decision`, `batch`. |
| `kortix review show <item>` | Shows one item, with a connector call's arguments. |
| `kortix review act <item> <verdict> [--message <t>]` | Decides one item. |
| `kortix review bulk <verdict> <id>…` | Decides several agent items. It skips changes and approvals, which each need their own review. |
| `kortix review submit --kind <k> --title <t>` | Submits an `output`, `decision` or `batch`. Options: `--summary`, `--risk` (`none`, `low`, `medium`, `high`), `--detail <json>`, `--agent`, `--session`. |

An output names its files in `--detail`. `files` takes `{"path", "note"}` objects or plain paths:

```bash
kortix review submit --kind output --title "Launch video" \
  --summary "Draft cut. Check the claims before you publish." \
  --detail '{"files":[{"path":"/drives/me/launch/cut.mp4"},{"path":"/drives/me/launch/sources.json","note":"Sources"}],"preview":"The post text."}'
```

The Review page also reads `artifact`, `description` and every other key whose value is a file path under `/drives/` or `/workspace/`, named after its key. A file under `/drives/` opens through the session that submitted the item. A file under `/workspace/` is listed and does not open: the Review page has no running sandbox.

Reading needs `project.review.read`, a verdict needs `project.review.act`, and `submit` needs `project.review.submit`. A Member holds read and submit. A Manager holds all three.

The REST routes are under `/v1/projects/<project_id>/review`:

| Route | Returns |
| --- | --- |
| `GET /review/items` | One page of items: **Needs you**, then **Waiting**, then **Done**, newest first within each. Filters: `segment`, `kind`, `session_id`. `limit` is 50 by default; above 200 it answers `400`. Pass `next_cursor` back as `cursor` for the next page; it is `null` on the last page. A walk is not a snapshot: an item whose status changes between two pages can come back on a later page or be missed. `kortix review ls` follows the cursor and lists each item once, with its newest status. |
| `GET /review/items/<item>` | One item by its id, including `cr:<id>` and a pending `call:<id>`. |
| `GET /review/summary` | `{ needs_you_total, needs_you_by_session }`: the counts the sidebar shows. A connector call that you may not decide counts for the people who may. |

### Config comes from the base branch

A session runs the current config release of its base branch: `kortix.yaml` and the files it registers. Those are agent prompts, skills, tools, commands and extensions. It does not run the files in `/workspace`.

- An edit in `/workspace` or on a session branch takes effect only when it lands on the base branch, through a merge or a push.
- Each commit on the base branch is a new config release. A running session converges to it at the start of its next turn. A push or a merge also starts the convergence in the background.
- To apply it at once, select **Reload config** in the session menu, or run `kortix sessions reload <session_id>`. It restarts the agent runtime. `--force` reloads during a turn and ends that turn; it asks first unless you pass `--yes`. `--no-repo` skips the git pull in `/workspace`. `--status` only reports whether the session is behind.
- To test config before a merge, create a session with `base_ref` set to your branch. Its change requests target that branch too.
- Such a session runs the branch's agent behavior: each agent's prompt, model and permission rules, and the skills, tools, commands and extensions it enables. Governance still comes from the default branch: which agents are declared, and their secret, connector and permission grants. A branch cannot widen what an agent may use before it merges. When the project requires declared agents, an agent declared only on the branch is refused with `400` `AGENT_NOT_DECLARED`.

When a release fails to load, the session runs the last release that worked, or else the platform default config. The session header shows **Older config**. Kortix does not retry the failed release until the base branch changes. Fix the config on the base branch: `kortix validate` checks the manifest.

| Limit | Value |
| --- | --- |
| Config release | 32 MiB compressed, 128 MiB uncompressed |
| Project snapshot | 512 MiB compressed. Above it, running sessions stop picking up config changes until a new session starts. |
| `kortix validate` warning | One file of 10 MiB or more, or more than 512 MiB in total |

To keep a file in git but out of the release, mark it `export-ignore` in `.gitattributes`. A directory rule covers everything under it. The release, the `kortix validate` size check and session config downloads leave those files out. The manifest file always ships whole.

```text
fixtures export-ignore
datasets/raw export-ignore
```

### Manage the repository

**Customize → Settings → Git repo** shows the repository, its **Status**, the **Base branch** and the **Manifest file**. It also holds **Work on this locally**, **Use your own Git client** and **People with access**. The base branch is the branch new sessions and change requests start from.

From the CLI, `kortix projects set --branch <branch>` and `--manifest <path>` change the same fields. Both need `project.settings.write`.

#### GitHub connection

`GET /v1/projects/<project_id>/git/connection` returns the connection `state`:

| `state` | Meaning |
| --- | --- |
| `connected` | Kortix can reach the repository. |
| `reconnect_required` | The Kortix GitHub App installation is missing, unusable, or does not match the repository. Open the `install_url` in the response to install the App again. |
| `unavailable` | Kortix cannot reach the repository for a reason you cannot fix with the App. |
| `not_connected` | The project has no repository. |

#### Repository disconnected

Kortix reaches a GitHub repository through the Kortix GitHub App installed on the repository's owner. When that access goes away, the project is **disconnected**. `GET /v1/projects/<project_id>/git/connection` then returns `state: reconnect_required`, `reason: repository_disconnected`, a `disconnected_reason`, a `message` with the cause and the fix, and an `install_url`.

| `disconnected_reason` | Cause | Fix |
| --- | --- | --- |
| `app_uninstalled` | The Kortix GitHub App was uninstalled from the owner, or its connection was removed from your Kortix account. | A GitHub owner of the organization reinstalls the App: **Customize → Settings → Git repo → Reconnect GitHub**. |
| `app_suspended` | An owner suspended the App on GitHub. | A GitHub owner unsuspends it under GitHub **Settings → GitHub Apps**. |
| `repo_not_accessible` | The repository was deleted, renamed, or removed from the App's repository access. | A GitHub owner adds the repository again under GitHub **Settings → GitHub Apps → Kortix → Repository access**, or you replace the repository. |

While a project is disconnected:

- Every API route that reads the repository answers `409` with `code: repository_disconnected`, the `reason`, and the same message. The project detail still loads: its `config.manifest_version.unknown_reason` is `repository_disconnected` and `git_connection.status` is `error`.
- Session create and start fail at once with that `409`. A session that is already running keeps running.
- Scheduled triggers skip each run and record the message as the trigger's last error. They do not retry.
- `git clone`, `fetch` and `push` through the Kortix git origin print the message as `remote: …`.
- The web app shows **GitHub disconnected** in the sidebar, in **Git repo** and on a session that cannot start. `kortix projects info` prints the reason and the reconnect link.

Kortix checks GitHub again at most every 5 minutes. After the fix on GitHub, the project reconnects on the next check. Reconnecting through **Reconnect GitHub** checks at once, and a project that stored the old installation moves to the new one.

#### Replace the repository

Under **Git repo**, select **Change** and enter the new GitHub repository URL. Kortix verifies access through the GitHub App. The new repository needs the manifest file on its default branch.

Stop active sessions, and close or merge open change requests, first. New sessions use the new repository. Existing sessions cannot restart after the change.

The route is `PUT /v1/projects/<project_id>/git/repository`. It needs `project.settings.write` and `project.connector.write`.

#### Give people direct access

A managed repository lives on GitHub under Kortix. To let someone work on it on github.com, add their GitHub username under **People with access**, with **Can edit** or **Can view**. GitHub emails them an invite.

This needs `project.members.manage`, and works on managed repositories only. For a repository you own, manage access on GitHub.

#### A repository on another git host

To let Kortix reach a repository on another git host, store a token with `PUT /v1/projects/<project_id>/git-credential` and the body `{ "token": "…" }`. Kortix keeps it as a platform credential, not as a secret a sandbox can read. It needs `project.connector.write`. GitHub and managed repositories refuse it with `409`.

For a repository on another git host, `kortix sessions new` run in a checkout whose `origin` is that repository creates the session branch. It pushes your current branch with your own git credentials.

#### Browse without a clone

`kortix files` reads the committed repository. It acts on the default branch unless `--ref <branch|tag|sha>` picks another. Every subcommand needs `project.file.read`.

| Command | Does |
| --- | --- |
| `kortix files ls [<path>]` | Lists files. |
| `kortix files cat <path>` | Prints a file. |
| `kortix files search <query> [--content]` | Searches file names, or file contents. |
| `kortix files history <path>` | Lists the commits that changed a file. |
| `kortix files branches` | Lists branches, ahead and behind the default branch. |
| `kortix files commits [--path <p>]` | Lists commits on `--ref`. |
| `kortix files show <sha>` | Shows one commit and its files. |
| `kortix files diff <sha> [--path <p>]` | Prints a commit's patch. |
| `kortix files download -o <file.zip> [--path <p>]` | Downloads the repository or a subtree as a zip. |

For uncommitted files in a running sandbox, use `kortix sessions cp <session_id>:<path> <dst>`. See [Computers](/docs/guide#computers).

#### Delete

`kortix projects rm <project_id>` deletes a project. It needs `project.delete`. Deleting a project deletes its managed repository. A repository you connected yourself is left untouched.

Through the API, `DELETE /v1/projects/<project-id>` answers `{ "ok": true, "deleted": true, "repo_deleted": <bool> }`. `repo_deleted` says whether a managed repository went with it. The response also carries `archived: true` for older clients; it means the same as `deleted`, and there is no restore.

## kortix.yaml reference

`kortix.yaml` is the manifest at the root of a project repository. It defines the project's agents and everything each agent may use. This section lists every key of `kortix_version: 3`.

The smallest useful manifest:

```yaml
# yaml-language-server: $schema=https://kortix.com/schema/kortix.v3.schema.json
kortix_version: 3
default_agent: assistant

agents:
  assistant:
    description: Answers questions about the project.
    prompt: You answer questions about this project. Read the files before you answer.
    skills: all                              # omitted: no skills
    workspace: { project_repository: true }  # omitted: no repository in /workspace
```

Every other key is optional. This agent may call every tool, and no tool call waits for a person: an omitted `permission` is `allow`.

### File and schema

Put `kortix.yaml` at the root of the project repository. Start it with the schema line, so a YAML editor validates and completes every key:

```yaml
# yaml-language-server: $schema=https://kortix.com/schema/kortix.v3.schema.json
```

`kortix schema --version 3` prints the same schema. `kortix schema --version 3 --url` prints its URL.

Validate before you push:

```bash
kortix validate                                  # ./kortix.yaml and its imports
kortix validate --file config/kortix.yaml --json
kortix validate --scopes                         # every grantable kortix_permissions entry
```

| Flag | Effect |
| --- | --- |
| `--file <path>` | Validate this file instead of `./kortix.yaml`. |
| `--json` | Print a JSON report: `valid`, `path`, `issues`. |
| `--scopes` | Print the grantable `kortix_permissions` entries and the renamed ones, then exit. |
| `--no-dockerfile-lint` | Skip the checks on sandbox template Dockerfiles. |

- `kortix validate` exits `0` with no errors, `1` with one or more errors, and `2` when the file is missing or unreadable.
- `kortix ship` runs the same checks before it commits.
- The change request merge runs the schema check on the merged manifest. An error blocks the merge with `422` `MANIFEST_INVALID`. A warning never blocks.
- An unknown key is an error. This holds at the top level and in agent blocks, commands, extensions, `workspace`, App blocks, trigger `on` and `run`, and imported files. The message names the key that replaces it, when one exists. An unknown key under `env` is a warning.

`kortix validate` and `kortix ship` also check the files around the manifest. Each of these is an error:

- an agent or command `prompt_file` that does not exist;
- a `skills` folder with no `SKILL.md`, or a `SKILL.md` whose frontmatter `name:` is not the skill name;
- a `tools` module or an `extensions.<name>.pi` repository path that does not exist;
- an agent `connectors` or `connectors_required` slug that `connectors` does not declare (`kortix_slack`, `kortix_teams`, `kortix_email` and `computer` always count as declared);
- an agent `secrets` name or a trigger `secret_env` that `env` does not declare.

They warn when the files in Git exceed 512 MiB, or when one file reaches 10 MiB.

> **Warning**
> `kortix.yaml` is in Git. Never write a secret value in it. Set values in **Secrets** and connector logins in **Connectors**. Connector `headers` are plain text.

### Example project

A support project: two agents, one skill, one tool, two commands, one extension, one connector with a read-only rule set, and a weekly trigger.

```yaml
# yaml-language-server: $schema=https://kortix.com/schema/kortix.v3.schema.json
kortix_version: 3
default_agent: support

project:
  name: support-desk
  description: Answers help desk tickets.

env:
  required: [HELPDESK_TOKEN]
  optional: [STRIPE_API_KEY]

sandbox:
  default: python
  templates:
    - slug: python
      name: Python 3.12
      image: python:3.12-slim
      cpu: 2
      memory: 4

# Registries: what the project has. A registry entry gives nothing to any agent.
skills:
  refund-policy: skills/refund-policy

tools:
  lookup_order: tools/lookup_order.ts

commands:
  triage:
    description: Sort a ticket by urgency.
    prompt: "Read ticket $1 and reply with its urgency: low, normal or high."
  weekly-report:
    description: Write the weekly support report.
    prompt_file: commands/weekly-report.md

extensions:
  web-access:
    pi: npm:pi-web-access@0.30.0

connectors:
  - slug: helpdesk
    provider: http
    base_url: https://helpdesk.example.com/api
    auth:
      type: bearer
    policy_sets:
      read-only:
        description: Reads only.
        policies:
          - match: "get_*"
            action: always_run
          - match: "*"
            action: block

policy:
  default_mode: risk
policies:
  - match: "helpdesk.delete_*"
    action: block
approvals:
  ttl: 4h

# Agents: each one enables registered things by name.
agents:
  support:
    description: Answers help desk tickets.
    mode: primary
    prompt_file: agents/support.md
    permission:
      bash:
        "*": allow
        "git push *": ask
    skills: [refund-policy, kortix-cli]
    subagents: [researcher]
    commands: all
    extensions: [web-access]
    connectors: [helpdesk]
    secrets: [HELPDESK_TOKEN]
    kortix_permissions: [project.session.read, project.gitops.push]
    workspace: { project_repository: true }
  researcher:
    description: Looks up an order's history. Never edits files.
    mode: subagent
    prompt: You research orders and report what you find. Never edit files.
    permission:
      edit: deny
    tools: [read, glob, grep, lookup_order, web_search]
    connectors: [helpdesk]
    policy_sets:
      helpdesk: read-only

triggers:
  - slug: weekly-report
    name: Weekly report
    on:
      cron: "0 0 9 * * 1"
      timezone: Europe/Berlin
    run:
      agent: support
      prompt: Write the weekly support report for the last 7 days.
```

The repository also holds `agents/support.md` (the prompt), `skills/refund-policy/SKILL.md` (with `name: refund-policy`), `commands/weekly-report.md` and the tool module:

```ts
// tools/lookup_order.ts
export default {
  description: 'Look up an order by id.',
  parameters: {
    type: 'object',
    properties: { id: { type: 'string', description: 'The order id' } },
    required: ['id'],
  },
  async execute(args: { id: string }) {
    return `order ${args.id}: shipped`;
  },
};
```

`kortix validate` shows what each agent enables. The `researcher` agent omits most grants, so it gets none of them:

```text
✓  kortix.yaml is valid

What each agent enables:
  support  skills=[refund-policy, kortix-cli]  subagents=[researcher]  commands=[all]  extensions=[web-access]  connectors=[helpdesk]  secrets=[HELPDESK_TOKEN]
  researcher  skills=[none (default-deny)]  subagents=[none (default-deny)]  commands=[none (default-deny)]  extensions=[none (default-deny)]  connectors=[helpdesk]  secrets=[none (default-deny)]
```

### Registries and grants

The project registers what exists. Each agent enables what it uses, by name. A registered item reaches no agent until an agent enables it.

| The project declares | An agent enables it with |
| --- | --- |
| `skills`: skill folders | `skills` |
| `tools`: tool modules | `tools` |
| `commands`: slash commands | `commands` |
| `extensions`: agent runtime packages | `extensions` |
| `connectors`: apps and APIs | `connectors`, `policy_sets` |
| `env`: secret names | `secrets` |
| `apps`: Apps | `apps` |
| `agents`: other agents | `subagents` |

- **A grant** is `all` (or `*`), `none` (or `""`), or a list of names. A list that contains `*` means all.
- **Omitted means none.** An omitted `skills`, `subagents`, `commands`, `extensions`, `connectors`, `secrets`, `apps` or `kortix_permissions` grants nothing.
- **Three keys are open by default:** `tools` is `all`, `permission` is `allow`, and `codemode` is `on`.
- **`workspace.project_repository` defaults to `false`.** Set `true` on every agent that reads or changes the repository, runs its scripts, or opens change requests.
- **Names must exist.** A listed skill, command, extension, subagent or tool that is not registered or built in is an error.
- **Built in, with no entry.** The Kortix skills: `kortix-cli`, `kortix-memory` and the other `kortix-*` skills. The Kortix tools: `web_search`, `image_search`, `image_gen`, `scrape_webpage`, `memory`, `show`. See [Kortix tools](/docs/developers#kortix-tools). The built-in tools: `bash`, `read`, `write`, `edit`, `glob`, `grep`, `question`, `task`. An agent loads a Kortix skill only when its `skills` grant names it or is `all`.
- **Only declared agents run.** There are no built-in agents.
- **Extensions** are packages for the agent runtime. A package holds runtime extensions, skills, prompts or themes. The change request merge builds npm packages, and a session never installs them. A session loads its extensions when its runtime starts, so a change reaches a running session at its next start.

### Top-level keys

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `kortix_version` | `3` | required | `3`. A higher version is an error. |
| `imports` | list | — | YAML files or folders whose collections merge into this file. See [imports](#imports). |
| `default_agent` | string | required | The agent a session or trigger runs when it names none. A declared agent that is enabled and whose `mode` is `primary` or `all`. |
| `project` | map | — | `name`: the project's name. A push that changes it renames the project, and a rename in Kortix commits it here. `description`: text for people who read the repository. |
| `env` | map | — | The secret names the project expects. Values live in Secrets, never in this file. See [env keys](#env-keys). |
| `sandbox` | map | the platform image | Sandbox templates and the project's default template. See [sandbox keys](#sandbox-keys). |
| `triggers` | list | — | Schedules, webhooks and App events that start agent work. See [triggers keys](#triggers-keys) and [Automations](/docs/guide#automations). |
| `connectors` | list | — | Apps and APIs agents can call. See [connectors keys](#connectors-keys) and [Connectors](/docs/guide#connectors). |
| `apps` | map | — | App slug → App block. See [apps keys](#apps-keys). |
| `skills` | map | the Kortix skills | Skill name → repository folder. The Kortix skills (`kortix-*`) are always registered. See [skills entries](#skills-entries). |
| `tools` | map | the Kortix tools | Tool name → repository module. The Kortix tools are always registered. See [tools entries](#tools-entries). |
| `commands` | map | — | Slash command name → command. See [commands entries](#commands-entries). |
| `extensions` | map | — | Extension name → package. See [extensions entries](#extensions-entries). |
| `policy` | map | — | The decision for a connector call that no rule matches. See [policy keys](#policy-keys). |
| `policies` | list | — | Connector rules for the whole project. They run before every policy set and base rule. See [policy keys](#policy-keys). |
| `approvals` | map | — | How long a held connector call waits for a decision. See [policy keys](#policy-keys). |
| `agents` | map | required | Agent name → agent block. At least 1 agent, here or in an imported file. A name is lowercase letters, digits, `-` and `_`, at most 128 characters. See [Agent keys](#agent-keys). |

### Agent keys

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `description` | string | — | What the agent does. An agent that may start it as a subagent reads it to choose. |
| `model` | string | the project default | `provider/model`. See [Models](/docs/guide#models). |
| `mode` | `primary`, `subagent`, `all` | `all` | `primary`: people and triggers run it; no agent starts it. `subagent`: only another agent starts it; agent pickers hide it. `all`: both. |
| `variant` | string | — | The model's reasoning level: `minimal`, `low`, `medium`, `high`, `xhigh` or `max`, when the model offers it. |
| `temperature` | number | the model's | Sampling temperature. A model that refuses a custom temperature ignores it. |
| `top_p` | number | the model's | Nucleus sampling. |
| `steps` | integer, 1 or more | no limit | Model requests in one run. The request that reaches the limit gets no tools, so the model answers. |
| `color` | string or `primary`, `secondary`, `accent`, `success`, `warning`, `error`, `info` | — | Accepted. Sessions do not use it. |
| `hidden` | boolean | `false` | `true`: agent pickers do not list the agent. |
| `options` | map | — | Accepted. Sessions do not use it. |
| `permission` | `ask`, `allow`, `deny` or map | `allow` | Which tool calls run, ask a person, or never run. `skill` and `task` belong to `skills` and `subagents`. See [Permission rules](#permission-rules). |
| `prompt` | string | — | The system prompt. Set `prompt` or `prompt_file`, not both. |
| `prompt_file` | string | — | A repository path to a `.md` file. The whole file is the system prompt. |
| `enabled` | boolean | `true` | `false`: no session starts with the agent, and no agent starts it as a subagent. |
| `codemode` | `on`, `only`, `off` | `on` | `on`: the model gets every tool plus `codemode`, which runs one JavaScript script that calls the other tools. `only`: `codemode` alone. `off`: no `codemode`. A script's calls follow the agent's `permission`. |
| `tools` | `all`, `none`, list or map | `all` | The tools the agent may call: built-in tools, Kortix tools and registered tools. A list allows only those. `{ exclude: [bash] }` allows every tool but those. |
| `skills` | grant | `none` | Registered skills and Kortix skills the agent may load. |
| `subagents` | grant | `none` | Agents this agent may start with the `task` tool: declared, enabled, `mode` `subagent` or `all`, and not itself. A subagent cannot start another subagent. |
| `commands` | grant | `none` | Registered commands the agent runs. |
| `extensions` | grant | `none` | Registered extensions that a session of the agent loads. |
| `connectors` | grant | `none` | Connector slugs the agent may call. |
| `connectors_required` | list | — | Connector slugs, a subset of `connectors`. Sessions do not check it. |
| `policy_sets` | map | the base rules | Connector slug → the name of a policy set on that connector. See [Connector rules and approvals](#connector-rules-and-approvals). |
| `secrets` | grant | `none` | Secret names the agent's sandbox receives. |
| `apps` | grant | `none` | App slugs the agent may open when the App's access is restricted or private. |
| `kortix_permissions` | grant | `none` | Project permissions (`project.*`) the agent holds, whoever starts the session. `kortix validate --scopes` prints them. An account permission is an error. See [Agents](/docs/guide#agents). |
| `workspace` | map | — | What new sessions of this agent get in /workspace. Any other key under `workspace` is an error. |
| `workspace.project_repository` | boolean | `false` | `true`: new sessions check out the project repository in `/workspace`, on their own session branch. `false`: no checkout, and repository calls answer `403` `session_repository_access_denied`. Project tools still load. |
| `sandbox` | string | `sandbox.default` | A template slug, or `default` for the platform image. |
| `network_egress` | map | — | Only `{ version: 1, default: deny, rules: [] }`. It validates with a warning: Kortix does not enforce it. |

### Registry entries

#### skills entries

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `skills.<name>` | string | — | The repository folder that holds the skill's `SKILL.md`. The folder's last segment is the name: `skills/pdf` for `pdf`. The `SKILL.md` frontmatter has `name: <name>`. |
| `<name>` | string | — | Lowercase letters and digits in words joined by `-`, at most 64 characters. A name that starts with `kortix-` is an error. |

#### tools entries

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `tools.<name>` | string | — | A repository path to a `.ts`, `.js`, `.mts`, `.mjs`, `.cts` or `.cjs` module. The module default-exports `{ description, parameters, execute(args, context) }`. `parameters` is a JSON Schema object. `execute` returns a string or a JSON value. |
| `<name>` | string | — | snake_case: a lowercase letter, then letters, digits or `_`, at most 64 characters. A built-in tool name or a `pty_` name is an error. A Kortix tool's name replaces that Kortix tool. `kortix tools eject <name>` copies a Kortix tool into `tools/<name>.ts` and writes its entry. |

#### commands entries

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `commands.<name>` | map | — | One slash command. An agent runs it only when its `commands` grant names it. Any other agent gets `403` `{"error": "command_not_enabled", "command": "<name>", "agent": "<agent>"}`. |
| `<name>` | string | — | Lowercase letters and digits in words joined by `-`, at most 64 characters. In the `/` menu, a command hides a skill with the same name. |
| `commands.<name>.description` | string | — | Shown in the `/` menu. |
| `commands.<name>.prompt` | string | — | The template. `$ARGUMENTS` takes the text after the command. `$1`, `$2` and on take the words in order, and the highest one takes the rest. A template with neither gets the arguments appended. Set `prompt` or `prompt_file`, not both. |
| `commands.<name>.prompt_file` | string | — | A repository path to a `.md` file. The whole file is the template, frontmatter included. |

#### extensions entries

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `extensions.<name>` | map | — | One package: runtime extensions, skills, prompts or themes for the agent runtime. |
| `<name>` | string | — | Lowercase letters and digits in words joined by `-`, at most 64 characters. |
| `extensions.<name>.pi` | string or map | — | `npm:<package>@<x.y.z>` with an exact version, or a repository path such as `extensions/audit.ts` (no leading `./`), or `{ source, extensions, skills, prompts, themes }`. A version range is an error. Set it on every extension: one without it loads in no new session. |
| `extensions.<name>.pi.source` | string | — | The npm pin or the repository path. Required in the map form. |
| `extensions.<name>.pi.extensions` | list | every one | Load only the package's extension files that match these globs. `[]` loads none. |
| `extensions.<name>.pi.skills` | list | every one | Load only the package's skill files that match these globs. `[]` loads none. |
| `extensions.<name>.pi.prompts` | list | every one | Load only the package's prompt files that match these globs. `[]` loads none. |
| `extensions.<name>.pi.themes` | list | every one | Load only the package's theme files that match these globs. `[]` loads none. |

### Section keys

#### env keys

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `env.required` | list | — | Secret names the project needs: uppercase letters, digits and `_`, not starting with a digit. The dashboard and `kortix ship` ask for a missing value. A missing value does not block a session. |
| `env.optional` | list | — | Secret names the same prompts offer. Values live in Secrets, never in this file. |

#### sandbox keys

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `sandbox.templates` | list | — | Named sandbox images. |
| `sandbox.templates[].slug` | string | required | Unique. Lowercase letters, digits, `-` and `_`, at most 64 characters. `default` is reserved. |
| `sandbox.templates[].name` | string | the slug | The label in the template picker. |
| `sandbox.templates[].entrypoint` | string | the image's | Overrides the container entrypoint. |
| `sandbox.templates[].image` | string | — | An image with a tag or a digest. No tag is an error. `:latest` is a warning. Set `image` or `dockerfile`, not both. |
| `sandbox.templates[].dockerfile` | string | — | A repository path to a Dockerfile. `kortix validate` lints it: no `COPY` from the repository, no `RUN` heredoc, a Debian-family base image. |
| `sandbox.templates[].cpu` | integer, 1 or more | the platform default | vCPU cores. Above 32 is a warning, and Kortix uses 32. |
| `sandbox.templates[].memory` | integer, 1 or more | the platform default | GiB of memory. Above 128 is a warning, and Kortix uses 128. |
| `sandbox.templates[].disk` | integer, 1 or more | the platform default | GiB of disk. Above 500 is a warning, and Kortix uses 500. |
| `sandbox.templates[].container_runtime` | boolean | `false` | `true`: Docker runs inside the sandbox. |
| `sandbox.default` | string | `default` | A slug from `sandbox.templates`, or `default` for the platform image. |

#### triggers keys

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `slug` | string | required | Unique. Lowercase letters, digits, `-` and `_`, at most 128 characters. |
| `name` | string | the slug | The label in the dashboard. |
| `enabled` | boolean | `true` | `false`: the trigger does not fire. |
| `filter` | map | — | Payload path → a value (equals), or one operator: `not`, `in`, `exists`, `prefix`, `not_prefix`. A fire runs only when every path matches. |
| `max_runs_per_hour` | integer, 1 or more | `60` | Runs the trigger starts in any 60 minutes. |
| `max_runs_per_day` | integer, 1 or more | — | Runs the trigger starts in a UTC day. |
| `max_lateness` | string | — | Schedules only, `60s` or more. A missed fire later than this does not run. |
| `overlap` | `skip`, `allow` | `skip` for a schedule, `allow` for others | `skip` holds a fire while the previous run is active. Only with `run.session: fresh`. |
| `on` | map | required | What fires the trigger: a schedule (`cron`, `every` or `at`), a webhook, or an App event. |
| `on.cron` | string | — | A 6-field cron expression, seconds first: `"0 0 9 * * 1"` is 09:00 every Monday. A trigger fires at most once every 60 seconds. |
| `on.timezone` | string | `UTC` | An IANA time zone for `cron` and `at`. |
| `on.every` | string | — | A fixed period: an integer and `s`, `m`, `h` or `d`, at least `60s`. |
| `on.at` | string | — | One fire at an ISO-8601 time, such as `2026-06-01T09:00:00Z`. |
| `on.webhook` | map | — | Fire on a signed request to the trigger's webhook URL. |
| `on.webhook.secret` | string | required | The name of the secret that signs requests to the trigger's webhook. |
| `on.event` | string | — | With `on.app`: the App event name, or `"*"` for every event. |
| `on.app` | string | — | The App's slug. The trigger fires on that App's events. |
| `run` | map | required | What a fire runs: an agent turn, or a call to an App. |
| `run.agent` | string | `default_agent` | A declared agent. |
| `run.prompt` | string | — | The first message of the run. Required unless `run.app` is set. |
| `run.session` | `fresh`, `reuse`, `pinned`, `keyed` | `fresh` | `fresh`: a new session for each fire. `reuse`: the last session this trigger created, or a new one when none can continue. `pinned`: the session `run.session_id`. `keyed`: one session for each value of `run.session_key`. |
| `run.session_key` | string | — | With `keyed`: a template over the payload, such as `"{{ body.chat_id }}"`. A key that renders empty runs `fresh`. |
| `run.session_id` | string | — | The session to run in. Required with `pinned`. |
| `run.model` | string | the agent's model | `provider/model`. |
| `run.app` | string | — | The fire calls this App instead of an agent. |
| `run.path` | string | — | With `run.app`: the request path. Starts with `/`. |
| `run.method` | `POST`, `GET` | — | With `run.app`: the request method. |
| `run.payload` | map | — | With `run.app`: the request body. |
| `type` | `cron`, `webhook`, `app` | — | Required in the flat form: the trigger without `on` and `run`. `type` next to `on` is an error. |
| `agent` | string | — | Flat form of `run.agent`. |
| `session_mode` | `fresh`, `reuse`, `pinned`, `keyed` | — | Flat form of `run.session`. |
| `session_key` | string | — | Flat form of `run.session_key`. |
| `session_id` | string | — | Flat form of `run.session_id`. |
| `prompt` | string | — | Flat form of `run.prompt`. |
| `model` | string | — | Flat form of `run.model`. |
| `cron` | string | — | Flat form of `on.cron`, with `type: cron`. |
| `run_at` | string | — | Flat form of `on.at`, with `type: cron`. |
| `timezone` | string | — | Flat form of `on.timezone`. |
| `secret_env` | string | — | Flat form of `on.webhook.secret`, with `type: webhook`. |
| `app` | string | — | Flat form of `on.app`, with `type: app`. |
| `target` | map | — | Flat form of `run.app`, `run.path`, `run.method` and `run.payload`: `{ app, path, method, payload }`. |
| `every` | string | — | Flat form of `on.every`, with `type: cron`. |

#### connectors keys

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `slug` | string | required | Unique. Lowercase letters, digits, `-` and `_`, at most 128 characters. `kortix_slack`, `kortix_teams`, `kortix_email` and `computer` are reserved for the built-in connectors. |
| `name` | string | the slug | The label. |
| `enabled` | boolean | `true` | `false`: the connector is off. |
| `provider` | `pipedream`, `composio`, `mcp`, `openapi`, `postman`, `graphql`, `http`, `channel` | required | How Kortix reaches the app or API. Kortix writes the `channel` entry when you connect a channel. |
| `app` | string | — | `composio` and `pipedream`: the app. Required for them. |
| `url` | string | — | `mcp`: the server URL. Required. |
| `endpoint` | string | — | `graphql`: the endpoint. Required. |
| `base_url` | string | — | `http`: the base URL. Required. |
| `transport` | `http`, `sse` | `http` | `mcp` only. |
| `spec` | string | — | `openapi` and `postman`: the spec URL or repository path. Without it the connector does not load. |
| `authorization_strategy` | `project`, `user` | `project` | `project`: the connector's accounts belong to the project. `user`: each person connects their own account. |
| `sensitive` | boolean | `false` | `true`: a call that no rule matches needs approval, reads included. |
| `auth` | map | — | How Kortix sends the credential. The credential lives in Kortix, never in this file. |
| `auth.type` | `bearer`, `basic`, `custom`, `api_key`, `oauth1`, `hmac`, `aws_sigv4`, `mtls`, `none` | required | `oauth1` works on `openapi`, `postman` and `http` only. |
| `auth.in` | `header`, `query`, `cookie` | `header` | Where the credential goes. |
| `auth.name` | string | — | The header, query or cookie name. Required for `custom` and `api_key`. |
| `auth.prefix` | string | — | Text before the credential value, such as `Bearer`. |
| `headers` | map | — | Static request headers, at most 32. The values are plain text in Git. |
| `policies` | list | — | The base rules: `{ match, action, conditions }`. |
| `policy_sets` | map | — | Set name → `{ description, policies }`: a rule set an agent can be given with `agents.<name>.policy_sets`. A set name is lowercase letters, digits, `-` and `_`, at most 64 characters, and not `base`. At most 32 sets of 200 rules each. Not on `channel` connectors. |

#### policy keys

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `policy.default_mode` | `allow_all`, `risk` | `allow_all` | `allow_all`: the call runs. `risk`: a read runs, and a write or a delete waits for approval. |
| `policies[].match` | string | — | Required. A glob over `<connector-slug>.<tool>`, such as `gmail.send_*` or `*.delete*`. Case-insensitive. |
| `policies[].action` | `always_run`, `require_approval`, `block` | — | Required. What a matching call does. |
| `policies[].conditions` | list | — | Every condition must hold. Each is `{ arg, match, negate }`: `arg` is a dot path into the call's arguments, `match` is a glob or a `/regex/`, and `negate: true` inverts it. |
| `approvals.ttl` | string | `24h` | An integer and `m`, `h` or `d`, such as `30m`, `4h` or `7d`. Kortix clamps it to 5 minutes through 30 days. |

#### apps keys

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `apps.<slug>` | map | — | One App's deploy settings. Any other key is an error. Its keys: `path`, `type`, `image`, `dockerfile`, `command`, `port`, `root`, `output_dir`, `install_command`, `build_command`, `spa`, `readiness_path`, `idle_timeout_seconds`, `always_on`, `uses`, `resources`, `env`, `secrets`, `processes`, `http_service`, `services`, `mounts`, `vm`, `deploy`, `labels`, `display`. Full reference: [Configure in kortix.yaml](/docs/developers#configure-in-kortixyaml). |

### Permission rules

`agents.<name>.permission` decides which tool calls run, wait for a person, or never run. What a person sees when an agent asks is on [Agents](/docs/guide#how-an-agent-asks-before-risky-actions).

```yaml
permission:
  bash:
    "*": allow
    "git push *": ask
    "rm -rf *": deny
  edit:
    "*": allow
    "secrets/*": deny
  external_directory: deny
  webfetch: ask
```

- **Value.** One action for every tool: `allow`, `ask` or `deny`. Or a map of key → action, or of key → map of glob → action.
- **Keys.** A capability: `read`, `edit`, `glob`, `grep`, `bash`, `external_directory`, `question`, `webfetch`, `websearch`. Or a tool's own name, which wins over its capability.
- **Tools that follow a capability.** `write` follows `edit`. `memory` follows `edit`, or `read` when it only views. `image_gen` follows `edit`. `web_search` and `image_search` follow `websearch`. `scrape_webpage` follows `webfetch`.
- **Action only.** `question`, `webfetch` and `websearch` take an action, not a glob map.
- **`bash` globs** match each command of a line. In `a; b`, `a | b` or `$(b)`, the strictest answer wins: `deny`, then `ask`, then `allow`.
- **Path globs** match the file, relative to `/workspace`. A path outside `/workspace` also answers to `external_directory`. A path rule does not govern `bash`.
- **Precedence.** The most specific matching rule wins, whatever the key order. The longest pattern is the most specific.
- `permission.skill` and `permission.task` are errors. Use `skills` and `subagents`.

### Connector rules and approvals

Kortix decides each connector call in this order. In each list, the first rule that matches decides.

1. The project `policies`.
2. The calling agent's policy set on that connector (`agents.<name>.policy_sets`). A set the connector does not define blocks every call of that agent on it.
3. The connector's own `policies`, the base rules.
4. No rule matched: a `sensitive` connector holds the call for approval. Otherwise `policy.default_mode` decides.

A set or a base rule cannot loosen a project rule. A set can loosen a base rule for its agent.

```yaml
policy:
  default_mode: risk
policies:
  - match: gmail.send_email
    action: require_approval
    conditions:
      - arg: to
        match: "*@example.com"     # a glob, or /regex/ in slashes
  - match: gmail.send_email
    action: block
approvals:
  ttl: 4h
```

- An agent `policy_sets` entry that names a set the connector does not define is an error. An entry for a connector the agent is not granted, or that `connectors` does not declare, is a warning: it does nothing.
- `kortix validate` does not check `policy` or `policies`. When Kortix syncs connectors, it skips a malformed rule and reports it as a connector sync error.
- A held call that nobody decides within `approvals.ttl` expires. Approving one is on [Connectors](/docs/guide#approving-a-held-action).

### Sandbox templates

A session picks its sandbox template in this order:

1. The template the session request names.
2. The `sandbox` of the agent the session runs.
3. `sandbox.default`.
4. The platform image.

A hardware value below 1 is an error. A value above the maximum is a warning, and Kortix clamps it. `gpu` is a warning and does nothing. An image key set directly on `sandbox` (`image`, `dockerfile`, `slug`, `cpu`, `memory`, `disk`, `entrypoint`, `context`, `context_dir`, `gpu`) is an error: put it in a template.

What each place on the sandbox keeps:

- `/workspace` holds this session's files, kept for the life of the session.
- `/drives` (Drive) holds your files and the company's files, shared across sessions and kept until deleted.
- Anything else (home directory, `/tmp`, installed packages) can be gone when the session reopens. Put tools in a sandbox template.

### imports

`imports` splits the manifest across several YAML files. Kortix merges them first. Validation, triggers, grants and connector sync all read the merged manifest.

```yaml
# kortix.yaml
kortix_version: 3
default_agent: assistant
imports:
  - triggers/          # every .yaml and .yml file below this folder
agents:
  assistant:
    prompt: You write reports.
```

```yaml
# triggers/reports/weekly.yaml
triggers:
  - slug: weekly-report
    on:
      cron: "0 0 15 * * 0"
    run:
      agent: assistant
      prompt: Build the weekly report.
```

1. A path is relative to the repository root. `..`, an absolute path and a glob are errors. A path that matches no `.yaml` or `.yml` file is an error.
2. A folder import takes every `.yaml` and `.yml` file below it, at any depth, sorted by path.
3. An imported file holds only `agents`, `apps`, `commands`, `connectors`, `extensions`, `skills`, `tools`, `triggers` and `imports`. Any other key in it is an error.
4. Imports nest up to 8 levels and 200 files. A cycle is an error. A file reached twice merges once.
5. One name, one file. A trigger slug, connector slug, agent name, App slug, skill, tool, command or extension declared in two files is an error that names both files.
6. Entries merge in order: the importing file first, then its imports as listed.
7. A Kortix edit to an entry rewrites the file that declares it. A new entry goes to `kortix.yaml`.

A broken import fails the whole manifest:

```text
error imports: triggers "weekly-report" is declared in both triggers/a.yaml and triggers/b.yaml — one name, one file
```

### Kortix tools

Kortix maintains six tools. A `kortix_version: 3` project registers all six with no entry. Every agent gets them unless its `tools` narrows them. The sandbox runs the same module on both harnesses, with the same name, arguments and output.

| Tool | What it does |
| --- | --- |
| `web_search` | Searches the web. Returns titles, URLs, snippets, and a short answer. |
| `image_search` | Searches for images. Returns image URLs, titles, source pages, and dimensions. |
| `image_gen` | Generates or edits 1 to 4 images with an image model the project can use, and saves them in the workspace. See [Generate images](/docs/developers#generate-images). |
| `scrape_webpage` | Fetches one or more pages and returns them as Markdown. |
| `memory` | Views, creates, edits, renames, and deletes files under `memory/`, the project memory. |
| `show` | Presents a file, image, URL, document, or text to the person in the session. Each shown item whose path is an image file (png, jpg, jpeg, webp, gif, avif, svg) of at most 32 MiB carries `sha256`, the lowercase hex SHA-256 of its bytes, when the file is read within 2 s. |

- **See what a session gets:** run `kortix tools ls` from the project root.
- **A `kortix_version: 2` project** selects Kortix tools under `tools:` as `<name>: kortix:<name>`. With no `tools:` key, in the root file or in any imported file, it gets all six. With a `tools:` key, even an empty one, it gets the Kortix tools the key lists, plus `image_gen`. Deleting a line removes that tool from every agent, except `image_gen`, which loads when its line is missing.
- **Own a Kortix tool:** `kortix tools eject <name>` writes the source the sandbox runs to `tools/<name>.ts` and points the tool at it. The copy imports only `node:*` modules. `web_search`, `image_search`, and `scrape_webpage` call the Kortix API with `KORTIX_API_URL` and `KORTIX_TOKEN` from `context.env`. `image_gen` calls the Kortix LLM gateway with `KORTIX_LLM_BASE_URL` and `KORTIX_TOKEN`. `memory` and `show` work on files in the project checkout. In a v2 project with no `tools:` key, the eject creates one and also lists the other five Kortix tools as `kortix:<name>`, so it removes nothing.
- **`context.turnId`** is optional in the `context` a tool's `execute(args, context)` receives: the turn the call belongs to, when the harness names it. pi sets it, and it is the same for every call of one turn, also for calls from a `codemode` script or a subagent. `context.signal` can differ per call. Key a per-turn limit on `turnId`. An OpenCode session does not set it.

### Generate images

`image_gen` generates one to four images from prompts, or edits images that are in the workspace. It uses an image model the project can reach: `gpt-image-2` on a connected ChatGPT plan, or an image model of an OpenAI, Google, xAI, or OpenRouter key. You can also name a model of your custom endpoint. Kortix-managed models do not generate images yet. It saves each image under `/workspace/output/images/`.

- **One call, one to four images.** Each prompt is one image. The tool sends one request per prompt, all at the same time.
- **It needs an image model.** Connect ChatGPT in **Customize → Models** (**Connect ChatGPT**) or with `kortix providers login openai`, or add an OpenAI, Google, xAI, or OpenRouter key. See [Models](/docs/guide#models). With none, the call fails with `image_model_unavailable`.
- **The output is paths, not images.** The model receives `{ action, model, provider, images: [{ path, size, prompt, sha256 }], failed: [{ prompt, error }] }`. `model` and `provider` name what made the first saved image. `size` is `WxH`, read from the saved file when its header is readable, else the size the provider reported. `sha256` is the lowercase hex SHA-256 of the saved file's bytes. `failed` is present only when a prompt failed. When every prompt fails, the call fails with the first error.

| Argument | Value |
| --- | --- |
| `action` | `generate` new images, or `edit` the reference images. Required. |
| `prompts` | 1 to 4 non-empty strings, one per image: what the image shows, or what to change. Required. |
| `model` | An image model id, for example `codex/gpt-image-2`, `openai/gpt-image-1`, `google/gemini-2.5-flash-image`, or `openrouter/google/gemini-2.5-flash-image`. Omit it to use `auto`. See [Which model makes the image](/docs/developers#which-model-makes-the-image). |
| `aspect_ratio` | `auto` (the default), `1:1`, `3:2`, `2:3`, `16:9`, `9:16`, `4:3`, or `3:4`. |
| `quality` | `auto` (the default), `low`, `medium`, or `high`. |
| `transparent_background` | `true` for a transparent background. The default is `false`. |
| `reference_images` | For `edit` only, and required there: 1 to 5 workspace paths of png, jpg, jpeg, or webp files. Every prompt uses them. |

- `quality` and `transparent_background` reach ChatGPT and OpenAI models only. Other models do not receive them.
- ChatGPT and OpenAI models receive a size, not a ratio. `auto` is `auto`. `1:1` is `1024x1024`. `3:2`, `4:3`, and `16:9` are `1536x1024`. `2:3`, `3:4`, and `9:16` are `1024x1536`. xAI models receive no aspect ratio. Other models receive the ratio as it is, and no ratio for `auto`.
- The file name is a slug of the prompt plus a timestamp. The extension comes from the image bytes: `.png`, `.jpg`, or `.webp`. Any other answer, for example an SVG, is not written.

#### The image card in the chat

A call shows one card with one frame per prompt.

- While the call runs, the card shows a placeholder per image in the requested aspect ratio, with "Generating image" (or "Generating 3 images"; "Editing image" for an edit) and the elapsed time. The images replace the placeholders when the call ends.
- One image fills the width of the message. Two to four images fill one rectangle: 2 side by side, 3 as one large image and two small ones, 4 as a 2 × 2 grid. On web, the card is at most 60% of the window height.
- The card shows no prompt and no model name. Click an image to open it full screen, uncropped. The full-screen view shows its prompt and the model that made it.
- A frame whose prompt failed shows the error. When the cause is a missing or expired connection, the card shows one connect action. A shared or read-only view shows no action.

#### Image limits

The tool enforces these limits in code. A prompt, a file, or a web page that the agent reads cannot raise them.

| Limit | Value |
| --- | --- |
| Images per call | 4. |
| Images per turn | 10. A call that would pass 10 is refused whole, before any request: `Image limit for this turn reached (10 images; 8 used, 3 asked).` |
| Images per session | 50, counted within one sandbox run. A sandbox restart resets the count. |
| Reference images per edit | 5. |
| Size of one reference image | 20 MiB. |
| Size of all reference images in one edit | 50 MiB. |
| Time per call | 300 seconds. |

- Every call of one turn counts toward the same 10: direct calls, parallel calls, calls from a `codemode` script, retried calls, and calls from a subagent the turn started.
- A reference image must be inside `/workspace`, after symlinks are resolved. Its content must match its extension. Any other file is refused before a request leaves the sandbox.
- An image is always written under `/workspace/output/images/`. An existing file is never replaced: the tool adds `-2`, `-3`, and so on to the name.
- When the turn stops, the requests stop and no further file is written.

#### Image errors

When the gateway refuses a prompt, its error is `Image generation failed (<status>): <message> <suggestion> [<code>]`. A rate limit adds `Retry after <n> s.` before the code. When the gateway names the provider that failed, `[provider:<id>]` comes right before the code, for example `[provider:openai] [provider_reauth_required]`. `openai-codex` is ChatGPT. The tool reports three failures itself: `Image generation returned no image.`, an image that is not png, jpeg, or webp, and its own 300-second timeout. The tool's timeout starts before the gateway's, so a slow request usually ends with the tool's timeout, not `image_timeout`.

| Code | Cause |
| --- | --- |
| `image_model_unavailable` | The project reaches no image model. |
| `not_an_image_model` | `model` names a model that cannot generate images. The suggestion names up to 5 image models the project can use. |
| `image_model_unsupported` | Kortix cannot call this model's provider for images, for example Anthropic or Bedrock. |
| `provider_not_connected` | `model` names a provider the project has no key or ChatGPT account for. |
| `provider_reauth_required` | The ChatGPT login expired, or the provider refused the key. Reconnect ChatGPT or check the key. |
| `image_rate_limited` | The provider's image limit is reached. The error gives the seconds until it resets. |
| `image_upstream_error` | The provider failed or answered without an image, for example after a safety refusal. |
| `image_upstream_unreachable` | The provider could not be reached. |
| `image_response_too_large` | The provider answered with more than 48 MiB. |
| `image_timeout` | The gateway stopped the request after 300 seconds. |

#### Turn image generation off

Turn `image_gen` off for one agent with its `tools` map, or deny it with a permission rule:

```yaml
agents:
  kortix:
    tools:
      image_gen: false        # the model does not see the tool
  researcher:
    permission:
      image_gen: deny         # the model sees it, every call is denied
```

- Set `image_gen: ask` to approve each call.
- `image_gen` writes a file, so `edit: deny` also denies it, unless the agent sets a rule for `image_gen`. The same applies to `memory`, on both harnesses.
- On OpenCode (deprecated), these tools cannot ask: `edit: ask` or an `edit` pattern map denies them.

#### Replace the image tool

Map `image_gen` to your own module. The module replaces the Kortix tool on every harness:

```yaml
tools:
  image_gen: tools/image_gen.ts
```

`kortix tools eject image_gen` writes the Kortix source to `tools/image_gen.ts` and sets this line. Keep the output shape `{ action, model, provider, images: [{ path, size, prompt, sha256 }], failed }`, or the chat shows the raw output in place of the image card. `sha256` is optional: outputs from before it was added have none.

#### Which model makes the image

The `image_gen` tool and the gateway's `/v1/images/*` endpoints use one image model per request. With `model` set to `auto` or left out, the gateway tries every image model the request can reach, in this order. Any error moves the request to the next model. A timeout or a stopped request ends it.

1. `codex/gpt-image-2`, when a ChatGPT account is connected.
2. One model per connected provider, in this order. Each provider uses the first id the model catalog lists as an image model.
   - OpenAI key: `gpt-image-1.5`, `gpt-image-1`, `gpt-image-1-mini`.
   - Google key: `gemini-2.5-flash-image`, `gemini-3.1-flash-image`.
   - OpenRouter key: `google/gemini-2.5-flash-image`, `google/gemini-3.1-flash-image`.
   - xAI key: `grok-imagine-image`.

- A disabled provider or model is skipped.
- A named model must be one the model catalog lists with image output. Any other model returns `not_an_image_model`, with up to 5 image models the project can use. A named model has no fallback.
- Two kinds of models with image output in the catalog do not count: Google Deep Research agents (`deep-research-*`) and routers that pick a model per request (an id whose last segment is `auto`, such as `openrouter/openrouter/auto`).
- Kortix-managed models do not generate images yet. No model in the managed lineup has image output.
- A model of your custom endpoint is tried when you name it, for example `my-vllm/flux-dev`. `auto` never picks one. The model must be in the endpoint's model list; a model missing from it fails with `model_not_found`. It works when the endpoint answers `/chat/completions` with the image in `choices[0].message.images[0].image_url.url`, as a `data:` URL. Otherwise the call fails with `image_upstream_error`: the endpoint's own error, or `<model> returned no image.`
- The routing policy and the [fallback models](/docs/guide#fallback-models) do not apply to images.
- Kortix calls the model with the protocol of its provider: the OpenAI Images API for ChatGPT, OpenAI, and xAI; Gemini `generateContent` for a Google key; chat completions with image output for OpenRouter and a custom endpoint. Anthropic and Bedrock models cannot generate images through Kortix.
- **A ChatGPT plan** sets its own image limit, separate from the plan's chat limit. Reaching the image limit does not pause the account for chat.

#### Image costs

- **A ChatGPT plan:** Kortix charges nothing. The image counts against the plan's image limit.
- **A custom endpoint:** Kortix charges nothing.
- **Your own key:** your provider bills the image. Kortix applies the same rule as for chat on your own key: see [Models](/docs/guide#models).
- **Kortix charges only for a delivered image.** A refusal, an answer without an image, a timeout, or a stopped request records no Kortix charge, and any credit hold is returned.

#### Image endpoints on the gateway

A gateway key (`kortix_gw_…`) also calls the image endpoints:

| Endpoint | Format |
| --- | --- |
| `POST /v1/images/generations` | Image generation. |
| `POST /v1/images/edits` | Image edit with 1 to 5 reference images. |

Both take `{ model, prompt, aspect_ratio, quality, background, images }`. `model` defaults to `auto`. `images` is for edits only: `[{ "image_url": "data:image/png;base64,..." }]`. The gateway makes one image per request and drops every other field. A legacy `size` (`1024x1024`, `1536x1024`, `1024x1536`) is read as the matching ratio when `aspect_ratio` is absent. The answer is `{ created, model, provider, data: [{ b64_json, mime_type }], size, usage }` for every provider. `size` and `usage` are present when the provider reports them.

### Migrate to v3

A project created in Kortix starts on `kortix_version: 3`. Projects on `kortix_version: 1` (`kortix.toml`) or `kortix_version: 2` keep running unchanged. Upgrading is opt-in, and it lands as a change request.

**From Kortix.** Select the upgrade alert in the project sidebar. Or open **Customize → Settings → Upgrades** and select **Run** on **Upgrade manifest to v3 (kortix.yaml)**. The alert shows only to people who can write to the project. Or run:

```bash
kortix projects upgrade [<id>]
```

Each one starts a session with the project's default agent. The agent converts the manifest with `kortix migrate`, runs `kortix validate` and opens a change request. It never merges it: a person reviews and merges it. Starting the upgrade needs permission to start sessions in the project.

**By hand.** In a checkout of a v1 or v2 project:

```bash
kortix migrate            # preview: the plan and the new kortix.yaml; nothing changes
kortix migrate --write    # apply it
kortix validate
```

`kortix migrate` does this:

- From v1, each `[[agents]]` entry and each agent `.md` becomes an agent. A grant v1 omitted is written as `all`, because v1 meant all. `env` becomes `secrets`. `default_agent` comes from the old harness config, else the first agent. `kortix.toml` is deleted.
- It moves each agent's `.md` frontmatter into its block. The `.md` keeps the prompt alone and becomes `prompt_file`.
- It copies the project-wide `permission` of the old harness config onto every agent that sets none, and deletes that config file.
- It turns `permission.task` into `subagents`.
- It turns the `skills/<name>/` folders into the `skills` registry.
- It turns the harness folder's command files into the `commands` registry. Each command is enabled on the agent its frontmatter names, else on `default_agent`.
- It turns plugins and `harnesses.pi.packages` into `extensions`, enabled on every agent that loaded them, and `harnesses.pi.codemode` into `codemode`.
- It sets `workspace: { project_repository: true }` on every agent that had the repository, because v3 defaults to `false`.
- It keeps a split manifest split. Each entry stays in its file, and the new registries go to `kortix.yaml`.

It lists what it cannot carry over under **Not carried over**. It refuses to write a result that does not validate. `--json` prints the plan as JSON. Then commit, push, and open a change request.

What changes from v1 and v2:

| v1 or v2 | v3 |
| --- | --- |
| `agents.<name>.file` and the agent `.md` frontmatter | `prompt_file` or `prompt`, and the behavior keys on the agent block |
| `agents.<name>.disable` | `enabled: false` |
| `agents.<name>.env` | `secrets` |
| `agents.<name>.kortix_cli` | `kortix_permissions` |
| `agents.<name>.connectors_personal` | `connectors_required` |
| `agents.<name>.repository_access` | `workspace.project_repository` |
| Top-level and agent `harnesses` | `extensions`, enabled with `agents.<name>.extensions`, and `agents.<name>.codemode` |
| Top-level `permission` | `agents.<name>.permission` |
| `permission.skill`, `permission.task` | `skills`, `subagents` |
| Harness config folders (`harnesses/`, `pi.config_dir`) | None: v3 reads no harness folder |
| `tools.<name>: kortix:<name>` | None: every Kortix tool is registered. Narrow an agent with `tools` |
| `channels` | None: channels are connected in the dashboard, and each one is a `channel` connector |
| `sandboxes` | `sandbox.templates` |
| `[[agents]]`, `[[triggers]]` and the other `kortix.toml` tables | The YAML maps and lists on this page |
| `workspace.project_repository` omitted: `true` | Omitted: `false` |
| A grant omitted in v1: all | Omitted: none |
## CLI

`kortix` is the Kortix command line. It does from a terminal what the web app does: create and clone projects, run sessions, review change requests, and configure agents, connectors and secrets. Use it on your own machine, in CI, from a coding agent, and inside a session sandbox, where it is already installed and signed in.

```sh
curl -fsSL https://kortix.com/install | bash
kortix login
kortix --help
```

### Install

The install script supports macOS and Linux on x86_64 and arm64. On Windows, run it inside WSL. It does these steps:

1. Downloads the release binary for your system from GitHub Releases.
2. Checks it against the release's `SHA256SUMS`. A missing or wrong checksum stops the install. When `cosign` is installed, it also checks the Sigstore signature of `SHA256SUMS`.
3. Puts the binary in `~/.kortix/kortix`.
4. Links `kortix` and `kortixt` (the [TUI](/docs/guide#everywhere)) into `/usr/local/bin`. If that directory is not writable, it uses `~/.local/bin` when it exists, else `sudo`.

| Variable | Effect |
| --- | --- |
| `KORTIX_VERSION=<x.y.z>` | Install that release instead of the latest. |
| `KORTIX_CHANNEL=dev` | Install the dev channel build (`dev-latest`). |
| `KORTIX_BIN_DIR=<dir>` | Link the commands into this directory instead of `/usr/local/bin`. |

```sh
curl -fsSL https://kortix.com/install | KORTIX_VERSION=0.14.8 bash
```

Check the install with `kortix version`. To update or remove the CLI, see [Maintain the CLI](#maintain-the-cli).

### Sign in to a host

```sh
kortix login
```

The CLI signs in to one Kortix instance at a time. An instance is a **host**. The order is always host › account › project › session.

You sign in to a host and pick an account in it. Then you pick a project in that account and run sessions in it.

`kortix login` does these steps:

1. Opens your browser. You approve the CLI there, and the token comes back on a local callback.
2. Selects your account. With one account it picks it. With several it asks. `--account <slug>` skips the question.
3. Sets a default project for the host. `--no-project` skips this step.

| Flag | Effect |
| --- | --- |
| `--token <token>` | Sign in with a token, with no browser. Use it on a headless machine. |
| `--device` | Approve the sign-in from a browser on any device, with no local callback. |
| `--account <slug>` | Select this account without a prompt. |
| `--no-project` | Do not set a default project. |
| `--host <name>` | Sign in to this host instead of the active one. |
| `--api <url>` | The API URL for the host. |

To create a token for `--token`, see [Security](/docs/guide#security).

#### Hosts

Every host keeps its own login. You use these two built-in hosts:

| Host | URL |
| --- | --- |
| `cloud` | `https://api.kortix.com`. The default. |
| `selfhost` | `http://localhost:13738`. `kortix self-host start` sets it to your instance's URL. |

| Task | Command |
| --- | --- |
| Add a host and sign in | `kortix hosts add <name> --url <api-url> --login` |
| Switch the active host | `kortix hosts use <name>` |
| Run one command on another host | `kortix <command> --host <name>` |
| List hosts and their login state | `kortix hosts ls` |
| Show one host or the active one | `kortix hosts info [<name>]` (`--json`) |
| Show the signed-in user on a host | `kortix hosts whoami [<name>]` |
| Remove a host you added | `kortix hosts rm <name>`. A built-in host is reset, not removed. |
| Sign out of a host | `kortix logout`, or `kortix hosts logout <name>` |
| Switch the active account | `kortix accounts use <slug>` |

The CLI changes a remote `http://` URL to `https://`. A private address keeps `http://`: `localhost`, a LAN IP, and a name that ends in `.local`, `.lan`, `.internal` or `.home.arpa`. A name with no dot, such as a Docker Compose service, also keeps it.

The logins live in `~/.config/kortix/config.json`, with file mode `0600`. `KORTIX_CONFIG_FILE` sets another path.

#### Check who you are

| Command | Output |
| --- | --- |
| `kortix whoami` | The host, the user, the active account, the default project and the token context. `--json` for scripts. |
| `kortix token` | Only the token context: the token kind (user, project or session token), its project, session and agent, and its connector and permission grants. It never prints the token itself. |

### Which project a command uses

A project command runs against the first project it finds in this order:

1. `--project <id>` on the command.
2. `.kortix/link.json` in the current directory. The CLI does not look in parent directories.
3. The `KORTIX_PROJECT_ID` environment variable.
4. The host's default project.

| Task | Command |
| --- | --- |
| Set the host's default project | `kortix projects use [<project_id>]` |
| Bind the current directory to a project | `kortix projects link [<project_id>]` |
| Remove the binding | `kortix projects unlink` |
| Clear the default project | `kortix projects unset` |

`kortix projects use` also switches the active account to the project's account. `kortix projects link` writes `.kortix/link.json` and works only in a directory that has a `.kortix/` directory or a `kortix.yaml`. `kortix projects clone` and the first `kortix ship` write the link for you.

The link also records the host. A command in a linked directory goes to that host, whatever host is active.

A session ID works across projects. When the session is not in the resolved project, the CLI searches your other accounts and logged-in hosts. `--host` or `--project` turns the search off. The 8-character short ID from `kortix sessions ls` works anywhere a session ID goes.

### The everyday loop

The loop runs on the project's git repository. You push your branch, each session works on its own branch, and you merge its change request. [Quickstart](/docs/developers#quickstart) walks through it the first time. [Git](/docs/developers#git) covers cloning, pushing from any git client, session branches and change requests.

```sh
kortix init my-agent && cd my-agent           # new project from the starter
kortix ship                                   # validate, commit, push; creates the project
kortix sessions new --prompt "Add a skill that drafts the weekly status report"
kortix chat <session_id>                      # talk to the agent; -p "…" sends one prompt
kortix sessions wait-for <session_id>         # block until the agent is done
kortix cr ls && kortix cr diff 1              # read its change request
kortix cr merge 1 && git pull                 # merge it and pull the result
```

- `kortix init` writes a `kortix_version: 3` project. Its fields are on [kortix.yaml reference](/docs/developers#kortixyaml-reference).
- `kortix projects clone <project_id> my-agent` gets an existing project instead. It clones with your Kortix login and installs a git credential helper, so plain `git pull` and `git push` work in the clone.
- `kortix ship` commits every change (`git add -A`) and asks for missing secrets. Then it pushes the current branch to the branch with the same name and asks you to connect declared connectors. `kortix deploy` is the same command.
- `1` is the change request number from `kortix cr ls`. `kortix cr request-changes 1 --message "…"` sends the note to the session that opened it.

### Run in CI and scripts

Give the job a token in the environment. The CLI then needs no `kortix login`:

| Variable | Value |
| --- | --- |
| `KORTIX_TOKEN` | A personal access key (`kortix_pat_`). |
| `KORTIX_PROJECT_ID` | The project to act on, when the job runs outside a linked directory. |
| `KORTIX_API_URL` | The host's API URL. Leave it unset for Kortix Cloud. |

`KORTIX_TOKEN` outranks any stored login, and the CLI never writes it to disk. Create a key that can reach only one project:

```sh
kortix tokens new ci-nightly --project <project_id> --expires 90d
```

The key prints once. Store it in your CI secret store. `--expires` takes an ISO-8601 date or a span such as `30d`, `12h`, `6w` or `1y`.

```yaml
# .github/workflows/nightly-report.yml
name: Nightly report
on:
  schedule:
    - cron: "0 6 * * *"
jobs:
  report:
    runs-on: ubuntu-latest
    env:
      KORTIX_TOKEN: ${{ secrets.KORTIX_TOKEN }}
      KORTIX_PROJECT_ID: ${{ vars.KORTIX_PROJECT_ID }}
    steps:
      - run: curl -fsSL https://kortix.com/install | bash
      - run: |
          id=$(kortix sessions new --prompt "Write the nightly report" --wait --json | jq -r .session_id)
          kortix sessions wait-for "$id" --timeout 1800
          kortix sessions log "$id" --limit 1
```

Pipe `--json` output into `jq`. Update checks are off when `CI` is set or stdout is not a terminal. To start sessions on a schedule inside Kortix itself, use [Automations](/docs/guide#automations).

### Use Kortix from your coding agent

A coding agent such as Claude Code, Codex, Cursor or pi needs only the binary, a login and the platform manual.

### Install and sign in

Install the CLI and run `kortix login`, or set `KORTIX_TOKEN` in the agent's environment.

### Check the setup

```sh
kortix doctor
```

`kortix doctor` checks the login and the project. Then it starts a throwaway session, waits up to 180 seconds for its sandbox, sends `ping`, waits for the reply and deletes the session. It exits `0` when every check passes and `1` when one fails. `--no-session` stops after the login and project checks.

### Load the manual

```sh
kortix system-skills get kortix-system
```

The host serves the Kortix system skills live, so they match the version you talk to. Tell your agent to run this command first.

### Wire the project into local tools

In a cloned project, run:

```sh
kortix init --force
```

It links the project's skills where each local coding tool looks for them, and adds a root `AGENTS.md` pointer for Codex, pi and Cursor. With no flags it wires every supported tool. `--primary <tool>` and `--agents <list>` pick the tools: `claude`, `codex`, `pi`, `cursor`.

The links go into `.git/info/exclude`, so they never show up as a change. It does not change how sessions run in Kortix.

### Delegate work

```sh
id=$(kortix sessions new --prompt "Fix the failing export test" --wait --json | jq -r .session_id)
kortix sessions wait-for "$id" --timeout 900
case $? in
0) kortix sessions log "$id" ;;
3) kortix sessions pending "$id" ;;
esac
```

Exit `3` means the agent waits on a question or a permission. Answer it with `kortix sessions answer` or `kortix sessions approve`, then wait again.

An MCP client can use Kortix without the CLI. See [Everywhere](/docs/guide#everywhere).

### Inside a session sandbox

Every sandbox has the CLI installed and signed in. The platform sets `KORTIX_TOKEN`, `KORTIX_API_URL` and `KORTIX_PROJECT_ID`, so the agent runs commands with no login and no link. The token is a session token: `kortix token` shows its session, its agent and its grants. It acts only on the session's own project.

Commands an agent uses:

| Command | Purpose |
| --- | --- |
| `kortix connectors call <slug> <action> [json]` | Call a connector action. See [Connectors](/docs/guide#connectors). |
| `kortix secrets request <NAME>` | Mint a link, valid for 7 days, for a person to enter a secret value. |
| `kortix secrets sync` | Pull this session's secrets and grants now. |
| `kortix cr open --title "<text>"` | Open a change request from the session branch. Inside a session it fills `--head` itself. |
| `kortix review submit --kind <kind> --title "<text>"` | Submit an output, a decision or a batch for review. |
| `kortix remind "<prompt>" --in 24h` | Prompt this session again later. Reminders are a project flag, on by default; `kortix projects features disable reminders` turns them off. |
| `kortix sessions update --label <label>` | Label the session the agent runs in. |

What an agent may do is set by its `kortix_permissions` in `kortix.yaml`. When the API refuses a command, the CLI prints the token kind, the agent and its grant. See [Agents](/docs/guide#agents). These commands are refused for an agent session's token, whatever the grant:

- `kortix projects cli-tokens new` and `rm`
- `kortix gateway keys` (`ls`, `new`, `rm`)

`kortix update` is refused in a sandbox. The platform manages the binary: restart the session to get the current CLI.

A `.kortix/link.json` for another project needs a stored login for that project's host. Run `kortix login` in the sandbox first.

### Command reference

Every command prints its subcommands and flags with `kortix <command> --help`, or `kortix help <command>`. A command that belongs to a feature that is off for the project is not listed in `kortix --help`.

**Start here**

| Command | What it does | See |
| --- | --- | --- |
| `system-skills [get <name>]` | Print the Kortix platform manual, served live by your host. `kortix skills` is the same command. | [Use Kortix from your coding agent](#use-kortix-from-your-coding-agent) |

**Sign in, per host**

| Command | What it does | See |
| --- | --- | --- |
| `hosts <subcommand>` | Add, switch, list and sign out of Kortix instances. | [Hosts](#hosts) |
| `login` | Sign in to the active host. Same as `kortix hosts login`. | [Sign in to a host](#sign-in-to-a-host) |
| `logout` | Sign out of the active host. Same as `kortix hosts logout`. | [Hosts](#hosts) |
| `whoami` | The host, the signed-in user and the active account. | [Check who you are](#check-who-you-are) |
| `token` | The token context: project, session, agent and grants. | [Check who you are](#check-who-you-are) |
| `self-host <subcommand>` | Set up, start, update and stop your own Kortix instance from Docker images. | [Self-hosting](/docs/developers#self-hosting) |

**Account, within the host**

| Command | What it does | See |
| --- | --- | --- |
| `accounts <subcommand>` | List and switch accounts. | [Teams](/docs/guide#teams) |
| `members <subcommand>` | Invite and remove people, and change their roles. | [Teams](/docs/guide#teams) |
| `groups <subcommand>` | Groups of people that share a role. | [Teams](/docs/guide#teams) |
| `tokens <subcommand>` | Personal access keys, service accounts and connected apps. | [Security](/docs/guide#security) |
| `billing <subcommand>` | The plan, credits, transactions, and costs per project and per session. | [Billing](/docs/guide#billing) |

**Project, within the account**

| Command | What it does | See |
| --- | --- | --- |
| `init [project-name]` | Create a project directory from the starter, with a `kortix_version: 3` `kortix.yaml`. | [The everyday loop](#the-everyday-loop) |
| `projects <subcommand>` | List, create, clone, link, use, rename and delete projects. Also feature flags (`features`), project CLI tokens (`cli-tokens`) and the upgrade to `kortix_version: 3` (`upgrade`). | [Which project a command uses](#which-project-a-command-uses) |

**Session, within the project**

| Command | What it does | See |
| --- | --- | --- |
| `sessions <subcommand>` | Start, talk to, follow, stop and delete sessions. | [Session commands](#session-commands) |
| `chat [session-id]` | Talk to a session's agent: a line chat, or one prompt with `-p`. Same as `kortix sessions chat`. | [Session commands](#session-commands) |
| `tui [options]` | The whole product as a terminal app. `kortix t` and `kortixt` start it too. Experimental. | [Everywhere](/docs/guide#everywhere) |

**Author and ship**

| Command | What it does | See |
| --- | --- | --- |
| `ship` | Validate, commit and push the current branch. A folder with no link and no default project gets a new project. `kortix deploy` is the same command. | [Commit and push](/docs/developers#commit-and-push) |
| `validate` | Check `kortix.yaml` and the sandbox template Dockerfiles. | [Check and validate](#check-and-validate) |
| `migrate [--write]` | Convert the project manifest to `kortix_version: 3`. It prints the plan; `--write` applies it. | [Migrate to v3](/docs/developers#migrate-to-v3) |
| `doctor [--no-session]` | Check the login and the project, then start a throwaway session and wait for an agent reply. | [Check and validate](#check-and-validate) |
| `schema --version 3` | Print the `kortix.yaml` JSON Schema. | [Check and validate](#check-and-validate) |

**Agents and connectors**

| Command | What it does | See |
| --- | --- | --- |
| `agents <subcommand>` | The default agent, model pins, scope and the full agent config. | [Agents](/docs/guide#agents) |
| `tools <subcommand>` | List the tools sessions get; `eject` copies a Kortix tool into the project so you can change it. | [Agents](/docs/guide#agents) |
| `models <subcommand>` | The models the project offers, and its default model. | [Models](/docs/guide#models) |
| `gateway <subcommand>` | LLM gateway routing, budgets, keys, usage, logs and tests. | [Models](/docs/guide#models) |
| `connectors <subcommand>` | Add, connect, call and govern connectors. `moves` and `move` move Pipedream and Composio connectors to Kortix. | [Connectors](/docs/guide#connectors) |
| `secrets <subcommand>` | Project secrets, their exposure and who can use them. | [Security](/docs/guide#security) |
| `providers <subcommand>` | Connect model providers with an API key or OAuth. | [Models](/docs/guide#models) |
| `env <subcommand>` | Pull secret names into a `.env` file, or push a `.env` file as secrets. | [Security](/docs/guide#security) |
| `channels <subcommand>` | Slack and Microsoft Teams connections, and the agent each channel runs. | [Channels](/docs/guide#channels) |
| `sandboxes <subcommand>` | Sandbox templates, their builds and their health. | [Computers](/docs/guide#computers) |
| `apps <subcommand>` | Deploy and manage Apps: sites and servers with stable Kortix URLs. | [Apps reference](/docs/developers#apps-reference) |
| `marketplace <subcommand>` | Search, show, install and uninstall marketplace skills. | [Skills](/docs/guide#skills) |

**Files, changes and triggers**

| Command | What it does | See |
| --- | --- | --- |
| `files <subcommand>` | Read the committed repository: files, history, branches, commits, diffs and a zip download. | [Git](/docs/developers#git) |
| `drive <subcommand>` | The project's Drive: list, read, upload, move and delete files, and share folders. | [Drive](/docs/guide#drive) |
| `cr <subcommand>` | Change requests: list, show, diff, open, merge, request changes, close and reopen. | [Git](/docs/developers#git) |
| `review <subcommand>` | The review inbox: change requests, held connector calls and submitted outputs. | [Git](/docs/developers#git) |
| `triggers <subcommand>` | List, add, fire, enable, disable, pause and resume triggers. | [Automations](/docs/guide#automations) |
| `reminders <subcommand>` | Prompt one session later or on repeat. `kortix remind "…" --in 24h` adds one. | [Automations](/docs/guide#automations) |
| `capture <subcommand>` | Search the Capture timeline of the person you act for. Capture is off until an account admin turns it on. | [Capture](/docs/capture) |

**Access and permissions**

| Command | What it does | See |
| --- | --- | --- |
| `access <subcommand>` | Grant, list and revoke role assignments for people, groups and agents. | [Teams](/docs/guide#teams) |
| `roles <subcommand>` | System and custom roles, and what each one permits. | [Teams](/docs/guide#teams) |
| `permissions <subcommand>` | The permission catalog roles are built from. `kortix perms` is the same command. | [Teams](/docs/guide#teams) |
| `audit <subcommand>` | Read the account audit log. | [Teams](/docs/guide#teams) |
| `grants <subcommand>` | Assign agents to members or groups. | [Teams](/docs/guide#teams) |

**The CLI itself**

| Command | What it does | See |
| --- | --- | --- |
| `feedback "<message>" [--kind bug\|idea\|friction]` | Send product feedback to the Kortix team. | [Maintain the CLI](#maintain-the-cli) |
| `update` | Replace the binary with the latest release. | [Maintain the CLI](#maintain-the-cli) |
| `uninstall` | Remove the CLI from this machine. | [Maintain the CLI](#maintain-the-cli) |
| `help` | The command list. `kortix help <command>` prints one command's help. | [Conventions](#conventions) |
| `version` | Print the CLI version. | [Maintain the CLI](#maintain-the-cli) |

### Session commands

The commands below are `kortix sessions <subcommand>`. `kortix sessions --help` lists every flag. The behavior behind them is on [Computers](/docs/guide#computers).

#### Start and list

| Command | Effect |
| --- | --- |
| `new --prompt "<text>"` | Start a session. `--agent <name>` and `--model <id>` override the project defaults. `--wait` blocks until the session takes prompts. `--json` prints the session object. |
| `new … --with-file <path>` | Upload a local file to `/workspace/incoming/<name>` before the prompt goes in. Repeatable. |
| `new … --label <label>`, `--meta <key>=<value>` | Classify the session. Both are repeatable. |
| `new … --connector <alias>=<connection-id>` | Bind a connection to the session. Repeatable. `--no-connectors` binds none. |
| `ls` | List sessions with who started each. `--mine`, `--shared`, `--automated`, `--search <q>`, `--label <label>`, `--children <session_id>`. The list leaves out the sessions you archived and marks a pinned session `(pinned)`. `--pinned` lists only your pinned sessions, `--archived` only your archived ones. `--deleted` lists the deleted sessions you can restore, with their full IDs and the date each one stays restorable. |
| `pin <session_id>…`, `unpin <session_id>…` | Pin sessions in your own list, or unpin them. Only your list changes. |
| `archive <session_id>…`, `unarchive <session_id>…` | Take sessions out of your own list, or put them back. Archiving unpins a session and does not stop it. |
| `status` | One line per active session with what its agent is doing now. `--all` adds stopped sessions. |
| `info <session_id>` | One session. |

#### Talk

| Command | Effect |
| --- | --- |
| `chat [<session_id>]` | A line chat with the agent. With no ID it picks your most recent running session. `--new` starts one. |
| `chat <session_id> -p "<text>"` | Send one prompt, print the reply and exit. `--json` prints the reply as JSON. |
| `chat <session_id> -p "<text>" --queue` | Store the prompt in the session's inbox and return at once. It survives a sleeping or busy sandbox. |
| `chat <session_id> -p "<text>" --steer` | Give the prompt to the running turn. The agent reads it at its next step. With no turn running, it starts one. |
| `queue <session_id>` | The inbox: `ls`, `rm <prompt-id>`, `now <prompt-id>`, `hold`, `release`. |

#### Watch

| Command | Effect |
| --- | --- |
| `log [<session_id>]` | The recent messages, read-only. A stopped session is read from its saved transcript and is not woken. `--limit <n>` (default 10). |
| `wait-for <session_id>` | Block until the agent is done. Exit `0` done, `3` blocked on a question or permission, `124` timed out. `--timeout <seconds>` (default 300). |
| `digest` | A short review of recent sessions. `--since 7d`. |

#### Unblock

| Command | Effect |
| --- | --- |
| `pending <session_id>` | The questions and tool permissions the agent waits on. |
| `approve <session_id> [<request-id>]` | Answer a permission. `--always`, `--reject`, `--message "<why>"`. |
| `answer <session_id> [<request-id>]` | Answer a question. `--option <value>`, `--text "<answer>"`, `--reject`. |
| `approvals <session_id>` | Connector calls a policy holds for a person: `ls`, `approve <execution-id>`, `deny <execution-id>`. |

#### Files and ports

| Command | Effect |
| --- | --- |
| `cp <src> <dst>` | Copy between your machine and a sandbox, or between two sandboxes. A sandbox path is `<session_id>:<path>`. `-r` for directories. |
| `files <session_id> <sub>` | The live `/workspace`: `ls`, `status`, `find`, `write`, `touch`, `mkdir`, `mv`, `rm`. |
| `attachments <session_id>` | Uploads and files the agent showed. Works while the session is stopped. `--download <id>`, `--all`, `--out <dir>`. |
| `preview <session_id> [port]` | Print a preview URL for a sandbox port. The default port is `3000`. |
| `forward <session_id> --port <sandbox>[:<local>]` | Forward a sandbox port to `localhost`, HTTP and WebSocket. Runs until Ctrl+C. |
| `shell [<session_id>]` | A raw terminal in the sandbox, with no agent. `--new` opens a fresh one. |

#### Lifecycle

| Command | Effect |
| --- | --- |
| `stop <session_id>` | Stop the sandbox. The files in `/workspace` and `/drives` are kept. |
| `start <session_id>` | Wake the session. `--wait` blocks until it is ready. |
| `restart <session_id>` | Stop and start the sandbox. When the session has no sandbox, boot a new one from the session's branch. |
| `recover <session_id>` | Continue a lost session in a new sandbox, from the session's branch and its saved conversation. `start` prints this command when the sandbox is lost. |
| `reload <session_id>` | Pull the repository and load the base branch's agent config into the running sandbox. It refuses during a turn unless you pass `--force`. `--status` only reports whether the session is behind. |
| `model <session_id> <model-id>` | Change the model. A running sandbox restarts its agent runtime, which ends the current turn. |
| `compact <session_id>` | Summarize the conversation and continue from the summary. A stopped session wakes first. |
| `rename <session_id> <name>` | Set the name. `""` restores the automatic title. |
| `update [<session_id>]` | Add or remove labels and metadata. |
| `rm <session_id>…` | Stop and delete sessions. Each one is restorable for 30 days; after that its messages, files and branch are deleted. Prints the `restore` command for each session. `--json` adds `restore_until` and `project_id`. |
| `restore <session_id>` | Restore a deleted session within 30 days. Pass the full session ID (`ls --deleted` lists it), and `--project` when the session is not in the linked project. |

#### Access

| Command | Effect |
| --- | --- |
| `share <session_id> --mode private\|project\|members` | Set who in Kortix can open the session. `--member <id\|email>`, `--group <id>`. |
| `scope <session_id>` | Read or replace the session's secrets and connections. The change applies to the next prompt. |
| `links <session_id>` | Public links to one preview port or one file: `ls`, `create`, `revoke <share-id>`. |
| `open <session_id>` | Open the session in the web app. |

### Check and validate

| Command | Effect |
| --- | --- |
| `kortix validate` | Check `kortix.yaml` against the schema, lint each `sandbox.templates` Dockerfile, and warn about large files in git. Exit `0` valid, `1` errors, `2` file not found. `--file <path>`, `--json`, `--no-dockerfile-lint`. `--scopes` prints every grantable `kortix_permissions` value. `kortix ship` runs the same checks. |
| `kortix schema --version 3` | Print the `kortix.yaml` JSON Schema. `--url` prints its URL instead: `https://kortix.com/schema/kortix.v3.schema.json`. |
| `kortix doctor` | Check the login and project, then start a throwaway session, send `ping` and wait for a reply. `--no-session`, `--keep-session`, `--prompt "<text>"`, `--timeout <seconds>`: how long to wait for the sandbox (default 180). Exit `0` pass, `1` fail. |
| `kortix system-skills list` | List the Kortix system skills. |
| `kortix system-skills get <name>` | Print one skill and the paths of its reference files. `--full` adds every reference file. |
| `kortix system-skills file <name> <path>` | Print one reference file. |
| `kortix system-skills path [<name>]` | Print the skill's directory on disk. |

The schema fields are on [kortix.yaml reference](/docs/developers#kortixyaml-reference).

### Conventions

- `kortix`, `kortix help` and `kortix --help` print the same screen: your host, account and project, then every command. `kortix help <command>` prints that command's help.
- `--host <name>` sends one command to another host. `--project <id>` sends it to another project. Both also work before the command: `kortix --host <name> sessions ls`.
- `--json`, on a command that supports it, prints JSON. On any command it turns off the host and update notices, also on stderr.
- `--debug` prints the resolved host, account and credential source to stderr.
- A mistyped command exits `2` and, when a command name is within two edits, prints it as a suggestion. It never scaffolds a project directory. Only `kortix init` does that.

| Exit code | Meaning |
| --- | --- |
| `0` | Success. |
| `1` | The command failed. The reason is on stderr. |
| `2` | Usage error: an unknown command or subcommand, a bad flag or a missing argument. `kortix validate` also returns `2` when the file is not found. |
| `3` | `kortix sessions wait-for` only: the agent is blocked on a question or a permission. |
| `124` | `kortix sessions wait-for` only: the timeout passed. |

| Alias | Command |
| --- | --- |
| `deploy` | `ship` |
| `session` | `sessions` |
| `skills` | `system-skills` |
| `perms` | `permissions` |
| `remind` | `reminders add` |
| `t`, `kortixt` | `tui` |
| `sessions pause` / `wake` | `sessions stop` / `start` |
| `sessions wait` | `sessions wait-for` |
| `sessions messages`, `sessions history` | `sessions log` |
| `sessions overview`, `sessions ps` | `sessions status` |
| `sessions terminal`, `sessions ssh` | `sessions shell` |
| `sessions copy` | `sessions cp` |
| `sessions undelete` | `sessions restore` |

### Maintain the CLI

| Command | Effect |
| --- | --- |
| `kortix version` | Print the version. `kortix --version` and `-v` do the same. |
| `kortix update` | Run the install script again and replace the binary. |
| `kortix uninstall` | Remove the binary, the `kortix` and `kortixt` links, and `~/.kortix`, and sign out of the active host. |
| `kortix feedback "<message>"` | Send product feedback to the Kortix team. |

The bare `kortix` command checks for a new release at most every 6 hours and offers to install it. Other commands show a one-line notice from the cached check. Turn the check off with `KORTIX_NO_UPDATE_CHECK=1`. Keep the notice but skip the question with `KORTIX_NO_UPDATE_PROMPT=1`.

`kortix uninstall` asks before it removes anything:

| Flag | Effect |
| --- | --- |
| `-y`, `--yes` | Skip the question. |
| `--keep-auth` | Keep the stored login for the active host. |
| `--keep-home` | Keep `~/.kortix`. Only the binary and the links go. |

`kortix feedback` takes `--kind bug|idea|friction` (default `idea`). Inside a session it tags the report with the session and project IDs.

## REST API

The Kortix REST API is the HTTP interface to accounts, projects, sessions,
secrets, connectors, triggers, and Apps. The web app, the CLI, the SDK, and the
Kortix MCP server all call it. Use it directly from any language that can send
an HTTPS request with a bearer token.

The shortest path: create a personal access key, list your projects, and start
a session.

```bash
kortix tokens new my-script --expires 30d    # prints kortix_pat_… once
export TOKEN=kortix_pat_...

curl -s https://api.kortix.com/v1/projects \
  -H "Authorization: Bearer $TOKEN"

curl -s https://api.kortix.com/v1/projects/<project_id>/sessions \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"initial_prompt": "Summarize the open change requests."}'
```

`GET /v1/projects` returns an array of projects, each with `project_id`. The
session create returns `201` with the session, including `session_id` and
`branch_name`. It returns `202` with `status`, `command_id`, and `session_id`
when Kortix queues the create. Read the session with
`GET /v1/projects/<project_id>/sessions/<session_id>` until it appears.

With only a `session_id`, `GET /v1/sessions/<session_id>` returns the same
session with its `project_id`. The access rules are the same.

A session you
cannot read, an unknown id, and a deleted session all return `404`. A session
in an account or project you have no access to also returns `404`, so the id
alone never shows that a session exists. A key scoped to one project cannot
call it. Use the project route with that key.

### Pick a surface

All four surfaces reach the same API with the same permissions.

| Surface | Use it for | Credential |
|---|---|---|
| REST API | Any language. Integrations, scripts, and servers that are not TypeScript. | A bearer token from [Authenticate](#authenticate) |
| [SDK](/docs/developers#sdk) (`@kortix/sdk`) | TypeScript and React. Sessions with live streaming, typed errors, automatic retries, and the session runtime. | `getToken` returns a bearer token |
| [CLI](/docs/developers#cli) (`kortix`) | Terminals, CI jobs, and agents inside a session sandbox. | `kortix login`, or a token in CI |
| [Kortix MCP server](/docs/guide#everywhere) | Claude, Cursor, ChatGPT, and other MCP clients that act as you. Its `search_api`, `describe_api`, and `call_api` tools read and call this API. | OAuth sign-in from the client |

Prefer the SDK for anything that drives a session. It owns the start, the
readiness wait, the event stream, and the runtime routes. The REST reference
does not list those routes.

### Base URL and reference

| Deployment | Base URL |
|---|---|
| Kortix Cloud | `https://api.kortix.com/v1` |
| Self-hosted with a domain | `https://api.<domain>/v1` by default. See [Self-hosting](/docs/developers#self-hosting). |
| Self-hosted on one machine | `http://localhost:13738/v1` by default |

Every deployment serves its own route reference:

- `GET /v1/docs`: the interactive reference. The **API reference** tab at the
  top of these docs opens the Kortix Cloud copy.
- `GET /v1/openapi.json`: the OpenAPI 3.1 document. Its `servers` entry is the
  origin you requested it from, so a client generated from a self-hosted
  instance points at that instance.
- `GET /v1/health`: no token needed. Returns `status`, `service`, `version`,
  `commit`, `environment`, `timestamp`, and `started_at`.

The reference leaves out operator routes. A self-host operator turns off both
`/v1/docs` and `/v1/openapi.json` with `OPENAPI_PUBLIC_DOCS=false` in the API
environment.

### Authenticate

Send one header on every request:

```http
Authorization: Bearer <token>
```

The token decides who the request acts as. Every Kortix credential starts with
a prefix that names its kind. Create and manage them on
[Security](/docs/guide#security).

| Prefix | Credential | Acts as |
|---|---|---|
| `kortix_pat_` | Personal access key (**Settings → Personal access keys**, `kortix tokens new`, `kortix login`) | You, with your roles. A key bound to one project gets `403` everywhere else. |
| `kortix_sa_` | Service account key (**Account settings → API keys**) | The service account. It has no access until someone assigns it a role. |
| `kortix_oat_` | OAuth access token from [Sign in with Kortix](/docs/developers#sign-in-with-kortix). It lasts 1 hour. A `kortix_ort_` refresh token gets a new one. | The person who approved the app. |
| `kortix_gw_` | Project gateway key (`kortix gateway keys new`) | The LLM gateway inference routes of one project, and nothing else. See [Models](/docs/guide#models). |
| `kortix_scim_` | SCIM token (**Account settings → Identity**) | Directory sync under `/scim/v2` for one account. |
| `kortix_pat_` in `KORTIX_TOKEN` | Session token. Kortix sets it in every session sandbox. | The agent of that one session, limited to its project. It ends with the session. |

An OAuth token without the `kortix` scope reaches only `/v1/accounts/me`,
`/v1/oauth/userinfo`, and `/v1/oauth/revoke`. Other routes answer `403`, and
`message` starts with `insufficient_scope`. Even with the `kortix` scope, an
OAuth token cannot change sign-in, password or MFA state. It also cannot
create a credential that outlives the grant, or request account deletion.

A person's access token from [headless sign-in](/docs/developers#sign-users-in-headlessly)
also works as a bearer token. It acts as that person.

A missing or invalid token returns `401`. A `401` with
`code: "session_token_revoked"` means the credential is revoked, expired, or
belongs to a closed session. It never works again: stop retrying and get a new
token.

The same tokens open the project's git origin. See [Git](/docs/developers#git).

### Errors

An error response is JSON. Every body has `error`. The generic shape adds
`message` and `status`. A typed error adds `code` and can add more fields.

| Field | Type | Meaning |
|---|---|---|
| `error` | `true` or text | Text is a human-readable reason. |
| `message` | text | A human-readable reason. |
| `code` | text | A stable machine code. Branch on this, never on the text. |
| `status` | number | The HTTP status, repeated. |

Some errors add fields. A failed body, query, or path validation returns:

```json
{ "error": true, "message": "Validation failed", "status": 400, "issues": [ … ] }
```

`issues` lists each invalid field with its `path` and `message`.

| Status | Meaning | What to do |
|---|---|---|
| `400` | The request is not valid. | Read `issues` or `message`. Fix the request. |
| `401` | No valid credential. | Send a valid token. Stop on `session_token_revoked`. |
| `402` | The account cannot pay for this, or its plan does not include it. | Read `code`: `insufficient_credits`, `subscription_required`, `no_account`, `entitlement_required`, `budget_exceeded`. A billing `402` (`insufficient_credits`, `subscription_required`, `no_account`) also carries `billing_state`, `balance`, and `account_id`. Branch on `billing_state` to choose between subscribe and top up. See [Common error codes](#common-error-codes). |
| `403` | The credential is valid, but the action is not allowed. | A permission denial names the `action` it needed and a `code`. `feature_disabled` adds `feature`, the flag key. |
| `404` | The object does not exist, or you may not see it. Kortix answers `404`, not `403`, for an object you may not see. | Check the id. Check your access to the object. |
| `409` | The request conflicts with the current state. | Read `code`. Reload the object and decide again. |
| `422` | The request is valid, but the operation cannot apply. | Read `code`, for example `CR_HEAD_NOT_AHEAD`. |
| `429` | A rate limit. | Wait `Retry-After` seconds. See [Retries and idempotency](#retries-and-idempotency). |
| `500` | A defect in Kortix. | Do not retry in a loop. Report it with the `X-Request-Id` response header. |
| `502`, `503`, `504` | A temporary failure. A `503` usually carries `Retry-After`. | Retry reads. Check the state before you repeat a write. |

Every response carries an `X-Request-Id` header. Quote it when you report a
problem.

### Common error codes

These are the codes clients branch on most. The linked section explains each fix.

| `code` | Status | Cause | Fix on |
|---|---|---|---|
| `session_token_revoked` | `401` | The credential can never work again. | [Security](/docs/guide#security) |
| `insufficient_credits` | `402` | The account's credit balance is empty. | [Billing](/docs/guide#billing) |
| `subscription_required` | `402` | The account has no plan for Kortix-billed usage. | [Billing](/docs/guide#billing) |
| `no_account` | `402` | The account has no credit account yet. Its setup is not complete. | [Billing](/docs/guide#billing) |
| `entitlement_required` | `402` | The plan lacks `sso`, `scim`, `rbac`, `auditAccess`, or `branding`. The body names `entitlement`. | [Security](/docs/guide#security) |
| `budget_exceeded` | `402` | A model budget set to block reached its limit. | [Models](/docs/guide#models) |
| `feature_disabled` | `403` | The project has this feature flag off. The body names `feature`. | **Customize → Settings → Feature flags** |
| `feature_operator_only` | `403` | Only Kortix (a platform admin) may change this flag. | Ask a platform admin. |
| `account_mfa_required` | `403` | The account requires two-factor, and this sign-in did not use it. | [Security](/docs/guide#security) |
| `expiry_required`, `expiry_too_far` | `400` | A new key breaks the account's key rules. | [Security](/docs/guide#security) |
| `INVALID_IDEMPOTENCY_KEY` | `400` | The `Idempotency-Key` header is not 1 to 255 characters of `A-Z a-z 0-9 . _ : + / = -`. | [Retries and idempotency](#retries-and-idempotency) |
| `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_*_CONFLICT` | `409` | The key is in use for another request, or for this one with different `connector_bindings`, `secrets`, `runtime_context`, or `provider_secret_pools`. | [Retries and idempotency](#retries-and-idempotency) |
| `IDEMPOTENCY_KEY_SESSION_DELETED` | `409` | The key belongs to a session that was deleted. | Use a new key. |
| `INVALID_SESSION_RUNTIME_CONTEXT`, `INVALID_SESSION_CONNECTOR_BINDINGS`, `INVALID_SESSION_SECRETS` | `400` | A session create field is malformed. | [Sessions from code](/docs/developers#session-create-errors) |
| `session_not_deleted` | `409` | A restore named a session that is not deleted. | [Computers](/docs/guide#computers) |
| `session_sandbox_removal_pending` | `409` | A restore came before the provider confirmed the deleted session's sandbox removed, or while a sandbox that was starting at the delete can still arrive. | Retry the restore in a minute. [Computers](/docs/guide#computers) |
| `session_purged` | `410` | The session was deleted more than 30 days ago. Its messages, files and branch are deleted. | [Computers](/docs/guide#computers) |
| `project_archived` | `404` | The project was deleted. | Use another project. |
| `request_deadline` | `503` | A request ran past the API's 25-second processing limit. | Retry the read. Check the state before you repeat a write. |
| `git_mirror_unavailable` | `503` | The project repository is temporarily unreachable. | Retry after `Retry-After`. [Git](/docs/developers#git) |
| `repository_push_rejected` | `409` | The git host refused a push because of branch protection or repository rules. | [Git](/docs/developers#who-can-clone-and-push) |
| `CR_HEAD_NOT_AHEAD` | `422` | The change request branch has no commits ahead of its base. | [Git](/docs/developers#change-requests) |
| `CR_SELF_MERGE_REFUSED` | `403` | A session tried to merge its own change request without a `project.gitops.merge` grant. | [Git](/docs/developers#change-requests) |
| `MANIFEST_INVALID` | `422` | The change request makes `kortix.yaml` invalid. The merge is blocked. `issues` lists the errors. | [kortix.yaml reference](/docs/developers#kortixyaml-reference) |
| `PERSONAL_SECRET_REQUIRES_PRIVATE_SESSION` | `409` | You tried to share a session that holds a secret shared only with you. | [Security](/docs/guide#security) |
| `project_secret_write_limit` | `429` | The project wrote secrets more than 100 times in one hour. | [Security](/docs/guide#security) |
| `app_config_invalid` | `400` | An App release config is not valid. `issues` lists the errors. | [Apps reference](/docs/developers#apps-reference) |
| `app_auth_required` | `401` | An App URL needs a viewer that may open it. | [App access](/docs/developers#app-access) |

A connector call reports its outcome in `reason`, not `code`, inside
`{ ok: false, status: "denied" | "error", reason }`. The reasons, such as
`connector_not_connected` and `policy_block`, are on
[Connectors](/docs/guide#connectors). LLM provider and model failures
are on [Models](/docs/guide#models).

### Retries and idempotency

The SDK retries reads, and a `DELETE` that got no response. It never repeats
any other write. In your own client, decide per response:

| Response | Retry |
|---|---|
| `GET` or `HEAD` with `502`, `503`, or `504` | Yes, after `Retry-After` when it is present. The SDK retries 2 times, after 250 ms and 500 ms. |
| A write with `502`, `503`, or `504` | Read the state first. |
| `429` | Yes, after `Retry-After` seconds. The body repeats the wait as `retry_after_seconds`. |
| A write with no response (network failure) | Read the state first, or resend with the same `Idempotency-Key` where the route takes one. |
| `400`, `401`, `402`, `403`, `404`, `409`, `422` | No. Change the request or the state first. |
| `500` | No. Report it with `X-Request-Id`. |

#### Idempotency-Key

Two create routes deduplicate a retried request:

| Route | Where the key goes | Format |
|---|---|---|
| `POST /v1/projects/<project_id>/sessions` (start a session) | `Idempotency-Key` header | 1 to 255 characters of `A-Z a-z 0-9 . _ : + / = -` |
| `POST /v1/projects/provision` (create a project with a new repository) | `idempotency_key` body field | Up to 200 characters of `A-Z a-z 0-9 . _ : -` |

Send a new random value, such as a UUID, for each intent. Send the same value
again when you retry that intent. A repeated session create returns the original
session instead of starting a second sandbox.

Session keys are unique across
all of Kortix. A key that another project or account already used returns
`409` `IDEMPOTENCY_KEY_CONFLICT`.

#### Pagination

A list route that pages takes `limit` and `cursor`. Pass the next cursor back as
`cursor`. No next cursor means you have the last page.

| List | Next cursor | `limit` |
|---|---|---|
| `GET /v1/projects/<project_id>/sessions` | `X-Next-Cursor` response header | Default 50, maximum 200 |
| `GET /v1/accounts/<account_id>/audit` | `next_cursor` in the body | Default 50, maximum 200 |
| `GET /v1/projects/<project_id>/files?depth=1` | `next_cursor` in the body | Default 500, maximum 2,000. Without `limit` or `cursor`: one response of up to 5,000 entries |

A session page holds only the sessions you may open. Every page except the
last holds exactly `limit` sessions. A delete does not
move a session in the list order.

The session list also returns an `ETag`. Send it back as `If-None-Match` and an
unchanged list answers `304` with no body.

#### Rate limits

A rate-limited route returns `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and
`X-RateLimit-Reset` (seconds until the window resets). When the limit is
reached it returns `429` with `Retry-After` and this body:

```json
{ "error": "rate_limit_exceeded", "message": "Rate limit exceeded. Please retry shortly.", "retry_after_seconds": 12 }
```

| Route | Limit |
|---|---|
| Headless sign-in routes under `/v1/auth` | 30 requests per minute per IP address. The `429` body is `{ "error": "over_request_rate_limit", "error_description": "…" }` with `Retry-After`, and no `X-RateLimit-*` headers. |
| The sandbox proxy, `/v1/p/<external_id>/<port>/*`: previews and the session runtime | 600 requests per minute per sandbox |
| Secret writes in one project | 100 per hour, `code: "project_secret_write_limit"` |

### What the reference does not cover

The reference does not list the session runtime routes. The surfaces below also
take a different credential or protocol than a plain bearer call:

| Surface | Use instead |
|---|---|
| The session runtime under `/v1/p/<external_id>/8000/kortix/runtime/*`: prompts, the event stream, files, and the transcript of a running session. The path takes the sandbox's external id at the provider, not the `session_id`. Read it from `sandbox_url` in `kortix sessions info <session_id> --json` (after `/v1/p/`), or from `sandboxId` in the SDK's `ensureReady()`. | The SDK session handle, `kortix.session(projectId, sessionId)`. See [Sessions from code](/docs/developers#sessions-from-code). |
| SCIM 2.0 under `/scim/v2/accounts/<account_id>`. It takes a `kortix_scim_` token, not a personal key. | [Single sign-on and directory sync](/docs/guide#single-sign-on-and-directory-sync) |
| Inbound trigger webhooks, `POST /v1/webhooks/projects/<project_id>/<slug>`. They authenticate with the trigger secret, not a Kortix token: send an HMAC signature (`X-Kortix-Signature` or `X-Hub-Signature-256`), `X-Kortix-Token`, or `Authorization: Bearer <secret>`. | [Webhooks](/docs/guide#automations) |
| App events and App runs: `POST …/apps/<app_id>/events`, `POST` and `DELETE …/apps/<app_id>/runs`. They take the App's `KORTIX_APP_TOKEN`, not a Kortix token. | [App events](/docs/guide#automations) |
| The LLM gateway's OpenAI- and Anthropic-compatible endpoints. They take a `kortix_gw_` key. | [Models](/docs/guide#models) |
| The project git origin, `https://api.kortix.com/v1/git/<project_id>.git`. | [Git](/docs/developers#git) |

### Machine-readable docs

These docs are also published as plain Markdown for coding agents and LLM
tools:

| URL | Content |
|---|---|
| `https://kortix.com/llms.txt` | An index of every public page, docs included, with a link to its Markdown |
| `https://kortix.com/llms-full.txt` | All public pages in one file |
| `https://kortix.com/markdown/docs/<path>.md` | One page. For example, these docs are `/markdown/docs/index.md`. |
| Any `/docs/<path>` URL, requested with `Accept: text/markdown` | The same page as Markdown |

Point a coding agent at `https://kortix.com/llms.txt` for the concepts and at
`https://api.kortix.com/v1/openapi.json` for the exact routes. An MCP client that uses the
[Kortix MCP server](/docs/guide#everywhere) reads the route reference itself through
`search_api` and `describe_api`.

## Apps reference

An App is a deployment that one project owns. It has one stable URL, an access
policy and numbered releases. Use Apps to host a site, an internal tool or a
backend for the people in your project. Kortix signs them in before your code
runs.

### What an App is

An App is either static or a server. A static App is files that Kortix serves
itself, with no machine. A server App runs machines that Kortix builds from your
source or image.

| Part | What it is |
|---|---|
| Release | `vN`: an artifact (files, a build or an OCI image) plus its resolved config. A release never changes. A failed release never replaces live traffic. A rollback is a new release. |
| Process group | A named command. A server App without `processes` has one group, `app`. |
| Machine | Runs one process group of the live release. Its `machine_id` stays the same across releases. Each release replaces its root disk. |
| Service | A named HTTP port of a process group. Each service has its own host and its own `access`. |
| Volume | A persistent disk that one machine mounts. It outlives every release and every machine. |
| Label | A free-form tag, for example `backend`. No behavior depends on labels. |

A database, a queue or any other backend is a server App with a volume. A
template writes a ready-made App for you: see
[Template: Convex backend](#template-convex-backend).

Every App lists what it supports in `capabilities`, for example `machines` or
`volumes`. A call that needs a missing capability answers
`409 app_capability_unsupported`.

### Show an App hosted elsewhere

An external App is a site that runs somewhere else: a dashboard, a vendor tool,
another team's service. Kortix lists it in the Apps page beside the Apps it runs
and opens it in place, in a frame. Kortix never deploys, wakes or bills it, and
it takes no App slot.

Add one from the Apps page with **Add app**, or from the CLI:

```bash
kortix apps create grafana --name "Grafana" --url https://grafana.example.com
```

- The URL must be `https`. A URL with a user name or password is refused.
- The App's `url` and `external_url` are the site. `capabilities` is
  `["preview"]`.
- The site decides whether it may be framed. Kortix reads its
  `X-Frame-Options` and CSP `frame-ancestors` headers when you add it. A site
  that refuses a frame gets `capabilities: []` and the warning
  `app_external_not_frameable`. Kortix then shows it with an **Open in a new
  tab** control instead of a frame.
- A site that keeps its sign-in in a cookie can ask you to sign in again
  inside the frame. Browsers limit cookies in frames from another site. Open
  it in a new tab when that happens.
- The access policy decides who in the project sees the App. The site's own
  sign-in decides who can use it.
- Move it to another URL with `kortix apps set grafana --url <https-url>`.
  Deploy, rollback, start, stop and sign-in tokens answer
  `409 app_capability_unsupported`.

### Self-hosting prerequisites

Every project can create and deploy Apps. On a self-hosted instance, App
hostnames resolve only after the operator sets an Apps base domain and its
wildcard DNS record: see [Self-hosting](/docs/developers#app-hosting).

### Deploy your first App

### Build the site
```bash
npm run build
```
### Deploy the output directory
```bash
kortix apps deploy dist --slug my-site --type static --spa
```
The command creates the App, uploads the files, and waits until the URL is
ready. It prints the URL. Without `--slug`, the slug is the directory name.
### Open it
A new App is `private`: only you can open it. Open the URL in a browser
where you are signed in to Kortix, or print a 5-minute sign-in link:
```bash
kortix apps access-link my-site
```
### Ship the next release
```bash
npm run build && kortix apps deploy dist --app my-site
```
Use `--app` (or a `kortix.yaml` block) for every later release. Without
it, the CLI creates a new App, and a slug that exists answers `409`.

The web app lists every App you may open at **Apps** in the project sidebar
(`/projects/<id>/apps`). The page shows live state, a preview and the access
controls. An App's detail lists its releases.

A server App adds a **Machines**
tab, and an App with a volume adds a **Volumes** tab. `?app=<app_id>` opens one
App's detail. You create and deploy Apps
from the CLI, the SDK or an agent session.

### Sources

| Source | You deploy | Kortix |
|---|---|---|
| `static` | Files: HTML, a built SPA, a `dist/` | Serves the files. No machine, no cold start. |
| `bundle` | A package source | Runs the install and build commands, then serves the output directory from a machine |
| `dockerfile` | A directory with a Dockerfile | Builds the image, then runs `--command` on `--port` |
| `oci_image` | A public image reference (`--image`) | Runs `--command` on `--port` |

- Pick `static` for every UI you can build ahead of time. Build locally and
  deploy the output. A Next.js site with `output: 'export'` deploys `out/` as
  `static`.
- Pick `dockerfile` for server rendering, native packages or any service.
- `dockerfile` and `oci_image` need `--command` (a JSON argv array is
  unambiguous) and `--port`, or `http_service.internal_port` in `kortix.yaml`.
  A worker-only App (`processes` and no service) needs no port.

For a directory, the CLI picks the source when you omit `--type`: a
`Dockerfile` makes it `dockerfile`, else a `package.json` makes it `bundle`,
else `static`. The defaults:

| Setting | `static` | `bundle` |
|---|---|---|
| Install command | — | `corepack enable && pnpm install --frozen-lockfile` |
| Build command | — | `pnpm build` |
| Output directory | — | `dist` |
| SPA fallback (`--spa`) | off | on |
| Readiness path | `/` | `/` |

What the CLI uploads:

- It honors `.gitignore`, `.dockerignore` and `.kortixignore`.
- It never uploads `.git/`, `.kortix/` or `.env*`. It leaves out
  `node_modules` unless you pass `--include-node-modules`.
- A build-time value, such as `VITE_*` in `.env.production`, reaches a static
  App only through a build you run before the deploy.

Limits:

| Limit | Value |
|---|---|
| Archive, compressed | 50 MiB |
| Archive, extracted | 1 GiB |
| Files in an archive | 100,000 |
| Files in a static App | 20,000, at most 50 MiB each. A larger site fails with `invalid_site`. |

A static App runs no code, so it ignores `env` and `secrets`. A deploy that sets
them records an `environment_ignored` event.

A deployment moves through `queued`, `validating`, `building`, `provisioning`,
`checking` and `ready`, or ends `failed` or `cancelled`. `kortix apps deploy`
waits up to `--wait-seconds` (default `1200`). `--no-wait` returns once the
deployment is queued.

### Configure in kortix.yaml

An `apps.<slug>` block in `kortix.yaml` holds an App's deploy settings and
release config. Commit it, and every deploy uses the same settings.

```yaml
apps:
  docs:
    path: docs/dist
    type: static
    spa: true
  api:
    path: services/api
    type: dockerfile
    command: ["node", "server.js"]
    labels: [backend]
    display: { preview: none }     # an icon card, not a live thumbnail
    always_on: true
    uses: [docs]
    processes:
      app: ""                      # "" runs the block's command
      worker: "node worker.js"     # no service names it: a worker
    http_service:
      internal_port: 3000
      access: app                  # public | app | admins
      processes: [app]
      checks: [{ type: http, path: /health }]
    services:
      - { name: admin, internal_port: 3001, access: admins }
    mounts:
      - { source: data, destination: /data, initial_size_gb: 10, processes: [app] }
    vm:
      - { cpu: 1, memory_gb: 2 }
      - { cpu: 1, memory_gb: 1, processes: [worker] }
    deploy:
      release_command: "node migrate.js"
    env:
      PUBLIC_BASE_URL: https://example.com
    secrets:
      API_TOKEN: api_token         # runtime name: project secret identifier
```

| Field | Meaning |
|---|---|
| `path`, `type`, `image`, `dockerfile`, `command`, `root`, `output_dir`, `install_command`, `build_command`, `spa` | The source. `type` is `static`, `bundle`, `dockerfile` or `oci_image`. `command` is the image's argv. Paths are relative to `kortix.yaml`. |
| `port`, `readiness_path` | Shorthand for `http_service.internal_port` and one HTTP check |
| `always_on`, `idle_timeout_seconds`, `resources` | Run mode, and the App's default machine size (`cpu`, `memory_gb`, `disk_gb`) |
| `env`, `secrets` | Runtime values, and runtime names mapped to project secret identifiers. The deploy reads both from this block only. |
| `uses` | The Apps this App uses. The list replaces the App's list on every deploy. An App cannot use itself. |
| `labels` | At most 10, each lowercase letters, digits and `-` (`^[a-z0-9][a-z0-9-]{0,30}$`) |
| `display` | Where Kortix shows the App. Presentation only. See [Where an App shows](#where-an-app-shows). |
| `processes`, `http_service`, `services`, `mounts`, `vm`, `deploy` | The release config. See the next sections. |

Rules:

- `kortix apps deploy` with no arguments deploys every block that has a `path`
  or an `image`: each App after the Apps it uses, then by name. It skips the
  other blocks.
- `kortix apps deploy <path>` uses the block whose `path` is that path.
  `--manifest-app <slug>` names a block. A flag you pass wins over the block.
- `kortix validate` applies the API's release config rules. The API refuses a
  bad config with `400 app_config_invalid` and `issues: [{ path, message }]`.
  It checks the machine size at deploy: see
  [Run mode, machines and scale](#run-mode-machines-and-scale).
- Any other key is an error. `backends` is an error: list used Apps in `uses`.
  `kind` is ignored, with a warning.
- A static App ignores the run mode, the machine size, `env`, `secrets` and the
  release config.

#### Where an App shows

`display` decides where an App appears in the **Apps** tab. It never changes how
the App runs or who may open it. The tab shows a card only for an App
registered with `listed: true`. Every other App (a poller, a bot, a backend)
sits in the **Background** list under the grid.

Each row shows the App's
status, how it runs, the trigger that calls it and its last activity. Opening
a background App shows what it does: how it runs, the triggers that call it
and the agents its [events](/docs/guide#automations) start. In the
web app, **Settings → Show on Apps page** moves an App between the grid and
the list. Like every App setting kortix.yaml can declare, the switch commits
`display.listed` to `apps.<slug>` in kortix.yaml; a push that edits it applies
it without a deploy.

| Key | Default | Effect |
|---|---|---|
| `listed` | `false` | `true` gives the App a card in the grid. `kortix apps deploy --listed` and `kortix apps set <app> --listed` set it; `--unlisted` clears it. An external App (`--url`) is listed from the start. |
| `preview` | `live` | `live` frames the App on its card. `none` shows an icon. Use `none` for an App whose root page is not for people, such as an API or a backend. |
| `parent` | none | `parent: <slug>` makes the App a tab in that App's detail, with no card of its own. The tab label is the App's name without the `<parent>-` prefix: `backend-dashboard` becomes **Dashboard**. A tab whose parent does not exist stays a card. |

An App with `preview: none` opens on its first tab. A deploy writes the
block's `display` when the block declares one; an absent `display` keeps what
the App has. `kortix apps show`
prints it on the `shown` row.

`PATCH /v1/projects/<project_id>/apps/<app_id>`
with `display` replaces it whole, and `{}` restores the defaults. An unknown
key, a `preview` other than `live` or `none`, or a malformed `parent` answers
`400`.

> **Warning**
> Never put access settings, passwords or member ids in `kortix.yaml`. Set them
> with `kortix apps access`. See [App access](/docs/developers#app-access).

The full `kortix.yaml` reference is [kortix.yaml reference](/docs/developers#kortixyaml-reference).

### Processes, services and checks

These settings apply to server Apps.

- **Process groups.** `processes` maps a group name to a shell command. `""`
  runs the App's `command`. A group name is lowercase letters, digits, `-`
  or `_`, and starts with a letter.
- **Primary service.** `http_service` answers on the App's own host. Its
  fields: `internal_port`, `access`, `auto_stop_machines` (`off`, `stop`,
  `suspend`; `suspend` acts like `stop`), `auto_start_machines`,
  `min_machines_running` (0–100), `processes`, `checks`.
- **More services.** `services` lists at most 10 more HTTP services, each
  `{ name, internal_port, access, processes, checks }`. A service answers on
  `<app host>--<name>`. `http` is the primary service's name and is reserved.
- **Workers.** A group that no service names is a worker. It runs and never
  idle-stops.
- **Ports.** `internal_port` is 1–65535. Ports `7331` and `8080` belong to the
  App supervisor and are refused. Two services cannot share a port.
- **HTTP only.** A service with `protocol: tcp` is refused. Apps have no TCP
  services and no private network between Apps.

Service `access` gates the service's host:

| `access` | Who gets through |
|---|---|
| `public` | Anyone. The service checks sign-in itself. |
| `app` (default) | Whoever the App's access policy admits |
| `admins` | Whoever the App's policy admits and who holds `project.app.admin`. Others get `403 app_service_admins_only`. |

**Checks** run inside the machine. A release goes live only when every check of
every new machine passes.

| Check field | Default | Bounds |
|---|---|---|
| `type` | `http` when a `path` is set, else `tcp` | `http`, `tcp` |
| `path` | `/` | Starts with `/` |
| `interval_s` | 15 | 1–3600 |
| `timeout_s` | 5 | 1–600 |
| `grace_period_s` | 10 | 0–3600 |

**Deploy strategy** (`deploy.strategy`):

| Strategy | What happens | Default when |
|---|---|---|
| `bluegreen` | New machines start and pass their checks, then traffic moves. No downtime. | The release has no mount |
| `rolling` | One machine at a time. A volume moves from the old machine to the new one. Seconds of downtime. | The release has a mount |
| `immediate` | Every machine is replaced at once, without waiting for checks | — |
| `canary` | One new machine first. When it is healthy, the rest roll. | — |

A release with a mount refuses `bluegreen` and `canary`: they would put two
machines on one volume. A release that never gets healthy is abandoned, and
traffic stays on the previous release.

`deploy.release_command` (at most 2000 characters) runs once per release,
before any machine changes. It runs in a temporary machine with the new image,
`env` and `secrets`, and no volume. A non-zero exit fails the release with
`release_command_failed`, and nothing changes. Use it for a migration of a
database outside the App.

### Run mode, machines and scale

A server App runs in one of two modes:

| Mode | Behavior | Use it for |
|---|---|---|
| Always on (`--always-on`) | Runs 24/7. Kortix checks every 5 minutes and restarts it when it stops. | WebSockets, background loops, databases |
| On demand (`--on-demand`) | Stops after `idle_timeout_seconds` without requests. The next request or a trigger that calls the App wakes it. A stop keeps the disk and loses memory. | Apps that only answer requests, pollers a schedule calls |

A new App is always on. Change the mode with `kortix apps set <app> --always-on`
or `--on-demand`, or with `always_on` in `kortix.yaml`. The change applies
within 5 minutes, without a redeploy. A static App has no runtime and ignores
the run mode.

Machine size:

| Setting | Default | Bounds |
|---|---|---|
| `cpu` | 1 | 1–32 |
| `memory_gb` | 2 | 1–128 |
| `disk_gb` | 10 | 1–500 |
| `idle_timeout_seconds` | 300 | 120–86400. On demand only. |

A value out of range answers `400 app_machine_out_of_range`. Kortix never
clamps it. The App's size is the default for every process group.

A `vm`
entry in the release sizes one group. A size change applies with the next
release.

```bash
kortix apps machines api                          # id, group, state, release, size, volume
kortix apps machines restart api <machine>        # also start, stop, destroy
kortix apps scale count api 3 --process worker    # 0-20 machines per group
kortix apps scale show api
kortix apps exec api -- node scripts/report.js    # run one command in a machine
```

- A group runs 1 machine by default. `scale count` sets 0–20 per group and
  applies in the background.
- A group with a mount runs one machine. `scale count` above 1 answers
  `409 volume_single_machine`, because a second machine would get its own,
  empty volume and serve different data behind the same URL. Scale a database
  up instead: `kortix apps set <app> --cpu <n> --memory-gb <n>`, then deploy
  (seconds of downtime, no data lost). `--new-volumes` accepts separate volumes,
  for software that replicates its own data.
- Destroying a machine keeps its volume. The group runs one machine fewer.
- `kortix apps stop` suspends a server App now. The next authorized request
  wakes it. `kortix apps start` wakes it before traffic arrives. A static App
  answers `409 static_app_no_runtime` to both.
- `exec` needs `project.app.admin` and is audited. It takes an argv of 1–256
  words and runs in a running machine of the live release (or `--machine <id>`).
  It times out after `--timeout` seconds (1–300, default 60). Each of stdout
  and stderr returns at most 1 MiB. The CLI exits with the command's exit code.
  A stopped machine answers `409 machine_not_running`.

Account limits:

| Code | HTTP | Meaning |
|---|---|---|
| `app_quota_exceeded` | 402 | The account is at its App limit. |
| `app_concurrency_limit` | 429 | The account runs its maximum number of Apps at once. |
| `app_account_unfunded` | 402 | The account cannot start compute. Its running Apps stop. |

A self-hosted instance without billing has none of these limits.

### Volumes and snapshots

A volume is a Platinum volume, separate from the machine's root disk. A
`mounts` entry in the release config declares it.

```yaml
mounts:
  - { source: data, destination: /data, initial_size_gb: 10, snapshot_retention_days: 5 }
```

- `source` is the volume name: 1–30 lowercase letters, digits or `_`.
- `destination` is an absolute path. It cannot be `/`, end in `/`, or sit
  under `/bin`, `/boot`, `/dev`, `/etc`, `/lib`, `/lib64`, `/proc`, `/root`,
  `/run`, `/sbin`, `/sys`, `/usr`, `/var` or `/kortix`. Use `/data`.
- `initial_size_gb` (1–100) is what the App needs. Platinum allots every
  volume the same fixed size, 100 GB today, and a volume cannot grow
  (`400 volume_fixed_size`). Keep files that can outgrow it, such as uploads
  and documents, in object storage.
- `kortix apps volumes` shows each volume's use and size, read live from
  Platinum. Plan a move to object storage before use reaches the size: a full
  volume fails every write.
- One machine writes a volume at a time, and a machine mounts at most one
  volume. Kortix enforces a single-writer lock.
- A volume outlives every release. A rollback changes code and config, never
  the data.

```bash
kortix apps volumes backend                             # name, group, used, size, attached machine, retention
kortix apps volumes create backend data --size 10 --process app
kortix apps volumes set backend data --retention 30     # keep daily snapshots 30 days
kortix apps volumes snapshots backend data              # daily, manual and final snapshots
kortix apps volumes snapshot backend data               # a manual snapshot now
kortix apps volumes restore backend data <snapshot>     # project.app.admin
kortix apps volumes fork backend data data_copy         # --snapshot <id> to copy a snapshot
kortix apps volumes destroy backend data --yes          # project.app.admin
```

| Operation | What it does |
|---|---|
| Daily snapshot | Kortix takes one per volume per day. `daily` and `manual` snapshots are kept `snapshot_retention_days` (1–60, default 5). |
| `snapshot` | A `manual` snapshot. Kortix commits the mounted volume first (a frozen point in time), then pins that commit. Daily snapshots work the same way. |
| `restore` | The machine stops, the volume returns to the snapshot, and the machine starts on it. The replaced contents stay in the volume's history. |
| `fork` | A new, unattached volume in the same App, made at once from the current contents or a snapshot |
| `create` | A volume made ahead of time. The next machine of its group with a mount of that name takes it. |
| `destroy` | Needs the volume's name as confirmation (`400 confirmation_required` without it). Refused with `409 volume_attached` while a machine mounts it. Kortix keeps a `final` snapshot for 7 days, then purges the data. |

### App access

An access mode decides who can open one App. It grants no permission that the
person's role denies.

| Mode | Who can open the App |
|---|---|
| `private` (default) | The creator |
| `project` | Everyone who can read the project |
| `restricted` | The members and groups you list (at most 100 of each, at least one) |
| `public` | Anyone. No sign-in. |
| `password` | Anyone with the password (8–256 characters) |

Project managers, account owners and account admins see and open every App of
the project, so a private App stays manageable after its creator leaves.

```bash
kortix apps deploy dist --app my-site --access project
kortix apps access my-site --mode restricted --members <member_id> --groups <group_id>
kortix apps access my-site --mode password --password '<password>'
kortix apps access-link my-site
```

- A person who can open the App signs in with their Kortix login. An access
  link (`kortix apps access-link`) works for 5 minutes and sets an 8-hour,
  host-only, secure cookie. Treat the link as a secret.
- A change to the policy revokes every App cookie and every viewer token the
  App holds. Everyone signs in again.
- Kortix stores only a password hash. The API, the CLI and the SDK never return
  it: the policy reports `password_configured`.
- The App list shows only the Apps you may open. An App you may not open
  answers `404`, never `403`.
- In the web app, `?open_app=<app_id>` on the Apps page opens the App through an
  access link.

### Know who is looking

The gate signs in every visitor before a request reaches your App. Your App
reads who they are without a login of its own. The viewer scope decides how much
it is told:

| `--viewer` | The App receives |
|---|---|
| `identity` (default) | The viewer's user id, email, name, picture, account role and groups, in a signed header on every request |
| `api` | The same, plus a 1-hour token that acts as the viewer on the Kortix API. The viewer's role caps it. |
| `off` | Nothing |

```bash
kortix apps access my-site --viewer api
```

A viewer exists when a person opens the App with their Kortix login or an access
link. A `password` App never has a viewer. A `public` App has one only for a
visitor who arrived through an access link.

**In the browser.** `GET /_kortix/viewer` on the App's origin returns the
viewer. Call the Kortix API through `/_kortix/api/v1/*` on the same origin: a
direct call to the API from an App origin fails CORS.

```tsx
import { createKortix, kortixAppViewerToken } from '@kortix/sdk';
import { useKortixAppViewer } from '@kortix/sdk/react';

const kortix = createKortix({ backendUrl: '/_kortix/api/v1', getToken: kortixAppViewerToken() });

function Header() {
  const { status, viewer } = useKortixAppViewer();   // 'loading' | 'anonymous' | 'viewer'
  return <span>{status === 'viewer' ? viewer.email : 'Signed out'}</span>;
}
```

`/_kortix/api/v1/*` answers `403 viewer_api_disabled` unless the scope is `api`,
`401 no_viewer_identity` without a viewer, and `403 cross_site_request` for a
request from another origin. `/v1/oauth/*` and MCP paths answer `404`.

**On the App's server.** Import from `@kortix/sdk/server`:

```ts
import { readAppViewer, createAppViewerKortix } from '@kortix/sdk/server';

const viewer = await readAppViewer(request);
// { userId, email, name, picture, groupIds, groups, role, projectId, accountId, appId, accessMode, token, expiresAt } or null
if (!viewer) return new Response('Not found', { status: 404 });

const kortix = await createAppViewerKortix(request, { backendUrl: 'https://api.kortix.com/v1' });   // scope api only
```

- `readAppViewer` verifies the signed header with `KORTIX_APP_VIEWER_SECRET`,
  which Kortix injects. A forged header fails.
- `createAppViewerKortix` builds a client that acts as the viewer, from the
  token the gate adds to each request. Build one per request. Never store or
  log the token.
- `createKortixAppGuard()` returns one resolver for both the gate header and
  your own Kortix sign-in. `requireViewer(request)` and
  `requireGroup(request, groupIds)` return `{ viewer }` or `{ response }`, never
  null. A group check keeps working when the App's access mode changes.
- A viewer with the `member` role needs an agent grant to start sessions on a
  project with agent permissions. Without it, the API answers
  `403 no_agent_access`.

An App on its own domain has no gate. Use
[Sign in with Kortix](/docs/developers#sdk-authentication) there.

### Apps that use Apps

An App can use other Apps of its project. Then its code gets sign-in tokens for
them and reaches them on its own origin.

```bash
kortix apps link crm --uses backend  # add; kortix apps unlink removes
kortix apps set crm --uses backend,files # replace; --uses= clears
```

`uses: [backend]` in the `kortix.yaml` block sets the same list on every deploy. An
unknown slug, or one you cannot see, answers `400 app_not_found` with `slugs`.

**Sign-in tokens.** Each project has one token issuer,
`https://<api host>/v1/projects/<project_id>`, with a public
`/.well-known/openid-configuration` and `/jwks.json`. A token is an ES256 JWT
that lives 15 minutes. Its `aud` is the id of the App it is for.

It carries
`sub`, `email`, `name`, `picture`, `groups`, `group_ids`, `role`, `account_id`
and `project_id`. An agent's token carries `kind: "agent"` and no groups or
role. A token a person got in one App for an App it uses also carries
`via_app_id`: the id of the App they signed in to.

| Who | How they get a token |
|---|---|
| A person in an App | `GET /_kortix/token?audience=<slug or id>` on the App's origin: the App itself (default) or an App it uses. `kortixToken({ audience })` in `@kortix/sdk`. |
| A member, a script, an agent session | `kortix apps token <app>` (`--json` adds `expires_at`), or `POST /v1/projects/<project_id>/apps/<app_id>/token`. An agent session gets a token that names its agent. |

Any other audience answers `403 app_not_linked`. Everyone an App admits gets a
token for every App it uses, even when they cannot open the used App
themselves. Keep a backend App `private`: it stays off their App list and its
own URL refuses them. The Apps that use it still work for their readers.

A token proves who the person is, not what they may do. Check `groups`,
`role` or `via_app_id` in every function whose data is not for everyone that
the Apps using it admit. A backend that serves two Apps can serve each App's
readers only that App's data with `via_app_id`. A person you remove loses
access within one token lifetime.

**Bindings.** `/_kortix/apps/<slug>/*` on an App's origin forwards HTTP and
WebSocket requests to the `http` service of an App it uses.
`/_kortix/apps/<slug>--<service>/*` reaches another service. Your browser code
needs one origin and no CORS. The used App checks the token itself.

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

const backend = kortixBinding('backend'); // { url: <origin>/_kortix/apps/backend, token: a token fetcher }
```

To start the first token request before your bundle loads (an inline `<script>`),
pass `fetch` to `kortixBinding`. Return the early response on its first call and
the real `fetch` after. The binding then does not request the token twice.

| The used App or service | Answer |
|---|---|
| Not in `uses` | `403 app_not_linked` |
| A service with `access: admins` | `403 app_binding_forbidden` |
| A static App | `409 app_binding_unsupported` |
| No running release | `503 app_binding_unavailable` |
| No such service | `404 app_service_not_found` |

**Server to server.** A machine has no viewer cookie, so it skips the binding.
It calls `KORTIX_USES_URL_<SLUG>` with a token. A link change reaches that
variable with the next deploy, because a deploy makes new machines.

**Verify a token.** `verifyKortixToken(bearer)` from `@kortix/sdk` checks the
signature, expiry (60 s skew), issuer and audience. In a server App it needs no
options: it reads the injected `KORTIX_AUTH_*` values. Elsewhere, pass the App's
`auth.issuer`, `auth.audience` and `auth.jwks_uri`.

To verify offline, for
example in a self-hosted Convex deployment, run `kortix apps auth-env <app>`.
It prints `KORTIX_AUTH_ISSUER`, `KORTIX_AUTH_AUDIENCE` and `KORTIX_AUTH_JWKS`
(the key set as a `data:` URL) as dotenv lines. These values are public.

`readKortixMember(claims)` turns a token's claims, or a viewer, into one member
object, or `null`. `requireKortixMember(claims, { groups, roles })` returns the
member or throws `KortixMemberError` with code `unauthenticated` or `forbidden`.
Each list means "any one of these".

A group matches by name or id. An empty list
admits nobody. Groups need the Enterprise plan; roles work on every plan.

### Agents and Apps

An agent session calls an App with its session credential, in
`Authorization: Bearer` or `X-Kortix-App-Authorization: Bearer`. Use the second
header when the App reads `Authorization` for its own key. Before the gate
forwards the request, it removes `X-Kortix-App-Authorization`, an
`Authorization` header that holds a Kortix credential, and the Kortix cookies.

Every other header and cookie reaches the App unchanged. App code never
receives a Kortix credential.

The gate judges an agent session as the agent, not as the person who started
it:

| App mode | The agent session gets in when |
|---|---|
| `public` | Always |
| `project` | The agent holds `project.app.read` |
| `restricted`, `private` | The App's slug is in the agent's `apps` list, and the agent holds `project.app.read` |
| `password` | Never |

A refused request answers `401 app_auth_required`. An agent session opens only
Apps of its own project.

```yaml
agents:
  report-writer:
    prompt: Write the weekly report from the reports App.
    kortix_permissions: [project.app.read]
    apps: [reports]    # App slugs, all, or none (the default)
```

Set the list in `kortix.yaml`, with `kortix agents scope report-writer --apps reports`
(also `all` and `none`), or in **Customize → Agents →** the agent **→ Apps**.
The App's access dialog lists the agents granted to it. From code, call `apps.access.agents(appId)`. See
[Agents](/docs/guide#agents).

A script, a CI job or a service account calls a non-public App with a personal access
key (`kortix_pat_`) or a service account key (`kortix_sa_`). Send it in
`Authorization: Bearer` or `X-Kortix-App-Authorization: Bearer`. The gate applies the same access decision
as for a signed-in browser, within the token's project binding. A `password` App
never accepts a Kortix credential.

An `openapi` or `http` connector whose base URL is an App of the same project
needs no Kortix credential. The connector gateway adds a signed, 60-second
assertion for the calling session. See [Connectors](/docs/guide#connectors).

### Triggers and App events

An App can start agents, and triggers can call it.

| You want | Use |
|---|---|
| An agent to run when the App sees something | The App posts an App event to `$KORTIX_APP_EVENTS_URL` with `Authorization: Bearer $KORTIX_APP_TOKEN`. A trigger with `on: { event, app }` picks the agent. See [App events](/docs/guide#automations). |
| The App called on a schedule or an event | A trigger with `run: { app, path, method, payload }`. Kortix wakes an on-demand App and sends the request. No session, no model. See [Call an App](/docs/guide#automations). |
| The App to call itself later | `POST /v1/projects/<project_id>/apps/<app_id>/runs` with the App token: `run_after` or `run_at`. See [Call an App](/docs/guide#automations). |

To react to the outside world, try a webhook first, then a schedule. Post App
events only when neither works. An App never starts an agent itself: it posts
events, and a trigger decides. Deploy a watcher App unlisted: it belongs in the
Background list.

App events and runs are REST only. They authenticate with the App token, not
a Kortix token.

### Environment inside an App

Kortix sets these variables in every machine:

| Variable | Value |
|---|---|
| `KORTIX_APP_SLUG` | The App's slug |
| `KORTIX_APP_URL` | The URL of the primary service |
| `KORTIX_SERVICE_URL_<NAME>` | The URL of each other HTTP service (`-` becomes `_`) |
| `KORTIX_USES_URL_<SLUG>` | The URL of each App this App uses (`-` becomes `_`) |
| `KORTIX_MACHINE_ID` | The machine's id, stable across releases |
| `KORTIX_PROCESS_GROUP` | The machine's process group |
| `KORTIX_AUTH_ISSUER`, `KORTIX_AUTH_AUDIENCE`, `KORTIX_AUTH_JWKS` | The values that verify the App's sign-in tokens |
| `KORTIX_APP_VIEWER_SECRET` | The key that verifies the signed viewer header |
| `KORTIX_APP_ACCOUNT_ID` | The account that owns the App |
| `KORTIX_APP_ID` | The App's id |
| `KORTIX_APP_TOKEN` | The App's token for [App events](/docs/guide#automations) and scheduled runs. A secret. |
| `KORTIX_APP_EVENTS_URL` | Where the App posts App events |

Your own values come from `env` and `secrets` in the `kortix.yaml` block:

- At most 128 `env` values and 128 `secrets` mappings. A value is at most
  32 KiB.
- A name is uppercase letters, digits and `_` (`^[A-Z_][A-Z0-9_]*$`), at most
  128 characters.
- `kortix validate` and the deploy refuse reserved names and say why. Reserved:
  every name that starts with `KORTIX_`; `PORT`, `PATH`, `HOME`, `PWD`, `USER`,
  `LOGNAME`, `SHELL`, `HOSTNAME`, `TERM`, `TMPDIR`, `NODE_ENV`, `NODE_OPTIONS`,
  `LD_PRELOAD`, `LD_LIBRARY_PATH`; and `SLACK_SIGNING_SECRET`,
  `SLACK_BOT_TOKEN`.
- A name cannot be in both `env` and `secrets`.
- `secrets` maps a runtime name to a project secret identifier. The secret must
  have the `environment` exposure. Kortix resolves it when it creates the
  machine and never writes it into the image. See [Security](/docs/guide#security).

### URLs and what visitors see

Kortix assigns the hostname when it creates the App and never changes it. On
Kortix Cloud it is `https://<env>-<slug>-<route-key>.apps.kortix.com`, where
`<route-key>` is 16 hex characters. Another service answers on the same label
plus `--<service>`.

A service URL is `null` when the label would pass 63
characters: reach that service through a binding. A self-hosted instance serves
Apps on its Apps base domain. See [Self-hosting](/docs/developers#app-hosting).

A static App serves files at once:

- HTML revalidates on every load (`no-cache`).
- Hashed build output (`_next/static/`, and hashed files under `assets/` or
  `static/js|css|media/`) is cached for a year. Every other file revalidates.
- With SPA fallback on, a page navigation to an unknown path gets
  `index.html`. A missing file gets your `404.html` with status `404`.
- `/docs` redirects `308` to `/docs/` when `docs/index.html` exists.
- Responses are `private` to shared caches unless the App is `public`.

An authorized request to a stopped server App wakes it, waits for readiness and
then serves that same request. While the App deploys or wakes, a browser gets a
status page with HTTP `202`, `retry-after: 3` and a 3-second refresh. Other
clients get `202` and JSON with a `code` that names the phase, for example
`app_deployment_building` or `app_starting`. Retry after 3 seconds.

| State | HTTP | `code` |
|---|---|---|
| Last deployment failed | 503 | `app_deployment_failed` |
| Last deployment cancelled | 503 | `app_deployment_cancelled` |

Account limits answer `402` or `429`: see
[Run mode, machines and scale](#run-mode-machines-and-scale).

### Releases, rollback and deletion

```bash
kortix apps releases my-site            # vN, status, strategy; marks the live one
kortix apps show my-site                # capabilities, links, run mode, releases
kortix apps logs my-site [deployment]   # runtime logs; --after N --limit N
kortix apps rollback my-site v3         # a new release from v3; waits unless --no-wait
```

- A rollback creates a new release from the target's artifact and config, and
  rolls it out like a deploy. Volumes keep their data. A target that is not
  `ready` answers `409 release_not_ready`.
- An App keeps its live release and the 5 newest other ready releases as
  rollback targets. Kortix retires older ones after each deploy. A self-hosted
  operator can change the number: see [Self-hosting](/docs/developers#app-hosting).
- Runtime log lines carry a `source`: `app` (your process), `appd` (the App
  supervisor) or `caddy` (the access log, which never holds request headers).
- In the web app, open the App, then a release, to read its events and build
  log.

Delete one release, or the whole App:

```bash
kortix apps delete my-site --deployment v1 --yes   # one release and its image
kortix apps delete my-site --yes                   # the App, its machines and images
```

- The live release cannot be deleted (`409 deployment_live`). Roll back first.
- A release that is still building cannot be deleted
  (`409 deployment_in_progress`).
- A build that hits the provider's image limit fails with
  `app_image_quota_exceeded`. Delete releases you do not need.
- Deleting an App needs `project.app.write`. An App with volumes also needs
  `project.app.admin`. Each volume keeps a `final` snapshot for 7 days, then
  Kortix purges it. Export data you need first.

### Template: Convex backend

The `convex` template deploys a self-hosted Convex backend with Kortix sign-in:
a database, server functions, realtime queries, file storage, schedules and
search. It is an ordinary server App with a volume.

```bash
kortix apps launch --template convex   # --no-deploy writes kortix.yaml only
```

Run it in the directory that holds your `kortix.yaml`. Without one, `launch`
creates it. A slug that is already in the file is refused: pick another
`--name`.

`launch` writes two blocks into `kortix.yaml` and the dashboard's two files,
`apps/backend/dashboard/Dockerfile` and `apps/backend/dashboard/start.js` (a file that
already exists is kept). It deploys `backend`, then writes the backend's admin key to
the project secret `BACKEND_ADMIN_KEY` (the slug in capitals, `-` becomes `_`). It
reads the key from the machine and never prints it.

The key belongs to the
backend that this launch created, so every launch writes it again. Then it deploys
`backend-dashboard`.

There are two Apps because Convex ships the backend and the dashboard as two
images, and a Kortix App runs one image. The **Apps** tab shows them as one:
the dashboard is the backend's **Dashboard** tab. The backend runs 24/7 with its
volume; the dashboard sleeps when idle.

| App | What it is |
|---|---|
| `backend` | The Convex backend, image pinned by digest. Service `http` on port 3210 (`access: public`, a `/version` check): the client URL, the sync WebSocket and the admin API. Service `site` on port 3211 (`access: public`): HTTP actions. Volume `data` at `/convex/data`, 10 GB. Always on, 1 vCPU / 1 GB / 10 GB disk. Label `backend`. `display: { preview: none }`. |
| `backend-dashboard` | The Convex dashboard, built from `apps/backend/dashboard/` (`type: dockerfile`). `access: admins`, on demand. It uses `backend` and reaches it through `/_kortix/apps/backend`. `display: { parent: backend }` makes it the **Dashboard** tab of `backend`. `secrets: { NEXT_PUBLIC_ADMIN_KEY: BACKEND_ADMIN_KEY }` signs it in. |

`start.js` signs the dashboard in with the admin key. The dashboard is
`access: admins`, so only people who hold `project.app.admin` reach it.

Both backend services are `public` because Convex checks sign-in in each
function. Keep the backend at 1 machine: a second machine gets its own empty
volume, which makes a second, separate database.

Connect and push your Convex code, for example from `apps/backend/`. A session has
`BACKEND_ADMIN_KEY` in its environment:

```bash
cd apps/backend
npm install convex@1.46.0 @kortix/sdk
export CONVEX_SELF_HOSTED_URL=<backend App URL> CONVEX_SELF_HOSTED_ADMIN_KEY="$BACKEND_ADMIN_KEY"
kortix apps auth-env backend | while IFS='=' read -r k v; do printf '%s' "$v" | npx convex env set "$k"; done
npx convex deploy
```

Use Convex CLI `1.46.0`: it matches the pinned backend.

> **Warning**
> The admin key controls the backend's code and data. Grant `BACKEND_ADMIN_KEY` only to
> agents that deploy the backend. Never commit the key or ship it in a web App.

Kortix sign-in for the functions:

```ts
// convex/auth.config.ts
export default {
  providers: [{
    type: 'customJwt',
    issuer: process.env.KORTIX_AUTH_ISSUER!,
    applicationID: process.env.KORTIX_AUTH_AUDIENCE!,
    jwks: process.env.KORTIX_AUTH_JWKS!,
    algorithm: 'ES256',
  }],
};
```

```ts
// convex/members.ts
import { query } from './_generated/server';
import { readKortixMember, requireKortixMember } from '@kortix/sdk';

export const me = query({
  args: {},
  handler: async (ctx) => readKortixMember(await ctx.auth.getUserIdentity()),   // null when signed out
});

// In every function that touches non-public data:
// const member = requireKortixMember(await ctx.auth.getUserIdentity(), { roles: ['owner', 'admin'] });
```

In React, read the member with `useQuery(api.members.me)`. Do not call
`useConvexAuth()`: it throws under a plain `ConvexProvider`.

- **Dashboard.** Open **Apps** → `backend` → **Dashboard** (project managers). It opens
  signed in. `kortix apps access-link backend-dashboard` prints a 5-minute link to
  the same page.
- **Before a migration, a bulk import or a backfill,** take
  `kortix apps volumes snapshot backend data`. Restore with
  `kortix apps volumes restore backend data <snapshot>`: the live data returns to the
  snapshot.
- **Keep your own copy** with `npx convex export --path backup.zip --include-file-storage`.
- **From outside**, `kortix apps connect backend` prints how to call the backend:
  its URLs and snippets for a sign-in token, an HTTP call and your own server.
  It prints no secret.

### Build an internal tool

An internal tool is a Convex backend App for data and logic, plus a static App
for the UI. Kortix signs the team in: the UI knows who is looking, and every
function knows who calls it.

```text
apps/backend/convex/        Convex functions: schema.ts, auth.config.ts, members.ts, ...
apps/crm/              Vite + React UI: src/convex.ts wires the binding
apps/backend/dashboard/     the dashboard App: Dockerfile + start.js (the template)
kortix.yaml            apps.backend and apps.backend-dashboard (the template), apps.crm
```

### Launch the backend
`kortix apps launch --template convex`.
### Write the functions
Write the schema and functions in `apps/backend/convex/`, with `auth.config.ts` from the template section. Call `requireKortixMember` first in every public query and mutation. Push with `npx convex deploy`.
### Wire the UI to the backend
Connect through the binding:

```ts
// apps/crm/src/convex.ts
import { ConvexReactClient } from 'convex/react';
import { kortixBinding } from '@kortix/sdk';

const backend = kortixBinding('backend');
export const convex = new ConvexReactClient(backend.url);
convex.setAuth(backend.token);
```
### Declare the UI App

```yaml
apps:
crm:
path: apps/crm/dist
type: static
spa: true
uses: [backend]
```
### Build, deploy and open it to the project

```bash
(cd apps/crm && npm run build)
kortix apps deploy apps/crm/dist
kortix apps access crm --mode project
```

Never use `public` for internal data. A `public` App has no signed-in member, so
every function that requires one refuses the call.

[Kortix UI](/docs/developers#kortix-ui) (`@kortix/ui`) is the component library for App UIs.
Install it from npm with `npm i @kortix/ui @kortix/sdk`, the same version of
each. Never copy component source into the App.

A Vite App without Tailwind imports
`@kortix/ui/styles.css` once in `src/main.tsx`. A Tailwind v4 App imports
`@import 'tailwindcss'; @import '@kortix/ui/tailwind.css';` in its main CSS.
Compose `Card`, `Table`, `Modal`, `Badge` and `InfoBanner` before you write a
component, and re-theme by CSS variables, never by a copied component. A chat
view of a session is `SessionThread` from `@kortix/ui/session`.

| What broke | Undo |
|---|---|
| The UI | `kortix apps rollback crm <vN>` |
| The backend's image or config | `kortix apps rollback backend <vN>`. The volume keeps its data. |
| Convex code | Check out the last good `apps/backend/convex` from git and run `npx convex deploy` again. |
| Data | `kortix apps volumes restore backend data <snapshot>`. Every later change is lost. |

### Build on Apps from TypeScript

`kortix.project(projectId).apps` in `@kortix/sdk` exposes every App operation.
The CLI uses the same calls.

Branch on an App's `capabilities`, never on how the App was made:

| Capability | The App has it when |
|---|---|
| `deployments`, `rollback`, `preview`, `member_tokens` | Always |
| `static` | The live release is static |
| `sleep`, `machines`, `scale`, `exec` | The live release runs a server |
| `volumes`, `snapshots` | The App has a volume |

A call that needs a capability the App lacks answers `409`:

```json
{ "error": "This App does not support exec.", "code": "app_capability_unsupported", "capability": "exec" }
```

The CLI checks first: it exits `1` with `App <slug> does not support <capability>.`
and sends nothing. The machine and volume routes of an App with no machines
answer `409 app_no_machines`.

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

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

const app = await apps.create({ slug: 'docs', name: 'Docs' });
const artifact = await apps.artifacts.uploadArchive(tarGzBytes);
const deployment = await apps.deployments.create(app.app_id, {
  artifact_id: artifact.artifact_id,
  source: { kind: 'static', spa: true },
});

for (;;) {
  const { deployment: current } = await apps.deployments.get(app.app_id, deployment.deployment_id);
  if (current.status === 'ready') break;
  if (current.status === 'failed' || current.status === 'cancelled') throw new Error(current.error ?? current.status);
  await new Promise((resolve) => setTimeout(resolve, 2000));
}
```

| Group | Calls |
|---|---|
| App | `list`, `create`, `get`, `update` (also `labels` and `display`), `remove`, `start`, `stop`, `rollback(appId, deploymentId)` |
| Artifacts | `artifacts.register` (`archive` or `oci_image`), `artifacts.uploadArchive`, `artifacts.finalize` |
| Releases | `deployments.create` (`artifact_id`, `source`, `provider?`, `environment?`, `secrets?`, `config?`), `deployments.list`, `deployments.get` (with events and build log), `deployments.logs`, `deployments.remove` |
| Machines | `machines.list`, `machines.start`, `machines.stop`, `machines.restart`, `machines.destroy`, `scale(appId, { worker: 3 })`, `exec(appId, { command: ['ls', '/data'] })` |
| Volumes | `volumes.list`, `volumes.create`, `volumes.update`, `volumes.destroy(appId, volumeId, name)`, `volumes.snapshots`, `volumes.snapshot`, `volumes.restore`, `volumes.fork` |
| Access | `access.get`, `access.update` (`mode`, `member_ids`, `group_ids`, `password`, `viewer_token_scope`), `access.session` (a 5-minute link), `access.agents` |
| Sign-in | `token(appId)`, `authEnv(appId)` |

React hooks in `@kortix/sdk/react`:
`useProjectApps`, `useAppDeployments`, `useAppDeployment`, `useAppAccess`,
`useAppMachines`, `useAppVolumes` and `useAppVolumeSnapshots`. Signatures and
types: [SDK reference](/docs/developers#the-project-handle) and
[React hooks](/docs/developers#react-hooks).

### Permissions and CLI reference

| Permission | Allows | Held by |
|---|---|---|
| `project.app.read` | List and open the Apps you may see. List the agents granted an App. Create an access link. Mint a sign-in token. Read the `auth-env` values. | Member, Manager |
| `project.app.write` | Create an App. Change its name, size, run mode, labels, `display`, `uses` and external URL. Read and change the access policy. Delete a release. Delete an App. | Manager |
| `project.app.deploy` | Upload artifacts, deploy, roll back, start and stop. Read releases, their events and build logs, and runtime logs. List, start, stop, restart and destroy machines. Scale. List and create volumes, set snapshot retention, list and take snapshots, fork a volume. | Manager |
| `project.app.admin` | `exec`. Restore or destroy a volume. Open an `access: admins` service. Delete an App that has volumes. Read the App's event credentials (`GET …/apps/<app_id>/events/credentials`). | Manager |

`project.app.write` and `project.app.deploy` include `project.app.read`.
`project.app.admin` includes `project.app.write`, not `project.app.deploy`.
Account owners and admins act as Managers on every project. See
[Teams](/docs/guide#teams) for roles and custom roles.

`<app>` is an App id or slug. Every subcommand takes `--project <id>`,
`--host <name>` and `--json`.

| Command | What it does |
|---|---|
| `kortix apps list` (`ls`) | The Apps you may open: slug, state, URL |
| `kortix apps create <slug>` (`new`) | Create an App without a deploy. `--name`, `--cpu`, `--memory`, `--disk`, `--idle-timeout`, `--always-on` or `--on-demand`, `--uses`. `--url <https-url>` creates an external App |
| `kortix apps launch --template convex` | Write a template's blocks to `kortix.yaml` in the current directory and deploy them. `--name <slug>`, `--no-deploy` |
| `kortix apps deploy [path]` | Deploy a directory, a `.tar.gz` or `--image`. Flags: `--app`, `--slug`, `--name`, `--manifest-app`, `--type static\|bundle\|dockerfile`, `--command`, `--port`, `--dockerfile`, `--root`, `--output-dir`, `--install-command`, `--build-command`, `--readiness-path`, `--spa` or `--no-spa`, `--include-node-modules`, `--access`, `--password`, `--members`, `--groups`, `--always-on` or `--on-demand`, `--listed` or `--unlisted`, `--no-wait`, `--wait-seconds` |
| `kortix apps set <app>` (`update`) | Change `--name`, `--url` (an external App), `--cpu`, `--memory-gb`, `--disk-gb`, `--idle-timeout`, `--always-on` or `--on-demand`, `--uses`, `--listed` or `--unlisted` |
| `kortix apps link <app> --uses <slugs>` | Add used Apps. `unlink` removes them. |
| `kortix apps show <app>` (`get`) | Capabilities, links, run mode and releases |
| `kortix apps releases <app>` | Releases, marking the live one |
| `kortix apps rollback <app> <vN>` | A new release from `vN`. `--no-wait` |
| `kortix apps logs <app> [deployment]` | Runtime logs. `--after N`, `--limit N` |
| `kortix apps start <app>`, `stop <app>` | Wake a server App, or suspend it now |
| `kortix apps machines [ls\|start\|stop\|restart\|destroy] <app> [machine]` (`machine`) | List machines or act on one |
| `kortix apps scale count <app> <n>` | Machines for a group (0–20). `--process <group>` (default `app`) |
| `kortix apps scale show <app>` | Machines per group |
| `kortix apps exec <app> -- <command…>` | Run one command. `--machine <id>`, `--timeout <s>` |
| `kortix apps volumes [ls\|create\|set\|snapshots\|snapshot\|restore\|fork\|destroy] <app> …` (`volume`) | Manage volumes and snapshots. `set --retention <days>` keeps daily snapshots that many days. |
| `kortix apps labels <app> [a,b]` | Show or replace labels. `--clear` |
| `kortix apps access <app>` | Read or set access. `--mode`, `--password`, `--members`, `--groups`, `--viewer off\|identity\|api` |
| `kortix apps access-link <app>` | A 5-minute sign-in link |
| `kortix apps connect <app>` | How to reach the App from code. No secret. |
| `kortix apps token <app>` | A 15-minute sign-in token that names you |
| `kortix apps auth-env <app>` | The `KORTIX_AUTH_*` values as dotenv lines |
| `kortix apps delete <app> --yes` (`rm`, `remove`) | Delete the App. `--deployment <vN>` deletes one release. |

The whole CLI: [CLI](/docs/developers#cli). Every route: the REST reference at
`https://api.kortix.com/v1/docs`.

## Self-hosting

A self-hosted instance is the full Kortix platform on a server you control: one Docker Compose stack that the `kortix self-host` CLI installs, configures and updates. This page is for the operator who runs that server. Sessions run on Platinum sandboxes, not on the server. The instance must be reachable from the internet so those sandboxes can call back.

### Point DNS at the server
Create A or AAAA records for `kortix.example.com` and `api.kortix.example.com`, plus a wildcard `*.p.kortix.example.com` for previews. Open ports 80 and 443.
### Run the bootstrap on the server
It installs Docker and the CLI, then starts the stack with a TLS certificate and your Platinum API key.

```sh
curl -fsSL https://raw.githubusercontent.com/kortix-ai/suna/dev/scripts/kortix-selfhost-up.sh \
| bash -s -- --domain kortix.example.com --email ops@example.com --platinum-key <platinum_api_key>
```
### Answer the setup questions
If the bootstrap added you to the `docker` group, open a new shell first. Run `kortix self-host configure`. Give your admin email and the preview domain `p.kortix.example.com`.

Enter keeps the Platinum API key the bootstrap set. Then run `kortix self-host start` to apply the answers.
### Sign up as the admin
Open `https://kortix.example.com` and sign up with the admin email. Connect GitHub at `/admin/git`, create the team account and a project, then add a model key. See [First sign-in and setup](#first-sign-in-and-setup).

Do this as soon as the stack starts. Until you set `EMAIL_URL` or `DISABLE_SIGNUP=true`, anyone who reaches the instance can create a confirmed account for any address, the admin address included.
### Check the instance
Run `kortix self-host doctor` and fix every failing check. See [Secure the instance](#secure-the-instance).

### What runs where

`kortix self-host init` writes `docker-compose.yml`, `.env`, a `Caddyfile` and `updater.sh` into `~/.config/kortix/self-host/<instance>/`. `kortix self-host start` runs `docker compose up`. One Compose project, `kortix-<instance>`, holds every service.

| Service | What it does |
|---|---|
| `frontend` | The web app. |
| `kortix-api` | The REST API at `/v1`. It relays any `/v1/llm` request that reaches it to `llm-gateway`. |
| `llm-gateway` | The standalone LLM gateway. Caddy sends `api.<domain>/v1/llm*` here. |
| `kortix-migrate` | A one-shot job. It migrates the database before `kortix-api` starts. |
| `kortix-updater` | Pulls new images once a day, migrates, and rolls the app services. It mounts the Docker socket. |
| `capture-store` | The object store for [Kortix Capture](/docs/capture) recordings (MinIO). It publishes no port: devices reach it through the API. Its data is in the `kortix-capture-data` volume and grows with every enrolled device. |
| `supabase-*` | The Supabase distribution, every image pinned by digest: Postgres (`supabase-db`), auth, REST, realtime, storage, imgproxy, meta, functions, Kong, the Supavisor pooler, Studio, analytics and vector. |
| `caddy` | Domain mode only. Terminates TLS on ports 80 and 443 with ACME HTTP-01 and routes traffic. |
| `cloudflared` | Tunnel mode only. Publishes `kortix-api` through a Cloudflare tunnel. |

Off the box:

- **Sandboxes.** Every session runs in its own Platinum sandbox. `kortix-api` creates it with `PLATINUM_API_KEY`. The sandbox calls back to the instance's public API URL, `KORTIX_URL`.
- **What a session keeps.** `/workspace` holds this session's files, kept for the life of the session. `/drives` (Drive) holds your files and the company's files, shared across sessions and kept until deleted. Anything else (home directory, `/tmp`, installed packages) can be gone when the session reopens. Put tools in a [sandbox template](/docs/developers#sandbox-templates).
- **Drive and App volumes.** Each Drive and each App volume is a Platinum volume. Its data is at Platinum, not on the server.
- **Images.** `kortix/kortix-frontend`, `kortix/kortix-api` and `kortix/kortix-gateway` on Docker Hub. Pulls need no credentials.
- **Model providers.** The gateway calls the providers your projects connect.

#### Reachability modes

| Mode | How you set it | Public URLs | App replicas | Sessions run |
|---|---|---|---|---|
| domain | `--domain <domain>` | `https://<domain>` and `https://api.<domain>` | 2 each of `kortix-api`, `llm-gateway`, `frontend` behind Caddy | yes |
| tunnel | `--tunnel cloudflare` | The web app stays on `http://localhost:13737`. Only the API is public, at the tunnel URL. | 1 each | yes |
| local | neither flag | none: every port binds `127.0.0.1` | 1 each | no, the sandbox cannot reach the API |

Domain mode is the production setup. Tunnel mode is for evaluation on your own machine. Without a domain, the services publish loopback ports: frontend `13737`, API `13738`, Supabase `13740`. When the stack is stopped, `start` moves a port that is in use to a free one.

### Requirements

- **A Linux server** with a public IP. The bootstrap installs Docker Engine and the Compose plugin. On a server that has them, install only the CLI. The CLI runs on Linux and macOS, on x86_64 and arm64.
- **DNS.** A or AAAA records for `<domain>` and `api.<domain>`, both pointed at the server, before the first start. Caddy needs them to get the certificate.
- **Ports 80 and 443** open inbound.
- **Wildcard records** for the optional origins:
  - `*.p.<domain>` for sandbox previews. Use it on every public instance: without it, browser previews fall back to a path proxy that breaks root-absolute links, and `doctor` fails.
  - `*.<apps base domain>`, for example `*.apps.<domain>`, to host [Apps](/docs/developers#apps-reference).
- **A Platinum API key** (`PLATINUM_API_KEY`). Without it, sessions do not start and projects have no Drive.
- **A model provider key.** You add it in the web app after the first sign-in.

### Install

#### Bootstrap a bare server

The bootstrap script runs on Linux only. It installs Docker and the Compose plugin when they are missing, installs the CLI, runs `kortix self-host init`, sets the domain, and runs `kortix self-host start`. Re-running it is safe.

| Flag | Default | Sets |
|---|---|---|
| `--domain <domain>` | none | `KORTIX_DOMAIN`. Turns on Caddy and TLS. |
| `--api-domain <domain>` | `api.<domain>` | `KORTIX_API_DOMAIN` |
| `--email <email>` | `admin@<domain>` | `KORTIX_ACME_EMAIL`, the certificate contact |
| `--channel <stable\|latest>` | `stable` | The image channel |
| `--auto-update <on\|off>` | `on` | The daily updater |
| `--instance <name>` | `default` | The instance name |
| `--platinum-key <key>` | `PLATINUM_API_KEY` from the environment | `PLATINUM_API_KEY` |

The bootstrap runs `init` non-interactively. It sets no admin email and no preview domain, so run `kortix self-host configure` and then `kortix self-host start` after it. When the script adds your user to the `docker` group, open a new shell before the next `kortix self-host` command.

#### Install by hand

Use this path on a server that already has Docker and the Compose plugin.

```sh
curl -fsSL https://kortix.com/install | bash
kortix self-host init --domain kortix.example.com --admin-email you@example.com
kortix self-host start
```

A first `init` in a terminal asks every setup question. `--yes`, or a shell with no terminal, skips the questions and keeps the defaults. `start` runs `init` first when the instance has no configuration. When the Platinum API key is missing, `init` and `start` print a warning and continue.

#### Evaluate with a Cloudflare tunnel

To try Kortix on your own machine with no domain:

```sh
kortix self-host init --tunnel cloudflare
kortix self-host start
```

The web app opens at `http://localhost:13737`. `cloudflared` publishes the API at an `https://<random>.trycloudflare.com` URL. That URL changes each time `cloudflared` restarts, and `start` and `update` capture the new one. For a stable URL, create a named tunnel in Cloudflare Zero Trust, point its public hostname at `http://kortix-api:8008`, then set both values:

```sh
kortix self-host env set CLOUDFLARE_TUNNEL_TOKEN=<token> CLOUDFLARE_TUNNEL_HOSTNAME=kortix-api.example.com
kortix self-host start
```

### Configure

`kortix self-host configure` asks the setup questions again, in this order. Each answer is pre-filled with the current value, so Enter keeps it. `configure` writes the files only. Run `kortix self-host start` to apply them.

| Step | What it sets |
|---|---|
| Reachability | `domain` (domain, API subdomain, ACME email) or `tunnel` (quick or named tunnel). |
| Admin email | `KORTIX_PLATFORM_ADMIN_EMAILS`, comma-separated. These people are platform admins. Also `--admin-email <email>`. |
| Enterprise license | `ENTERPRISE_LICENSE_AVAILABLE`. With a license, it unlocks SSO, SCIM, role-based access control and audit. Also `--enterprise-license`. |
| Restrict account creation | On by default. Only a platform admin can create a new account. Others get `403` `account_creation_restricted`. Sign-ups, invites, SSO and each person's personal account still work. Also `--no-restrict-account-creation` and `--restrict-account-creation`. |
| Platinum | `PLATINUM_API_KEY`, `PLATINUM_API_URL` (default `https://api.platinum.dev`) and `PLATINUM_TEMPLATE` (optional). |
| Apps hosting | Optional. `KORTIX_APPS_BASE_DOMAIN`, which needs a wildcard DNS record. |
| Preview origins | `KORTIX_PREVIEW_BASE_DOMAIN`, suggested `p.<domain>`, which needs a `*.p.<domain>` record. In domain mode the default answer is configure. |
| Composio | Optional. `COMPOSIO_API_KEY`, the app catalog for [connectors](/docs/guide#connectors). |
| Update policy | Auto-update on or off, channel, daily time and timezone. |

SAML sign-in is on in every instance, and `init` generates `SAML_PRIVATE_KEY` once. Do not change that key after you register an identity provider: it is the instance's SAML identity. To set up SSO, see [Single sign-on and directory sync](/docs/guide#single-sign-on-and-directory-sync).

### First sign-in and setup

1. **Sign up as the admin.** Open `https://<domain>` and sign up with an address in `KORTIX_PLATFORM_ADMIN_EMAILS`. A password, email-code or social sign-in matches that list. A SAML sign-in never does. To change the admins later, set the full comma-separated list: `kortix self-host env set KORTIX_PLATFORM_ADMIN_EMAILS=you@example.com,ops@example.com`. The value replaces the list.
2. **Connect managed git.** Open `/admin/git`. Kortix creates and pushes every new project repository through this one GitHub identity. Pick **Create a GitHub App** (recommended), **Paste an existing App**, or **Use an access token**. Until it is set, Kortix cannot create project repositories. You can still connect an existing repository. See [Git](/docs/developers#git).
3. **Create the team account.** Your sign-up account is personal. It cannot hold SAML SSO (`403` `sso_personal_account`), and a project cannot move to another account. The menu that creates accounts lives inside a project, so first create one project at `/new`. In it, open the project menu → **Switch Project** → **Create an account…**. Only platform admins can create accounts by default. Invite your team and set up SSO in this account.
4. **Create a project** in the team account. After **Create an account…**, Kortix opens `/new` with the new account selected.
5. **Add a model key** in the project: **Customize → Models → Providers**, or `kortix providers set <provider>`. Each project needs its own key. A self-hosted instance has no Kortix-managed models. A project can connect **Platinum** as its provider with a Platinum API key. See [Connect your own provider](/docs/guide#models).
6. **Turn on email.** Set one connection string:

   ```sh
   kortix self-host env set EMAIL_URL=smtp://user:password@smtp.example.com:587
   ```

   Kortix then sends sign-in and invitation mail through it, requires email confirmation for new accounts, and adds magic-link sign-in. `EMAIL_FROM` defaults to `Kortix <noreply@<domain>>`. Other forms: `smtps://user:password@host:465`, `resend://<api-key>`, `ses://<key-id>:<secret>@<region>`, `ses://<region>`, `mailtrap://<api-token>`. Separate several with commas for a fallback chain, tried left to right.
7. **Close sign-up.** When your team has signed up, run `kortix self-host env set DISABLE_SIGNUP=true`, then `kortix self-host start` so the auth service reads it.

A new instance starts with these defaults:

| Setting | Default |
|---|---|
| Sign-in methods | Password only. Email sign-ups confirm without an email, so a sign-up's address is unproven. See [Unconfirmed email addresses](#unconfirmed-email-addresses). |
| Social sign-in | None |
| Billing | Off |
| Marketing pages | Off. Every marketing route opens the web app. Turn on with `KORTIX_PUBLIC_DISABLE_LANDING_PAGE=false`. |
| Connector app catalog | Hidden until `COMPOSIO_API_KEY` is set |
| Account creation | Platform admins only |

### Secure the instance

`kortix self-host doctor` runs these checks. It exits `0` when all pass and `1` when one fails. `--json` prints the result as JSON.

| Check | Fails when | Fix |
|---|---|---|
| `docker` | `docker --version` fails | Install Docker. |
| `docker-compose` | The Compose plugin is missing | Install the Compose plugin. |
| `compose-config` | The rendered `docker-compose.yml` is not valid | Correct the value the error names with `env set`. |
| `capture-store` | The Compose file has no `capture-store` service, so Capture devices cannot sync | Run `kortix self-host update`. |
| `instance-dir` | The instance directory moved since the last write | Run `kortix self-host configure`, or any of `init`, `start`, `update`, `env set`. |
| `email-url` | `EMAIL_URL` is set but does not parse | Correct the value. |
| `preview-origins` | Domain mode and `KORTIX_PREVIEW_BASE_DOMAIN` is empty | Point `*.p.<domain>` at the server, then `kortix self-host env set KORTIX_PREVIEW_BASE_DOMAIN=p.<domain> KORTIX_PREVIEW_ALLOW_DIRECT_EDGE=true`. |
| `open-sign-up` | A domain or tunnel instance has sign-up open and email confirmation off | Set `EMAIL_URL`, or set `DISABLE_SIGNUP=true` once your team is in. |

> **Warning**
> A new instance confirms every sign-up without an email. Until you fix `open-sign-up`, anyone who reaches the instance can create a confirmed account for any address, an admin address included. Sign up as the admin first, then set `EMAIL_URL` or `DISABLE_SIGNUP=true`. `kortix self-host init` prints the same warning on a domain or tunnel instance.
>
> `init --disable-signup` turns sign-up off from the first start, for an install that creates its accounts another way. While it is off, nobody can sign up, the admin included.

What the stack does by itself:

- Only Caddy publishes public ports, 80 and 443. Every other published port binds `127.0.0.1`.
- `.env` and `docker-compose.yml` are written with mode `0600`.
- [Connectors](/docs/guide#connectors) refuse private and internal addresses with `connector_egress_blocked`. List the internal hosts they may reach in `KORTIX_CONNECTOR_EGRESS_ALLOW_HOSTS`.
- `kortix-updater` holds the Docker socket, so its image is pinned by digest.

Keep `.env` safe: it holds every secret, and `API_KEY_SECRET` in it encrypts every stored secret, connector credential and token. A lost `API_KEY_SECRET` makes them unreadable. The Secrets page marks each such project secret **Undecryptable**, and **Customize → Models → Providers** does the same for each account key and ChatGPT login.

A connector credential shows no mark: a call through it returns `credential_undecryptable`. A session start names the first value it needs, and any other request that needs one answers `409 SECRET_UNDECRYPTABLE`. Enter each value again to fix it, and save each OAuth application again before you reconnect its account.

#### Unconfirmed email addresses

`ENABLE_EMAIL_AUTOCONFIRM=true` is the default until you set `EMAIL_URL`. Auth then confirms each sign-up without an email, and the API reads the same value from `.env`. While it is `true`, the API treats the email of a password, email-code or social login as unproven. Whoever signed up first holds the address:

| Path | With `ENABLE_EMAIL_AUTOCONFIRM=true` |
|---|---|
| Sign-in with a pending invite | Sign-in does not accept the invite. |
| SCIM `POST /Users` for that address | Creates a pending invite (`201`). No membership. A password login does not see that invite, and accepting or declining it returns `403` `email_unconfirmed`. The invite page then shows **Continue with single sign-on**. It signs in through the account's SAML provider, also for an address outside the provider's domain, and the person joins. An admin can instead click **Resend** on the pending invite: it is then an invite a person sent. |
| **Members** → **Invite** for that address | Creates a pending invite instead of adding the login. |
| An invite a person sent or resent (**Members** → **Invite** or **Resend**, a project invite) | The login that holds the address sees it in the project chooser and the **Switch Project** menu, and can accept it. That login is whoever signed up first with the address, so it may not be the person you invited. |
| `KORTIX_PLATFORM_ADMIN_EMAILS` | Matches a password login with the address. Whoever signs up first with an allowlisted address is platform admin. |

A login that is already a member of the account, and a login through the account's own SAML provider, still match inside that account. `KORTIX_PLATFORM_ADMIN_EMAILS` never matches a SAML login. Setting `EMAIL_URL` turns `ENABLE_EMAIL_AUTOCONFIRM` off and restores the matching. A login created while it was on is not confirmed again: after you add email, its address matches invites and SCIM like a confirmed one.

Set `EMAIL_URL` before you invite people. Without it, invite a person only after that person has signed up: an address has one password login, so nobody else can take it then. A cloud instance never sets the value.

### The platform admin console

Platform admins open `/admin`. On a self-hosted instance, a platform admin is a person listed in `KORTIX_PLATFORM_ADMIN_EMAILS`.

| Page | Path | What it holds |
|---|---|---|
| Overview | `/admin` | Live instance numbers, refreshed every 15 seconds. |
| Accounts | `/admin/accounts` | Every account. Filter, sort and inspect one. |
| Projects | `/admin/projects` | Every project across every account, most active first, with its operator-managed feature flags. |
| Analytics | `/admin/analytics` | Daily platform activity, by UTC day. |
| Sandboxes | `/admin/sandboxes` | Where new sandboxes are placed, and the live fleet. |
| Git | `/admin/git` | The instance's managed GitHub identity. When `MANAGED_GIT_*` environment variables set it, the page is read-only. |
| Maintenance | `/admin/utils` | System-wide notices and the full-lockdown switch. |

### Connect your clients

`kortix self-host start` registers the CLI host `selfhost` on the server. To point the web, CLI, TUI, desktop and mobile clients at the instance, see [Connect to a self-hosted instance](/docs/guide#everywhere).

### Integrations

Set these with `kortix self-host env set`. Each row says what the variable turns on or off.

| Variables | Effect |
|---|---|
| `COMPOSIO_API_KEY` | The connector app catalog. Without it, Easy connect, the `/connections` page and the onboarding apps step are hidden. Custom connectors work without it. |
| `KORTIX_OAUTH_CLIENTS` | Your own OAuth clients for the [app catalog](/docs/guide#connectors) apps whose servers have no dynamic client registration. JSON, one entry per vendor: `google`, `github`, `slack`, `hubspot`, `asana`, `box`. Without an entry, those apps are not listed. See [Kortix OAuth clients](#kortix-oauth-clients). |
| `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `SLACK_SIGNING_SECRET` | One-click **Add to Slack** with your own Slack app. Without all three, each project connects its own Slack app. See [Channels](/docs/guide#channels). |
| `MICROSOFT_APP_ID`, `MICROSOFT_APP_PASSWORD`, `MICROSOFT_APP_TENANT` (default `botframework.com`) | One shared Microsoft Teams bot. Without them, each project brings its own bot app. |
| `RESEND_API_KEY` | Transactional email through Resend. |
| `TUNNEL_ENABLED` | Default `true`. `false` turns off [computers](/docs/guide#computers): `/v1/tunnel/*` answers `503` `tunnel_disabled`. |
| `PLATINUM_API_KEY` | Required. Sessions, App machines and volumes, and Drive (`/drives`). |

OAuth callbacks, such as `<KORTIX_URL>/v1/connectors/oauth2/callback` and the Slack and Teams redirects, use `KORTIX_URL`. Domain mode and a named tunnel keep it stable. A quick tunnel changes it at every restart and breaks every callback you registered.

#### Kortix OAuth clients

Some MCP servers in the catalog do not let Kortix register itself. Kortix signs in to them with an OAuth client that you create at the vendor once per instance.

1. At the vendor, create an OAuth app with the redirect URI `<KORTIX_URL>/v1/connectors/oauth2/callback`.
   - Google: a **Web application** client. Select **Use this client for an AI-powered agent**. Enable the Gmail, Calendar, Drive, Docs and Sheets APIs and their MCP services. Add the scopes from Google's [MCP server guide](https://developers.google.com/workspace/guides/configure-mcp-servers). The Workspace MCP servers are a Google developer preview.
   - GitHub: an OAuth app (not a GitHub App).
2. Store the clients as one JSON value:

   ```sh
   kortix self-host env set KORTIX_OAUTH_CLIENTS='{"google":{"client_id":"<id>","client_secret":"<secret>"},"github":{"client_id":"<id>","client_secret":"<secret>"}}'
   ```

3. Restart the API. The apps of each configured vendor appear in the catalog.

#### Composio toolkits that need your own OAuth app

Some toolkits have no Composio-managed sign-in. Syncing one fails with `422` `composio_auth_config_required`. In the Composio dashboard, open **Auth Configs** and create one with your own OAuth app.

Then sync again. No restart is needed.

### Updates and versions

`kortix-updater` checks once a day at `KORTIX_UPDATE_TIME` (default `02:00`) in `KORTIX_UPDATE_TZ` (default `America/New_York`). Auto-update is on by default.

| Track | Images |
|---|---|
| `stable` (default) | A release that Kortix promotes to self-hosted instances after it runs in production. |
| `latest` | Every Kortix release. |
| `--tag <version>` | One exact version. A pin wins over the channel. |

Each run:

1. Checks free disk space. It stops below `KORTIX_MIN_FREE_DISK_MB` (default `2048`).
2. Pulls the configured tags. When no image changed, it stops.
3. Runs `kortix-migrate`. When a migration fails, nothing is swapped and the previous version keeps serving.
4. Rolls `kortix-api`, `llm-gateway` and `frontend`. With 2 replicas, new containers must pass their health check within `KORTIX_ROLLOUT_TIMEOUT` (default `300` seconds) before the old ones stop, so the instance stays up. A swap that fails keeps the previous version. With 1 replica, each service restarts in place with a short interruption.
5. Removes superseded images after a fully successful run. It keeps the current and the previous version for a fast rollback.

`kortix self-host status` shows the last outcome (`ok`, `degraded`, `failed`, `skipped` or `never-run`) and the version change. It also shows a drift check of running images against the configuration, and the schedule. Set `KORTIX_UPDATE_WEBHOOK_URL` to receive each run's status as a JSON `POST`.

```sh
kortix self-host update                          # apply the channel now
kortix self-host update --channel latest         # switch channel
kortix self-host update --tag <version>          # pin one version
kortix self-host rollback --release <version>    # go back to a version
kortix self-host version                         # running, latest and channel
```

A manual `update` uses the same rollout as the daily run. When another update holds the lock, it exits `75` and skips the rollout. A pin holds through every daily run.

`init` or `update` without `--tag` moves the instance back to its channel. To change auto-update, the time or the timezone, run `kortix self-host configure`, then `kortix self-host start`.

#### Sessions saved by an older version

Each session's history is its session log. An instance that ran a version without the session log also holds history in the older v1 store. The API converts it by itself:

- A background pass (`SESSION_LOG_BACKFILL`, default `write`) converts every old session, oldest first, on the leader API container. It logs `[session-log] backfill tick` with counts.
- Opening an old session before the pass reaches it converts that session first.
- A session over the conversion size cap (24 MiB, `SESSION_LOG_RESTORE_MAX_BYTES`) is converted only by the background pass. Until then, its log read answers `503` `session_log_converting` with `Retry-After: 60`.

`SESSION_LOG_BACKFILL=off` stops the pass. Keep it on until the API logs `the session logs hold every v1 mirror message`: until then, the v1 store is the only copy of some history.

### Operate and tune

#### Settings and secrets

Every setting lives in the instance `.env`.

- `kortix self-host env ls` lists every value by group and masks secrets. `--show` reveals them.
- `kortix self-host env set KEY=VALUE ...` writes values and restarts the services that read them. A bare `KEY` prompts for the value. A new `API_KEY_SECRET` makes every stored credential undecryptable, so `env set` warns first. At a terminal it asks you to confirm. A script must pass `--yes`: without it the command exits `2` and writes nothing.
- `kortix self-host env rotate <KEY>` or `--all-generated` regenerates a generated secret. Rotating `SUPABASE_JWT_SECRET` also regenerates the anon and service-role keys. The CLI refuses `API_KEY_SECRET`, `POSTGRES_PASSWORD` and `SAML_PRIVATE_KEY`, because a new value breaks stored data or connections.

`env set` refuses the image and version keys (`KORTIX_VERSION`, `API_IMAGE`, `FRONTEND_IMAGE`, `GATEWAY_IMAGE`). Use `--tag` or `--channel` instead. When a change does not take effect, run `kortix self-host start`: it recreates every service whose configuration changed.

#### Memory limits

| Variable | Default | Container |
|---|---|---|
| `KORTIX_API_MEMORY_LIMIT` | `2048m` | `kortix-api`, which relays `/v1/llm` request bodies when they reach it |
| `KORTIX_GATEWAY_MEMORY_LIMIT` | `2048m` | `llm-gateway` |
| `KORTIX_FRONTEND_MEMORY_LIMIT` | `512m` | `frontend` |

Postgres has a fixed limit of `1280m`. `KORTIX_GATEWAY_MAX_REQUEST_BYTES` raises the gateway's request-body cap for image-heavy turns. Raise the gateway memory limit with it.

#### Session and sandbox lifecycle

| Variable | Default | Effect |
|---|---|---|
| `KORTIX_SANDBOX_AUTOSTOP_MINUTES` | `15` | A sandbox stops this many idle minutes after its last turn ends. An open session page never holds the sandbox past this window. |
| `KORTIX_SANDBOX_TURN_GRANT_MINUTES` | `240` | How long a running turn holds its sandbox. Model calls and an observed running turn renew it. |
| `KORTIX_SANDBOX_TURN_WAITING_MINUTES` | `120` | How long a turn that waits on a person holds its sandbox. At most half the turn grant. Unattended runs get 30. |
| `KORTIX_SANDBOX_PREVIEW_GRANT_MINUTES` | `30` | How long preview traffic keeps a sandbox running. |
| `KORTIX_SANDBOX_PROVIDER_AUTOSTOP_MINUTES` | `720` | Platinum's own idle stop, a backstop for sandboxes the API cannot reach. |
| `KORTIX_DEFAULT_SANDBOX_CPU`, `_MEMORY_GB`, `_DISK_GB` | `2`, `4`, `20` | The sandbox size when a template sets none. |

People see the effect of these values in [Computers](/docs/guide#computers).

#### App hosting

| Variable | Default | Effect |
|---|---|---|
| `KORTIX_APPS_BASE_DOMAIN` | none | The domain [Apps](/docs/developers#apps-reference) are served on, for example `apps.kortix.example.com`. It needs a `*.apps.kortix.example.com` DNS record. `kortix self-host configure` sets it. Caddy serves Apps only when it is set. |
| `KORTIX_APPS_RETAINED_DEPLOYMENTS` | `5` | The ready releases each App keeps as rollback targets, beside the live one. At least `1`. |

#### Logs, containers and the database console

- `kortix self-host logs [service]` follows the logs, for example `kortix self-host logs kortix-api`. Each container keeps 3 log files of 10 MB.
- `kortix self-host ps` lists the containers. `status` adds the update report.
- Supabase Studio answers on `http://127.0.0.1:13740` (`SUPABASE_PORT`) on the server. Reach it through an SSH tunnel and sign in as `kortix` with `DASHBOARD_PASSWORD` from `env ls --show`.

#### Several instances on one server

Pass `--instance <name>` to every command. A name starts with a letter and holds letters, digits, dots, underscores and dashes. Each instance has its own directory and Compose project.

Only one instance per server can run in domain mode, because Caddy binds ports 80 and 443. `KORTIX_SELF_HOST_CONFIG_DIR` moves the root directory from `~/.config/kortix/self-host`.

#### How Caddy routes

| Host | Path | Goes to |
|---|---|---|
| `api.<domain>` | `/v1/llm*` | `llm-gateway:8090` |
| `api.<domain>` | everything else | `kortix-api:8008` |
| `<domain>` | `/rest/v1`, `/auth/v1`, `/storage/v1`, `/realtime/v1`, `/functions/v1`, `/graphql/v1` | `supabase-kong:8000` |
| `<domain>` | everything else | `frontend:3000` |
| `*.<preview base domain>` | all | `kortix-api`, one origin per sandbox port |
| `*.<apps base domain>` | all | `kortix-api`, one hostname per App |

Caddy health-checks each replica and retries a failed connection on the other one. It sends `Strict-Transport-Security` with a 30-day max-age. The two wildcard blocks exist only when their base domain is set.

Caddy gets one certificate per hostname on its first request. The API approves each hostname before Caddy asks for the certificate. You need a wildcard DNS record, not a wildcard certificate.

### Back up and uninstall

The server holds its data in two places: the instance directory, `~/.config/kortix/self-host/<instance>/`, and named Docker volumes. Each Docker volume is named `kortix-<instance>_<volume>`, for example `kortix-default_kortix-capture-data`.

| Data | Where | Server backup |
|---|---|---|
| Every secret and signing key | `.env` in the instance directory | Yes |
| The Postgres database | `volumes/db/data` in the instance directory | Yes |
| Stored files | `volumes/storage` in the instance directory | Yes |
| Postgres settings and its pgsodium key | Docker volume `db-config` | Yes |
| Kortix Capture recordings | Docker volume `kortix-capture-data` | Yes |
| TLS certificates | Docker volume `kortix-caddy-data` | Yes |
| Updater status, the deployed version and image history | Docker volume `kortix-updater-state` | Yes |
| Drive folders and App volumes | Platinum volumes, at Platinum | No |
| Each session's `/workspace` | The session's Platinum sandbox | No |

Stop the stack so the files are consistent. Copy the instance directory and each Docker volume, then start again:

```sh
kortix self-host stop
cd ~
sudo tar -czf kortix-backup.tgz -C ~/.config/kortix/self-host default
for v in db-config kortix-capture-data kortix-caddy-data kortix-updater-state; do
  docker run --rm -v "kortix-default_$v":/data -v "$PWD":/backup alpine:3.20 \
    tar -czf "/backup/$v.tgz" -C /data .
done
kortix self-host start
```

Replace `default` with your instance name. App volumes keep their own daily snapshots: see [Volumes and snapshots](/docs/developers#volumes-and-snapshots).

To restore on a server with Docker and the CLI, put the files from the backup in your home directory and run:

```sh
cd ~
mkdir -p ~/.config/kortix/self-host
sudo tar -xzf kortix-backup.tgz -C ~/.config/kortix/self-host
for v in db-config kortix-capture-data kortix-caddy-data kortix-updater-state; do
  docker run --rm -v "kortix-default_$v":/data -v "$PWD":/backup alpine:3.20 \
    tar -xzf "/backup/$v.tgz" -C /data
done
kortix self-host start
```

`start` warns that each volume `already exists but was not created by Docker Compose`. The warning is harmless: the stack uses the restored volumes.

> **Warning**
> `kortix self-host uninstall` runs `docker compose down --volumes --remove-orphans` and deletes the instance directory. It deletes the database, the stored files, the Capture recordings and the certificates. It cannot be undone.
>
> In a terminal you type the instance name to confirm. Without a terminal it needs `--yes`.

### Commands

Every subcommand takes `--instance <name>` (default `default`).

| Command | What it does |
|---|---|
| `init` | Writes or refreshes the Compose file and `.env`. A first run in a terminal asks the setup questions. Alias `setup`. |
| `start` | Pulls the images and starts the stack. Aliases `up`, `deploy`. |
| `stop` | Stops the stack. Data stays. Alias `down`. |
| `restart` | `stop`, then `start`. |
| `configure` | Asks the setup questions again. Alias `config`. |
| `status` | Container state, the last update, drift and the schedule. `--json` |
| `ps` | `docker compose ps` only. |
| `doctor` | Runs the checks in [Secure the instance](#secure-the-instance). `--json` |
| `plan` | Validates the Compose file. Applies nothing. `--json` |
| `logs [service]` | Follows the logs. |
| `open` | Opens the web app in a browser. |
| `version` | The running version, the newest release and the channel. |
| `update` | Applies the channel, or `--tag <version>`, now. Aliases `upgrade`, `reconcile`. |
| `rollback --release <version>` | Updates to that version. `--tag` also works. |
| `env ls [--show]` | Lists every setting. `--json` |
| `env set KEY=VALUE ...` | Sets values and restarts the affected services. |
| `env rotate KEY` or `--all-generated` | Regenerates a generated secret. |
| `uninstall` | Deletes the stack, its data and its directory. |
| `connect-github` | Deprecated. Prints where to connect GitHub. Use `/admin/git`. |

| Flag | Used with | Meaning |
|---|---|---|
| `--domain <domain>` | `init`, `configure`, `update` | Domain mode. `--domain ""` clears the domain. |
| `--tunnel cloudflare` | `init`, `configure`, `update` | Tunnel mode. |
| `--tag <version>` | `init`, `update`, `rollback` | Pin a version. Aliases `--version`, `--release`. |
| `--channel <stable\|latest>` | `init`, `update`, `configure` | The channel to track. |
| `--auto-update <on\|off>` | `init`, `update`, `configure` | The daily updater. Takes effect at the next `start`. |
| `--update-time <HH:MM>`, `--update-tz <zone>` | `init`, `update`, `configure` | The daily window, as 24-hour time and an IANA zone. Takes effect at the next `start`. |
| `--admin-email <email>` | `init`, `configure` | Sets `KORTIX_PLATFORM_ADMIN_EMAILS`. |
| `--enterprise-license` | `init`, `configure`, `update` | Sets `ENTERPRISE_LICENSE_AVAILABLE=true`. |
| `--restrict-account-creation`, `--no-restrict-account-creation` | `init`, `configure`, `update` | Account creation for admins only, or for everyone. |
| `--disable-signup` | `init`, `configure`, `update` | Sets `DISABLE_SIGNUP=true`. Nobody can sign up, the admin included. `env set DISABLE_SIGNUP=false` opens it again. |
| `--yes` | any | Accept defaults. Required for `uninstall` without a terminal. |
| `--json` | `status`, `doctor`, `plan`, `env ls` | Machine-readable output. |

## SDK

`@kortix/sdk` is the TypeScript client for the Kortix API. It creates projects and sessions, sends prompts, streams the agent's reply and reads the result. One package holds layers for servers, browsers, React and Generative UI, and `@kortix/ui` adds prebuilt components on top.

### What the SDK is

| Entry point | Runs in | Depends on | Use it when |
| --- | --- | --- | --- |
| [`@kortix/sdk`](/docs/developers#sessions-from-code) | Both | A global `fetch` | You call the API from any runtime: Node 18 or later, Bun, a browser, an edge worker. It holds the client and every framework-free module. |
| [`@kortix/sdk/server`](/docs/developers#sdk-authentication) | Server | `node:async_hooks` | One server acts for many users, signs people in with Kortix, or reads the viewer of a Kortix App. Never bundle it for a browser. |
| [`@kortix/sdk/react`](/docs/developers#react) | Browser | `react` 18 or later, `@tanstack/react-query` 5.75.2 or a later 5.x | You run a session in a React or React Native app. `useSession` owns the whole session. |
| [`@kortix/sdk/genui/fence`](/docs/developers#generative-ui) | Both | Nothing | You only detect and split `openui` blocks in agent text. It loads no parser. |
| [`@kortix/sdk/genui`](/docs/developers#generative-ui) | Both | `zod`, `@openuidev/lang-core` 0.3.1 | You parse blocks, or turn them into Markdown for a CLI, email or bot. |
| [`@kortix/sdk/genui/react`](/docs/developers#generative-ui) | Browser | `react`, `zod`, `@openuidev/lang-core` 0.3.1 | You render blocks with your own React components. |
| [`@kortix/ui`](/docs/developers#kortix-ui) | Browser | `@kortix/sdk`, `react` 19 or later, `react-dom` 19 or later, `@tanstack/react-query` | You want the Kortix session chat and components without building them. |

Every task guide is in a later H2: [SDK authentication](/docs/developers#sdk-authentication), [Sessions from code](/docs/developers#sessions-from-code), [Projects from code](/docs/developers#projects-from-code), [React](/docs/developers#react), [Generative UI](/docs/developers#generative-ui), [Kortix UI](/docs/developers#kortix-ui). Exact signatures are in the [SDK reference](/docs/developers#sdk-reference).

### Install

```bash
npm install @kortix/sdk
```

The package is ESM only. Add the optional peers of the layers you use:

| Layer | Install |
| --- | --- |
| `@kortix/sdk/react` | `npm install react @tanstack/react-query` |
| `@kortix/sdk/genui`, `@kortix/sdk/genui/react` | `npm install zod @openuidev/lang-core@0.3.1` |
| `@kortix/ui` | `npm install @kortix/ui`, at the same version as `@kortix/sdk`. See [Kortix UI](/docs/developers#kortix-ui). |

A page with no build step loads the browser bundle. It defines the global `Kortix` with the same names as the root entry point, for example `Kortix.createKortix` and `Kortix.narrowChatEvent`:

```html
<script src="https://cdn.jsdelivr.net/npm/@kortix/sdk@<version>/dist/kortix.global.js"></script>
```

Replace `<version>` with a published version. The package also ships an ES module bundle at `dist/kortix.esm.min.js`. Load the global bundle or the ESM build on one page, never both. With both, the page has two `ApiError` classes and `instanceof ApiError` fails.

A browser calls the Kortix API directly only from an origin the API allows. Kortix Cloud allows its own origins. From your own site, send browser calls through your server, for example the same-origin proxy of [Sign in with Kortix](/docs/developers#sign-in-with-kortix). A self-hosted instance adds origins with the API's `CORS_ALLOWED_ORIGINS` setting.

### Create the client

Call `createKortix` once per process, with the API base URL and a function that returns a token.

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

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

| Option | Type | What it does |
| --- | --- | --- |
| `backendUrl` | `string`, required | The API base URL, including `/v1`. Kortix Cloud is `https://api.kortix.com/v1`. A self-hosted instance is its API origin plus `/v1`. |
| `getToken` | `() => Promise<string \| null>`, required | Returns the credential, or `null` when no one is signed in. The SDK calls it on every request and stores nothing. Your host owns storage and refresh. A getter that caches can carry `invalidate(rejectedToken)`: the SDK calls it on a `401` before its one replay. See [SDK authentication](/docs/developers#sdk-authentication). |
| `credentialHeader` | `string` | Sends the token raw in this header instead of `Authorization: Bearer`. For a proxy that reads a custom header. |
| `clientVersion` | `string` | Your surface and version as `<surface>/<version>`, for example `my-app/1.2.0`. The SDK sends it as `X-Kortix-Client-Version`. The API writes it to the request log only. It grants nothing. |
| `fetch` | fetch-shaped function | A fetch implementation for tests, edge runtimes and adapters. Default: the global `fetch`. |
| `eventStreamTransport` | `RuntimeEventTransport` | Delivers the event stream's bytes when your `fetch` cannot stream a response body, for example React Native without `expo/fetch`. The SDK keeps reconnect, resume and heartbeat. Default: the configured `fetch`, read as a stream. |
| `getUserId` | `() => Promise<string \| null>` | Returns the current user id. The SDK scopes the offline message cache by it. |
| `billingEnabled` | `boolean` | Turns on the billing gates of `@kortix/ui`: upgrade and balance prompts. Default: off. |
| `sandboxId` | `string \| null` | The default sandbox id for a host with a single local sandbox. |
| `featureFlags` | `KortixFeatureFlagOverrides` | Per-flag overrides for a host without `NEXT_PUBLIC_*` build-time variables, such as React Native or a CLI. |
| `onError` | `(error, context?) => void` | An error sink for a toast or a log. Default: no-op. |
| `onAccessRemedy` | `(code: 'sso_required' \| 'account_mfa_required') => void` | Runs when the API refuses a request until the person signs in through single sign-on or completes multi-factor authentication. It runs for REST responses, not for the session runtime or its event stream. A browser also gets the `kortix:sso-required` and `kortix:mfa-required` window events. Default: no-op. |
| `onToast` | `(level, message, options?) => void` | A toast sink. `level` is `'info'`, `'success'`, `'error'` or `'warning'`. Your host renders it. |
| `onNotify` | `(event: { kind, sessionId, ... }) => void` | An OS or web notification sink. Your host renders it. The React layer sends turn-end notices here. |

> **Warning**
> The client holds one configuration for the whole process. A second `createKortix()` call with a different `backendUrl` or `getToken` re-points every earlier client and logs one console warning. A server that acts for many users at the same time creates one client per request with `createScopedKortix()` from `@kortix/sdk/server`. See [Serve many users from one backend](/docs/developers#serve-many-users-from-one-backend).

The client's handles (`kortix.projects`, `kortix.project(projectId)`, `kortix.session(projectId, sessionId)`, `kortix.auth`, `kortix.accounts` and more) are listed in the [SDK reference](/docs/developers#sdk-reference). `kortix.validateToken()` checks the token `getToken` returns now and resolves `{ valid, identity?, error? }`. It never throws.

### Get an API key

A script, a CI job or a server authenticates with a personal access key. The key starts with `kortix_pat_`. Create one in the web app under **Settings → Personal access keys**, or with the CLI:

```bash
kortix tokens new my-script --expires 90d
```

Kortix shows the key once. Store it as a secret and return it from `getToken`. Unattended automation that acts for no single person uses a service account key (`kortix_sa_`, **Account settings → API keys**).

> **Warning**
> A personal access key acts as you. Keep it on a server. Never put it in a browser bundle, a mobile app or a public repository. A browser app signs people in with [Sign in with Kortix](/docs/developers#sign-in-with-kortix) instead. Every credential and where it runs: [Choose a credential](/docs/developers#choose-a-credential).

### Your first session

This script creates a session in your first project, waits until it is ready, sends one prompt, streams the events until the turn ends, and prints the agent's final text.

```ts title="first-session.mts"
import { createKortix, narrowChatEvent } from '@kortix/sdk';

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

const [project] = await kortix.projects.list();
if (!project) throw new Error('No project. Create one with kortix.projects.provision({ name }).');
const { session_id } = await kortix.project(project.project_id).sessions.create({ name: 'First session' });
const session = kortix.session(project.project_id, session_id);
const { runtimeSessionId } = await session.ensureReady();

const replyIds = new Set<string>();
const textParts = new Map<string, { messageID: string; text: string }>();
let finish!: (error?: unknown) => void;
const turnEnded = new Promise<unknown>((resolve) => (finish = resolve));

const stream = await session.stream({
  onEvent: (raw) => {
    const e = narrowChatEvent(raw);
    if (!e || ('sessionID' in e && e.sessionID && e.sessionID !== runtimeSessionId)) return;
    if (e.type === 'message.updated' && e.message.role === 'assistant') replyIds.add(e.message.id);
    if (e.type === 'message.part.updated' && e.part.type === 'text') textParts.set(e.part.id, e.part);
    if (e.type === 'session.idle' && replyIds.size > 0) finish();
    if (e.type === 'session.error') finish(e.error ?? 'session.error');
  },
});

await session.send('List the top-level files in this repository.');
const error = await turnEnded;
stream.close();
if (error) throw error;
for (const part of textParts.values()) if (replyIds.has(part.messageID)) console.log(part.text);
```

Run it with Bun or `tsx`:

```bash
KORTIX_API_KEY=kortix_pat_... bun first-session.mts
KORTIX_API_KEY=kortix_pat_... npx tsx first-session.mts
```

What each step does:

1. `sessions.create()` returns the session row with its `session_id`. The sandbox starts to provision in the background.
2. `ensureReady()` resolves when the runtime accepts work. It returns `runtimeSessionId`, the id of the root conversation. It waits up to 180 seconds by default.
3. `stream()` opens before `send()`, so the script misses no early event.
4. `send()` resolves when the prompt is stored, not when the turn ends.
5. `narrowChatEvent()` reduces raw runtime events to the chat events. Each `message.part.updated` event carries the whole part, so the map keeps the latest text of each part.
6. The turn ends on `session.idle` for `runtimeSessionId`, after an assistant message exists. `session.error` ends it with an error.

Each run creates a new session, and the session appears in the web app with the same transcript. Answers to questions and permission requests, files, errors and retries are in [Sessions from code](/docs/developers#sessions-from-code).

### Versions and stability

- `@kortix/sdk` publishes to npm with each Kortix platform release and carries the release version.
- These entry points follow semver: `@kortix/sdk`, `/react`, `/server`, `/genui`, `/genui/fence`, `/genui/react`, `/wire-message-id`, `/workspace-search` and `/capture-rules`.
- Exported names, types included, are the public contract. A renamed export keeps a `@deprecated` alias until the next major version.
- `@kortix/sdk/internal/*` serves the Kortix web app and has no semver. It can change in any release.
- Other subpaths, such as `@kortix/sdk/turns` and `@kortix/sdk/projects-client`, are deprecated aliases of root names. They resolve until the next major version. The list is in the [SDK reference](/docs/developers#sdk-reference).
- A runtime does not serve every session control. Check a control with `runtimeSupports` before you show it: see [Gate on runtime capabilities](/docs/developers#gate-on-runtime-capabilities).
- Changes per release are in the [CHANGELOG](https://github.com/kortix-ai/suna/blob/dev/packages/sdk/CHANGELOG.md). Runnable examples are in [packages/sdk/examples](https://github.com/kortix-ai/suna/tree/dev/packages/sdk/examples) on GitHub. The npm package does not include them.

## SDK authentication

Every SDK request carries one bearer credential. `createKortix` gets it from your
`getToken` function. `backendUrl` names the API that receives it. This section
lists every source of those two values. Pick a row in the matrix, then read its
subsection. The client options themselves are in
[Create the client](/docs/developers#sdk).

On Kortix Cloud, `backendUrl` is `https://api.kortix.com/v1`. On a self-hosted
instance, it is `<your API origin>/v1`.

### Choose a credential

A credential holds the roles of the principal it acts as, and nothing more.

| Caller | Credential | Runs in | `getToken` returns | Read |
|---|---|---|---|---|
| A script, a CI job, your own backend | Personal access key, `kortix_pat_` | Server only | The key from an environment variable | [getToken and 401 recovery](#gettoken-and-401-recovery) |
| A web app that signs people in through the instance's Supabase | Supabase JWT | Browser or server | `(await supabase.auth.getSession()).data.session?.access_token ?? null` | [Sign users in headlessly](#sign-users-in-headlessly) for new code |
| Your own web app with a "Sign in with Kortix" button | OAuth access token, `kortix_oat_` (1 hour; refresh token `kortix_ort_`, 30 days) | Server holds it; the browser sends a placeholder | `auth.kortix(request)` on the server; `'kortix-session'` in the browser | [Sign in with Kortix](#sign-in-with-kortix) |
| Code inside a Kortix App | App viewer token | Browser of the App, or the App's server | `kortixAppViewerToken()` in the browser; `createAppViewerKortix(request, …)` on the server | [Know the viewer in a Kortix App](#know-the-viewer-in-a-kortix-app) |
| One server process that acts for many users at the same time | Any of the above, per request | Server only | A function of the current request | [Serve many users from one backend](#serve-many-users-from-one-backend) |

Two more credentials exist. A service account key, `kortix_sa_`, acts as a
service account for unattended automation. It has no access until you assign
it a role. Create one under **Account settings → API keys**, or with
`kortix tokens service-accounts new`. Kortix sets `KORTIX_TOKEN` and
`KORTIX_API_URL` inside a session sandbox. They act as the session's agent,
limited to that project and session. Their permissions are the agent's
`kortix_permissions`, capped by its ceiling role (see
[What an agent may do](/docs/guide#agents)).
Creation, expiry, project binding and key rules for every credential are in
[Security](/docs/guide#security). A valid credential without the needed permission
gets `403`. Grant the role in [Teams](/docs/guide#teams).

> **Warning**
> A `kortix_pat_` or `kortix_sa_` key never ships to a browser. Keep it on a
> server and give browser code a proxy:
> [Sign in with Kortix](#sign-in-with-kortix) for people,
> [`forwardKortixRequest`](#serve-many-users-from-one-backend) for a key your
> server holds.

### getToken and 401 recovery

The SDK calls `getToken` before every request. It caches nothing, so your code
owns token storage and refresh.

- `getToken` returns `null`: the SDK sends no request. A typed call such as
  `kortix.projects.list()` throws `AuthError` with `code: 'NO_SESSION'`.
  `kortix.validateToken()` resolves `{ valid: false, error }` instead.
- The SDK sends the token as `Authorization: Bearer <token>`. With
  `credentialHeader` set (for example `'x-app-reader-token'`, for a proxy that
  reads a custom header), it sends the raw token in that header and sends no
  `Authorization`. The `401` replay still calls `getToken` again. A header of
  the same name that a call sets itself wins.
- The API answers `401`: the SDK calls `getToken.invalidate(rejectedToken)`
  when it exists, calls `getToken` again, and replays the request once. A
  second `401` goes to the caller.
- Parallel `401`s on one token share one `invalidate` call and one `getToken`
  call. A host that rotates its refresh token on each call does not race
  itself.
- The SDK does not replay when the new token equals the rejected one, when the
  request body is a `ReadableStream`, or when the call set its own
  `Authorization` header.

A `getToken` that caches must attach `invalidate`. Without it, the second call
returns the same dead token and the SDK does not replay.

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

declare function fetchTokenFromMyServer(): Promise<string | null>;

let cached: string | null = null;

const getToken = Object.assign(
  async () => (cached ??= await fetchTokenFromMyServer()),
  {
    invalidate: (rejected: string) => {
      if (cached === rejected) cached = null;
    },
  },
);

const kortix = createKortix({ backendUrl: 'https://api.kortix.com/v1', getToken });
```

To branch on an auth failure, call `isAuthFailure(error)`. It returns `true`
for `AuthError` and for any error with `status` `401` or `403`. It reads the
status, never the message text.

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

declare function redirectToSignIn(): void;

const kortix = createKortix({
  backendUrl: 'https://api.kortix.com/v1',
  getToken: async () => localStorage.getItem('kortix-token'),
});

try {
  await kortix.projects.list();
} catch (error) {
  if (isAuthFailure(error)) redirectToSignIn();
  else throw error;
}
```

To check a credential without a try block, call `kortix.validateToken()`. It
reads `GET /accounts/me` and never throws. It resolves to
`{ valid: true, identity }` or `{ valid: false, error }`.

`clientVersion` (for example `'my-app/1.4.0'`) goes out as
`X-Kortix-Client-Version`. Kortix writes it to request logs only. It grants
nothing. The audit log records the credential that `getToken` returned.

### Sign users in headlessly

`kortix.auth.*` signs people up and in through the Kortix API at `/v1/auth/*`.
It needs no Supabase URL or key. It works the same on Kortix Cloud and on a
self-hosted instance. Use it for a CLI, a native app, or your own sign-in
screen.

Create one session store and one client. The store keeps the tokens, refreshes
them 60 s before they expire, and feeds `getToken`.

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

const session = createKortixSession({
  storage: {
    get: () => localStorage.getItem('kortix'),
    set: (value) => localStorage.setItem('kortix', value),
    remove: () => localStorage.removeItem('kortix'),
  },
});

const kortix = createKortix({
  backendUrl: 'https://api.kortix.com/v1',
  getToken: session.getToken,
});

const { session: tokens, user } = await kortix.auth.signInWithPassword({
  email: 'ada@example.test',
  password: 'correct-horse-battery',
});
await session.set(tokens, user);

await kortix.projects.list(); // runs as that person
```

Create the store with `createKortixSession`. Do not create a second
`createKortix` client to reach `kortix.auth.session`: every `createKortix` call
re-points the one process-wide config.

#### Sign-in flows

| Flow | Calls |
|---|---|
| Sign up | `auth.signUp({ email, password, data?, redirect_to? })` returns `{ user, session, requires_email_confirmation }`. When `requires_email_confirmation` is `true`, `session` is `null` until the person confirms with the emailed link or with `verifyOtp({ type: 'signup' })`. |
| Password | `auth.signInWithPassword({ email, password })` |
| Magic link or code | `auth.sendMagicLink({ email, create_user?, redirect_to?, data? })`, then `auth.verifyOtp({ email, token, type: 'magiclink' })` with the code from the email |
| Social login (PKCE) | `auth.signInWithProvider({ provider, redirect_to, scopes? })` returns `{ url, code_verifier }`. Keep `code_verifier`. Send the browser to `url`. Then call `auth.exchangeCode({ code, code_verifier })` with the `code` from the redirect. `provider` is one the instance has configured, for example `google` or `github`. |
| Enterprise SSO | `auth.signInWithSso({ domain, redirect_to? })` returns `{ url }`. A `404` means the domain has no identity provider. See [Security](/docs/guide#security). |
| Password reset | `auth.resetPassword({ email, redirect_to? })`, then `auth.verifyOtp({ email, token, type: 'recovery' })`, then `auth.updatePassword({ password }, accessToken)` with the access token from that session. The password is 8 to 256 characters. |

Every flow that returns a session returns `{ session, user }`. Pass both to
`session.set(...)`. `redirect_to` is an absolute `http` or `https` URL that the
instance allows.

#### The signed-in person

These calls take the access token as an argument, so they work before
`getToken` is wired. `auth.refresh` takes the refresh token instead.
`auth.user`, `auth.updateUserMetadata`, `auth.updatePassword` and the
`auth.mfa.*` calls refuse any other credential, such as a personal access key,
with `401 invalid_token`. `auth.signOut` always answers `200`.

| Call | Does |
|---|---|
| `auth.user(accessToken)` | Returns `{ user }` |
| `auth.updateUserMetadata({ data }, accessToken)` | Writes profile metadata: display name, avatar, language. It refuses a password or email change. |
| `auth.updatePassword({ password }, accessToken)` | Sets a new password |
| `auth.mfa.enroll({ factor_type, friendly_name?, phone?, issuer? }, accessToken)` | Enrolls a `totp` or `phone` factor |
| `auth.mfa.challenge(factorId, { channel? }, accessToken)` | Starts a challenge |
| `auth.mfa.verify(factorId, { challenge_id, code }, accessToken)` | Verifies the code. Returns the new token pair (`access_token`, `refresh_token`, `expires_at`) at the top level, not as `{ session, user }`. Store it with `session.set(result as unknown as AuthSession)`. |
| `auth.mfa.unenroll(factorId, accessToken)` | Removes a factor |
| `auth.refresh({ refresh_token })` | Rotates the token pair. The session store calls it for you. |
| `auth.signOut(accessToken, { scope? })` | Revokes the session at Kortix. `scope`: `global` (default, every device), `local` (this session), `others` (every other session). |

To sign out, revoke the session, then clear the store:

```ts
const token = await session.getToken();
if (token) await kortix.auth.signOut(token);
await session.clear();
```

#### The session store

`kortix.auth.session(options?)` is the same function as
`createKortixSession(options?)`. Each call creates a new store. Create one and
keep it.

| Option | Default | Does |
|---|---|---|
| `storage` | none (memory only) | `{ get, set, remove }`, sync or async: `localStorage`, a file, a cookie jar, a database row |
| `onChange` | none | Runs after every sign-in, refresh and sign-out |
| `refreshSkewSeconds` | `60` | Refreshes this many seconds before expiry |
| `request` | the client's `backendUrl` | `{ backendUrl?, fetch?, signal? }` for refresh calls |

| Method | Does |
|---|---|
| `getToken()` | A valid access token, refreshed first when needed. `null` when signed out. |
| `set(session, user?)` | Stores a session that a `kortix.auth.*` call returned |
| `current()`, `user()` | The stored session and user. No refresh. |
| `refresh()` | Rotates now. Concurrent callers share one refresh. |
| `subscribe(listener)` | Calls `listener` on every change. Returns an unsubscribe function. |
| `load()` | Reads storage now instead of on first use |
| `clear()` | Forgets the session locally. Call `auth.signOut` first to revoke it. |

`session.getToken` has no `invalidate`. A `401` on a token that has not expired
goes to the caller. Handle it with `isAuthFailure`. A refresh that Kortix
rejects with a `4xx` signs the person out: the store clears itself and
`getToken()` returns `null`. A network failure keeps the stored session for the
next call.

#### Errors and limits

Every `kortix.auth.*` call throws `HeadlessAuthError` with `code`, `message` and
`status`.

| `code` | `status` | Cause |
|---|---|---|
| `sso_required` | `403` | The email domain must sign in with SSO. Sign-up, password, magic link and `verifyOtp` refuse it. Use `signInWithSso`. |
| `signup_closed` | `403` | The instance does not accept sign-ups from this address |
| `over_request_rate_limit` | `429` | Too many attempts. Wait for the `retry-after` header. |
| `invalid_token` | `401` | A call that needs the person's access token got another credential |
| `not_configured` | `0` | No `backendUrl`. Create the client first. |

Every `/v1/auth/*` route that takes no access token shares one limit: 30
attempts per minute per client IP, counted on each API replica. Other codes come unchanged from the
identity service.

### Sign in with Kortix

Sign in with Kortix makes Kortix the identity provider for a web app you run.
People sign in with their Kortix account. Your server knows who they are. Your
app calls Kortix as that person, with that person's roles. The protocol is
OAuth 2.1 with PKCE. The browser never holds a Kortix token.

Register the app first. The fields are in
[OAuth client registration](#oauth-client-registration).

### Create the auth kit on your server
```ts
// lib/kortix-auth.ts (server only)
import { createKortixAuth } from '@kortix/sdk/server';

export const auth = createKortixAuth({
backendUrl: 'https://api.kortix.com/v1',
clientId: process.env.KORTIX_OAUTH_CLIENT_ID!,
clientSecret: process.env.KORTIX_OAUTH_CLIENT_SECRET, // omit for a public client
redirectUri: 'https://dashboard.example.test/api/kortix/auth/callback',
cookieSecret: process.env.KORTIX_AUTH_COOKIE_SECRET!, // 32 characters or more
});
```
### Mount one catch-all route
```ts
// app/api/kortix/auth/[...kortix]/route.ts (Next.js App Router)
import { auth } from '@/lib/kortix-auth';

const handle = (request: Request) => auth.handler(request);
export { handle as GET, handle as POST };
```

Any framework works. Pass a Web `Request` to `auth.handler` and return its
`Response`.
### Gate pages and call Kortix as the person
```ts
import { auth } from '@/lib/kortix-auth';

export async function GET(request: Request) {
const gate = await auth.requireViewer(request);
if (gate.response) return gate.response; // 302 to /refresh or /signin

const kortix = await auth.kortix(request); // acts as the signed-in person
return Response.json(await kortix.projects.list());
}
```

`requireViewer` sends a person whose session cookie holds an expired
access token to `/refresh`. A person with no session cookie goes to
`/signin`.
### Call Kortix from the browser
```tsx
import { createKortix } from '@kortix/sdk';
import { SignInWithKortix, useKortixViewer } from '@kortix/sdk/react';

// The same values auth.clientConfig() returns on the server.
createKortix({
backendUrl: window.location.origin + '/api/kortix/auth/proxy',
getToken: async () => 'kortix-session',
});

export function Header() {
const { status, viewer } = useKortixViewer();
if (status === 'signed-in') return <span>{viewer.email}</span>;
return <SignInWithKortix className="button" />;
}
```

The browser sends the placeholder bearer `kortix-session`
(`KORTIX_SESSION_SENTINEL`). The `/proxy` route replaces it with the
person's token on your server. Every SDK call works through it, including
[`useSession`](/docs/developers#react).

> **Warning**
> Never import `@kortix/sdk/server`, or a module that imports it such as
> `lib/kortix-auth.ts`, into browser code. It pulls in `node:async_hooks`.
> `auth.clientConfig(origin?)` returns
> `{ backendUrl: '<origin><basePath>/proxy', getToken }` on the server. In the
> browser, write the same two values literally, as in step 4.

#### createKortixAuth options

| Option | Default | Does |
|---|---|---|
| `backendUrl` | required | Kortix API base, including `/v1` |
| `clientId` | required | The registered client id |
| `clientSecret` | none | The client secret. Omit it for a public client. |
| `redirectUri` | required | Absolute callback URL, byte-identical to a registered one |
| `cookieSecret` | required | 32 characters or more. Encrypts the cookies with AES-256-GCM. |
| `scopes` | `['profile', 'email', 'kortix']` | Scopes to request |
| `basePath` | the `redirectUri` path without `/callback` | Where `handler` is mounted |
| `sessionTtlSeconds` | `2592000` (30 days) | How long a session lives without a new sign-in |
| `cookieSameSite` | `'lax'` | `'none'` only for an app that signs in while embedded in another origin. It needs `https`. Over `http` the cookie stays `Lax`. |
| `fetch` | global `fetch` | Custom fetch for tests or edge runtimes |

The kit treats an access token as expired 30 s before Kortix does, so a call
in flight never lands on a dead token.

`createKortixAuth` throws `KortixAuthError` with code `invalid_options` when
`backendUrl` or `clientId` is empty, `redirectUri` is not an absolute `http` or
`https` URL, or `cookieSecret` is shorter than 32 characters. `auth.kortix`
throws `KortixAuthError` with code `unauthenticated` when nobody is signed in.
The handler throws nothing. It reports `state_mismatch` and
`token_exchange_failed` as the `error` field of a JSON response.

| Scope | Grants your app |
|---|---|
| `profile` | The person's id, email and account memberships |
| `email` | The email address |
| `kortix` | Acting as the person on the whole Kortix API. Without it, the token reaches only the identity routes. |

#### Routes the handler serves

All paths are under `basePath`, `/api/kortix/auth` in the example.

| Path | Does |
|---|---|
| `/signin?return_to=/path` | Starts sign-in with PKCE and a 10-minute transaction cookie, then redirects to Kortix |
| `/callback` | Exchanges the code, sets the encrypted `HttpOnly` session cookie, redirects to `return_to` |
| `/refresh?return_to=/path` | Rotates the token pair and redirects. `requireViewer` sends people here. |
| `/signout?return_to=/path` | Revokes the refresh token at Kortix and clears the cookie |
| `/me` | The person as JSON, or `401`. Refreshes an expired access token. |
| `/proxy/*` | Forwards the request to the Kortix API as the person |

`return_to` is always reduced to a same-origin path (`safeReturnTo`). When the
person declines consent, the callback redirects to `return_to` with
`?kortix_auth_error=<code>`. A missing or expired transaction answers `400`
with `error: 'state_mismatch'`. A failed code exchange answers with
`error: 'token_exchange_failed'`.

#### Server and browser API

| From | API | Returns |
|---|---|---|
| `@kortix/sdk/server` | `auth.viewer(request)` | `{ userId, email, accounts, scopes, token, expiresAt }` or `null`. Read-only: it never rotates the refresh token. |
| `@kortix/sdk/server` | `auth.requireViewer(request)` | `{ viewer }`, or `{ response }` with a redirect to `/refresh` or `/signin` |
| `@kortix/sdk/server` | `auth.kortix(request)` | A request-scoped client that acts as the person. Throws `KortixAuthError` `unauthenticated` when signed out. |
| `@kortix/sdk/server` | `auth.signInUrl(returnTo?)`, `auth.signOutUrl(returnTo?)` | Link targets |
| `@kortix/sdk/server` | `auth.clientConfig(origin?)` | The browser `createKortix` config: `backendUrl` is `/proxy`, `getToken` yields `kortix-session` |
| `@kortix/sdk/react` | `useKortixViewer({ basePath? })` | `{ status, viewer, reload }`. `status` is `loading`, `signed-in`, `signed-out` or `error`. |
| `@kortix/sdk/react` | `<SignInWithKortix basePath? returnTo? />` | A link that starts sign-in and returns to the current page |
| `@kortix/sdk/react` | `kortixSignOutHref(basePath?, returnTo?)` | The sign-out link target |

The React helpers default `basePath` to `/api/kortix/auth`. Pass `basePath`
when you mount `handler` elsewhere.

#### Consent and revocation

The first sign-in shows a Kortix consent screen with your app's name and
scopes. Kortix remembers the approval per person and app. Later sign-ins
return straight to your app. A person revokes an app with
`kortix.accounts.connectedApps.list()` and `.revoke(clientId)`, or on the web
and in the CLI as [Security](/docs/guide#security) describes. After a revoke, the
app's tokens stop working and the app must ask again.

#### Discovery for other OAuth clients

The SDK derives every endpoint from `backendUrl`. A generic OAuth client reads
`GET /.well-known/oauth-authorization-server` on the API origin. The same
document is at `/v1/oauth/.well-known/oauth-authorization-server`. Dynamic
client registration is at `POST /v1/oauth/register`. Kortix deletes a
self-registered client after 7 days when it has no approval and no token.

#### OAuth client registration

Register in **Account → Tokens → OAuth apps**, or with
`kortix.iam.oauthClients.create(accountId, input)`. Registration needs
`token.create` on the account. `kortix.iam.oauthClients` also has `list`,
`get`, `update`, `rotateSecret` and `remove`.

| Field | Value |
|---|---|
| `name` | Shown on the consent screen |
| `client_type` | `'confidential'` (default) for a server that keeps a secret. `'public'` for a browser or native app: PKCE, no secret. |
| `redirect_uris` | Compared byte for byte. `https` only, except on a loopback host (`localhost`, `*.localhost`, `127.0.0.1`, `[::1]`). |
| `scopes` | Any of `profile`, `email`, `kortix`. Default: all three. |
| Result | `client_id`. `client_secret` is shown once. |

### Know the viewer in a Kortix App

An App that Kortix hosts needs no sign-in of its own. The Apps gate signs the
visitor in before the App serves its first byte. The App asks the gate who the
visitor is. The App's viewer setting decides what it gets:

| `kortix apps access <app> --viewer` | The App gets |
|---|---|
| `off` | No viewer |
| `identity` | Who the viewer is: user id, email, name, groups, role |
| `api` | Identity, plus a token that acts as the viewer on the Kortix API, capped by their role |

The viewer token is not the person's Kortix session. It expires in 1 hour. It
carries `profile email`, plus `kortix` on an `api` App. It dies with the App.
An App on its own domain has no gate: use
[Sign in with Kortix](#sign-in-with-kortix) there.

#### In the browser

`/_kortix/api/v1/*` on the App's own origin forwards each call to the Kortix
API as the viewer. The browser makes no cross-origin request.

```tsx
import { createKortix, kortixAppViewerToken } from '@kortix/sdk';
import { useKortixAppViewer } from '@kortix/sdk/react';

createKortix({
  backendUrl: '/_kortix/api/v1',
  getToken: kortixAppViewerToken(),
});

export function Greeting() {
  const { status, viewer } = useKortixAppViewer();
  if (status === 'loading') return null;
  if (status === 'anonymous') return <span>Not signed in</span>;
  return <span>{viewer.name ?? viewer.email}</span>;
}
```

| API | Does |
|---|---|
| `kortixAppViewerToken({ path?, fetch? })` | A `getToken` with `invalidate`. Yields the viewer token, or `null` when nobody is signed in or the App is `identity`. Refetches 60 s before expiry. |
| `fetchKortixAppViewer({ path?, fetch? })` | `GET /_kortix/viewer`. Returns `{ user_id, email, name, picture, group_ids, groups, role, account_id, project_id, app_id, access_mode, scopes, access_token, expires_at }` or `null`. Concurrent callers share one request. |
| `useKortixAppViewer({ path? })` | `{ status: 'loading' \| 'anonymous' \| 'viewer', viewer }` |
| `clearKortixAppViewerCache()` | Drops the cached viewer and tokens, for example after sign-out |

`fetchKortixAppViewer` never throws. Any non-`2xx` gate answer yields `null`.
The gate sends these two:

| Status | `error` | Cause |
|---|---|---|
| `401` | `no_viewer_identity` | Nobody is signed in, or the viewer has no access to the App |
| `404` | `viewer_disabled` | The App's viewer setting is `off` |

An agent session gets its identity with `access_token: null`. The gate never
gives an agent a token that acts as the person who started it.

#### On the App's server

The gate signs the viewer into every proxied request as the
`x-kortix-app-viewer` header (`APP_VIEWER_HEADER`). An `api` App also gets the
token in `x-kortix-app-viewer-token` (`APP_VIEWER_TOKEN_HEADER`). The gate
deletes a client-supplied copy. The App verifies the header with
`KORTIX_APP_VIEWER_SECRET` (`APP_VIEWER_SECRET_ENV`), which Kortix sets at
deploy. Without the secret, every function below finds no viewer.

```ts
import { createAppViewerKortix, createKortixAppGuard } from '@kortix/sdk/server';

const guard = createKortixAppGuard();

export async function GET(request: Request) {
  const gate = await guard.requireGroup(request, ['Finance']);
  if (gate.response) return gate.response; // 404 { error: 'not_found' }

  const kortix = await createAppViewerKortix(request, {
    backendUrl: 'https://api.kortix.com/v1',
  });
  return Response.json({
    viewer: gate.viewer.userId,
    projects: await kortix.projects.list(),
  });
}
```

| API (`@kortix/sdk/server`) | Does |
|---|---|
| `readAppViewer(request, { secret? })` | The verified viewer `{ userId, email, name, picture, groupIds, groups, role, projectId, accountId, appId, accessMode, token, expiresAt }`, or `null`. One HMAC check, no network call. |
| `createAppViewerKortix(request, { backendUrl, secret? })` | A request-scoped client that acts as the viewer. Throws `AppViewerUnavailableError` with code `no_viewer` (no verified viewer) or `identity_only` (the App is not `api`). |
| `createKortixAppGuard(options?)` | One resolver for the gate header and, with `auth` from `createKortixAuth`, the App's own sign-in. Returns `{ viewer, requireViewer, requireGroup, identityConfigured }`. |
| `guard.requireViewer(request)` | `{ viewer }`, or `{ response }`: a `302` to sign-in when `auth` is set, else `404`. |
| `guard.requireGroup(request, groups)` | `{ viewer }` when the viewer is in any of `groups` (ids or names), else `{ response }` `404`. An empty list refuses everyone. |

`createKortixAppGuard` options: `secret`, `backendUrl` (for the group lookup on
the sign-in path), `auth`, `accountId` (default `KORTIX_APP_ACCOUNT_ID`),
`groupCacheTtlMs` (default `60000`), `fetch`. A failed group lookup yields no
groups, so a group gate denies.

#### Member tokens for App-to-App calls

A member token is a 15-minute ES256 token that names the viewer for one App.
Its `aud` is the App id. One App uses it to call another App that it lists in
`uses`. The functions come from `@kortix/sdk` and run in a browser, Node 18+,
Bun, Deno and a Worker.

```ts
// Browser of the calling App
import { kortixBinding } from '@kortix/sdk';

const api = kortixBinding('api');
const res = await fetch(`${api.url}/orders`, {
  headers: { authorization: `Bearer ${await api.token()}` },
});
```

```ts
// Server of the called App
import { KortixMemberError, requireKortixMember, verifyKortixToken } from '@kortix/sdk';

export async function GET(request: Request) {
  const bearer = request.headers.get('authorization')?.replace(/^Bearer /, '') ?? '';
  try {
    const member = requireKortixMember(await verifyKortixToken(bearer), { groups: ['Finance'] });
    return Response.json({ userId: member.userId });
  } catch (error) {
    if (error instanceof KortixMemberError) {
      return new Response(null, { status: error.code === 'forbidden' ? 403 : 401 });
    }
    throw error;
  }
}
```

| API (`@kortix/sdk`) | Does |
|---|---|
| `kortixToken({ audience?, path?, fetch? })` | A fetcher for `GET /_kortix/token`. `audience` is this App (default) or a slug or id from `uses`. Yields `null` with no viewer. `{ forceRefreshToken: true }` asks the gate again. |
| `kortixBinding(slug, { origin?, fetch? })` | `{ url, token }`. `url` is `<origin>/_kortix/apps/<slug>` for HTTP and WebSocket. Outside a browser, pass `origin`. |
| `verifyKortixToken(token, { jwks?, issuer?, audience?, fetch? })` | Checks signature, `exp` (60 s skew), issuer and audience. Defaults come from `KORTIX_AUTH_JWKS`, `KORTIX_AUTH_ISSUER`, `KORTIX_AUTH_AUDIENCE`. With no audience it refuses; `audience: false` accepts any App of the project. Returns a `KortixMember`. Throws `KortixMemberError` with code `unauthenticated` when any check fails. |
| `readKortixMember(claims)` | A `KortixMember` from claims something already verified, or `null`. It never checks a signature. |
| `requireKortixMember(claims, { groups?, roles? })` | The member, or throws `KortixMemberError`. An empty list refuses. |
| `KortixMemberError` | `code` is `unauthenticated` (map to `401`) or `forbidden` (map to `403`) |

A `KortixMember` is `{ userId, email, name, picture, groups, groupIds, role, accountId, projectId }`.
The token route answers `403 app_not_linked` for an App not in `uses` and
`403 agent_viewer` for an agent session. A binding to a static App answers
`409 app_binding_unsupported`. `kortixToken` yields `null` for every refusal
and logs one console warning for `app_not_linked`. The issuer, key
set and audience values are in [Apps reference](/docs/developers#apps-reference).

### Serve many users from one backend

`createKortix` stores its config in one process-wide slot. That is correct for
a browser tab, a CLI or a single-tenant job. On a server that handles requests
for different people at the same time, the last `createKortix` call wins for
every request in flight. Use `@kortix/sdk/server`. It isolates each request's
config in Node's `AsyncLocalStorage` and runs in Node and Bun.

**One client per request.** `createScopedKortix` takes the same options as
`createKortix` and never touches the process-wide config.

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

declare function tokenFor(request: Request): Promise<string | null>;

export async function GET(request: Request) {
  const kortix = createScopedKortix({
    backendUrl: 'https://api.kortix.com/v1',
    getToken: () => tokenFor(request),
  });
  return Response.json(await kortix.projects.list());
}
```

A scoped client's top-level `kortix.runtime()` throws, because it would resolve
another request's sandbox. Work with a session through its handle,
`kortix.session(projectId, sessionId)`, and its verbs in
[Sessions from code](/docs/developers#sessions-from-code).

**One scope per request.** `runWithKortix(config, fn)` sets the config for
everything `fn` awaits, including calls on a client made with `createKortix`.
`getScopedConfig()` returns the config of the current scope, or `undefined`
outside one.

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

declare function tokenFor(request: Request): Promise<string | null>;
declare function handleRequest(request: Request): Promise<Response>;

export function handle(request: Request): Promise<Response> {
  return runWithKortix(
    { backendUrl: 'https://api.kortix.com/v1', getToken: () => tokenFor(request) },
    () => handleRequest(request),
  );
}
```

**A proxy for browser code.** `forwardKortixRequest({ request, upstreamUrl, token })`
forwards one request from your route to the Kortix API with a token your
server holds.

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

declare function tokenFor(request: Request): Promise<string>;

export async function handle(request: Request): Promise<Response> {
  const url = new URL(request.url);
  const rest = url.pathname.replace(/^\/api\/kortix\//, '');
  return forwardKortixRequest({
    request,
    upstreamUrl: 'https://api.kortix.com/v1/' + rest + url.search,
    token: await tokenFor(request),
  });
}
```

It removes the browser's `cookie` and `host` headers, sets the bearer, keeps
streamed response bodies, and removes `set-cookie` from the answer. When Kortix
is unreachable, it answers `502` with `{ "error": "Upstream request failed" }`.
Point the browser client at the route with any non-null placeholder token:
`createKortix({ backendUrl: '/api/kortix', getToken: async () => 'proxy' })`.
The key stays on the server.

To start sessions for your users with secrets, connector bindings and an
`Idempotency-Key`, see [Sessions from code](/docs/developers#sessions-from-code).

## Sessions from code

A session is one agent run in its own sandbox, on its own git branch. This section drives a session from TypeScript with the framework-free `@kortix/sdk` root entry point. The steps are in lifecycle order: create, wait until ready, send, stream, get the reply, answer, gate on capabilities, control, files, and start sessions for your users. The one error catalog of the SDK is at the end.

Every sample uses the client from [SDK](/docs/developers#sdk). A React app uses `useSession` instead of these calls. See [React](/docs/developers#react).

### The session handle

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

- Creating a handle sends no request.
- The session owns its runtime. A handle resolves only the sandbox of its own session. Handles for two different sessions never share a runtime. Handles for one session in one process share its resolved runtime.
- `session_id` is the id of the sandbox and the name of the session's git branch.
- `ensureReady()` returns `sandboxId`. That value is the provider's external id of the sandbox, not `session_id`.
- A session runs the pi runtime. The controls it does not serve are in [Gate on runtime capabilities](#gate-on-runtime-capabilities).

### Create a session

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

const project = kortix.project(projectId);
const sessionId = generateSessionId(); // a v4 UUID, known before the create returns

const created = await project.sessions.create({
  session_id: sessionId,
  agent_name: 'support',
  initial_prompt: 'Triage the open issues labeled bug.',
  labels: ['triage'],
  metadata: { app_ticket: 'T-142' },
});
```

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 to provision the sandbox at once. Write a new secret value or connector credential before the create that needs it.

| Field | Meaning |
| --- | --- |
| `initial_prompt` | The first prompt. The server delivers it when the runtime is up. You do not call `send()` for it. |
| `agent_name` | The agent to run. Default: the project's default agent. |
| `model` | The model id (see [Models](/docs/guide#models)). 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. A server-managed key, for example `source`, `trigger_kind` or `trigger_slug`, returns `400`. Prefix your keys, for example `app_`. |
| `session_id` | A v4 UUID from `generateSessionId()`. A second create with the same id returns `409` `session_already_exists`. |
| `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`. |
| `runtime_context`, `connector_bindings`, `inherit_unbound`, `secrets` | Per-customer context, connector accounts and secrets. See [Start sessions for your users](#start-sessions-for-your-users). |

`require_connectors` is accepted and ignored.

Change the name, labels or metadata later:

```ts
const s = kortix.session(projectId, created.session_id);
await s.update({ name: 'Bug triage', labels: ['triage', 'done'], metadata: { app_ticket: null } });

const { items, next_cursor } = await project.sessions.listPage({ labels: ['triage', 'done'] });
```

- `labels` replaces the whole list. `[]` clears it.
- `metadata` merges keys. A `null` value removes a key.
- `name: null` or `name: ''` returns the session to the automatic title.
- `listPage({ labels })` returns only sessions that carry every label. Pass `next_cursor` back as `cursor` for the next page. `list()` returns one page as a bare array.

The full `listPage` options are in [SDK reference](/docs/developers#sdk-reference).

### 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 calls `POST .../start` 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_000` ms. Pass `readyTimeoutMs` to change it.
- At the deadline, or at once on a `failed` or `stopped` answer, it throws an `ApiError` with `code: 'RUNTIME_UNAVAILABLE'`.
- A `4xx` answer other than `408` or `429` rejects at once with a `SessionStartError` (`status`, `code`, `terminal: true`). A `402` is one example.
- Concurrent calls for one session share one start request. A call on a ready handle returns at once.
- `runtimeSessionId` is the id of the root conversation in the runtime. Events carry it as `sessionID`.

Read the session row with `s.get()`. The row costs no sandbox wake-up. Pass it to `ensureReady()`: a `running` row that names its sandbox and `runtime_session_id` is used without a start request.

```ts
const row = await s.get();
const ready = await s.ensureReady({ session: row });
```

`s.start()` and `s.startOrThrow()` send one start request and return the raw `SessionStartResult` (`stage`, `retriable`, `sandbox`, `failure`). `start()` resolves `null` on a transient failure and rejects with a `SessionStartError` on any other `4xx`. `startOrThrow()` rejects with the `ApiError`, a `402` as `BillingError`. Use them to drive your own progress display. Use `ensureReady()` everywhere else.

| Behavior | Methods |
| --- | --- |
| Call `ensureReady()` themselves | `send`, `abort`, `rewind`, `restoreRewind`, `stream`, `messages`, `pending`, `answerPermission`, `answerQuestion`, `compact`, every `files.*` method |
| Throw `SessionNotReadyError` until the handle has a runtime | `previewUrl`, `proxyUrl`, `sandboxPortUrl` |
| Read only the Kortix API, never the sandbox | `get`, `update`, `cost`, `scope`, `log`, `turn`, `messageAuthors`, `modelUsage`, `prompts.*` |

`s.health()` never throws `SessionNotReadyError`. Before the handle has a runtime, it returns `status: 0`, `ok: false` and `health: null`.

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
const { prompt_id, state, message_id } = await s.send('Refactor the auth module.');
```

- `send()` puts the prompt in the session's durable prompt inbox. The web app and the CLI write to the same inbox.
- `send()` resolves when the prompt is durable, not when the turn ends. Read the reply from the stream or from `s.messages()`.
- `send()` throws an `ApiError` with `code: 'PROMPT_FAILED'` when the inbox row already failed.
- No helper sends a prompt and waits for the reply. [Get the reply](#get-the-reply) shows the pattern.

Pick the model and agent per call or per handle:

```ts
await s.send('Write the tests.', { agent: 'tester' }); // this call only
s.setModel({ providerID: 'kortix', modelID: 'glm-5.3-flash' }); // later send() calls on this handle
s.setAgent('reviewer');
```

Precedence: the `send()` option, then `setModel()` / `setAgent()` on this handle, then the session's saved default. Model ids come from `kortix.projects.modelPicker(projectId)`. Agent names come from `kortix.projects.detail(projectId)`, in `config.agents`. To change the saved model, see [Control the session](#control-the-session).

#### 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 sets `steer_fallback` on the inbox row:

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

The inbox lists only the prompts that the session did not deliver yet.

| Method | What it does |
| --- | --- |
| `s.prompts.list()` | `{ prompts }`: the waiting prompts, oldest first, each with `state`, `reason` and `steer_fallback`. |
| `s.prompts.edit(promptId, text)` | Replaces the text of a waiting prompt. `409` when the prompt is 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(held)` | Holds (`true`) or releases (`false`) the whole queue. |

#### Attach files

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

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

const report = new File([reportBytes], 'report.pdf', { type: 'application/pdf' });
const upload = await kortix.project(projectId).attachments.upload(report);

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

- Limits: 1 byte to 50 MiB per file, 100 MiB per message, 20 files per message.
- 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 (`expires_at`).
- A browser composer uses `usePromptAttachments` from `@kortix/sdk/react`.

### Stream events

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

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

> **Info**
> Open the stream before you call `send()`. A stream that opens after the send can miss the first events of the turn.

Each raw event has `type` and `properties`. `narrowChatEvent(event)` maps the events a chat needs to the typed `KortixChatEvent` union and returns `null` for every other event:

| `KortixChatEvent.type` | When it fires | Payload |
| --- | --- | --- |
| `message.updated` / `message.removed` | A message changed or was deleted. | `message` / `messageID` |
| `message.part.updated` / `message.part.removed` | A part (text, tool call, file) changed or was removed. It carries the full part. | `part` / `messageID`, `partID` |
| `session.status` | A conversation's busy state changed. | `status` |
| `session.idle` | A turn finished. | `sessionID` |
| `session.error` | A turn failed. | `error` |
| `permission.asked` / `permission.replied` | The agent asked for a permission, and the answer. | `requestID`, `permission`, `patterns` / `reply` |
| `question.asked` / `question.answered` | The agent asked a question, and the answer. `question.answered` merges the wire's `question.replied` and `question.rejected`. | `requestID`, `questions` / `outcome`, `answers` |
| `todo.updated` | The agent's todo list changed. Only a runtime that lists `session.todo` sends it. | `todos` |
| `connection` | The stream connected (`server.connected`). | `status` |

Every event except `connection` and `heartbeat-gap` carries `sessionID`. A subagent's conversation sends its own events with its own `sessionID`. `narrowChatEvent` drops `message.part.delta`. To render text as it is typed, read `message.part.delta` from the raw event and append `properties.delta`. `heartbeatGapEvent(gapMs)` builds the union's synthetic `heartbeat-gap` member from `onGapRehydrate`.

The SDK keeps the connection alive:

- Events arrive in batches every 16 ms. Consecutive `message.part.delta` events for one part field in one batch arrive as one event: `properties.delta` holds the joined text, `id` is the last event's id, and `coalesced` lists the events it replaced.
- After a healthy stream drops, the SDK reconnects after 250 ms. Otherwise it backs off from 1 s to 30 s. Every delay gets up to 50% jitter. It 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 hard failures (a stopped or deleted sandbox), the stream stops for good. Open a new stream after the session runs again.

#### Raw streams and React Native

`openEventStream({ url: runtimeUrl, onEvent })` is the primitive under `stream()`. Two calls for one runtime URL share one connection. Its options (`onParked`, `onConnectionChange`, the timeouts and the failure limit) are in [SDK reference > Event stream](/docs/developers#event-stream).

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

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

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

const kortix = createKortix({ backendUrl, getToken, eventStreamTransport });
```

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.

### Get the reply

A turn ends with `session.idle` or `session.error` for your `runtimeSessionId`. Wait for one of them, then read the last message:

```ts
import { classifyTurn, narrowChatEvent } from '@kortix/sdk';

const s = kortix.session(projectId, sessionId);
const { runtimeSessionId } = await s.ensureReady();

let settle!: (error?: unknown) => void;
const turnEnded = new Promise<void>((resolve, reject) => {
  settle = (error) => (error === undefined ? resolve() : reject(error));
});

const stream = await s.stream({
  onEvent: (raw) => {
    const event = narrowChatEvent(raw);
    if (event?.type === 'session.idle' && event.sessionID === runtimeSessionId) settle();
    if (event?.type === 'session.error' && event.sessionID === runtimeSessionId) {
      settle(event.error ?? new Error('The turn failed.'));
    }
  },
});

try {
  await s.send('List the open TODO comments in this repository.');
  await turnEnded;
} finally {
  stream.close();
}

const { messages } = await s.messages({ limit: 1 });
const last = messages.at(-1);
const turn = last ? classifyTurn(last) : null;
const text = (turn?.parts ?? [])
  .flatMap((part) => (part.kind === 'text' && !part.synthetic ? [part.text] : []))
  .join('\n');
```

- `classifyTurn(message)` returns `{ parts, error, isEmpty }`. Each part has a `kind`: `text`, `reasoning`, `tool`, `file` and more. `error` is the turn's normalized error.
- `s.turn()` answers without the stream: `turns` (empty when idle), `last_ended`, `recent_failures` and `can_stop`. Poll it when you cannot hold a stream open.
- An agent reply can hold generative UI blocks (`openui` fences). For plain text, pass the text through `genuiToMarkdown(text)` from `@kortix/sdk/genui`. See [Generative UI](/docs/developers#generative-ui).

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.log({ thread?, before_seq?, limit? })` | One page of the stored session log (`kortix.session/2`): the session record, every thread head, and the newest `limit` messages of one thread (default 40, at most 500). Pass the first message's `seq` as `before_seq` for older ones. `null` while the session has no stored log. | No |
| `s.messageAuthors()` | The member or session that sent each message, and the member who stopped each stopped turn. | No |
| `s.modelUsage()` | The model that answered each turn, and `fallback_from` when a fallback model answered. | No |

Use `s.log()` while the runtime is stopped. It reads the API's database, not the sandbox.

### Answer the agent

The agent can stop and wait for a permission or an answer. Read what it waits on, then answer:

```ts
const { statuses, permissions, questions } = await s.pending();

const permission = permissions[0];
if (permission) await s.answerPermission(permission.id, 'once'); // 'once' | 'always' | 'reject'

const question = questions[0];
if (question) await s.answerQuestion(question.id, [['Yes']]); // one answer list per question
```

- `'always'` grants the permission for the rest of this session.
- `answerQuestion(id, null)` dismisses the question.
- The stream announces both with `permission.asked` and `question.asked`. Their `requestID` is the `id` to answer.

### Gate on runtime capabilities

A runtime does not serve every feature. `s.health()` lists what it serves 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();
const canCompact = runtimeSupports(health?.capabilities, 'session.compact');
```

| Capability | Feature |
| --- | --- |
| `session.subagents` | Subagent child sessions |
| `session.compact` | Summarize the conversation on demand |
| `session.commands` | Project slash commands |
| `session.steer` | A prompt read by the running turn at its next step |
| `session.log.v2` | The event stream serves the session log as `log.*` frames |
| `session.rewind` | Roll back to a message, and restore it |
| `session.fork` | Fork a session at a message |
| `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 runtime's own terminal client |
| `session.config` | A runtime config document a client reads and patches |

- pi lists the first five rows.
- A route for an unlisted feature answers `501` `feature_not_supported`.
- `runtimeSupports` returns `true` before a health answer arrives, and for a list with no `session.*` entry.

### Control the session

| Method | What it does |
| --- | --- |
| `s.abort()` | Ends the running turn. The session stays. |
| `s.setModel(model)` / `s.setAgent(agent)` | The model and agent for later `send()` calls on this handle only. |
| `s.changeModel(modelId)` | Saves a new model for the session. See below. |
| `s.compact(model?)` | Summarizes the conversation into a shorter context. Needs `session.compact`. |
| `s.restart()` | Stops and starts the sandbox in place. Clears a failed-start verdict. |
| `s.stop()` | Stops the sandbox. The disk, the branch and the conversation stay. |
| `s.recover()` | Continues a lost session (the provider removed its sandbox) on a new sandbox. The next start boots from the branch and loads the saved conversation. |
| `s.delete()` | Deletes the session and its sandbox. Resolves to `{ ok, restore_until }`. The session is restorable for 30 days. After that, its messages, files and branch are deleted. |
| `s.restore()` | Restores a deleted session before `restore_until`. The next start boots a new sandbox from the branch. |
| `s.rewind(messageId)` / `s.restoreRewind()` | Rolls the conversation back to a user message, and undoes that before the next prompt. Needs `session.rewind`, which pi does not list. |
| `s.configState()` / `s.reloadConfig({ force? })` | Reads whether the session runs the current config of the base branch, and loads it. The reload restarts the runtime. During a turn it returns `409` `SESSION_BUSY`; `force: true` ends the turn. |

```ts
const { applied_live, push_failed } = await s.changeModel('glm-5.3-flash');
```

- `changeModel()` restarts the runtime when the model changes on a running session. A running turn ends.
- `applied_live: true`: the running session took the model now. `false`: it applies at the next start.
- `push_failed: true`: 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`.
- The Kortix web and mobile apps open a session on its saved model. A model set at create or with `changeModel()` is the model their next message uses, unless the person picks another model in that session. `sessionModelCandidates()` from `@kortix/sdk` is the same ranking.

A refused `restore()` throws an `ApiError` with `code` `session_not_deleted`, `session_sandbox_removal_pending` (retry in a minute) or `session_purged`. A refused `recover()` throws `session_not_lost`, `session_transcript_missing`, `session_sandbox_removal_pending` or `session_log_restore_off`.

Sharing, public links and reminders are in [SDK reference](/docs/developers#sdk-reference).

### Files and previews

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

| Method | What it does |
| --- | --- |
| `list(dir)`, `read(path)`, `readBlob(path)` | Lists a directory. 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. It overwrites the file in place. |
| `upload(file, targetPath?, filename?)` | Writes a new file. It never overwrites: a taken name gets a unique name. |
| `create(path)`, `copy(from, to)`, `remove(path)`, `mkdir(dir)`, `rename(from, to)` | Edits the file tree. |

```ts
await s.files.write('/workspace/NOTES.md', new Blob(['# Notes\n']));
const readme = await s.files.read('/workspace/README.md');
```

Preview URLs 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(); // the ports the runtime exposes
```

See [Computers](/docs/guide#computers) for ports and previews.

### Start sessions for your users

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

1. Use a personal access key (`kortix_pat_…`, acts as you) or a service account key (`kortix_sa_…`, acts as itself and needs a project role first). See [Security](/docs/guide#security).
2. Create the client with `createScopedKortix` from `@kortix/sdk/server`. It never writes the process-global config, so one request never reads the config of another. See [SDK authentication](/docs/developers#sdk-authentication).

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

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

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

- Both credential types give the session the origin `backend`. The request body cannot set the origin. A token from inside a session sandbox is never `backend`.
- On a scoped client, `kortix.runtime()` throws. Reach a session through `kortix.session(projectId, sessionId)`.
- Find a customer's sessions with `listPage({ labels: ['customer:c-42'] })`.

#### Retry a create safely

`sessions.create()` sends no `Idempotency-Key`. To retry an SDK create, generate `session_id` with `generateSessionId()` and send the same id again. A `409` `session_already_exists` on the retry means the first create succeeded.

Over HTTP, send an `Idempotency-Key` header:

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

- The API answers `201` with the session. A replay with the same key returns the same session.
- A key is 1 to 255 characters of `A-Z a-z 0-9 . _ : + / = -`.
- A key is unique across all of Kortix. A key that another project or account 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`, `secrets` or `runtime_context` returns `409` `IDEMPOTENCY_BINDING_CONFLICT`, `IDEMPOTENCY_SECRETS_CONFLICT` or `IDEMPOTENCY_CONTEXT_CONFLICT`.
- A key whose session was deleted returns `409` `IDEMPOTENCY_KEY_SESSION_DELETED`. Use a new key.

#### 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. A rescope does not change it.

`connector_bindings` pins the connector account of each alias, as `{ alias: { connection_id } }`, at most 64 entries. The bindings are all-or-nothing:

- When you set it, an alias you did not bind gets no account. `inherit_unbound: true` lets unbound aliases use the project default.
- A shared (`project`) account works for both credential types.
- A private (`member`) account works only for its owner, and only in a private session. A new session is private. A service account never uses a private account.
- The credential never enters the sandbox. Kortix resolves it on every connector call. See [Connectors](/docs/guide#connectors).

`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. See [Security](/docs/guide#security).

#### Change the scope of a running session

```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.dropped_secrets; // the secrets the change removed
next.retroactive; // false when the change removed a secret
```

The answer is the new `SessionScope`, with `added_secrets`, `dropped_secrets` and `dropped_bindings` for this change.

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

- `{}` 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.
- 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. Anyone else gets `403` with no code.

#### Read usage per session

Usage reads never wake the sandbox:

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

const page = await kortix.billing.sessionCosts.list({
  projectId,
  from: '2026-10-01T00:00:00Z',
  to: '2026-11-01T00:00:00Z',
  limit: 25,
});
```

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

### Errors and retries

#### Session create errors

`sessions.create()` and `POST /projects/:id/sessions` return these errors. The `code` field of the JSON body holds the code, and the SDK copies it to `ApiError.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. |
| `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` | `repository_disconnected` | The GitHub App can no longer reach the project's repository. |
| `409` | `IDEMPOTENCY_*_CONFLICT` | Another create holds the key, or the 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: true`. Retry it. |
| `503` | `SESSION_MODEL_RESOLUTION_FAILED` | The server could not resolve the default model. Retry it. |

#### Rescope errors

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

#### Error classes

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

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

try {
  await kortix.project(projectId).sessions.create({ agent_name: 'support' });
} catch (err) {
  if (err instanceof BillingError) {
    const ui = formatBillingErrorForUI(err); // { alertTitle, alertSubtitle }
  } else if (isAuthFailure(err)) {
    // 401, 403 or no token: sign in again or fix the key
  } else if (err instanceof ApiError) {
    console.error(err.status, err.code, err.details);
  } 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`, `details` (the parsed body), `detail` (the body's FastAPI-style `detail` field), `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`: the request headers are too large, for example from too many files in one request. | `detail.message`, `detail.suggestion` |
| `SessionNotReadyError` | A preview method ran before `ensureReady()`. | `name` is `SessionNotReadyError` |
| `SessionStartError` | `ensureReady()`, `start()` or `startOrThrow()` got a `4xx` other than `408` or `429`. It does not extend `ApiError`. | `status`, `code`, `terminal`. `isSessionStartError(err)` checks it. |

Helpers that classify an error:

| Helper | True when |
| --- | --- |
| `isAuthFailure(err)` | The error is an `AuthError`, or its `status` is `401` or `403`. |
| `isFeatureDisabledError(err)` | A flag-gated route refused: `403` with `code` `FEATURE_DISABLED_CODE` (`feature_disabled`). `featureDisabledKey(err)` names the flag. |
| `isRepositoryDisconnectedError(err)` | The GitHub App can no longer reach the repository: the `code` is `REPOSITORY_DISCONNECTED_CODE` (`repository_disconnected`). The API sends it with `409`. |
| `isSandboxNotReadyError(err)`, `isRuntimeStartingError(err)` | The sandbox is starting, stopped or parked. Show a waiting state and retry. |
| `formatBillingErrorForUI(err)` | Returns `{ alertTitle, alertSubtitle }` for a `BillingError`, else `null`. |

- `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.
- The default timeout is `30000` ms per request. A request past its timeout has `code: 'TIMEOUT'`.
- A `401` is sent once more after `getToken()` runs again. See [SDK authentication](/docs/developers#sdk-authentication).
- Sign-in calls reject with `HeadlessAuthError`. See [SDK authentication](/docs/developers#sdk-authentication).

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

A session create is a `POST`. To make it retry-safe, see [Retry a create safely](#retry-a-create-safely).

## Projects from code

A project owns its repository, connectors, secrets, triggers and Apps. Sessions run inside it. This section shows one call for each project-level job. Every method is on the project handle, `kortix.project(projectId)`. Creating the handle sends no request. The full method tables are in [SDK reference > The project handle](/docs/developers#sdk-reference).

Each sample assumes one client, made as in [Create the client](/docs/developers#sdk):

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

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

### Provision and update a project

`kortix.projects.provision(input)` creates a project with a Kortix-managed git repository. It seeds the Kortix starter, so a session can start at once. The request timeout is 120,000 ms.

| Field | Rule |
| --- | --- |
| `name` | Required. |
| `idempotency_key` | Send the same key on every attempt at one create. A retry then returns the project of the first attempt. The key is scoped to the account. It identifies the attempt, not the payload: a reused key with a different `name` returns the first project. |
| `account_id` | The account that owns the project. Omit it for the caller's default account. |
| `starter_template` | `general-knowledge-worker` or `minimal`. |
| `source_item_id` | Clone a `registry:project` marketplace item instead of the starter. It wins over `starter_template`. |
| `marketplace_items` | Marketplace item ids to install. |
| `default_branch` | The branch of the new repository. Omit it for the server default. |
| `icon` · `icon_glyph` | An emoji string, or a `ProjectGlyph` object. `icon_glyph` wins when both are set. An invalid value is dropped and never fails the create. |

A `409` with code `provision_in_flight` (`PROVISION_IN_FLIGHT_CODE`) means an earlier call with the same key is still running. Retry with the same key.

```ts
import { ApiError, PROVISION_IN_FLIGHT_CODE } from '@kortix/sdk';

const key = crypto.randomUUID();

async function provision() {
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      return await kortix.projects.provision({ name: 'Northwind support', idempotency_key: key });
    } catch (error) {
      if (!(error instanceof ApiError) || error.code !== PROVISION_IN_FLIGHT_CODE) throw error;
      await new Promise((resolve) => setTimeout(resolve, 2_000));
    }
  }
  throw new Error('provision still in flight after 5 attempts');
}

const project = await provision();
console.log(project.project_id, project.default_branch);
```

Read and change the project through its handle:

| Method | Returns |
| --- | --- |
| `p.get()` | The `KortixProject` row: `project_id`, `account_id`, `name`, `repo_url`, `default_branch`, `manifest_path`, `status` (`active` or `archived`). |
| `p.detail()` | The project with its configuration. |
| `p.update(input)` | The updated row. `input` takes `name`, `default_branch`, `manifest_path`, `icon` and `icon_glyph`. Omit a key to keep its value. `icon: null` removes the icon. |
| `p.archive()` | `{ ok }`. The project moves to `status: 'archived'`. |

```ts
await p.update({ name: 'Northwind support (EU)' });
```

### Call a connector

`p.connector(slug)` returns one connector handle. `run(action, args, options?)` returns the action's output without the provider envelope. A connector call is not idempotent, so `run` never retries.

```ts
import { ConnectorApprovalPendingError, ConnectorCallError } from '@kortix/sdk';

const gmail = p.connector('gmail');

try {
  const output = await gmail.run(
    'send_email',
    { to: 'ops@example.test', subject: 'Weekly report', body: 'Attached.' },
    { approvalContext: 'Send the weekly report to the ops list' },
  );
  console.log(output);
} catch (error) {
  if (error instanceof ConnectorApprovalPendingError) {
    console.log('Waiting for approval:', error.approvalUrl ?? error.executionId);
  } else if (error instanceof ConnectorCallError) {
    console.error(error.status, error.code, error.hint);
    if (error.connectUrl) console.error('Connect an account:', error.connectUrl);
  } else {
    throw error;
  }
}
```

`run` options (`ConnectorCallOptions`):

| Option | Rule |
| --- | --- |
| `account` | A connection label or id from `accounts()`, `me` (the caller's own default private account) or `project` (the default account shared with the project). Omit it for the connector default. A named account is never replaced by another. |
| `approvalContext` | What the call does, in the caller's words. A person sees it when a policy holds the call. The provider never receives it. |
| `timeoutMs` | Client deadline. Default 90,000 ms. After a client timeout, the call can still have run. |
| `signal` | Aborts the request. The call can still have run upstream. |

The other handle methods:

| Method | Returns |
| --- | --- |
| `describe()` | The connector with the input and output schema of every action, or `null` when the caller cannot call it. |
| `describe(action)` | One action with its schemas, or `null`. |
| `accounts()` | The accounts the caller may run the connector as, default first. |
| `paginate(action, args, { next, maxPages? })` | An async iterator over the output of each page. `next(output, args)` returns the next page's args, or `undefined` after the last page. `maxPages` defaults to 100. |
| `call(action, args, options?)` | The raw call result. A `200` and a `202` resolve. |
| `uploadAttachment(content, input)` | Stage a file for a later call of this connector. |

A Composio tool's file field (for example YouTube's `videoFile` or a Gmail `attachment`) takes a public `https` URL. Pass the URL as the field's value, or as the `s3key` or `url` of the file object. Kortix downloads the file through its egress guard (public hosts only, at most 100 MB), uploads it to Composio, and sends Composio's file reference. A failed download answers `file upload failed for <field>: …`.

```bash
kortix connectors call youtube multipart_upload_video '{"title":"Demo","videoFile":"https://example.com/demo.mp4"}'
```

`kortix connectors types --out kortix-connectors.d.ts` generates types for `run`, `call` and `paginate`. Without the file, args are any object and outputs are `unknown`.

#### Connector errors

| Error | When | Fields to read |
| --- | --- | --- |
| `ConnectorCallError` | The call did not run, or ran and failed. Extends `ApiError`. | `code`: `upstream_<status>` (for example `upstream_429`), `upstream_error` for a failure inside a `2xx`, or a server reason such as `connector_not_connected`, `account_required`, `credential_not_shared`, `credential_undecryptable`, `policy_block`, `upstream_timeout`, `computer_offline`, `invalid_json`, else `connector_error`. Also `reason`, `hint`, `connectUrl`, `availableAccounts`, `requestedAccount`, `upstreamStatus`, `retryAfterSeconds`, `binding`. |
| `ConnectorApprovalPendingError` | A policy holds the call for a person's decision (HTTP `202`). Nothing ran. | `executionId`, `approvalUrl`, `approvalSummary`, `approvalInstructions`, `retryable`. When `retryable` is true, the identical call runs after approval. |
| `ConnectorPageLimitError` | `paginate` reached `maxPages` and more pages exist. `code` is `max_pages_exceeded`. | `nextArgs`: pass them to `paginate` to resume. |

Retry only on `upstreamStatus` `429` or `503`, after `retryAfterSeconds`. Every other error needs a person: connect an account from `connectUrl`, pick an account from `availableAccounts`, or decide an approval.

#### Read a connector in React

`useConnectorQuery(projectId, slug, action, args, options?)` from `@kortix/sdk/react` reads one action as a cached query. Use it only for an action that reads: each fetch runs the action upstream and writes an audit row.

```tsx
import { useConnectorQuery } from '@kortix/sdk/react';

function Inbox({ projectId }: { projectId: string }) {
  const { data, error, isPending } = useConnectorQuery(projectId, 'gmail', 'fetch_emails', { max_results: 10 });
  if (isPending) return <p>Loading</p>;
  if (error) return <p>{error.message}</p>;
  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
```

| Option | Rule |
| --- | --- |
| `account` | As in `run`. |
| `enabled` | `false` keeps the query idle. A `null` `projectId` also keeps it idle. |
| `staleTime` | Default 30,000 ms. |

The hook retries only an upstream `429` or `503`, 2 times, after `Retry-After`. A window focus never refetches. Pair a `connector_not_connected` error with `useConnectorSetup` to render a connect button. In a Kortix App, the client reads as the viewer: see [Know the viewer in a Kortix App](/docs/developers#sdk-authentication).

#### Manage connectors and policies

Configuration lives under `p.connectors`:

| Task | Call |
| --- | --- |
| List and inspect | `p.connectors.list()` · `.config(slug)` · `.catalog()` · `.search(query)` |
| Add and remove | `p.connectors.create(input)` · `.remove(slug)` · `.discover.install({ id, way?, name?, slug? })` |
| Set base rules | `p.connectors.policies.set(slug, rules)` |
| Set a policy set's rules | `p.connectors.policies.set(slug, rules, { policySet: name })` |
| Create or list policy sets | `p.connectors.policySets.upsert(slug, name, { description? })` · `.list(slug)` |
| Bind a policy set to an agent | `p.setAgentScope(agentName, { policy_sets: { [slug]: name } })` |

A rule is `{ match, action, conditions? }`. `match` is a glob over tool names. `action` is `always_run`, `require_approval` or `block`. `policies.set` replaces the whole list.

```ts
await p.connectors.policies.set('gmail', [
  { match: 'fetch_*', action: 'always_run' },
  { match: 'send_*', action: 'require_approval' },
  { match: 'delete_*', action: 'block' },
]);
```

How project rules, policy sets and base rules combine is in [Connectors](/docs/guide#connectors).

### Store secrets

`p.secrets.upsert(input)` creates or updates a project secret. The value is write-only: no call returns it. With the `runtime` strategy, a session reads the secret as an environment variable named `name`.

```ts
await p.secrets.upsert({ name: 'ACME_API_KEY', value: process.env.ACME_API_KEY! });

const { items } = await p.secrets.list();
for (const secret of items) {
  console.log(secret.identifier, secret.configured, secret.status ?? 'ok');
}
```

| `upsert` field | Rule |
| --- | --- |
| `name` | Required. The environment variable name. |
| `value` | Omit it to keep the stored value. |
| `identifier` | Unique per project. Defaults to `name`. Set it to store a second secret under the same `name`. |
| `strategy` | `runtime` (environment variable), `egress` (enforced at the network boundary), `broker` (the value stays out of the session) or `denied`. |
| `consumer` · `egress_policy` · `handle_prefix` | Delivery settings for `egress` and `broker`. See [Security](/docs/guide#security). |
| `shared_with` | People, groups and agents that may use the shared value. `[]` means everyone in the project. Omit it to keep the current list. |

`list()` returns `{ items, required, optional }`. `required` and `optional` are the environment keys `kortix.yaml` declares. A secret with `status: 'undecryptable'` must be set again with `upsert`. `mine` describes the caller's own value, which `setPersonal(name, { value })` sets.

`p.secrets.setGitCredential({ token })` stores the token for a bring-your-own git repository. It returns `{ configured, provider, git_connection }`.

```ts
await p.secrets.setGitCredential({ token: process.env.GIT_TOKEN! });
```

### Run triggers

A trigger starts a session, or calls an App, on a schedule, a webhook or an App event. `p.triggers.create(input)` returns the whole trigger list (`{ triggers, errors, triggers_paused }`).

```ts
await p.triggers.create({
  name: 'Daily digest',
  slug: 'daily-digest',
  type: 'cron',
  every: '1d',
  prompt_template: 'Summarize yesterday’s support tickets.',
});

const run = await p.triggers.fire('daily-digest');
console.log(run.status, run.session_id, run.run_id);
```

| `create` field | Rule |
| --- | --- |
| `name` · `type` | Required. `type` is `cron`, `webhook` or `app`. The API refuses `monitor` with `400`: monitor triggers were removed. |
| `slug` | Derived from `name` when omitted. It cannot change after create. |
| `prompt_template` | Required unless `target` calls an App. |
| `cron` · `every` · `run_at` · `timezone` | A schedule for `cron`: a 6-field cron expression, a period such as `5m` or `1h` (at least `1m`), or one ISO-8601 instant. `timezone` defaults to `UTC`. |
| `secret_env` | `webhook`: the project secret that signs deliveries. |
| `app` | Required for `app`: the slug of the App whose events fire the trigger. The App must exist. |
| `filter` | Payload paths mapped to the condition each must meet, for example `{ 'event.name': … }`. A payload that fails the filter starts no session. |
| `target` | `{ app, path?, method?, payload? }`: call an App instead of an agent. |
| `agent` · `model` | The agent (default `default`) and a `provider/model` override. |

Every field, including session reuse and rate caps, is in [Automations](/docs/guide#automations).

| Call | What it does |
| --- | --- |
| `p.triggers.update(slug, input)` | Change a trigger. `null` clears a nullable field. |
| `p.triggers.fire(slug, { payload?, dryRun? })` | Run one trigger now, also while triggers are paused. `payload` deep-merges over the manual payload. `dryRun: true` writes nothing and returns `rendered_prompt`, `filter_matches`, `session_key` and `payload`. A real fire returns `status` (`fired`, `queued`, `deduped` or `failed`), `session_id`, `command_id` and `run_id`. |
| `p.triggers.runs(slug, { cursor?, limit? })` | One trigger's runs, newest first: `{ runs, next_cursor }`. `limit` is 1 to 100, default 25. A page can hold fewer than `limit` runs while `next_cursor` is set. Follow `next_cursor` until it is `null`. |
| `p.triggers.replay(slug, runId, { confirmUnverified? })` | Fire again with the run's stored payload and the current trigger. `409 run_in_progress` while the run is queued or running. `409 payload_unverified` for an unverified webhook payload without `confirmUnverified: true`. `422 payload_truncated` for a payload over 64 KiB. |
| `p.triggers.setActivation(paused)` | `true` pauses every trigger of the project. `false` resumes them. A manual `fire` still works. |

```ts
const { runs } = await p.triggers.runs('daily-digest', { limit: 10 });
const failed = runs.find((r) => r.status === 'failed');
if (failed) await p.triggers.replay('daily-digest', failed.run_id);
```

### Resolve approvals

A policy rule with `require_approval` holds a connector call until a person decides. `p.approvals.list()` returns `{ count, approvals }`. Each approval carries `execution_id`, `action`, `session_id`, `requested_at` and `expires_at`.

`p.approvals.resolve(executionId, decision, { note? })` decides one call. `decision` is `'approve'` or `'deny'`. `note` goes to the agent as an instruction. A project manager or the person who started the session may decide. A decision covers that one call only. To run a tool without approval, write an `always_run` rule.

```ts
const { approvals } = await p.approvals.list();
for (const approval of approvals) {
  if (approval.action === 'gmail.send_email') {
    await p.approvals.resolve(approval.execution_id, 'approve');
  }
}

await p.approvals.resolve('<execution_id>', 'deny', { note: 'Send it to the ops list only.' });
```

The `executionId` of a held call from code is `ConnectorApprovalPendingError.executionId`. After approval, repeat the identical `run`. `p.approvals.sessionsNeedingInput()` returns `{ total, sessions }`: the count of held calls per session. The Review inbox (`p.review.*`) is in [SDK reference > The project handle](/docs/developers#sdk-reference).

### Deploy and operate Apps

An App is a service the project hosts. `p.apps` creates it, uploads a build, deploys a release and runs its machines.

### Create the App

```ts
const app = await p.apps.create({ slug: 'hello', name: 'Hello App' });
```

`slug` and `name` are required. `cpu`, `memory_gb`, `disk_gb`, `idle_timeout_seconds`, `always_on` and `uses` are optional. `external_url` lists an App that is hosted elsewhere and never deployed.

### Register a build

```ts
const { artifact } = await p.apps.artifacts.register({
kind: 'oci_image',
image: 'docker.io/hashicorp/http-echo:1.0',
});
```

For a `.tar.gz` build, call `artifacts.uploadArchive(bytes)` instead. It registers a `kind: 'archive'` artifact, uploads the bytes, and finalizes the artifact with `sha256` and `size_bytes`. It returns the `AppArtifact`.

### Deploy a release

```ts
const deployment = await p.apps.deployments.create(app.app_id, {
artifact_id: artifact.artifact_id,
source: {
kind: 'oci_image',
image: 'docker.io/hashicorp/http-echo:1.0',
command: ['http-echo', '-listen=:5678', '-text=Hello'],
port: 5678,
},
});
```

`source.kind` is `static`, `bundle`, `dockerfile` or `oci_image`. `environment` holds non-secret values. `secrets` maps an environment key to a project secret identifier.

### Wait for the release

```ts
let status = deployment.status;
while (!['ready', 'failed', 'cancelled'].includes(status)) {
await new Promise((resolve) => setTimeout(resolve, 3_000));
status = (await p.apps.deployments.get(app.app_id, deployment.deployment_id)).deployment.status;
}
console.log(status, app.url);
```

A release moves through `queued`, `validating`, `building`, `provisioning` and `checking` to `ready`, `failed` or `cancelled`. `deployments.logs(appId, deploymentId, { after?, limit? })` returns `{ entries, next_cursor }`.

Operate the App:

| Call | What it does |
| --- | --- |
| `p.apps.start(appId)` · `p.apps.stop(appId)` | Start or stop the App. Returns the `App`. |
| `p.apps.deployments.list(appId)` | Every release of the App. |
| `p.apps.rollback(appId, deploymentId)` | Queue a new release from an earlier release's artifact and config. Returns the `App` with that `deployment`. |
| `p.apps.scale(appId, { app: 2 })` | Machines per process group, 0 to 20, applied in the background. A group with a volume runs 1 machine unless `{ newVolumes: true }` is passed as the third argument. |
| `p.apps.exec(appId, { command, machine_id?, timeout_s? })` | Run one command in a machine. Project admins only. `timeout_s` is 1 to 300, default 60. Returns `exit_code`, `stdout`, `stderr` (up to 1 MiB each) and `truncated`. |
| `p.apps.deployments.remove(appId, deploymentId)` | Delete a release that is not live. `409 deployment_live` for the live release, `409 deployment_in_progress` for a running build. |

```ts
const result = await p.apps.exec(app.app_id, { command: ['sh', '-c', 'uptime'] });
console.log(result.exit_code, result.stdout);
```

Machines, volumes, access, App tokens and `authEnv` are in [SDK reference > The project handle](/docs/developers#sdk-reference). App configuration in `kortix.yaml` is in [Apps reference](/docs/developers#apps-reference).

### Files, git and change requests

These calls read and write the project repository. To work with the files of a running session, use `s.files` from [Sessions from code](/docs/developers#sessions-from-code).

| Call | What it does |
| --- | --- |
| `p.files.listDirectory({ path?, ref?, cursor?, limit? })` | One folder level: `{ entries, truncated, next_cursor }`. With `cursor` or `limit`, one page holds `limit` entries (default 500, at most 2,000). Without them, one response holds up to 5,000 entries. |
| `p.files.read(path, ref?)` | `{ path, ref, content, revision? }`. A server older than the `revision` field omits it. |
| `p.files.write({ path, ref, content, expected_revision })` | Commit one file. `expected_revision` is the `revision` from `read`. Returns `{ commit_sha }`. |
| `p.files.search(query, { content?, ref?, limit? })` | Search file names, or contents with `content: true`. |
| `p.git.commits()` · `p.git.commitDiff(sha)` · `p.git.branches()` | The log, one commit's diff, the branches. |
| `p.changeRequests.list(status?, { limit?, cursor? })` | One page, newest number first: `{ change_requests, next_cursor }`. `status` is `open`, `merged`, `closed` or `all`. `limit` defaults to 50. Above 500 the server answers `400`. |
| `p.changeRequests.open({ title, head_ref, base_ref?, description? })` | Open a change request from a branch. |
| `p.changeRequests.merge(crId, { message? })` · `.close(crId)` | Merge or close it. |

Edit one file without overwriting a concurrent change:

```ts
const file = await p.files.read('README.md');
await p.files.write({
  path: 'README.md',
  ref: file.ref,
  content: `${file.content}\nOwned by the Acme support team.\n`,
  expected_revision: file.revision ?? '',
});
```

Review open change requests:

```ts
const { change_requests } = await p.changeRequests.list('open', { limit: 20 });
for (const cr of change_requests) console.log(cr.number, cr.title, cr.head_ref);
```

How sessions create branches and change requests is in [Git](/docs/developers#git).

## React

`@kortix/sdk/react` is the React layer of the SDK. One hook, `useSession`, owns one session view: start, stream, transcript, prompts and replies. The layer reads the client you create in [SDK > Create the client](/docs/developers#create-the-client) and adds no second client. It runs in React and React Native.

This section covers one session view, from setup to drawing every message yourself. The full hooks catalog for accounts, projects, triggers, Apps and access checks is in [SDK reference](/docs/developers#sdk-reference). The prebuilt components are in [Kortix UI](/docs/developers#kortix-ui).

### Set up

### Install the peers
`react` 18 or later and `@tanstack/react-query` `^5.75.2` are optional peers of `@kortix/sdk`. The React layer needs both.

```bash
npm install @kortix/sdk react @tanstack/react-query
```
### Import the client module once
`createKortix()` writes a process-global config. Every hook reads that config, so no hook takes a client argument. Import the module that creates the client before the first hook renders.

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

export const kortix = createKortix({
backendUrl: 'https://api.example.test/v1',
getToken: async () => readSessionToken(),
});

declare function readSessionToken(): Promise<string | null>;
```

The options and the "one client per host" rule are in [Create the client](/docs/developers#create-the-client). The sources of `getToken` are in [SDK authentication](/docs/developers#sdk-authentication).
### Mount QueryClientProvider
`useSession`, the prompt hooks and most data hooks are React Query hooks. They throw without a `QueryClientProvider`. Keep one `QueryClient` per host.

```tsx
'use client';
import '@/lib/kortix';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';

export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
```
### Optional: KortixProjectProvider
The SDK reads no router. Some hooks need "the project on screen", for example the runtime provider and local-config hooks. `KortixProjectProvider` supplies that id, and `useKortixRouteProjectId()` reads it. Outside the provider the id is `null`. `useSession` does not need it.

```tsx
import { KortixProjectProvider } from '@kortix/sdk/react';
import type { ReactNode } from 'react';

export function ProjectScope({ projectId, children }: { projectId: string | null; children: ReactNode }) {
return <KortixProjectProvider projectId={projectId}>{children}</KortixProjectProvider>;
}
```

### useSession

`useSession(projectId, sessionId, options?)` runs one session view end to end. It long-polls `POST …/start` until the sandbox is ready, opens the event stream, reads saved history, syncs messages and status, and sends prompts. Call it once per session view. The sandbox is ready when `/start` answers `stage: 'ready'`. The hook runs no separate health check.

```tsx
import { useSession } from '@kortix/sdk/react';

export function SessionView({ projectId, sessionId }: { projectId: string; sessionId: string }) {
  const s = useSession(projectId, sessionId);

  if (s.messages.length === 0 && s.phase !== 'ready') {
    if (s.phase === 'error') return <NotRunning stage={s.stage} onRetry={s.retry} />;
    if (s.conversationEmpty) return <Composer disabled onSend={s.send} />;
    return s.savedTranscript === 'none' ? <Booting stage={s.stage} /> : <MessageSkeleton />;
  }

  return (
    <>
      {s.messages.map(({ info, parts }) => (
        <Message key={info.id} info={info} parts={parts} />
      ))}
      {s.sendError && <p role="alert">{s.sendError.message}</p>}
      <Composer
        disabled={s.phase !== 'ready'}
        busy={s.isBusy}
        canStop={s.canStop}
        onSend={s.send}
        onStop={() => void s.cancel()}
      />
    </>
  );
}
```

`NotRunning`, `Composer`, `Booting`, `MessageSkeleton` and `Message` are your components.

#### The render rule

While the session starts, pick the view by this rule:

| State | Render |
| --- | --- |
| `messages` has rows | The transcript. Saved history paints before the sandbox wakes. |
| No rows and `conversationEmpty` is `true` | The composer. The session has no messages and nothing to wait for. |
| No rows and `savedTranscript` is `'loading'` | Placeholder rows. A saved copy is on its way. |
| No rows and `savedTranscript` is `'none'` | A boot screen with `stage`. Nothing can show until the runtime answers. |
| `phase` is `'error'` | `startError` or `failure`, with a Retry button that calls `retry()`. |

Saved history is the copy of the session log that the API stores. A stopped sandbox does not block it. The first read holds the newest 40 messages, and `loadOlder()` pages back. An unknown `savedTranscript` is `'loading'`, never `'none'`. To show a session before `useSession` binds it, for example on a connecting screen, call `readSavedSessionCopy({ projectId, sessionId, runtimeSessionId })` from `@kortix/sdk`. It never throws. Its `empty` field is `true` when the copy proves the conversation empty and no turn ended or is open: show the composer then. The function reads the session log. `useSession` reads that log page again when it binds.

`readSavedSessionCopy` resolves `{ log, empty, envelope }`. `log` is the newest session-log page (`GET …/log`). A new session with no stored log yet answers `404` `session_log_missing`: `log` is `null`, and nothing else is read. `envelope` is deprecated.

A sub-agent's saved history: `useSessionSync(childRuntimeSessionId, { kortixSessionScope: sessionId, savedChild: true })`. It reads the session log, and the thread comes from `useSessionLogThread(parentSession, childRuntimeSessionId)`.

#### Returns

| Field | Type | What it holds |
| --- | --- | --- |
| `phase` | `'starting' \| 'ready' \| 'error'` | The overall state. Enable sends at `ready`. `error` when `stage` is `failed` or `stopped`, or `/start` failed. |
| `stage` | `string \| null` | The raw `/start` stage: `provisioning`, `starting`, `ready`, `stopped` or `failed`. For boot screens. |
| `startError` | error or `null` | The terminal `/start` failure. Render it instead of a spinner. |
| `failure` | object or `null` | The typed terminal provisioning failure from `/start`. |
| `retry` | `() => void` | Polls `/start` again. For a Retry button. |
| `messages` | `{ info, parts }[]` | The transcript. Parts stream in live. |
| `savedTranscript` | `'loading' \| 'shown' \| 'none'` | Whether saved history can show before the sandbox wakes. |
| `conversationEmpty` | `boolean` | The saved copy proves the session has no messages, no turn ended since, and nothing is open or queued. |
| `freshness` | `'idle' \| 'loading' \| 'fresh' \| 'stale' \| 'error'` | The transcript read state. `loading` or `error` with no rows is a wait or a failure, not an empty session. |
| `retryTranscript` | `() => void` | Reads the transcript tail again. |
| `hasOlder`, `isLoadingOlder`, `loadOlder` | `boolean`, `boolean`, `() => Promise<void>` | Paging toward older messages. |
| `status` | `SessionStatus` | The runtime status of the session. |
| `working` | `WorkingProjection` | `{ state: 'idle' \| 'working', pendingDelivery?, source, turnId, since, serverOpenTurnToken }`. The one working answer. |
| `isBusy` | `boolean` | `working.state === 'working'`. |
| `canStop` | `boolean` | `false` only when the server's `/turn` answer says `can_stop: false`. In a shared session only the member who started the turn, the session owner or a project manager may stop it. Show no Stop then. |
| `presenceAwake` | `{ until: Date, capAt: Date } \| null` | With `browserPresence`: `until` is when the sandbox stops after the latest presence renewal, and `capAt` is the latest time presence can keep it awake. `null` while the person is absent, for a person who may not start the session, once the session start reports the sandbox not ready, and from the start of a turn until the next renewal answers. The hook reads it again every 5 minutes while the person is present, when a turn ends, and when the sandbox becomes ready. |
| `isSending` | `boolean` | A `send` request is in flight. |
| `sendError` | `KortixSendError \| null` | The last `send` failure. A new `send` clears it. |
| `questions`, `permissions` | arrays | Open agent questions and tool-approval requests. A permission here is one tool approval, not an access-control permission. |
| `hasPending` | `boolean` | At least one question or permission is open. |
| `diffs`, `todos` | arrays | Live file diffs and the todo list. `todos` stays empty unless the runtime lists `session.todo`. |
| `rewindMessageId`, `rewindPending`, `rewindError` | `string \| null`, `boolean`, `KortixSendError \| null` | The staged rewind, a rewind or restore in flight, and its last failure. |
| `models` | `FlatModel[]` | Selectable models. |
| `agents`, `defaultAgent`, `commands` | arrays, `string \| null`, array | Selectable agents, the default agent and slash commands. Available before the sandbox starts. |
| `agentName` | `string \| null` | The agent the session was created with, from the first `/start` answer. `null` when none was bound. |
| `picks` | `SessionPicks` | This session's chosen `model`, `agent` and `variant`, with `setModel`, `setAgent` and `setVariant`. `send` uses them. |
| `runtimeSessionId` | `string \| null` | The runtime's root session id, or `null` while it resolves. |
| `runtimeUrl` | `string \| null` | The session runtime's proxy URL, or `null` until `stage` is `ready`. For a direct runtime call. |

#### Actions

| Action | What it does |
| --- | --- |
| `send(text, override?)` | Puts a prompt in the session's prompt inbox. The message shows at once. `override` is `{ model?, agent?, variant? }` for this message only. Before `phase` is `ready` it does nothing. It never throws: a failure goes to `sendError`. |
| `sendParts(parts, override?)` | Sends text and file parts. `override` also takes `clientMessageId`: send a failed submission again with the same value, and the server delivers it once. Throws a not-ready error before `ready`. |
| `cancel()` | Stops the running turn. It holds the queued prompts first, so a queued prompt does not start the next turn. Resolves `{ status: 'aborted' }`, `{ status: 'timed-out' }`, `{ status: 'failed', error }`, or `{ status: 'skipped' }` before the runtime is bound. It never rejects. |
| `runCommand(command, args, options?)` | Runs a project slash command. `args` is a string. `options` is `{ agent?, model?, variant? }`. Before `ready` it resolves and does nothing. Gated by `session.commands`. |
| `rewind(messageId)` | Rewinds the session to a user message and restores file state. Throws while the session is busy. Gated by `session.rewind`: pi does not serve it. |
| `restoreRewind()` | Restores the removed messages before another prompt commits the rewind. Gated by `session.rewind`. |
| `answerQuestion(id, answers)` | Answers an open question. `answers` is `string[][]`, one array per question. |
| `rejectQuestion(id)` | Rejects an open question. |
| `answerPermission(id, reply, message?)` | Answers a tool approval. `reply` is `'once'`, `'always'` or `'reject'`. |

The three reply actions remove the item only after the server accepts the reply. On failure they throw a `KortixSendError`, and the item stays open. `answerQuestion`, `rejectQuestion` and `answerPermission` are also exported from `@kortix/sdk/react` as plain functions. Hide gated actions with [`useRuntimeSupports`](/docs/developers#gate-controls-by-capability). `removeQuestion` and `removePermission` are deprecated: they never reach the agent, so the run stays blocked.

#### Options

| Option | Default | What it does |
| --- | --- | --- |
| `enabled` | `true` | `false` holds the whole hook: no `/start`, no stream. Use it until a precondition passes, for example a billing check. |
| `waitMs` | `15000` | The long-poll budget in ms that the hook sends to `/start`. The server clamps it. |
| `initialRuntimeSessionId` | `null` | A runtime session id your server already holds. The transcript renders from it while `/start` runs. The `/start` answer wins. |
| `replayStartStash` | `true` | Sends the first prompt that a "new session" screen saved, once the session is ready and empty. Set `false` when your host hands off the first message itself. |
| `chatEngine` | `true` | `false` when your app mounts its own chat surface for this session. Then `messages`, `diffs` and `todos` are empty, `questions` and `permissions` stay live, and `replayStartStash` is off. |
| `subscribeMessages` | `true` | `false` re-renders the caller only when the transcript changes shape, not on each streamed delta. Draw the rows in a child with `useSessionMessages`. See [Render messages yourself](/docs/developers#render-messages-yourself). |
| `browserPresence` | `false` | Holds this tab's presence lease while a person uses the page (input in the last 10 minutes). The lease routes notifications and fills `presenceAwake`. The sandbox stops the idle window (15 minutes by default) after its latest turn, with the page open or not. |
| `presenceAlerts` | `false` | `true` tells the server that this tab shows its own notifications, so the server skips phone and Web Push while the tab is in use. Pass `true` only when the tab's browser notifications are on and permitted. Needs `browserPresence`. |
| `presencePageExit` | `false` | Ends the presence lease when the page closes. With `false`, the lease ends at its 90-second expiry. Needs `browserPresence`. |
| `repositoryMode` | none | `'previous'` is accepted for callers built against an older `/start`. The server reads it as telemetry only. It changes nothing. |

### Send and handle errors

#### KortixSendError

`sendError`, `rewindError` and the reply actions use one type, `KortixSendError`: `{ kind, message, cause }` plus one detail field per kind. `message` is ready to display.

| `kind` | Meaning | Detail field |
| --- | --- | --- |
| `billing` | The API refused the turn with `402`. | `billing`: the parsed `BillingError`. |
| `connector` | The session needs a connector that has no usable connection. The API refused the turn before the sandbox saw it. | `connectors`: `{ id, slug, name, authorization_strategy }[]`. `project` means one shared connection fixes it. `user` means the person the session runs as must connect. |
| `runtime-not-ready` | The sandbox is still starting. Try again when `phase` is `ready`. | none |
| `runtime-error` | Any other failure. | `gateway`, when the LLM gateway answered: `{ provider?, code?, suggestion?, upstreamStatus?, requestId? }`. |

`classifySendError(error)` turns any thrown value into a `KortixSendError`. Use it in a `catch` around `sendParts`, `rewind` or a reply action.

```tsx
import { classifySendError, type UseSessionResult } from '@kortix/sdk/react';

async function approve(session: UseSessionResult, permissionId: string, report: (message: string) => void) {
  try {
    await session.answerPermission(permissionId, 'once');
  } catch (error) {
    const failure = classifySendError(error);
    if (failure.kind === 'connector') report(`Connect ${failure.connectors?.map((c) => c.name).join(', ')}`);
    else report(failure.message);
  }
}
```

#### useSessionSend

`useSession().send` covers a composer that sends text. `useSessionSend(runtimeSessionId, options?)` is the lower-level send for a host that builds its own parts, for example after an upload step. It returns `{ send, stop, isSending, isStopping, sendError }`.

| Option | What it does |
| --- | --- |
| `workingSessionId` | The Kortix session id. Pass it, because the hook's first argument is the runtime session id. Send and stop receipts are filed under this id. |
| `projectId` | The project id. `stop()` then holds the session's prompt inbox before it aborts. |
| `classify` | Replaces `classifySendError`. |

`send(parts, options?, callOptions?)` resolves `{ ok: true }` or `{ ok: false, error, cause }`, where `error` is a `KortixSendError`. It shows the message at once. On failure it reads the runtime's messages again, and it removes the message when that read fails or returns nothing. `stop()` resolves `{ status: 'skipped' }` while an earlier stop is still in flight.

### Pending prompts and turn outcomes

`send` covers a composer that sends one prompt at a time. The hooks below add a queue, steering of a running turn, and the reason a turn ended. They take the same `projectId` and `sessionId` as `useSession`.

#### useSessionPrompts

`useSessionPrompts(projectId, sessionId, options?)` reads and writes the session's prompt inbox on the server. The server delivers pending prompts in order. A control stream pushes queue changes. A poll runs only while that stream is down. `options` is `{ enabled?, pollMs? }`.

| Member | What it does |
| --- | --- |
| `prompts` | The pending rows (`SessionPrompt[]`). Each row has `prompt_id`, `state` (`queued`, `delivering`, `waiting`, `failed`), `delivery`, `placement`, `full_text` and `last_error`. A delivered prompt leaves the list. |
| `enqueue(input)` | Adds one prompt. It resolves when the prompt is stored, not when it is delivered. |
| `edit(promptId, text)` | Replaces a waiting row's text. The row keeps its place. |
| `remove(promptId)` | Removes a waiting row and returns it, so an undo can enqueue it again. |
| `interrupt(promptId)` | Turns a waiting row into an `interrupt`: the running turn ends after its running tool, then this row runs. |
| `retry(promptId)` | Runs this row next. Use it for a `failed` row. |
| `hold(held)` | Holds or releases the whole queue. A new send or `retry` releases it. |

`edit`, `remove` and `interrupt` throw a `409` for a row that is already on its way to the runtime.

`enqueue` takes `delivery` (`steer`, `queue` or `interrupt`), and an optional `placement`. The delivery modes are in [Sessions from code > Send prompts](/docs/developers#send-prompts). `placement` sets only where the pending row shows: `transcript` in the message list, implied by `interrupt`, or `composer` in the list above the input, implied by `steer` and `queue`.

#### useSessionWorking

`useSessionWorking(projectId, sessionId, options?)` returns the same `WorkingProjection` as `useSession().working`, for a component that does not hold the `useSession` result. `options` is `{ enabled?, runtimeSessionId? }`. Pass `runtimeSessionId` so the hook reads that runtime's status events. It stays `working` while a prompt waits in the inbox, and it sets `pendingDelivery: true` while that prompt is still on its way. Show Stop whenever `state` is `'working'`.

#### Why a turn ended

The transcript of an interrupted turn carries only an abort error. The API records the cause that the sandbox named, for example its memory guard. A stop that a person asked for is never a failure.

```tsx
import { turnEndNotice } from '@kortix/sdk';
import { useSessionTurnOutcome } from '@kortix/sdk/react';

function TurnEndLine(props: {
  projectId: string;
  sessionId: string;
  userMessageId: string;
  hasError: boolean;
  isAbort: boolean;
}) {
  const outcome = useSessionTurnOutcome(props.projectId, props.sessionId);
  const notice = turnEndNotice(outcome, props.userMessageId, { hasError: props.hasError, isAbort: props.isAbort });
  if (!notice) return null;
  if (notice.kind === 'sandbox-memory') return <p>The computer ran out of memory ({notice.usedPct ?? '?'}% used).</p>;
  if (notice.kind === 'cause') return <p>{notice.message}</p>;
  return <p>The turn stopped without a recorded cause.</p>;
}
```

- Pass the id of the user message that started the turn.
- `hasError` is `true` when the transcript shows an error on that turn. `isAbort` is `true` when that error is an abort.
- `turnEndNotice` returns `{ kind: 'sandbox-memory', usedPct, detail }`, `{ kind: 'cause', name, message }`, `{ kind: 'unexplained' }` or `null`. `null` means: show what the transcript shows. A non-abort transcript error always returns `null`.
- `useSessionTurnOutcome` makes no request. It reads the `/turn` cache that `useSessionWorking` keeps fresh.
- `turnEndCause(outcome, messageId)` returns the raw recorded cause, or `null`.

#### Turn-end notifications

`useSession` calls the `onNotify` option of `createKortix` when a turn ends: `{ kind: 'task-complete', sessionId, sessionTitle }` or `{ kind: 'session-error', sessionId, errorTitle, sessionTitle }`. Open questions and tool approvals raise `{ kind: 'question', sessionId, questionText, sessionTitle }` and `{ kind: 'permission', sessionId, toolName, sessionTitle }`. Show a notification only when the person is not looking at that session.

While the view is mounted, its event stream raises these events. When the view unmounts with a turn still running, `useSession` hands the turn to a watch:

1. The watch listens for `kortix.control.turn` frames on the session's control stream. A hidden window that throttles its timers still receives the end.
2. While that stream is down, it reads `GET …/turn` every 5 s, and every 15 s after 5 minutes. It stops after 12 hours.
3. When no turn is open, it raises `task-complete`, or `session-error` with the recorded cause. A stop that a person asked for raises nothing.
4. These events also carry `projectId`. `task-complete` also carries `watchedSinceMs`, the watch start in the host's `Date.now()`. A host that relays completions between tabs skips one it already showed after that time.
5. A new view of the session ends the watch, so one turn end is announced once.

A host without `onNotify` gets no watch and no extra `/turn` reads.

### Gate controls by capability

`useRuntimeSupports(capability)` returns whether the open session's runtime serves a capability. It reads the `capabilities` list of the runtime's last `/kortix/health` answer. Hide a control whose capability is absent, instead of letting it fail with `501`.

```tsx
import { useRuntimeSupports, type UseSessionResult } from '@kortix/sdk/react';

function RewindButton({ session, messageId }: { session: UseSessionResult; messageId: string }) {
  const canRewind = useRuntimeSupports('session.rewind');
  if (!canRewind) return null;
  return (
    <button disabled={session.isBusy} onClick={() => void session.rewind(messageId)}>
      Rewind
    </button>
  );
}
```

The hook returns `true` until the runtime first answers, and for a runtime that lists no `session.*` capability. The capability list, what pi serves, and the framework-free `runtimeSupports(caps, name)` are in [Sessions from code > Gate on runtime capabilities](/docs/developers#gate-on-runtime-capabilities).

### Render messages yourself

Start with `SessionThread` from `@kortix/ui/session`. It renders the whole chat from one `useSession` result: the transcript, tool calls, [Generative UI](/docs/developers#generative-ui) blocks, pending prompts, the send error and the composer. Setup, styles, props and tool-row overrides are in [Kortix UI > Add a session](/docs/developers#add-a-session). Use the headless kit below only when you draw every part with your own components.

#### Keep the view still while a turn streams

A streaming turn changes the transcript about every 16 ms. Call `useSession(projectId, sessionId, { subscribeMessages: false })` in the view. Read the rows in the transcript component with `useSessionMessages(session)`, and pass the whole `useSession` result. Only that component then re-renders per delta. `useSessionMessages(session, { throttleMs })` delivers changes at most once per `throttleMs`, and the last change always lands.

#### Classify and render parts

`useChatTurns(messages)` returns one `TurnView` per message: `{ message, parts, error?, isEmpty }`. It is memoized on the `messages` array. `renderParts(parts, renderers)` calls the renderer for each part's `kind`. Its type requires one renderer per kind, so a new kind fails your build, not your UI.

```tsx
import { toolViewModel } from '@kortix/sdk';
import { renderParts, useChatTurns, useSessionMessages, type UseSessionResult } from '@kortix/sdk/react';

export function Transcript({ session }: { session: UseSessionResult }) {
  const messages = useSessionMessages(session, { throttleMs: 50 });
  const turns = useChatTurns(messages);
  return (
    <ol>
      {turns.map((turn) =>
        turn.isEmpty ? null : (
          <li key={turn.message.info.id} data-role={turn.message.info.role}>
            {renderParts(turn.parts, {
              text: (p) => (p.synthetic ? null : <Markdown key={p.id} text={p.text} />),
              reasoning: (p) => <Thinking key={p.id} text={p.text} />,
              tool: (p) => <ToolCard key={p.id} view={toolViewModel(p.tool)} status={p.tool.status} />,
              file: (p) => <Attachment key={p.id} name={p.filename} url={p.url} isImage={p.isImage} />,
              subtask: (p) => <Subtask key={p.id} description={p.description} agent={p.agent} />,
              retry: (p) => <p key={p.id}>Retry {p.attempt}: {p.message}</p>,
              compaction: (p) => <p key={p.id}>Earlier messages were summarized.</p>,
              patch: () => null,
              snapshot: () => null,
              agent: () => null,
              step: () => null,
              unknown: () => null,
            })}
            {turn.error && <p role="alert">{turn.error.message}</p>}
          </li>
        ),
      )}
    </ol>
  );
}
```

`Markdown`, `Thinking`, `ToolCard`, `Attachment` and `Subtask` are your components.

| Part `kind` | Fields |
| --- | --- |
| `text` | `id`, `text`, `synthetic`. A `synthetic` part is internal to the runtime: do not render it as a chat bubble. |
| `reasoning` | `id`, `text` |
| `tool` | `id`, `tool: ToolView` |
| `file` | `id`, `filename?`, `mime`, `url`, `isImage`, `isPdf` |
| `subtask` | `id`, `description`, `agent`, `prompt`, `model?` |
| `patch` | `id`, `hash`, `files`, `fileCount` |
| `snapshot` | `id`, `snapshot` |
| `agent` | `id`, `name` |
| `retry` | `id`, `attempt`, `message`, `createdAt` |
| `compaction` | `id`, `auto`, `overflow`, `tailStartId?` |
| `step` | `id`, `phase: 'start' \| 'finish'`, `snapshot?`, `reason?`, `cost?`, `tokens?` |
| `unknown` | `raw`: a part type this SDK version does not know. Classification never throws. |

`classifyPart(part)` and `classifyTurn(message)` from `@kortix/sdk` are the classifiers behind `useChatTurns`, for code outside React. `classifyTurn` returns `{ parts, error?, isEmpty }`. `isEmpty` is `true` when the message has no error and no visible part.

#### Read a tool call

A `tool` part carries a `ToolView`: `name`, `kind`, `title`, `status` (`pending`, `running`, `done`, `error`), `input`, `output`, `error`, `outputParsed` (the parsed JSON output, for output up to 256 KiB), `outputText`, `files`, `diff`, `diagnostics`, `answers` and `childSessionId`. A tool that reports `success: false` inside a completed state has `status: 'error'`. Read these fields, not the runtime's own tool metadata.

| Function | Returns |
| --- | --- |
| `toolViewModel(view)` | One shape per tool family, keyed by `kind`. See the table below. |
| `toolKind(name)` | One `ToolKind` for any tool name: `read`, `list`, `glob`, `grep`, `write`, `edit`, `apply_patch`, `bash`, `pty`, `web_search`, `webfetch`, `media`, `show`, `task`, `delegate`, `sessions`, `todowrite`, `question`, `memory`, `connectors`, `automations`, `projects`, `skill`, `context`, `retired` or `other`. |
| `toolInfo(name)` | `{ label, category }`. `category` is `shell`, `files`, `search`, `edit`, `web`, `task` or `other`. |
| `getToolInfo(name, input?)` | `{ icon, title, subtitle }` for a tool card. An unknown tool gets its raw name as `title`. |
| `humanizeToolName(name)` | A readable label for a tool name with no known label. `toolInfo` falls back to it. |
| `toToolView(call)` | A `ToolView` from one v1 tool part or one session-log `ToolCallBlock`. |
| `getPermissionForTool(permissions, callId)`, `getQuestionForTool(questions, callId)` | The open tool approval or question that belongs to a tool call. |
| `inputPath(input)` | A file tool's path, read from `filePath`, `file_path` or `path`. |

`toolViewModel` shapes:

| `kind` | Fields |
| --- | --- |
| `web-search` | `query`, `results?`, `answer?`, `error?` |
| `shell` | `command`, `stdout?`, `exitCode?` |
| `file-read` · `file-write` | `path`, `preview?` |
| `file-edit` | `path`, `diff?`: lines of `{ type: 'added' \| 'removed' \| 'unchanged', text }` |
| `search` | `pattern`, `matches?` |
| `task` | `description`, `agent?` |
| `todo` | `items` |
| `question` | `questions`, `answers?` |
| `generic` | `label`, `inputPretty?`, `outputPretty?`. Every other tool. Always safe to render. |

```ts
import { classifyTurn, toolViewModel } from '@kortix/sdk';
import type { MessageWithParts } from '@kortix/sdk/react';

declare const message: MessageWithParts;

for (const part of classifyTurn(message).parts) {
  if (part.kind !== 'tool') continue;
  const view = toolViewModel(part.tool);
  if (view.kind === 'shell') console.log(view.command, view.exitCode);
}
```

#### Render blocks from the session log

The session log (`kortix.session/2`) is the runtime-neutral record of a session: threads, messages by `seq`, and typed blocks. Render from it when you need sub-agent threads, turn grouping or the full tool-call record. `useSession` loads the log. These hooks start no request of their own, except the first page of a sub-agent thread.

| Hook | Returns |
| --- | --- |
| `useSessionLogThread(session, threadId?, { throttleMs? })` | One `SessionLogThread`: the root thread, or the sub-agent thread that `threadId` names. `null` before the first message. |
| `useSessionLogOlder(session, threadId?)` | `{ hasOlder, isLoadingOlder, loadOlder }` for one thread. For the root thread, `useSession().loadOlder` does the same. |
| `useSessionLog(session)` | The whole `SessionLog`. It changes with every message. |
| `useSessionLogRecord(session)` | The session fields without threads, for example `restore_grade`, `grade_counts`, `harness` and `ext`. `null` before the log loads. It does not change on message updates. |

`sessionLogTurns(thread, options?)` from `@kortix/sdk` groups a thread into one `SessionLogTurn` per user message: `id`, `user`, `assistant`, `blocks`, `status`, `error`, `response` (the last text block), `live`, `queued` and `retrying`, among others. Pass `busy` so the running turn stays live.

```tsx
import { sessionLogCallStatus, sessionLogOpenCall, sessionLogTurns, toolCallStatus } from '@kortix/sdk';
import { useSessionLogThread, type UseSessionResult } from '@kortix/sdk/react';

export function BlockTranscript({ session }: { session: UseSessionResult }) {
  const thread = useSessionLogThread(session, undefined, { throttleMs: 50 });
  if (!thread) return null;
  const turns = sessionLogTurns(thread, { busy: session.isBusy });
  return (
    <ol>
      {turns.map((turn) => (
        <li key={turn.id}>
          {turn.blocks.map((block) => {
            if (block.type === 'text') return <p key={block.id}>{block.text}</p>;
            if (block.type !== 'tool_call') return null;
            const call = sessionLogOpenCall(block);
            return (
              <p key={block.id}>
                {call.name}: {sessionLogCallStatus(block, turn.live)} ({toolCallStatus(call)})
              </p>
            );
          })}
        </li>
      ))}
    </ol>
  );
}
```

Read a `ToolCallBlock` with these functions:

| Function | What it does |
| --- | --- |
| `toolCallStatus(call)` | `pending`, `running`, `completed` or `error`. |
| `sessionLogCallStatus(call, live)` | The status to render: `pending`, `running`, `complete`, `error` or `interrupted`. An open call on a turn that is not live is `interrupted`. |
| `sessionLogOpenCall(call)` | The call in its open shape when the runtime closed it without a result. Render calls through it, so a live turn draws each call the same way. |
| `toolCallRawOutput(call)` | A completed call's output text. `''` for any other call. |
| `toolCallError(call)` | A failed call's error text. `undefined` for a call that did not fail. |
| `subAgentCalls(thread)` | The tool calls a sub-agent row lists, in order. |
| `toolCallBlock(part)` | The `ToolCallBlock` of a tool part from `useSessionMessages`. |

A pending permission or question matches a call by `call.call_id`: `getPermissionForTool(permissions, call.call_id)`. The segment, burst, cost, restore and full-text helpers over blocks (`sessionLogTurnSegments`, `sessionLogBurstSteps`, `sessionLogThreadMetrics` and others) are in [SDK reference > Session log helpers](/docs/developers#session-log-helpers).

#### Next: rich blocks

An agent reply can hold `openui` blocks: charts, tables, forms and other components inside a text part. `SessionThread` renders them. A custom renderer finds and renders them with `@kortix/sdk/genui/react`, or turns them into plain Markdown with `genuiToMarkdown` from `@kortix/sdk/genui`. See [Generative UI](/docs/developers#generative-ui).

### Fast opens and offline

#### Start the open read early

`prefetchSessionOpen(queryClient, projectId, sessionId)` starts the read that a session view needs before the view mounts. It sends one `GET …/snapshot?transcript=0` request for the session row, the current turn and the prompt queue. `useSession` then uses that request instead of its own.

```tsx
import { prefetchSessionOpen } from '@kortix/sdk/react';
import { useQueryClient } from '@tanstack/react-query';
import type { ReactNode } from 'react';

export function SessionLink(props: { projectId: string; sessionId: string; children: ReactNode }) {
  const queryClient = useQueryClient();
  const warm = () => void prefetchSessionOpen(queryClient, props.projectId, props.sessionId);
  return (
    <a href={`/sessions/${props.sessionId}`} onPointerEnter={warm} onFocus={warm} onTouchStart={warm}>
      {props.children}
    </a>
  );
}
```

- Call it on hover, focus or touch of a session link, or when a route names a session.
- It never calls `/start`, so it never wakes a sandbox.
- It never rejects.
- A second call for the same session within 30 seconds sends nothing.

#### Render from the device on a reload

Two device stores keep what a person already saw, so a reload or a cold start paints it in the first frame. The server's answer then refreshes it in place. Both stores are per user and bounded. Both come from `@kortix/sdk`.

| Store | Keeps | Default bounds |
| --- | --- | --- |
| `createPersistedQueryCache({ storage, userId, shouldPersist })` | React Query results that `shouldPersist` accepts. `isPersistableQueryKey` accepts accounts, projects, project detail, session lists and session rows. A restored entry refetches when it is first read. | 1,000,000 characters, 7 days |
| `createSavedCopyStore({ storage, userId })`, registered with `setSavedCopyStore` | The saved session copies the server sent. `useSession` paints a kept copy before the server answers. | 12 sessions, 1,500,000 characters, 300,000 characters per copy, 14 days |

```ts
import type { QueryClient } from '@tanstack/react-query';
import {
  browserKeyValueStorage,
  createPersistedQueryCache,
  createSavedCopyStore,
  isPersistableQueryKey,
  setSavedCopyStore,
} from '@kortix/sdk';

export function adoptDeviceCaches(queryClient: QueryClient, userId: string) {
  const storage = browserKeyValueStorage(); // null where localStorage is blocked or absent
  if (!storage) return null;
  const cache = createPersistedQueryCache({ storage, userId, shouldPersist: isPersistableQueryKey });
  cache.restore(queryClient); // synchronous with localStorage
  const stop = cache.persist(queryClient);
  window.addEventListener('pagehide', () => void cache.flush());
  const copies = createSavedCopyStore({ storage, userId });
  setSavedCopyStore(copies);
  return { cache, copies, stop };
}

export type DeviceCaches = ReturnType<typeof adoptDeviceCaches>;
```

- `persist` writes at most once per second. Call `flush()` on `pagehide`, so a reload keeps the last change.
- On React Native, pass AsyncStorage as `storage`. `restore` then returns a promise.
- The saved-copy store keeps only copies the server sent, never the live transcript. A stopped turn cannot come back as a running turn.
- A full, corrupt or missing store never throws. The app then opens from the server.
- `isPersistableQueryKey` never keeps a permission probe (`useCan`).

### Sign-out and identity changes

The SDK keeps per-person session state in memory: transcripts, open questions and tool approvals, turn receipts, open-read prefetches and model picks. Clear it on every identity change: a sign-out, a different person signing in, and a sign-in in another tab that changes the person without a page load.

```ts
import type { QueryClient } from '@tanstack/react-query';
import { setSavedCopyStore } from '@kortix/sdk';
import { resetIdentityState } from '@kortix/sdk/react';
import type { DeviceCaches } from './device-caches';

export async function onIdentityChange(queryClient: QueryClient, device: DeviceCaches) {
  setSavedCopyStore(null);
  device?.stop();
  queryClient.clear();
  resetIdentityState();
  await device?.cache.clear();
  await device?.copies.clear();
}
```

`resetIdentityState()` never throws. It also removes the model picks from `localStorage`. It does not clear React Query, your own stores, or the token source you passed to `createKortix`. Reset those in the same handler. Neither device store restores data for another person, but a signed-out device must not keep it.

## Generative UI

`SessionThread` renders generative UI blocks with no code, and `UnifiedMarkdown` renders them when you pass `genui`. Both come from [Kortix UI](/docs/developers#kortix-ui). This section is for hosts that render blocks themselves: a custom web renderer, a native app, or a text channel such as a CLI, email or a bot.

### What a block is

The agent answers in markdown. When it needs structure, it adds a fenced block with the language tag `openui`: a ranked recommendation, a comparison, a chart from tool data, places on a map. Short answers, explanations and code stay prose.

````markdown
Two options fit the brief.

```openui
root = Compare([acme, northwind], ["Price", "Seats"], "Acme")
acme = CompareItem("Acme", ["120", "40"])
northwind = CompareItem("Northwind", ["95", "25"])
```
````

The SDK reads a reply in two shapes:

| Type | Shape | Use |
| --- | --- | --- |
| `GenuiSegment` | `{ kind: 'markdown', text }` or `{ kind: 'genui', code, version, closed }` | One piece of a reply, in order. `closed: false` marks the last block while it streams. |
| `GenuiNode` | `{ id, type, props, partial }` | One validated component instance. `id` is a stable React key. `type` is a catalog name such as `BarChart`. `partial: true` means the model has not finished the statement. |

A block holds data only. The SDK parses it, validates it against the catalog, and gives you a tree of `GenuiNode`s. You map each `type` to a component.

The fence tags `openui` and `openui-lang` mean schema version 1. The tag `openui-v<N>` names version `N`. Any other tag is an ordinary code block.

### Turn it on

Generative UI is the `genui` project flag. It is on by default. The API setting `GENUI_ENABLED=false` turns it off for every project. The flag decides one thing: whether new sessions of the project list the `kortix-genui` system skill.

- Flag on: a new session lists `kortix-genui` by name and a one-line description. The agent loads the full catalog only when an answer needs a block. A turn without blocks does not load the catalog.
- Flag off: the skill is hidden and the agent writes markdown only. Blocks already in a transcript still render.
- An agent sees the skill only when its `skills` grant in `kortix.yaml` is `all` or names `kortix-genui`. The default grant is `none`. The starter agents grant `all`. See [kortix.yaml reference](/docs/developers#kortixyaml-reference).

Set the flag from code with `updateFeatureFlag`:

```ts
await kortix.project(projectId).updateFeatureFlag('genui', false); // off
await kortix.project(projectId).updateFeatureFlag('genui', null);  // back to the default
```

The caller needs `project.settings.write`. A running session keeps its skill list until a new session starts. The full method is in [SDK reference](/docs/developers#sdk-reference).

The viewer has a separate switch. Pass `enabled={false}` to `GenuiBlock` and the block renders as markdown.

### Install the peers

`@kortix/sdk/genui` and `@kortix/sdk/genui/react` need two optional peer dependencies. Without them, the import fails.

```bash
npm i zod @openuidev/lang-core@0.3.1
```

`@kortix/sdk/genui/react` also needs `react` 18 or later. `@openuidev/lang-core` sends one pseudonymous PostHog event at install time. Set `OPENUI_TELEMETRY_DISABLED=1` or `DO_NOT_TRACK=1` to turn it off.

| Entry point | Holds | Loads the peers |
| --- | --- | --- |
| `@kortix/sdk/genui/fence` | Fence detection: `splitGenui`, `separateGenuiClosers`, `genuiVersionOf`, `genuiVersionFromClassName` | No |
| `@kortix/sdk/genui` | Parser, text conversion, prompt, `safeUrl`, version constants, and the fence functions | Yes |
| `@kortix/sdk/genui/react` | `GenuiBlock`, `useGenuiParse`, `defaultGenuiComponents` | Yes |

The root `@kortix/sdk` and `@kortix/sdk/react` entries never load the peers.

### Detect blocks without the parser

`@kortix/sdk/genui/fence` never imports `zod` or `@openuidev/*`. Put it in your main bundle. Load the renderer lazily when a reply holds a block.

| Function | Returns |
| --- | --- |
| `splitGenui(text)` | `GenuiSegment[]`: markdown and block segments in order. Only top-level fences count. A fence inside a blockquote or a list item stays markdown. An `openui` tag inside another fence stays markdown. |
| `separateGenuiClosers(text)` | The text with a fence closer that the model glued to the last statement moved onto its own line. Run it before any markdown parser or `splitGenui`. Returns the same string when nothing changes. |
| `genuiVersionOf(tag)` | The schema version for a fence tag, or `null` when the fence is not generative UI. |
| `genuiVersionFromClassName(className)` | The same, for a markdown renderer's `language-…` class name. |

```ts
import { separateGenuiClosers, splitGenui } from '@kortix/sdk/genui/fence';

const hasBlock = splitGenui(separateGenuiClosers(text)).some((segment) => segment.kind === 'genui');
```

### Render blocks in React

`GenuiBlock` renders one block. It never throws and never shows the block source. This sample splits a reply into segments and renders each one. It uses `react-markdown` for prose; any markdown renderer works.

```tsx
import Markdown from 'react-markdown';
import { separateGenuiClosers, splitGenui } from '@kortix/sdk/genui/fence';
import { defaultGenuiComponents, GenuiBlock } from '@kortix/sdk/genui/react';

// A module constant: GenuiBlock needs a stable renderMarkdown.
const renderMarkdown = (markdown: string) => <Markdown>{markdown}</Markdown>;

export function AssistantText({ text, streaming }: { text: string; streaming: boolean }) {
  const segments = splitGenui(separateGenuiClosers(text));
  return (
    <>
      {segments.map((segment, index) =>
        segment.kind === 'markdown' ? (
          <Markdown key={index}>{segment.text}</Markdown>
        ) : (
          <GenuiBlock
            key={index}
            code={segment.code}
            version={segment.version}
            streaming={streaming && !segment.closed}
            components={defaultGenuiComponents}
            renderMarkdown={renderMarkdown}
          />
        ),
      )}
    </>
  );
}
```

To render blocks from a markdown renderer's `code` override instead, read the version with `genuiVersionFromClassName(className)`. A `null` version means an ordinary code block. Pass `streaming: true` only to the block whose fence is still open.

#### `GenuiBlock` props

| Prop | Required | Meaning |
| --- | --- | --- |
| `code` | Yes | The fence body so far. |
| `streaming` | Yes | `true` while this block's fence is open. Pass `false` when the fence closes, so the block settles while the reply goes on. |
| `components` | Yes | A `GenuiComponentMap`: block name to your component. A missing name renders that node as markdown. |
| `renderMarkdown` | Yes | `(markdown: string) => ReactNode`, for fallbacks. Pass a stable function (a module constant or `useCallback`). A new function re-renders the block's markdown parts and node views. |
| `version` | No | The schema version from the fence tag. Default: `GENUI_SCHEMA_VERSION`. A version this build does not render shows `GENUI_UNSUPPORTED_NOTE`. |
| `enabled` | No | Default `true`. `false` renders the block as markdown (a viewer setting). |
| `renderPending` | No | `(node: GenuiNode) => ReactNode` for a node the model has not finished. Default: nothing. Return a fixed-height box for a chart or a table, so the layout does not jump. |
| `onSettled` | No | Fires once per block, when it stops streaming, with a `GenuiBlockEvent`: `outcome` (`rendered`, `fallback`, `parse_error`, `render_error`, `unsupported`), `components` (sorted names), `msToFirstPaint`, `issueCount`. It never holds content. |

A settled block with no valid root renders a one-line note: `GENUI_CUT_OFF_NOTE` when the stream ended inside a statement, else `This content could not be displayed.` A component that throws renders the block's markdown. A throw while the block streams gets one more try when the block settles.

#### Components

`defaultGenuiComponents` holds unbranded components for all 19 blocks. They render semantic HTML themed by CSS variables: `--genui-fg`, `--genui-muted`, `--genui-border`, `--genui-surface`, `--genui-accent`, `--genui-radius`. Charts and maps render as data tables and place lists. A host that wants drawn charts passes its own components for those names.

Each component gets `GenuiComponentProps`:

| Field | Meaning |
| --- | --- |
| `node` | The `GenuiNode`. |
| `props` | The validated props. Child slots hold `GenuiNode[]`. |
| `renderChild` | `(node: GenuiNode) => ReactNode`. Renders a child node through the same map. |
| `streaming` | The block is still streaming. |

Replace one name and keep the defaults for the others:

```tsx
import type { GenuiNode } from '@kortix/sdk/genui';
import { defaultGenuiComponents, type GenuiComponentMap, type GenuiComponentProps } from '@kortix/sdk/genui/react';

function Callout({ props }: GenuiComponentProps) {
  return (
    <aside data-tone={props.tone}>
      {props.title ? <strong>{props.title}</strong> : null}
      <p>{props.body}</p>
    </aside>
  );
}

function Stack({ props, renderChild }: GenuiComponentProps) {
  const children = props.children as GenuiNode[];
  return <div style={{ display: 'flex', flexDirection: props.direction === 'row' ? 'row' : 'column', gap: 12 }}>{children.map(renderChild)}</div>;
}

export const components: GenuiComponentMap = { ...defaultGenuiComponents, Callout, Stack };
```

Define the map once at module level. A React Native host passes a map of native components and a `renderMarkdown` that returns native nodes. `GenuiBlock` uses no DOM API.

`useGenuiParse(code, version, streaming)` returns the `GenuiParseResult` that `GenuiBlock` renders. Use it to build your own block shell. It keeps one parser per block.

### Parse and stream

`@kortix/sdk/genui` parses without React.

| Function | Returns |
| --- | --- |
| `parseGenui(code, version?)` | A `GenuiParseResult` for a finished block. |
| `createGenuiParser(version?)` | A `GenuiParser`. `update(code, streaming)` takes the full block text so far and parses only the new text. The same input returns the same result object. Unchanged nodes keep their identity across calls. |

`GenuiParseResult` holds `root` (the root `Stack`, or `null`), `pending` (statement names referenced but not written yet), `issues` and `streaming`.

`issues` lists everything the parser dropped or repaired. Use it for logs and debugging, never for the user. A result holds at most 50 issues, plus a `cut-off` issue when the cap drops it. Each `GenuiIssue` has a `code`, a `message` and, where known, `component` and `statementId`.

| `GenuiIssueCode` | Meaning |
| --- | --- |
| `schema` | A node broke its field limits. It is dropped. Its valid children move to the parent slot. |
| `unknown-component` | The name is not in this build's catalog. |
| `wrong-child` | The slot does not accept this component. |
| `url` | An unsafe link or image URL. |
| `depth` | Container nesting deeper than 4. |
| `too-many-nodes` | The block expands to more than 500 nodes. |
| `too-large` | The block source is longer than 65,536 characters. It is not parsed. |
| `version` | The fence names a schema version this build does not render. |
| `no-root` | The settled block has no valid root. |
| `cut-off` | The stream ended inside a statement. |
| `unsupported-statement` | A `Query`, `Mutation` or `$state` statement. It is ignored and never runs. |
| `parse-failed` | The parser failed on this input. Nothing renders. |

A node that fails is dropped and its siblings still render.

### Render in text hosts

A host that cannot render UI converts blocks to markdown. The output is deterministic and never holds block source.

| Function | Converts |
| --- | --- |
| `genuiToMarkdown(text, { streaming? })` | A whole reply. Blocks inside blockquotes and list items convert too. Text without a block comes back unchanged. |
| `genuiBlockToMarkdown(code, version?, { streaming? })` | One block body. |
| `genuiNodeToMarkdown(node)` | One `GenuiNode` and its children. An unknown or partial node gives `''`. |
| `genuiA11yText(node)` | Screen-reader text for a chart or map node, else `null`. |

Charts become tables with a `Source:` line. Maps become place lists. Tabs and accordions expand. Steps become a numbered list. A Checklist becomes a task list (`- [ ]`). For a reply that still streams, pass `{ streaming: true }`: the unfinished last block gets no cut-off note.

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

const markdown = genuiToMarkdown(replyText);
```

The Kortix CLI prints session replies this way. Treat the output as untrusted markdown and render it through the same sanitizer as prose. To get the reply text of a session, see [Sessions from code](/docs/developers#sessions-from-code).

### Teach another model the format

| Name | Value |
| --- | --- |
| `buildGenuiPrompt()` | The `kortix-genui` skill body as a string: syntax, component signatures, rules and examples. Give it to a model outside Kortix as a system prompt. |
| `GENUI_PROMPT_VERSION` | An 8-character hex hash of that prompt. Store it with evaluation results, so a prompt change is visible. |
| `GENUI_SCHEMA_VERSION` | `1`. The schema version this build renders. |

### Blocks

The catalog has 19 top-level blocks. Child nodes appear only inside their parent.

| Block | Holds | Limits |
| --- | --- | --- |
| `Stack` | Layout of blocks; `direction` `col` or `row`; `layout` `list` or `grid`. The parser wraps every root in a `Stack`. | 1–12 children |
| `Card` | One option with an image, text, badges and a rating | up to 4 `Badge`s; rating 0–5 |
| `Stat` | One key number with a trend | |
| `StatRow` | Key numbers in a row | 2–4 `Stat`s |
| `Table` | Rows to scan | 1–8 columns, up to 50 rows, cells up to 200 characters |
| `Compare` | Side-by-side options; `winner` names the pick | 2–4 `CompareItem`s, up to 12 specs |
| `RankedList` | A ranked recommendation, best first | 1–10 `RankedItem`s |
| `BarChart` | Amounts across categories | 1–24 categories, 1–4 `Series` |
| `LineChart` | A trend over time | 2–365 x labels, 1–4 `Series` |
| `PieChart` | Shares of a whole | 2–6 `Slice`s |
| `Map` | Places and an optional route | 1–25 `Marker`s, zoom 1–18, route up to 500 points |
| `Steps` | A process or journey in order | 1–12 `Step`s |
| `Checklist` | Items the reader ticks off | 1–20 items, up to 120 characters each |
| `Tabs` | Views the user switches between | 2–5 `Tab`s, each with 1–8 blocks |
| `Accordion` | Sections a reader may skip | 1–10 `AccordionItem`s, each with 1–8 blocks |
| `Badge` | A status tag; tone `neutral`, `good`, `warn`, `bad` | label up to 24 characters |
| `Callout` | A takeaway or warning; tone `info`, `warn`, `success` | body up to 400 characters |
| `Image` | A picture with optional caption and text | |
| `Link` | A link to a source or document | label up to 80 characters |

Child nodes: `Tab`, `AccordionItem`, `CompareItem`, `RankedItem`, `Series` (up to 365 values), `Slice`, `Marker`, `Step`.

Limits for every block:

- Container nesting: 4 levels.
- Nodes per block after references expand: 500.
- Block source: 65,536 characters.
- URL length: 2,048 characters.

An older SDK does not know a block added later. It drops that node with `unknown-component`. Update `@kortix/sdk` to render new blocks.

### Safety

- Blocks hold data, never code. `Query`, `Mutation` and `$state` statements never run.
- A block cannot call tools or change state. A Checklist keeps its ticks in the component only. Nothing is stored or sent.
- Every link and image URL passes through `safeUrl(raw)`. It returns a normalized absolute `http` or `https` URL, or `null`. It rejects URLs with credentials and URLs longer than 2,048 characters. Use it in your own components for any URL you render.
- `GENUI_CUT_OFF_NOTE` is `'Response was cut off.'`. It shows under a settled block whose stream ended inside a statement.
- `GENUI_UNSUPPORTED_NOTE` is `'This content needs a newer version of Kortix.'`. It shows in place of a block with a schema version this build does not render.

## Kortix UI

`@kortix/ui` is the React component library of the Kortix web app. It builds on [`@kortix/sdk/react`](/docs/developers#react) and `@kortix/sdk/genui/react`. It ships the primitives (buttons, forms, overlays) and the whole session experience as modules: the session workspace, the thread, the composer, files, the terminal, the project home and the session list. Kortix Apps, white-label hosts and your own app render the same components.

The components read the one client you created in [Create the client](/docs/developers#create-the-client). `@kortix/ui` adds no second client and no second setup. Components are Tailwind CSS v4 class strings that read CSS variables. You re-theme them with tokens, never by copying a component.

### Install and peers

```bash
npm i @kortix/ui @kortix/sdk @tanstack/react-query @openuidev/lang-core@0.3.1 zod @phosphor-icons/react@2.1.10 react@19 react-dom@19
```

Install the same release version of `@kortix/ui` and `@kortix/sdk`. Version rules are in [Versions and stability](/docs/developers#versions-and-stability). The module entries (`@kortix/ui/session`, `/workspace`, `/theme` and the others in [Modules](/docs/developers#modules)) ship from 0.14.4; 0.14.3 has the root, the component subpaths and the stylesheets. Inside the [kortix-ai/suna](https://github.com/kortix-ai/suna) repository, add `"@kortix/ui": "workspace:*"` to your package's dependencies instead.

| Peer | Required for |
| --- | --- |
| `react` >= 19, `react-dom` >= 19 | Every entry. |
| `@kortix/sdk`, `@tanstack/react-query` 5 | `@kortix/ui/session`, `@kortix/ui/workspace` and every other entry that renders project or session data. Mount one `QueryClientProvider` ([Set up](/docs/developers#set-up)). |
| `@openuidev/lang-core@0.3.1`, `zod` | The session thread, because it renders [Generative UI](/docs/developers#generative-ui) blocks. Both are optional peers of `@kortix/sdk`. Without `@openuidev/lang-core`, `vite build` fails with `Missing export` in `@kortix/sdk/dist/genui`. |
| `tw-animate-css`, `tailwind-scrollbar`, `tailwind-scrollbar-hide` | `@kortix/ui/tailwind.css` in a Tailwind v4 host. A host without Tailwind does not install them. |
| `@phosphor-icons/react@2.1.10` | Your own icons. Pin the version `@kortix/ui` uses, so `IconProvider` reaches your icons too. |

> **Info**
> Name `react@19 react-dom@19` in the install. An unversioned `react` lets npm pick React 18 to satisfy the React 18 peer of `cmdk@0.2.1`, and the install does not finish. `Command` works on React 19. A host with `strict-peer-dependencies` must allow the React peer of `cmdk`.

#### Import map

| Import | Holds |
| --- | --- |
| `@kortix/ui` | The core: `UiProvider`, `useUiStrings`, `useUiLink`, `useUiBrand`, `defaultUiStrings`, `SessionIdsProvider`, `setUiRoutes`, `setUiTelemetry`, `setUiHostEnv`, `lazyClient`, `cn`, `useIsDark`, `useIsMobile`, `useDebounce`, and the primitives listed in [Components and modules](/docs/developers#components-and-modules). |
| `@kortix/ui/<component>` | One component file, for example `@kortix/ui/modal` or `@kortix/ui/toast`. Heavy components (charts, maps, data grids, the emoji picker) load only from their own subpath. |
| `@kortix/ui/<module>` | One curated entry per module, for example `@kortix/ui/workspace` or `@kortix/ui/session`. See [Modules](/docs/developers#modules). |
| `@kortix/ui/theme` | `KortixTheme`, `themeToCss`, `themeToCssVars`, `ThemeScope`. See [Theming](/docs/developers#theming). |
| `@kortix/ui/tailwind.css`, `/tokens.css`, `/theme.css`, `/styles.css`, `/fonts.css` | The stylesheets. See [Add the styles](/docs/developers#add-the-styles). |

#### Server rendering

These entries import CSS (KaTeX and others) from JavaScript: `session`, `composer`, `session-panel`, `workspace`, `files`, `file-viewer`, `markdown`, `genui`, `customize`. Plain Node cannot evaluate them and fails with `ERR_UNKNOWN_FILE_EXTENSION ".css"`. Use one of these fixes:

- A Vite SSR host adds `ssr: { noExternal: ['@kortix/ui'] }`.
- A Next.js host lists `@kortix/ui` in `transpilePackages`.
- Any host loads the block on the client only with `lazyClient`.

The root and the `theme`, `i18n`, `icons`, `diff`, `changes`, `terminal`, `browser`, `apps` and `access` entries evaluate in Node.

### Add the styles

Load the styles once, in your main stylesheet or entry file. Pick the tab for your host.

**Tailwind CSS v4 host**
Import `@kortix/ui/tailwind.css` after Tailwind. It brings the tokens (`tokens.css`), the component layer (`theme.css`), the animation utilities and the scrollbar plugins. `theme.css` adds an `@source` rule, so Tailwind scans the package for class names.

```css title="app.css"
@import 'tailwindcss';
@import '@kortix/ui/tailwind.css';
```

Your own classes resolve against the same tokens: `bg-background`, `text-muted-foreground`, `border-border`.

To bring your own token values, import the parts one by one. Every line except `tokens.css` is required. Without `tw-animate-css` and the two plugins, overlays lose their enter and exit animations and `scrollbar-hide` does nothing. The build reports no error.

```css title="app.css"
@import 'tailwindcss';
@import 'tw-animate-css';
@import '@kortix/ui/tokens.css'; /* optional: or set every variable yourself */
@import '@kortix/ui/theme.css';
@plugin 'tailwind-scrollbar';
@plugin 'tailwind-scrollbar-hide';
```

> **Warning**
> `tokens.css` sets `--spacing: 0.23rem`, 8% tighter than the Tailwind default of `0.25rem`. Your own spacing utilities follow it: `p-4` is 14.72px, not 16px. To keep the Tailwind default for your classes, set `--spacing: 0.25rem` in `:root` after the import. The components then render 8% larger.
**Host without Tailwind**
Import one prebuilt file. It holds the reset, the tokens and every class the components use.

```ts title="main.tsx"
import '@kortix/ui/styles.css';
```

It holds no class that only your code uses. Style your own markup with plain CSS that reads the variables, for example `var(--border)`.

- Dark mode is the `dark` class on `<html>`. `useIsDark()` returns the current state.
- Both paths style the page: `body` paints `--background` and `--foreground`, borders default to `--border`, native controls follow `color-scheme`, text selection reads `--selection`, and interactive elements show the pointer cursor. You add no base rules.
- `@kortix/ui/fonts.css` loads the Roobert font. It is for Kortix-owned surfaces only. Every other host uses the system font stack (`--font-sans-system`) or sets `fonts.sans` in its [theme](/docs/developers#theming).

### Mount the providers

Mount the providers once, at the root of your app, inside your router and inside one `QueryClientProvider`.

```tsx title="providers.tsx"
import { UiProvider, type UiLinkComponent } from '@kortix/ui';
import { IconProvider } from '@kortix/ui/icon-provider';
import { Toaster } from '@kortix/ui/toast';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
import { Link } from 'react-router-dom';

import './kortix'; // creates the one client: see SDK > Create the client

// UiProvider calls `link` with anchor props, `href` and a Next.js-style `prefetch` hint.
const RouterLink: UiLinkComponent = ({ href, prefetch: _prefetch, ...props }) => (
  <Link to={href} {...props} />
);

export function Providers({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  return (
    <QueryClientProvider client={queryClient}>
      <UiProvider link={RouterLink}>
        <IconProvider>
          {children}
          <Toaster />
        </IconProvider>
      </UiProvider>
    </QueryClientProvider>
  );
}
```

| Provider | What it does | Without it |
| --- | --- | --- |
| `UiProvider` (`@kortix/ui`) | Connects the components to your router, copy, brand and host features. Options are in the next table. It also loads the animation features. | English copy, plain `<a>` and `<img>`, full page loads, the Kortix logo, no viewer, no slots. Expanded rows stay closed. |
| `IconProvider` (`@kortix/ui/icon-provider`) | Gives every Phosphor icon the Kortix weight and size. | Phosphor defaults. |
| `<Toaster />` (`@kortix/ui/toast`) | Renders the toasts that `successToast`, `errorToast` and the other helpers in `@kortix/ui/toast` raise. | No toast appears. |
| `TooltipProvider` (`@kortix/ui/tooltip`) | Optional. Sets one hover delay for the tooltips below it, for example `delayDuration={200}`. `Hint` mounts its own provider with a 300 ms delay. | Each tooltip uses its own delay. |

`setUiTelemetry` (`@kortix/ui`) is not a provider. Call it once at module load with `track`, `captureException`, `captureMessage` and `addBreadcrumb`. Declare `UiTelemetryRegister { event: YourEvents }` so `tsc` checks event names. Without it, `track` does nothing and `captureException` logs to the console.

#### `UiProvider` options

Every option is optional. Pass objects with a stable identity.

| Option | What it does |
| --- | --- |
| `link` | Your router's link component, called with `href`. |
| `navigation` | Your router as four hooks: `useRouter` (`push`, `replace`, `prefetch`, `back`), `usePathname`, `useSearchParams`, `useParams`. Add `observesHistory: true` when your router observes `history.pushState` (Next.js does, React Router does not). A Next.js host passes the `next/navigation` hooks. |
| `strings` | The fallback copy of the primitives: `confirm`, `cancel`, `close`, `commandPaletteTitle`, `commandPaletteDescription`. Pass only the keys you translate. |
| `i18n` | The session UI's translations: `useTranslations` and `useLocale` (next-intl signatures), or `locale` and `messages`. Default: the bundled English catalog of `@kortix/ui/i18n`. |
| `image` | Your image component. Default: a plain `<img>`. |
| `useBrand` | A hook that returns your logo URLs. `KortixLogo` then renders your marks. |
| `useViewerId` | A hook that returns the signed-in user's id. Without a viewer, a session several people write in names no sender. |
| `useViewer` | A hook that returns the signed-in user as `{ id, email?, metadata? }`. The UI reads `name`, `full_name` and `avatar_url` from `metadata`. |
| `updateViewerMetadata` | Saves UI preferences on the signed-in user, so they follow the user across devices. Without it, they stay in the browser. |
| `components` | Host features the session UI opens but does not own: `ShareSessionModal` (exported by `@kortix/ui/workspace`), `ProjectProviderModal`, `ConnectorConnectModal`, `HomeAnnouncement`, `Wallpaper`. A control whose slot is missing is hidden. |
| `resolvePreviewUrl` | `(request) => Promise<string \| null>`. Returns the URL the session browser frames, with your host's own credentials. `null` keeps the SDK preview URL. Use it when the browser must not hold a Kortix token. |
| `config` | Host feature switches. See the next table. |

| `config` key | Default | What it does |
| --- | --- | --- |
| `connectorsEnabled` | `true` | `false` hides the hosted app-connector catalog. Custom connectors stay. |
| `managedModelsEnabled` | `false` | `true` when the deployment serves its managed model lineup. The model picker lists it. |
| `terminalEnabled` | `true` | `false` hides the Terminal, for a host whose proxy cannot upgrade a WebSocket. |
| `cliConnectEnabled` | `true` | `false` hides the Terminal's "Connect from your machine" bar, for a host that serves no `<origin>/install` and ships no Kortix CLI. |
| `publicSharesEnabled` | `true` | `false` hides every public-link control, for a host that serves no page at `/share/session/<token>`. |
| `accountApiEnabled` | `true` | `false` for a host that fronts one operator key and blocks the account routes. No IAM, billing or session-oversight request runs. Every permission gate shows its control, and the server enforces each route. |
| `modelPickerEnabled` | `true` | `false` hides the model and reasoning-effort pickers. The session runs its default model. |
| `contextMeterEnabled` | `true` | `false` hides the context-usage ring. |
| `sessionOverridesEnabled` | `true` | `false` hides the session-overrides gear (sandbox, secrets, connectors, folders). |
| `runtimeSlashEnabled` | `true` | `false` removes the runtime's skills, commands and Compact session from the `/` menu. |
| `useSlashActions` | none | A hook that returns your own `/` menu rows: `{ id, label, description, group, onSelect }`. |
| `offeredAgents`, `agentLabels` | every agent, title-cased names | The agents the agent picker offers, and their display names. |
| `takeFiles`, `attachAccept` | upload as prompt attachments | `takeFiles(files)` takes attached, dropped and pasted files into your own store. `attachAccept` is the picker's `accept` list. |
| `commandPaletteShortcut`, `settingsShortcut` | `false` | `true` when your host opens a command palette on ⌘K or settings on ⌘,. The composer then hints at them. |
| `providerIconBaseUrl` | models.dev | Where your host serves provider logos, as `<base>/<icon>.svg`. The Kortix provider draws the `useBrand` icon. |
| `deploymentVersion` | latest | The release version for version-pinned CLI install commands. |

Billing gates are not a `UiProvider` option. They read `billingEnabled` from [`createKortix`](/docs/developers#create-the-client). A custom host leaves it off, and no upgrade control renders.

### Add a session

A session needs one [`useSession`](/docs/developers#usesession) result. Pass it to a component; there is no second chat hook and no transport to write.

`SessionWorkspace` (`@kortix/ui/workspace`) renders the full session screen of the Kortix web app: the thread and the resizable side panel its actions open (files, the file viewer, the browser, the terminal, changes). On a phone the panel is a sheet. The parent sets the size.

```tsx title="session.tsx"
import { useSession } from '@kortix/sdk/react';
import { SessionWorkspace } from '@kortix/ui/workspace';

export function Session({ projectId, sessionId }: { projectId: string; sessionId: string }) {
  const session = useSession(projectId, sessionId);
  return <SessionWorkspace session={session} />;
}
```

`SessionWorkspace` takes `session`, `children` (your own column in place of the thread), `hideHeader`, `readOnly` and `className`. `hideHeader` and `readOnly` apply to the default thread only.

#### The thread alone

`SessionThread` (`@kortix/ui/session`) renders the chat of one session: the transcript, the streaming turn, tool rows, permission and question prompts, the send error, and the composer with attachments, slash commands, the model and agent pickers and Stop.

```tsx title="chat.tsx"
import { useSession } from '@kortix/sdk/react';
import { SessionThread } from '@kortix/ui/session';

export function Chat({ projectId, sessionId }: { projectId: string; sessionId: string }) {
  const session = useSession(projectId, sessionId);
  return (
    <div className="flex h-dvh flex-col">
      <SessionThread session={session} hideHeader />
    </div>
  );
}
```

| Prop | Type | What it does |
| --- | --- | --- |
| `session` | `UseSessionResult`, required | The `useSession(projectId, sessionId)` result. |
| `hideHeader` | `boolean` | Hides the title row (title, share, menu). Use it when your page has its own header. |
| `readOnly` | `boolean` | Hides the composer. The thread is a transcript to read. |
| `composerPlaceholder` | `string` | The composer's placeholder. Default: the composer's own. |
| `quote` | `{ id: number; text: string } \| null` | Adds `text` to the composer's reply quotes, as a transcript selection's Reply does. Each new `id` adds its text once. |
| `className` | `string` | Classes on the thread's root. |

- The parent sets the height. The thread fills it.
- `SessionThread`, `SessionWorkspace` and `ProjectHome` mount the project scope and `SessionIdsProvider` themselves.
- With no side panel on the page, a file or preview the thread opens shows in a dialog.
- `SessionThread` renders [Generative UI](/docs/developers#generative-ui) blocks by default. You write no code for them.
- `ModelSelector` and `AgentSelector` (`@kortix/ui/session`) are the composer's pickers. They are controlled, so a form outside a session uses them too.

#### Replace one tool row

`@kortix/ui/session` registers every built-in tool row when it loads. `ToolRegistry.register(name, Component)` replaces the row of one tool name. A later registration for the same name wins. The row gets `ToolProps`: `call` is the session-log tool call with `name`, `input`, `status` (`pending`, `running`, `complete`, `error`) and `result.content` (text and attachment parts). `BasicTool` draws the standard row. `GenericTool` is the default row.

```tsx title="src/shell-row.tsx"
import { BasicTool, ToolRegistry, type ToolProps } from '@kortix/ui/session';
import { TerminalWindowIcon } from '@phosphor-icons/react';

function ShellRow({ call }: ToolProps) {
  const command = (call.input as { command?: string } | null)?.command ?? '';
  const output = (call.result?.content ?? [])
    .map((part) => (part.type === 'text' ? part.text : ''))
    .join('');
  return (
    <BasicTool icon={<TerminalWindowIcon />} trigger={{ title: 'Shell', subtitle: command }}>
      <pre className="bg-muted overflow-x-auto rounded-md p-3 font-mono text-xs">
        {output || 'No output yet'}
      </pre>
    </BasicTool>
  );
}

// Replaces the built-in row of the `bash` tool.
ToolRegistry.register('bash', ShellRow);
```

Import the file once at your entry: `import './shell-row'`.

#### Compose a custom workspace

The modules compose at any depth. Pick the level you need.

| You want | Use |
| --- | --- |
| The Kortix session screen in your chrome | `SessionWorkspace` |
| Your own conversation column beside the Kortix side panel | `<SessionWorkspace session={session}>{yourColumn}</SessionWorkspace>` |
| The thread, no panel | `SessionThread`. It mounts its own preview host. A page with no `SessionThread` and no side panel mounts `<FilePreviewHost />` from `@kortix/ui/files` once. |
| Your own panel | `SessionPanelProvider` and `ActionPanel` from `@kortix/ui/session-panel`, or `SessionTerminal` from `@kortix/ui/terminal` |
| Single blocks | `UserMessage`, `ThrottledMarkdown`, `ToolPartRenderer`, `ModelSelector`, `AgentSelector`, `SessionPermissionPrompt`, `QuestionPrompt` from `@kortix/ui/session`; `ComposerChatInput` from `@kortix/ui/composer` |
| Every part drawn by your own components | No `@kortix/ui/session`. Use `useChatTurns` and `renderParts` from `@kortix/sdk/react`: [Render messages yourself](/docs/developers#render-messages-yourself). |

A block outside `SessionThread`, `SessionWorkspace` and `ProjectHome` needs the two scopes those screens mount for you: `KortixProjectProvider` from `@kortix/sdk/react` and `SessionIdsProvider` from `@kortix/ui`. Without `SessionIdsProvider`, `useSessionIds()` returns no ids.

```tsx title="ask.tsx"
import { KortixProjectProvider } from '@kortix/sdk/react';
import { SessionIdsProvider } from '@kortix/ui';
import { ComposerChatInput } from '@kortix/ui/composer';

// The composer before a session exists. You decide what a send does.
export function Ask({ projectId, onAsk }: { projectId: string; onAsk: (text: string) => void }) {
  return (
    <KortixProjectProvider projectId={projectId}>
      <SessionIdsProvider projectId={projectId}>
        <ComposerChatInput projectId={projectId} onSend={(text) => onAsk(text)} />
      </SessionIdsProvider>
    </KortixProjectProvider>
  );
}
```

### Routes

Every URL the components link to or navigate to comes from one route table. Call `setUiRoutes` (`@kortix/ui`) once at module load. An omitted route keeps its default.

- Return your own path for a page you serve at another URL.
- Return `null` for a page you do not serve. Every link to it is hidden and no navigation goes there.
- `project` and `session` cannot return `null`.

| Route | Arguments | Default |
| --- | --- | --- |
| `project` | `projectId` | `/projects/<id>` (the new-session composer) |
| `session` | `projectId`, `sessionId` | `/projects/<id>/sessions/<sessionId>` |
| `sessions` | `projectId`, `view?: 'archived' \| 'deleted'` | `/projects/<id>/sessions`, `?view=<view>` |
| `files` | `projectId`, `panel?: 'proposed-changes'` | `/projects/<id>/files`, `?panel=<panel>` |
| `drive` | `projectId` | `/projects/<id>/drive` |
| `apps` | `projectId` | `/projects/<id>/apps` |
| `members` | `projectId` | `/projects/<id>/members` |
| `sandboxSettings` | `projectId` | `/projects/<id>/config?section=sandbox` |
| `customize` | `projectId`, `path?` | `/projects/<id>/customize/<path>` (agents, skills, connectors, triggers, reminders, models, secrets, review, settings) |

```ts
import { setUiRoutes } from '@kortix/ui';

setUiRoutes({
  session: (projectId, sessionId) => `/w/${projectId}/chat/${sessionId}`,
  drive: () => null,
  customize: () => null,
});
```

`uiRoutes()` returns the active table. `defaultUiRoutes` holds the defaults.

### Theming

White-label means CSS variable overrides. Every value a host varies is a CSS variable: colors in light and dark, the radius, border width, fonts, the type scale, spacing density, shadows and motion. `@kortix/ui/theme` turns a typed theme object into those variables. Every field is optional. An unset field keeps the Kortix default. An unset `dark` color falls back to the Kortix dark default, not to your `light` value.

```tsx title="app/layout.tsx"
import { themeToCss, type KortixTheme } from '@kortix/ui/theme';
import type { ReactNode } from 'react';

const brand: KortixTheme = {
  light: { blue: '#4f46e5', ring: '#4f46e5', border: '#e0e7ff' },
  dark: { blue: '#818cf8', ring: '#818cf8', border: '#312e81' },
  radius: '0.25rem',
  fonts: { sans: 'Inter, system-ui, sans-serif' },
};

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <head>
        <style>{themeToCss(brand)}</style>
      </head>
      <body>{children}</body>
    </html>
  );
}
```

`themeToCss` is a pure function. It runs on the server, so the first paint has your theme. Its rules out-rank `tokens.css`, so the `<style>` element can sit anywhere in the page. The white-label examples theme this way: `apps/white-label/lumen/src/config/theme.ts` and `apps/white-label/reskin/src/config/theme.ts`.

| Export | Signature | Use |
| --- | --- | --- |
| `KortixTheme` | `{ light?, dark?, radius?, spacing?, borderWidth?, fonts?, text?, lineHeights?, shadows?, durations?, easing?, vars? }` | The theme. `light` and `dark` take the color keys below plus any `--custom` variable. `vars` sets any other variable in both modes. An unknown field, color or step is a type error and throws at runtime. |
| `themeToCss` | `(theme: KortixTheme, selector = ':root') => string` | A stylesheet: light outside `.dark`, dark on or inside `.dark`. Pass a selector to theme one element. It throws on a value that contains `;`, `{`, `}`, `<`, `>`, `\` or a comment, and on a variable name that does not match `--[A-Za-z0-9_-]+`. A theme built from tenant data cannot add CSS rules. |
| `themeToCssVars` | `(theme: KortixTheme) => { light: Record<string, string>; dark: Record<string, string> }` | The same variables as data, for a host that writes its own CSS or sets them from JavaScript. Mode-independent values appear in both. |
| `ThemeScope` | `<ThemeScope theme={...} className? {...divProps}>` | Themes one subtree: an embedded widget, a customer preview, a settings pane. |
| `THEME_COLOR_VARS` | `Record<ThemeColor, string>` | The color key to CSS variable map. |

#### ThemeScope

```tsx title="themed-chat.tsx"
import type { UseSessionResult } from '@kortix/sdk/react';
import { SessionThread } from '@kortix/ui/session';
import { ThemeScope } from '@kortix/ui/theme';

export function ThemedChat({ session }: { session: UseSessionResult }) {
  return (
    <ThemeScope theme={{ light: { primary: '#0f766e' }, radius: '1rem' }} className="bg-background h-dvh">
      <SessionThread session={session} />
    </ThemeScope>
  );
}
```

- Dialogs, popovers, menus, tooltips, selects, sheets and drawers opened inside the scope portal into a host at the end of `<body>` that carries the scope's theme. A nested scope's overlays carry both themes. A `transform`, `filter`, `backdrop-filter`, `contain` or `overflow: hidden` ancestor of the scope does not clip or offset the overlays.
- The scope sets `font-family` and `color` on its element. It paints no background: add `bg-background`.
- `className="dark"` forces dark for the subtree. A scope cannot force light inside a dark page.
- The scope element holds only your children. Its `<style>` renders as the sibling before it. `flex`, `grid`, `space-y-*`, `first:` and `last:` on the children behave as without the scope.
- Toasts do not portal. To theme toasts, mount `<Toaster />` inside the `ThemeScope`, at its top level, not inside a transformed panel.

#### Tokens

Colors take any CSS color. Defaults are light / dark.

| Key | Variable | Controls | Default |
| --- | --- | --- | --- |
| `background` | `--background` | The page | `#ffffff` / `#0b0b0b` |
| `pane`, `surface` | `--pane`, `--surface` | Base and raised panes | `#ffffff` / `#0c0c0c`, `#fcfcfc` / `#141414` |
| `foreground` | `--foreground` | Text | `#1f1f1f` / `#ffffff` |
| `foregroundStrong`, `foregroundWeak` | `--foreground-strong`, `--foreground-weak` | Emphasized and quiet text | follow `foreground` / `mutedForeground` |
| `action`, `actionForeground` | `--action`, `--action-foreground` | The default `Button` fill and its text (primary actions, Send) | follow `foreground` / `background` |
| `card`, `cardForeground` | `--card`, `--card-foreground` | `Card`, chat tool cards and questions | `#f4f4f4` / `#141414` |
| `popover`, `popoverForeground` | `--popover`, `--popover-foreground` | Menus, popovers, inputs | `#ffffff` / `#141414` |
| `primary`, `primaryForeground` | `--primary`, `--primary-foreground` | `Progress`, `Slider`, `Calendar` selection, menu highlight | `#1f1f1f` / `#ffffff` |
| `secondary`, `secondaryForeground` | `--secondary`, `--secondary-foreground` | Secondary buttons | `#ededed` / `#1c1c1c` |
| `muted`, `mutedForeground` | `--muted`, `--muted-foreground` | Quiet fills, the chat user bubble, code blocks, captions | `#ededed` / `#1c1c1c`, `#666666` / `#999999` |
| `accent`, `accentForeground` | `--accent`, `--accent-foreground` | Hovered rows | `#f4f4f4` / `#141414` |
| `destructive`, `destructiveForeground` | `--destructive`, `--destructive-foreground` | Errors, destructive buttons | `#e7000b` / `#ff6467` |
| `border` | `--border` | Every hairline and default border color | `#e2e2e2` / `#262626` |
| `input` | `--input` | Input fills | `#ededed` / `#1c1c1c` |
| `ring`, `focusRing` | `--ring`, `--focus-ring` | Focus rings, the focused composer; card focus ring | `#0099ff`; `focusRing` follows `ring` |
| `hover`, `active` | `--hover`, `--active` | Translucent hover and pressed tints | ink 4.5% / 7.5%, white 6% / 10% |
| `selection`, `selectionForeground` | `--selection`, `--selection-foreground` | Text selection | `primary` at 72% |
| `sidebar`, `sidebarForeground`, `sidebarPrimary`, `sidebarPrimaryForeground`, `sidebarAccent`, `sidebarAccentForeground`, `sidebarRow`, `sidebarRowControl`, `sidebarBorder`, `sidebarRing` | `--sidebar-*` | The sidebar. `sidebar` also fills `Dialog`, `AlertDialog`, `Modal` and `Sheet`. | Kortix surface values |
| `blue` | `--kortix-blue` | The accent: a checked `Switch`, `Checkbox` or `RadioGroup`, the `brand` and `blue` `Button` variants, info `Badge`, `InfoBanner` | `#2b91f7` |
| `green`, `orange`, `yellow`, `red`, `purple` | `--kortix-*` | Status tones. Keep their meaning. | `#199338`, `#d18b19`, `#cca300`, `#f14b4c`, `#ab80d6` |
| `brand` | `--kortix-base` | The Kortix base accent | follows `ring` |
| `chart1` … `chart5` | `--chart-1` … `--chart-5` | Chart series | an amber ramp |
| `terminalSurface`, `terminalForeground`, `terminalBorder` | `--terminal-*` | The terminal pane (always dark) | `#0f0f0f`, `#e5e5e5`, white 10% |

These keys apply in both modes:

| Key | Variable | Controls | Default |
| --- | --- | --- | --- |
| `radius` | `--radius` | Every corner. `rounded` is −6px, `-sm` −4px, `-md` −2px, `-lg` equal, `-xl` +4px, `-2xl` +6px. | `0.625rem` |
| `borderWidth` | `--border-width` | Every `border*` and `divide-*` line with no explicit width | `1px` |
| `spacing` | `--spacing` | Density: every `p-*`, `m-*`, `gap-*`, `size-*` is a multiple | `0.23rem` |
| `fonts.sans`, `fonts.mono` | `--font-family-sans`, `--font-family-mono` | Body text and headings; code, ids, `Badge` | system stacks |
| `text.xs` … `text['8xl']` | `--text-<step>` | The font size of each `text-*` step | `0.8125rem` … `6rem` |
| `lineHeights.<step>` | `--text-<step>--line-height` | The line height of each `text-*` step | the Kortix ratio per step |
| `shadows['2xs']` … `shadows['2xl']` | `--shadow-<step>` | The elevation of each `shadow-*` step. `0 0 #0000` flattens one. | the Tailwind shadow ramp |
| `durations.fast` … `durations.slower` | `--duration-<step>` | `duration-fast` … `duration-slower` | `100ms`, `150ms`, `200ms`, `300ms`, `500ms` |
| `easing.default`, `in`, `out`, `inOut` | `--ease-*` | The `ease-*` curves | the Kortix curves |
| `vars` | any `--name` | Any other variable, for example `--liquid-glass-blur` | none |

#### Limits

- An explicit width class (`border-2`, `border-[3px]`) stays fixed. Only bare `border*` and `divide-*` follow `borderWidth`.
- Font sizes with no step stay fixed: the 10px, 11px and 12px captions and the `0.8rem` inline code chip.
- Durations with no step stay fixed: `duration-80`, `duration-0`, the sidebar slide (`200` / `240` / `260ms` on `cubic-bezier(0.32, 0.72, 0, 1)`), the command palette (`140` / `180ms`) and the enter and exit keyframes (`200` / `250ms`). Every `duration-100` / `150` / `200` / `300` / `500` reads `duration-fast` … `duration-slower`.
- Chat surfaces have no tokens of their own. They read `muted`, `card`, `border` and `mutedForeground`.
- Decorative values stay fixed: the dialog scrims, the `inverse` `Button` variant, the `Tag` status palette, the loader and dot-matrix gradients and the chart legend swatches.

#### Rules

- Theme through tokens. Never fork or restyle a component: a forked copy stops receiving fixes.
- Pass your logo through the `useBrand` option of `UiProvider`.
- Do not load `fonts.css`. Set `fonts.sans`.
- Plain CSS works after the `@kortix/ui` import. Put light values in `:root:not(.dark)` and dark values in `.dark`. A bare `:root` rule also matches `<html class="dark">` and overrides the dark value. `themeToCss` writes these selectors for you.

### Components and modules

#### Primitives

The root `@kortix/ui` exports the primitives with their parts: `Button`, `Badge`, `Card`, `Input`, `Textarea`, `Label`, `Checkbox`, `Switch`, `Select`, `Modal`, `Hint`, `Popover`, `DropdownMenu`, `Tabs`, `FilterBar`, `Separator`, `Skeleton`, `Bubble`, `Message`, `Marker`. Every other component is one subpath, `@kortix/ui/<file>`. The live catalog at [kortix.com/design-system](https://kortix.com/design-system) renders each one in light and dark, with its usage rules.

| Need | Compose | Import |
| --- | --- | --- |
| Actions | `Button`, `ButtonGroup`, `Toggle` | `button`, `button-group`, `toggle` |
| Forms | `Field`, `Input`, `Textarea`, `Select`, `CheckboxGroup`, `RadioGroup`, `Switch`, `Slider`, `Label` | `field`, `input`, `textarea`, `select`, `checkbox-group`, `radio-group`, `switch`, `slider`, `label` |
| Search | `InputGroupSearch`, `PageSearchBar`, `Command` | `input-group`, `page-search-bar`, `command` |
| Overlays | `Modal`, `Sheet`, `Popover`, `DropdownMenu`, `Hint` | `modal`, `sheet`, `popover`, `dropdown-menu`, `hint` |
| Confirm | `ConfirmDialog`, `AlertDialog` | `confirm-dialog`, `alert-dialog` |
| Feedback | `InfoBanner`, the toast helpers, `Loading`, `Skeleton`, `Progress` | `info-banner`, `toast`, `loading`, `skeleton`, `progress` |
| Status | `Badge`, `CountBadge`, `StatusBadge` | `badge`, `count-badge`, `status` |
| Layout | `Card`, `Section`, `Tabs`, `Disclosure`, `Accordion`, `Separator`, `ScrollArea`, `Sidebar` | `card`, `section`, `tabs`, `disclosure`, `accordion`, `separator`, `scroll-area`, `sidebar` |
| Data | `Table`, `DefinitionList`, `InlineMeta`, `Empty`, `Calendar` | `table`, `definition-list`, `inline-meta`, `empty`, `calendar` |
| People | `UserAvatar`, `EntityAvatar` | `user-avatar`, `entity-avatar` |

Compose before you create:

- `Modal` for a dialog, `Hint` for a tooltip, `Loading` for a spinner, `Badge` for a chip, `ConfirmDialog` before a destructive action.
- `Dialog` and `Tooltip` are banned in feature code. The root exports them because `Modal` and `Hint` build on them, and both carry `@deprecated`.
- Never call the `toast()` function of `sonner` directly. Use the helpers in `@kortix/ui/toast`.
- Never use an icon with `animate-spin` as a spinner. Icons come from `@phosphor-icons/react`.

#### Modules

Each module has one entry. The names an entry exports are its public API, under semver. Every module needs `UiProvider` and a `QueryClientProvider` at the root.

| Entry | Holds | Minimal mount |
| --- | --- | --- |
| `@kortix/ui/workspace` | Composed project screens: `SessionWorkspace`, `ProjectHome` (the new-session composer; it creates the session and opens it), `ProjectSessionList`, `ProjectSessionsView`, `ProjectFilesView`, `ProjectSidebar`, `ShareSessionModal` | `<SessionWorkspace session={session} />`, `<ProjectHome projectId={projectId} />`; `ProjectSessionList` inside a `SidebarProvider` |
| `@kortix/ui/session` | The conversation: `SessionThread`, turns, tool rows, `ToolRegistry`, `BasicTool`, `GenericTool`, prompts, `ModelSelector`, `AgentSelector` | `<SessionThread session={session} />` |
| `@kortix/ui/composer` | The composer: `ComposerChatInput`, `SessionChatInput`, its parts and the send pipeline | `<ComposerChatInput projectId={projectId} onSend={...} />` in the project scope |
| `@kortix/ui/session-panel` | The side panel: `SessionPanelProvider`, `ActionPanel`, `SessionFiles`, `SessionChanges`, `SessionAudit`, `openSessionQuickView` | `SessionPanelProvider` with the runtime ids |
| `@kortix/ui/files` | Project files (the git-backed repo: `ProjectFilesProvider`, `FileExplorerPage`, `DriveExplorer`), sandbox files (`SandboxFileExplorer`), `FilePreviewHost`, drive and change-request hooks | `ProjectFilesView` from `workspace` (it fills its parent; the parent sets the height); `<FilePreviewHost />` once on a page with no side panel and no `SessionThread` |
| `@kortix/ui/file-viewer` | `FileViewer`, `FileContentRenderer` (pdf, docx, xlsx, csv, pptx, zip, images, code), `FilePreviewModal`, `FileSourceProvider` | `<FileViewer content={text} fileName="notes.md" />` |
| `@kortix/ui/terminal`, `@kortix/ui/browser` | `SessionTerminal`; `SessionBrowser`, `BrowserLanding` | the runtime ids of a mounted `useSession` |
| `@kortix/ui/markdown` | `UnifiedMarkdown`, `MarkdownWithFrontmatter`, `HighlightedCode` | `<UnifiedMarkdown content={text} trust="agent" />` |
| `@kortix/ui/genui` | `GenuiBlock` and `genuiComponents`, the web component set over `@kortix/sdk/genui/react`. See [Render blocks in React](/docs/developers#render-blocks-in-react). | none |
| `@kortix/ui/diff`, `@kortix/ui/changes` | `DiffView`; `ChangeList` | `<DiffView before={{ name, contents }} after={{ name, contents }} />` |
| `@kortix/ui/apps` | `AppPreview` and the App view model | none |
| `@kortix/ui/access`, `@kortix/ui/customize` | Sharing and roles (`AccessDialog`, `RoleSelect`, `PrincipalPicker`); the project configuration pages (`ConnectorsPage`, `AgentsPage`, `TriggersPage`, `SecretsPage`, `ModelsPage`, `SkillsPage`, `ProjectSettingsPage`) | `<ConnectorsPage projectId={projectId} />` |
| `@kortix/ui/theme`, `@kortix/ui/i18n`, `@kortix/ui/icons` | The typed theme; `useTranslations` and `useLocale`; the library's glyphs | none |

The package skill lists every name of every entry with its props and one example: `packages/ui/skills/kortix-ui/references/modules.md`.

> **Warning**
> Deep paths have no semver. `@kortix/ui/internal/*` and `@kortix/ui/<module>/<path>` (for example `@kortix/ui/session/thread/session-chat`) resolve, but they exist only for Kortix's own apps (`packages/ui/scripts/deep-exports.json`). They change or disappear in any release. Import the entry. If an entry lacks a name you need, report it so the name is added to the entry.

### Build a dashboard

A dashboard is your chrome around the composed screens: the Kortix session list in your sidebar, `ProjectHome` to start a session, `SessionWorkspace` to work in one, `ProjectFilesView` for the project's files. The steps below are the files of one Vite + React 19 + react-router app. Add `react-router-dom` to the install in [Install and peers](/docs/developers#install-and-peers). The browser holds a signed-in user's refreshing token, never an API key.

### Create the client
Start from the client in [Create the client](/docs/developers#create-the-client). In a browser, feed `getToken` from the headless session store ([Sign users in headlessly](/docs/developers#sign-users-in-headlessly)). The store keeps the user's tokens and refreshes them 60 s before they expire.

```ts title="src/kortix.ts"
import { createKortix, createKortixSession } from '@kortix/sdk';

// The signed-in user's tokens. Memory only: pass `storage` to keep them across reloads.
export const authSession = createKortixSession();

// One client per host, at module scope. Every @kortix/sdk/react hook and
// every @kortix/ui component reads it.
export const kortix = createKortix({
backendUrl: import.meta.env.VITE_KORTIX_API_URL ?? 'https://api.kortix.com/v1',
getToken: () => authSession.getToken(),
});
```

The browser origin must be in the API's CORS list. In local development, proxy `/v1` through your dev server and point `backendUrl` at it. Inside a Kortix App, the client acts as the viewer instead: see [Know the viewer in a Kortix App](/docs/developers#know-the-viewer-in-a-kortix-app).
### Connect your router and theme
```ts title="src/theme.ts"
import type { KortixTheme } from '@kortix/ui/theme';

export const acmeTheme: KortixTheme = {
light: { blue: '#0f766e', ring: '#0f766e', border: '#cbd5e1', muted: '#f1f5f9' },
dark: { blue: '#2dd4bf', ring: '#2dd4bf', border: '#334155', muted: '#1e293b' },
radius: '0.375rem',
};
```

```tsx title="src/router-adapters.tsx"
import type { UiLinkComponent, UiNavigation, UiRouter } from '@kortix/ui';
import { useMemo } from 'react';
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';

export const RouterLink: UiLinkComponent = ({ href, prefetch: _prefetch, ...props }) => (
<Link to={href} {...props} />
);

function useUiRouterAdapter(): UiRouter {
const navigate = useNavigate();
return useMemo(
() => ({
push: (href) => navigate(href),
replace: (href) => navigate(href, { replace: true }),
prefetch: () => {},
back: () => navigate(-1),
}),
[navigate],
);
}

export const navigation: UiNavigation = {
useRouter: useUiRouterAdapter,
usePathname: () => useLocation().pathname,
useSearchParams: () => useSearchParams()[0],
useParams: () => useParams(),
};
```

```tsx title="src/providers.tsx"
import { UiProvider } from '@kortix/ui';
import { IconProvider } from '@kortix/ui/icon-provider';
import { themeToCss } from '@kortix/ui/theme';
import { Toaster } from '@kortix/ui/toast';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';

import './kortix';
import { navigation, RouterLink } from './router-adapters';
import { acmeTheme } from './theme';

const themeCss = themeToCss(acmeTheme);
const config = { managedModelsEnabled: true };

// Mount inside the router: `navigation` calls its hooks.
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
<UiProvider link={RouterLink} navigation={navigation} config={config}>
<IconProvider>
<style href="acme-theme" precedence="default">{themeCss}</style>
{children}
<Toaster />
</IconProvider>
</UiProvider>
</QueryClientProvider>
);
}
```

React 19 hoists `<style href precedence>` into `<head>`. A Next.js host passes `next/link` and the `next/navigation` hooks unchanged.
### Put the session list in your sidebar
```tsx title="src/project-shell.tsx"
import { SessionIdsProvider } from '@kortix/ui';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarRail,
} from '@kortix/ui/sidebar';
import { ProjectSessionList } from '@kortix/ui/workspace';
import { FolderSimpleIcon, NavigationArrowIcon, SquaresFourIcon } from '@phosphor-icons/react';
import { Link, Outlet, useLocation, useParams } from 'react-router-dom';

export function ProjectShell() {
const { projectId = '', sessionId } = useParams();
const { pathname } = useLocation();
const nav = [
{ to: `/projects/${projectId}`, label: 'New session', icon: NavigationArrowIcon },
{ to: `/projects/${projectId}/files`, label: 'Files', icon: FolderSimpleIcon },
{ to: '/', label: 'All projects', icon: SquaresFourIcon },
];
return (
<SessionIdsProvider projectId={projectId} sessionId={sessionId}>
<SidebarProvider>
<Sidebar collapsible="offcanvas" variant="inset">
<SidebarHeader className="px-3 py-2 text-sm font-medium">Acme Agents</SidebarHeader>
<SidebarContent className="min-h-0">
<SidebarGroup className="py-0">
<SidebarMenu>
{nav.map(({ to, label, icon: Icon }) => (
<SidebarMenuItem key={to}>
<SidebarMenuButton asChild isActive={pathname === to}>
<Link to={to}>
<Icon />
<span>{label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
{/* The Kortix session list: sections, status, rename, delete, live updates. */}
<SidebarGroup className="min-h-0 flex-1 flex-col py-0">
<ProjectSessionList projectId={projectId} />
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
<SidebarInset className="h-dvh min-h-0 overflow-hidden">
<Outlet />
</SidebarInset>
</SidebarProvider>
</SessionIdsProvider>
);
}
```
### Add the pages
```tsx title="src/pages.tsx"
import { useProjects, useSession } from '@kortix/sdk/react';
import { Card, CardContent, CardHeader, CardTitle } from '@kortix/ui';
import Loading from '@kortix/ui/loading';
import { ProjectFilesView, ProjectHome, SessionWorkspace } from '@kortix/ui/workspace';
import { Link, useParams } from 'react-router-dom';

export function ProjectsPage() {
const projects = useProjects();
return (
<main className="mx-auto flex max-w-xl flex-col gap-4 p-8">
<Card>
<CardHeader>
<CardTitle>Projects</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1">
{projects.isLoading && <Loading />}
{projects.data?.map((project) => (
<Link
key={project.project_id}
to={`/projects/${project.project_id}`}
className="hover:bg-accent rounded-md px-2 py-1.5"
>
{project.name}
</Link>
))}
</CardContent>
</Card>
</main>
);
}

// The project home: sending creates the session and opens it.
export function HomePage() {
const { projectId = '' } = useParams();
return <ProjectHome projectId={projectId} />;
}

// The session: the thread and the side panel (files, viewer, terminal, browser).
export function SessionPage() {
const { projectId = '', sessionId = '' } = useParams();
const session = useSession(projectId, sessionId);
return <SessionWorkspace key={sessionId} session={session} />;
}

// The project's git-backed files. It fills its parent.
export function FilesPage() {
const { projectId = '' } = useParams();
return <ProjectFilesView projectId={projectId} />;
}
```

`ProjectHome` takes `beforeSend` to gate a send and `onSend` to replace it.
### Sign in and route
```tsx title="src/main.tsx"
import { Button, Input, Label, setUiRoutes } from '@kortix/ui';
import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter, Outlet, RouterProvider } from 'react-router-dom';

import './app.css';
import './shell-row';
import { authSession, kortix } from './kortix';
import { FilesPage, HomePage, ProjectsPage, SessionPage } from './pages';
import { ProjectShell } from './project-shell';
import { Providers } from './providers';

function SignIn() {
const [signedIn, setSignedIn] = useState(authSession.current() !== null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);

useEffect(() => authSession.subscribe((session) => setSignedIn(session !== null)), []);

if (signedIn) return <Outlet />;
return (
<form
className="mx-auto flex max-w-sm flex-col gap-3 p-8"
onSubmit={async (event) => {
event.preventDefault();
setError(null);
try {
const { session, user } = await kortix.auth.signInWithPassword({ email, password });
await authSession.set(session, user);
} catch (err) {
setError(err instanceof Error ? err.message : 'Sign-in failed');
}
}}
>
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
{error && <p className="text-destructive text-sm">{error}</p>}
<Button type="submit" disabled={!email || !password}>
Sign in
</Button>
</form>
);
}

// This app serves `project`, `files` and `session` at their default paths.
// Every link to another page is hidden.
setUiRoutes({
sessions: () => null,
drive: () => null,
apps: () => null,
members: () => null,
sandboxSettings: () => null,
customize: () => null,
});

const router = createBrowserRouter([
{
element: (
<Providers>
<SignIn />
</Providers>
),
children: [
{ path: '/', element: <ProjectsPage /> },
{
path: '/projects/:projectId',
element: <ProjectShell />,
children: [
{ index: true, element: <HomePage /> },
{ path: 'files', element: <FilesPage /> },
{ path: 'sessions/:sessionId', element: <SessionPage /> },
],
},
],
},
]);

createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
```

`./app.css` holds the imports from [Add the styles](/docs/developers#add-the-styles). `./shell-row` is the row from [Replace one tool row](/docs/developers#replace-one-tool-row).

- More data comes from the hooks in [React hooks](/docs/developers#react-hooks), for example `useProjectSessions`, `useProjectApps` and `useProjectTriggers`.
- Never `fetch` the Kortix API yourself. If the SDK lacks a call you need, report it as an SDK gap.
- Three white-label examples follow this structure ([overview](https://github.com/kortix-ai/suna/tree/dev/apps/white-label)):
  - [Lumen Workspace](https://github.com/kortix-ai/suna/tree/dev/apps/white-label/reskin): the Kortix workspace under its own theme, with direct mode (the browser holds the credential) or wrapper mode (its own server holds it).
  - [Team Console](https://github.com/kortix-ai/suna/tree/dev/apps/white-label/team-console): a static [Kortix App](/docs/guide#apps) over one account, signed in as the viewer.
  - [Lumen](https://github.com/kortix-ai/suna/tree/dev/apps/white-label/lumen): a product with its own users, data and billing, with Kortix as the agent backend.

### For agents

`@kortix/ui` ships the `kortix-ui` skill at `packages/ui/skills/kortix-ui/SKILL.md`. Every Kortix project gets it as a managed skill. An agent loads it when its `skills` grant in `kortix.yaml` is `all` or names `kortix-ui`. It tells an agent how to set up the package, which component to compose, which primitives are banned and how to verify a page. Its references:

| File | Holds |
| --- | --- |
| `references/custom-dashboard.md` | The dashboard of this section as one checklist: install, client, router adapters, theme, sidebar, pages, a custom tool row, an App's sign-in, verify. |
| `references/modules.md` | Every name of every entry, with its props and an example. |
| `references/theming.md` | Every theme key. |

In a Kortix session, an agent reads a reference with `kortix system-skills file kortix-ui references/custom-dashboard.md`. Point your own coding agent at `SKILL.md` and its references before it writes UI with `@kortix/ui`.

## SDK reference

This section is for lookup. Each table gives the name, the arguments, what the call returns and the errors it adds. The guides in [SDK](/docs/developers#sdk), [Sessions from code](/docs/developers#sessions-from-code), [Projects from code](/docs/developers#projects-from-code) and [React](/docs/developers#react) show each call in use.

Conventions in this section:

- `kortix` is the client from `createKortix`. `p` is `kortix.project(projectId)`. `s` is `kortix.session(projectId, sessionId)`.
- Route paths are relative to `backendUrl`, which ends in `/v1`. The [REST reference](/docs/developers#rest-api) lists each route.
- A failed call throws `ApiError`. `status` is the HTTP status, `code` is the API's error code, `details` is the parsed response body and `detail` is the FastAPI error field. The error catalog is in [Sessions from code > Errors and retries](/docs/developers#errors-and-retries).
- Creating a handle (`kortix.project(id)`, `kortix.session(pid, sid)`, `p.connector(slug)`) sends no request.

### Entry points and stability

| Import | Status | What it holds |
| --- | --- | --- |
| `@kortix/sdk` | Stable | The client and every framework-free module. It imports no React and no `node:*` module. It runs in browsers, Node 18 or later, Bun and edge runtimes. |
| `@kortix/sdk/react` | Stable | The React hooks. It is the only entry that imports React. Peers: `react`, `@tanstack/react-query`. |
| `@kortix/sdk/server` | Stable | Server helpers for Node and Bun (table below). It imports `node:async_hooks`. Never bundle it for a browser. |
| `@kortix/sdk/genui` | Stable | The Generative UI parser and text renderers. Peers: `zod`, `@openuidev/lang-core@0.3.1`. See [Generative UI](/docs/developers#generative-ui). |
| `@kortix/sdk/genui/fence` | Stable | `splitGenui`, `separateGenuiClosers`, `genuiVersionOf`, `genuiVersionFromClassName`. It loads no `zod`. |
| `@kortix/sdk/genui/react` | Stable | `GenuiBlock`, `useGenuiParse`, `defaultGenuiComponents`. |
| `@kortix/sdk/wire-message-id` | Stable | `mintWireMessageId` and `WIRE_MESSAGE_ID` in one module with no imports. The root exports the same names. |
| `@kortix/sdk/workspace-search` | Stable | `createWorkspaceSearchClient` and its ranking helpers (see [Workspace search](#workspace-search)). |
| `@kortix/sdk/capture-rules` | Stable | The Kortix Capture constants: `CAPTURE_LAYERS`, `CAPTURE_EXCLUSION_GROUPS`, `CAPTURE_RETENTION_LIMITS`, `CAPTURE_FRAME_INTERVALS_MS`, `CAPTURE_MAX_HEIGHTS`, `CAPTURE_DEVICE_DEFAULTS`, `normalizeCaptureAppId`, `normalizeCaptureDomain`. |
| `@kortix/sdk/internal/*` | Internal | State stores of the Kortix web app, for example `sync-store`, `server-store`, `sandbox-connection-store`, `idb-sync-cache`, `diagnostics-store`, `managed-storage`. No semver. Do not import. The first three are also deprecated. |
| Other subpaths | Deprecated | Aliases that resolve until the next major. See [Deprecated names](#deprecated-names). |

Stable entry points follow semver. A deep path into `dist/` has no semver.

#### `@kortix/sdk/server`

| Export | What it does |
| --- | --- |
| `createScopedKortix(config)` | A client like `createKortix`, but every call, including calls on its `project()` and `session()` handles, uses this config only. Create one per request. See [Serve many users from one backend](/docs/developers#serve-many-users-from-one-backend). |
| `runWithKortix(config, fn)` | Runs `fn` with a request-scoped config (Node `AsyncLocalStorage`). |
| `getScopedConfig()` | The config in scope, or `undefined` outside `runWithKortix`. |
| `forwardKortixRequest(options)` | Forwards one same-origin request to the Kortix API with an upstream token. It strips host credentials and keeps a streaming response body. An upstream failure returns `502` with `{ error: 'Upstream request failed' }`. |
| `createKortixAuth(options)` | Sign in with Kortix for your own app: OAuth 2.1 with PKCE, an encrypted session cookie, refresh, sign-out, `/me` and a same-origin `/proxy`. See [Sign in with Kortix](/docs/developers#sign-in-with-kortix). |
| `readAppViewer(request, options?)` | Inside a Kortix App: verifies the signed viewer header and returns the viewer, or `null`. |
| `createAppViewerKortix(request, options)` | Inside a Kortix App: a client that calls the Kortix API as the viewer. |
| `createKortixAppGuard(options)` | One viewer resolver for an App. `requireViewer` and `requireGroup` return a viewer or a `Response`. See [Know the viewer in a Kortix App](/docs/developers#know-the-viewer-in-a-kortix-app). |
| `KortixAuthError`, `AppViewerUnavailableError` | Errors of `createKortixAuth` and of the App viewer helpers. |
| `safeReturnTo` | Accepts a same-origin return path and rejects every other value. |
| `APP_VIEWER_HEADER`, `APP_VIEWER_TOKEN_HEADER`, `APP_VIEWER_SECRET_ENV`, `KORTIX_SESSION_SENTINEL` | Header and environment variable names. |

### The client

`createKortix(config)` returns the client. Its options are in [Create the client](/docs/developers#create-the-client).

| Member | What it reaches | Routes |
| --- | --- | --- |
| `config` | The config in effect. | none |
| `auth` | Headless sign-up, sign-in, magic link, OTP, OAuth, SSO, MFA, refresh, sign-out, and the token store `auth.session(options)`. | `/auth/…` |
| `accounts` | Accounts, members, invites, API keys, connected apps, account secrets, branding, audit log. | `/accounts/…` |
| `iam` | Assignments, roles, permissions, groups, agent identities, OAuth clients, permission probes. | `/accounts/{accountId}/iam/…` |
| `accountInvites` | The invitee's side of an account invite. | `/account-invites/…` |
| `projects` | Project list, create, provision, model access and custom model providers. | `/projects/…` |
| `project(projectId)` | The [project handle](#the-project-handle). | `/projects/{projectId}/…` |
| `session(projectId, sessionId)` | The [session handle](#the-session-handle). | `/projects/{projectId}/sessions/{sessionId}/…` |
| `connectors` · `connector(slug)` | Connector calls with a token that carries its project, such as a session token. Same methods as the data-plane half of `p.connectors` and as `p.connector(slug)`. | `/connectors/…` |
| `github` | GitHub App installations and repository links, per account. | `/projects/github/…` |
| `gitBackend` | The instance git backend that holds Kortix-managed repositories. | `/projects/git/backend` |
| `billing` | Plan, credit balance, transactions, usage, per-session usage, subscription. | `/billing/…` |
| `sandboxShares` | Public share links for one sandbox port. | `/p/share` |
| `connectStatus()` | Whether the managed OAuth connect provider is configured on this instance. | `/connectors/connect-status` |
| `marketplace` | The public marketplace catalog and its sources. | `/marketplace/…` |
| `capture` | Kortix Capture for one account, the device sign-in approval, and the caller's own reads. | `/accounts/{accountId}/capture/…`, `/capture/…` |
| `notifications` | The push tokens of a person's native devices. | `/notifications/…` |
| `mobileClientPolicy()` | `{ minimum_version, store_url_ios, store_url_android }`. A `null` field means the operator set no value. Needs no token. | `/clients/mobile` |
| `validateToken()` | Checks a pasted API key. Never throws. | `/accounts/me` |
| `runtime()` | Escape hatch: the runtime client of the process-wide active sandbox. It throws on a scoped client. Use the [session handle](#the-session-handle) instead. | sandbox |

#### `kortix.projects`

| Method | What it does |
| --- | --- |
| `list()` · `listForAccount(accountId)` | Projects the caller can open · projects in one account. |
| `get(projectId)` · `detail(projectId)` | The summary · the full detail with config. |
| `provision(input)` | A new project on a new Kortix-managed repository. Input: `{ name, account_id?, seed_starter?, starter_template?: 'general-knowledge-worker' \| 'minimal', marketplace_items?, source_item_id?, default_branch?, icon?, icon_glyph?, idempotency_key? }`. |
| `create(input)` · `createRepo(input)` | A project on an existing `repo_url` · a project on a new GitHub repository. |
| `update(projectId, input)` · `archive(projectId)` | Change settings · archive. |
| `llmCatalog(projectId)` · `modelPicker(projectId)` | The full model catalog · the compact list for a model picker. |
| `modelAccess(projectId)` · `setModelAccess(projectId, { target: 'provider' \| 'model', id, enabled })` | Read · turn one provider or model on or off. Provider id `kortix` is the Kortix-managed models. Disabling the current default returns `409 cannot_disable_default`. |
| `customLlmProviders(projectId)` | Every custom OpenAI-compatible provider of the project, as `CustomLlmProvider[]`. A legacy key-only row has `configured: false`. |
| `setCustomLlmProvider(projectId, providerId, { name, base_url, models })` | Create or replace one custom provider. Each `models` entry is `{ id, name, context?, output?, attachment?, reasoning? }`; a field you leave out comes from the endpoint's `GET /models`. Rejects with `400` `invalid_body`, `reserved_provider_id` or `unsafe_base_url`. |
| `deleteCustomLlmProvider(projectId, providerId)` | Delete the definition, then its `CUSTOM_<ID>_API_KEY` project secret. A missing definition or key is not an error. |
| `sandboxHealth(projectId)` · `sandboxTemplates(projectId)` | Sandbox build health · build templates. |
| `sessions(projectId)` · `createSession(projectId, input?)` | Same as `p.sessions.list()` · `p.sessions.create()`. |

`idempotency_key` is 1 to 200 characters of `A–Z a–z 0–9 . _ : -`. Send one new value per create attempt. A repeat with the same key returns the first project (`201`, same `project_id`). A repeat while the first call runs returns `409 provision_in_flight`: retry with the same key.

#### `kortix.github` and `kortix.gitBackend`

| Method | What it does |
| --- | --- |
| `github.getInstallation(accountId)` · `github.listInstallations(accountId)` | The account's GitHub App installation · the installations the user can reach. |
| `github.listLinkableInstallations(input)` · `.linkInstallation(input)` · `.saveInstallation(input)` · `.deleteInstallation(accountId, installationId?)` | Installations the GitHub user may link · link · record · unlink. |
| `github.listRepositories(accountId, installationId?)` · `.listRepositoryBranches(…)` | Repositories the installation sees · their branches and default branch. |
| `github.linkRepository(input)` | Import a repository as a project. |
| `github.replaceProjectRepository(input)` | Point a project at another repository. A stale `expected_repo_url` returns `409`. New sessions use the new repository. Running sessions keep their checkout. |
| `github.storeUserToken(input)` | Store the user's GitHub authorization. It is necessary to create a repository in a personal GitHub account. |
| `gitBackend.get()` | `{ configured, kind: 'app' \| 'pat' \| null, owner }`. Any signed-in user can read it. |
| `gitBackend.repositories({ search?, limit? })` | Repositories under that owner. Self-host operators only; other callers get `403`. |

#### `kortix.marketplace` and `kortix.sandboxShares`

| Method | What it does |
| --- | --- |
| `marketplace.items(options?)` · `.item(id)` · `.itemFile(id, path)` | Browse the catalog · one item · one file of an item. |
| `marketplace.marketplaces()` · `.featured()` | All marketplaces · featured marketplaces. |
| `marketplace.sources.list()` · `.add(input)` · `.remove(id)` | Marketplace sources. |
| `sandboxShares.list(sandboxId)` · `.create({ sandboxId, port, ttl?, label? })` · `.revoke(sandboxId, token)` | Public share links for one sandbox port. |

### Accounts, IAM, billing and notifications

#### `kortix.auth`

| Member | What it does |
| --- | --- |
| `signUp` · `signInWithPassword` · `sendMagicLink` · `verifyOtp` · `signInWithProvider` · `exchangeCode` · `signInWithSso` | Sign-in flows. |
| `refresh` · `resetPassword` · `updatePassword` · `updateUserMetadata` · `user` · `signOut` | Token and account maintenance. |
| `mfa.enroll` · `mfa.challenge` · `mfa.verify` · `mfa.unenroll` | Multi-factor authentication. |
| `session(options)` | A token store. It saves tokens through your storage adapter and refreshes them 60 seconds before they expire. Pass its `getToken` to `createKortix`. |

Arguments, `HeadlessAuthError` codes and limits are in [Sign users in headlessly](/docs/developers#sign-users-in-headlessly).

#### `kortix.accounts`

| Method | What it does |
| --- | --- |
| `list()` · `get(accountId)` · `create({ name })` · `updateName(accountId, name)` · `leave(accountId)` | The accounts the caller belongs to. |
| `members(accountId)` · `invite(accountId, input)` · `updateMemberRole(accountId, userId, role)` · `removeMember(accountId, userId)` | Members and their account role. |
| `invites(accountId)` · `cancelInvite(accountId, inviteId)` · `resendInvite(accountId, inviteId)` | Pending invites. |
| `tokens.list(accountId?, { mine? })` · `tokens.create({ accountId?, name, expiresAt?, projectId? })` · `tokens.revoke(tokenId, accountId?)` | Personal access keys (`kortix_pat_…`). `mine: true` lists only the caller's keys. `projectId` binds the key to one project. |
| `connectedApps.list()` · `connectedApps.revoke(…)` | OAuth and MCP clients the person approved, across all accounts. Route `/oauth/grants`. |
| `secretResources.list` · `.create` · `.rotate` · `.retry` · `.remove` · `.grant` · `.revoke` · `.setAccess` | Account-level provider secrets and the projects that may use them. |
| `branding.get(accountId)` · `.update(accountId, { app_name })` · `.uploadAsset(accountId, kind, file)` · `.removeAsset(accountId, kind)` · `.reset(accountId)` | Organization branding. `kind`: `logo`, `icon`, `favicon`, `logo_dark`, `icon_dark`, `favicon_dark`. |
| `audit.log(accountId, filters?)` · `audit.export(accountId, { format: 'csv' \| 'jsonl', …filters })` | The audit log, filtered by project, session, actor, source, outcome, request, resource, action or time · the same events as a file. |
| `audit.activity(accountId, { since?, until?, projectId?, actor?, client?, actorType?, credentialKind? })` | Activity per UTC day: per user, the active days, distinct active hours and events. `client: 'browser'` keeps only a person in a web page. |
| `audit.webhooks.list` · `.create` · `.update` · `.remove` | Signed webhooks that stream audit events to a SIEM. |

#### `kortix.iam`

An assignment is the one grant record: one principal (`user`, `group`, `service_account`, `pending` or `project`), one role, one scope (`account` or one `project`), an optional object (`agent`, `skill`, `secret`, `app`, `trigger` or `connection`) and an optional `expiresAt`.

| Method | What it does |
| --- | --- |
| `assignments.list(accountId, filter?)` | Live assignments. Filters: `principalType` with `principalId`, `scopeType`, `scopeId`, `objectType`, `objectId`, `roleId`, `includeExpired`. |
| `assignments.create(accountId, { principal, scope, object?, expiresAt?, roleId \| roleKey })` · `assignments.revoke(accountId, assignmentId)` | Grant · revoke. `roleKey` names a built-in role: `owner`, `admin`, `member`, `manager`, `agent-user`. |
| `permissions.list(accountId)` · `permissions.forRole(…)` | The permission catalog (`action`, `scope_type`, `resource_type`, `delegable`, `description`, `area`, `level`, `implies`) · one role's permissions. |
| `roles.list` · `.create` · `.update` · `.setPermissions` · `.remove` · `.usage` | Custom roles. Without the account's `rbac` entitlement the API returns `402 entitlement_required`. |
| `groups.list` · `.get` · `.create` · `.update` · `.remove` · `groups.members.list` · `.add` · `.remove` | Groups and their members. |
| `agentIdentities(accountId)` | Agent principals, to bind a role to an agent. |
| `oauthClients.list` · `.get` · `.create` · `.update` · `.rotateSecret` · `.remove` | Sign in with Kortix clients. The API returns the secret once, on `create` and on `rotateSecret`. |
| `can(accountId, userId, { action, resourceType?, resourceId? })` · `canBatch(accountId, userId, probes)` | Whether a principal may do one action · many actions in one request, answers in input order. An unknown action returns `400`. |

Probe helpers (root exports):

| Name | What it does |
| --- | --- |
| `PROJECT_ACTIONS` | Every project action the API answers about. Example: `PROJECT_ACTIONS.PROJECT_GITOPS_MERGE` is `project.gitops.merge`. |
| `TRIGGER_CONTROL_ACTIONS` · `triggerControlsFrom(cans)` | The trigger probes · `{ canCreate, canFire, canUpdate, canDelete }`. Create, update and delete also need `project.write`. |
| `REVIEW_CONTROL_ACTIONS` · `changeRequestControlsFrom(cans)` | The review probes · `{ canOpen, canMerge, canClose }`. Open needs `project.write` and `project.gitops.push`. Merge needs `project.write` and `project.gitops.merge`. Close and reopen need `project.write`. |
| `reviewVerdictAllowed(item, verdict, cans)` | Whether to offer one verdict on one review item. Approving a change request merges it, so it follows `canMerge`. Other verdicts need `project.write` and `project.review.act`. |

A connector call carries `can_resolve` (on a `SessionAuditAction`) or `canResolve` (on a review item). Show Approve and Deny only when it is not `false`. `resolvers` is `managers` for a session that an API token or a service account started, else `managers_and_launcher`.

#### `kortix.accountInvites`

| Method | What it does |
| --- | --- |
| `listMine()` | Pending invites addressed to the caller's email. |
| `describe(inviteId)` · `accept(inviteId)` · `decline(inviteId)` | Act on one invite. They need no account membership. |
| `acceptAndLand(inviteId, invite?)` | Accept and return `{ accepted, landing }`. `landing` is `{ kind: 'project', projectId, accountId }` for the first project the invite granted, or `{ kind: 'account', accountId }`. Pass the invite you read: an accept that grants nothing then lands in the first project the invite names. |

`accountInviteLanding(accepted, invite?)` (root export) applies the same rule to an invite accepted earlier, with no request.

#### `kortix.billing`

| Method | What it does |
| --- | --- |
| `accountState(accountId?)` · `accountStateMinimal(accountId?)` | Plan, credit balance and limits · the short form. |
| `creditBreakdown(accountId?)` | The credit balance per bucket. |
| `transactions(params?)` · `transactionsSummary(params?)` | Transaction history · totals. |
| `usageHistory(params?)` · `usageRollup(params?)` | Usage over time · usage grouped by model, provider or day. |
| `sessionCosts.list({ accountId?, projectId?, limit?, offset? })` · `sessionCosts.get(sessionId, options?)` | Usage records per session · one session's model and compute ledger. |
| `tierConfigurations()` | The named plan tiers and the allowed range of `monthlyUsd`. |
| `setPlan({ monthlyUsd, successUrl, cancelUrl, locale?, accountId? })` | Subscribe to, or change, the credits plan. Resolves to `{ status: 'checkout_created', checkout_url }` (redirect there), `{ status: 'updated' \| 'unchanged' }`, or `{ status: 'payment_pending', client_secret }`. React: `useSetBillingPlan()`. |
| `previewPlan({ monthlyUsd, accountId? })` | What `setPlan` would do, with no change. |
| `acceptEnterpriseOffer(…)` | A Stripe Checkout link for the open Enterprise offer. Needs `billing.write`. `404` when no offer is open, `409` when the offer is invoiced. |
| `contactSales(…)` | Sends a contact request to Kortix sales. |
| `subscription.createPortalSession(returnUrl, accountId?)` · `.cancel(feedback?, accountId?)` · `.reactivate(accountId?)` | Manage the subscription. |
| `credits.purchase(input)` · `credits.autoTopupSettings(accountId?)` · `credits.configureAutoTopup(input)` | Buy credits · read and set auto top-up. |

`canOfferTopup(state)` and `canManageBilling(state)` (root exports) read an account state. `canOfferTopup` is `true` only when the plan may buy credits and the caller may change billing. Both return `false` while `state` is `undefined` or `null`. Retired billing calls are in [Deprecated names](#deprecated-names).

#### `kortix.notifications`

Every method except `releaseDeviceToken` needs a person's credential. A service account gets `403`.

| Method | What it does |
| --- | --- |
| `registerDeviceToken(input)` · `unregisterDeviceToken(token)` · `releaseDeviceToken(token, { backendUrl? })` | A native app's push token. `releaseDeviceToken` needs no credential. |

A session push goes to the phones of the person who created the session. It goes out when a turn ends or fails, or when the agent asks a question or for a permission. While one of that person's tabs holds a presence lease on the session (`s.presence`), no push goes out.

#### `kortix.capture`

Capture belongs to the account. `kortix.capture.account(accountId)` binds one account. Every account call returns `403 capture_disabled` while Capture is off for the account. Reading another member's data needs the `capture.read_all` permission and writes a `capture.member_view` audit event. Writes need `capture.manage`.

| Method | What it does |
| --- | --- |
| `account(id).workspace.get()` · `.workspace.setEnabled(on)` | `{ account_id, enabled, can_read_all, can_manage, updated_at }` · turn Capture on or off. |
| `account(id).devices.list({ userId?, scope? })` | Devices with live status: `recording`, `paused`, `permission_missing`, `not_recording`, or `offline` after 120 s without a status. `scope: 'account'` lists every member's devices. |
| `account(id).devices.revoke(deviceId)` · `.devices.link(deviceId, userId \| null)` · `.devices.assetUrl(deviceId, name)` | Revoke a device's token · link a device to a member, or unlink it · a 5-minute signed URL of an action screenshot. |
| `account(id).enrollment.tokens.list()` · `.tokens.create(input)` · `.tokens.revoke(tokenId)` | IT rollout tokens. |
| `account(id).enrollment.list({ status? })` · `.enrollment.approve(enrollmentId, userId?)` · `.enrollment.deny(enrollmentId)` | Computers that presented a rollout token. |
| `account(id).policy.get()` · `.policy.set(policy)` | Layers, privacy, retention, recording and notice. |
| `account(id).storage.get()` · `.storage.set(input)` | Where the account keeps recordings: the managed store or its own S3 bucket. |
| `account(id).timeline.get(query?)` · `.timeline.items(query?)` · `.timeline.days(query?)` | App and window runs for a UTC day or a window up to 31 days · frames, actions and audio in a window, 500 of each · the days with recorded items, newest first. |
| `account(id).search({ q, kinds?, app?, from?, to?, limit?, userId?, scope? })` | Full-text search over on-screen text, actions and audio, newest first. |
| `account(id).frame(frameId, { userId? })` · `.media(chunkId, { userId? })` | One frame with its on-screen text and a 5-minute signed video URL · signed URLs of an item's video and audio. |
| `deviceGrant(userCode)` · `approveDevice(userCode, accountId?)` · `denyDevice(userCode)` | The person's half of the device sign-in. |
| `me.search(query)` · `me.timeline(query)` · `me.frame(frameId)` | Reads for the person the token acts for, in the token's account. A credential that acts for no person gets `403 capture_no_human`. |

### The project handle

`kortix.project(projectId)` returns `p`. Every method has the project id bound. `p.session(sessionId)` returns the [session handle](#the-session-handle). Task guides for this handle are in [Projects from code](/docs/developers#projects-from-code).

#### Project

| Method | What it does |
| --- | --- |
| `get()` · `detail()` · `update(input)` · `archive()` | Read · full detail with config · change settings · archive. |
| `llmCatalog()` · `modelPicker()` · `modelAccess()` · `setModelAccess(change)` · `customLlmProviders()` · `setCustomLlmProvider(providerId, input)` · `deleteCustomLlmProvider(providerId)` · `sandboxHealth()` | As on [`kortix.projects`](#kortixprojects), with the project id bound. |
| `validateManifest(raw)` | Validates `kortix.yaml` text on the server with the schema a merge uses. |
| `audit(options?)` | The project's audit timeline. |
| `skills.create(input)` · `skills.remove(…)` | Write a skill to the default branch · remove one. A whole-folder removal includes user edits; shared dependencies stay. |
| `attachments.upload(…)` · `.delete(…)` · `.createController(options?)` | Prompt attachments, and a controller that tracks uploads for a composer. |
| `onboardingComplete(…)` | Marks project onboarding done. |
| `updateFeatureFlag(key, enabled)` | Sets one flag for the project (`PATCH /projects/{projectId}/features`). `key` is one of `FEATURE_FLAG_KEYS`. `enabled: null` clears the override. Needs `project.settings.write`. |
| `connector(slug)` · `session(sessionId)` | One connector handle · the session handle. |

A route behind a flag that is off returns `403` with `code: 'feature_disabled'` and `feature`. Test it with `isFeatureDisabledError(error)`. Read the key with `featureDisabledKey(error)`.

#### Sessions

| Method | What it does |
| --- | --- |
| `sessions.list(options?)` | One page of sessions as an array. |
| `sessions.listPage(options?)` | One page plus `next_cursor`. `null` means the last page. A cursor is valid only for the filters that issued it. |
| `sessions.create(input?)` | Creates a session. The sandbox starts in the background. Fields: [Create a session](/docs/developers#create-a-session). |

Options of `list` and `listPage`:

| Option | Values |
| --- | --- |
| `scope` | `visible` (default) or `project`: the manager inventory. Both leave out sessions the caller cannot open. |
| `limit` · `cursor` | 1 to `PROJECT_SESSION_PAGE_MAX_LIMIT` (200) rows · the `next_cursor` of the previous page. |
| `parent` | `root`: top-level sessions, each with `child_count`. A session id: that session's children. |
| `startedBy` | `me`, `others` or `automated`. |
| `createdBy` | A user id: the sessions that person started. |
| `agent` | An agent slug: the sessions that run that agent. |
| `updatedAfter` | ISO 8601: the sessions with activity at or after that time. |
| `q` | Search over every session the caller can open, 1 to 200 characters. |
| `labels` | The sessions that carry every label (exact match). |
| `view` | `pinned`, `archived`, or `deleted` (restorable sessions, each with `restore_until`). Without `view`, the list leaves out the caller's archived sessions. |

Each row carries `parent_session_id` and `initiator: { type: 'member' | 'trigger' | 'channel' | 'api' | 'system', id, label }`. `sessionParentId(session)` (root export) reads the parent.

#### Files, git and change requests

These methods read and write the project repository. To work with files in a running session, use [`s.files`](#the-session-handle).

| Method | What it does |
| --- | --- |
| `files.listDirectory({ ref?, path?, cursor?, limit? })` | One folder level: `{ entries, truncated, next_cursor }`. Without `cursor` and `limit`, one response holds up to 5,000 entries. With either, a page holds `limit` entries (default 500, maximum 2,000). Pass `next_cursor` as `cursor` while it is a string. Every page reads the commit of the first page. |
| `files.list(options?)` | Every file under a path, recursive, cut at 1,000. A cut response carries `X-Kortix-Truncated: 1`. |
| `files.read(path, ref?)` · `files.history(path, { ref?, limit?, skip? })` | A file at a ref · its commits. |
| `files.write({ path, ref, content, expected_revision })` | Commits an edited text file only when its blob revision is unchanged. Returns `{ commit_sha }`. |
| `files.search(query, { content?, ref?, limit? })` | Search file names, or contents with `content: true`. A content match carries `line_number` and `line_text`. |
| `files.archive(ref, path?)` · `files.archiveRequest(ref, path?)` | Download a tarball as a `Blob` · the same download as a request your host streams to disk. |
| `git.commits()` · `git.commit(sha)` · `git.commitDiff(sha)` | The log · one commit · its diff. |
| `git.branches()` · `git.versionDiff(…)` | Branches · the diff between two refs. |
| `git.inviteCollaborator(…)` | Invites a GitHub user to a Kortix-managed repository. |
| `gitToken()` | A fresh scoped push token for a Kortix-managed repository. A bring-your-own repository returns `409`. |
| `changeRequests.list(status?, { limit?, cursor? })` · `.get(crId)` · `.diff(crId)` · `.mergePreview(crId)` | Read change requests. `list` returns one page, newest first: 50 by default, maximum 500. |
| `changeRequests.open(input)` · `.update(crId, input)` · `.merge(crId, input?)` · `.close(crId, input?)` · `.reopen(crId, input?)` | The change request lifecycle. |
| `changeRequests.requestChanges(crId, input)` | Records feedback and can send it to the session that made the change. |

Routes: `/projects/{projectId}/files`, `/commits`, `/branches`, `/version-diff`, `/change-requests`. See [Git](/docs/developers#git).

#### Review and approvals

| Method | What it does |
| --- | --- |
| `review.list(params?)` | One page of the review inbox (change requests, approvals, agent outputs): `{ review_items, next_cursor }`, 50 items by default, maximum 200. Order: Needs you, Waiting, Done. Filters: `segment`, `kind`, `sessionId`. An item decided between two pages can come back on a later page: key rows by `review_item_id`. |
| `review.get(id)` · `.submit(input)` · `.act(id, …)` · `.bulkAct(input)` · `.recoverChangeRequest(…)` | One item, including `cr:<id>` and a pending `call:<id>` · submit · decide one · decide many · recover a change request. |
| `review.summary()` | `{ needs_you_total, needs_you_by_session }`, counted on the server. Use it for a badge, not the list. |
| `approvals.list(options?)` · `approvals.sessionsNeedingInput(options?)` | Connector calls that wait for a decision · sessions blocked on one, or on an open agent question, with a count per session. |
| `approvals.resolve(executionId, 'approve' \| 'deny', { note? })` | Decides one call. `note` goes to the agent as an instruction. |

#### Secrets, tokens and setup links

| Method | What it does |
| --- | --- |
| `secrets.list()` | Secret metadata, never values. `status` is the shared value's state and `mine.status` the caller's own value's state. `'undecryptable'` means the server cannot decrypt it: write the value again. |
| `secrets.upsert(input)` · `secrets.remove(name)` | Create or update a write-only value with its delivery policy · delete. |
| `secrets.setStrategy(identifier, 'runtime' \| 'egress' \| 'broker' \| 'denied', options?)` | The exposure: `runtime` is an environment variable, `egress` is injected at the network edge, `broker` keeps the value out of the session, `denied` is none. |
| `secrets.broker(identifier, request)` | Sends one policy-bound HTTPS request with the secret. Needs a session-scoped token. |
| `secrets.setPersonal(name, { value?, active? })` · `secrets.removePersonal(name)` | The caller's own value, used in the caller's sessions. `active: false` keeps it but uses the shared value. |
| `secrets.setGitCredential(input)` | The credential for a bring-your-own git repository. |
| `secrets.listProviderOAuth()` · `.startProviderOAuth(…)` · `.pollProviderOAuth(…)` · `.removeProviderOAuth(provider)` | Model provider subscription logins, through a device-code flow. |
| `tokens.list()` · `tokens.create(input?)` · `tokens.revoke(tokenId)` | Project API keys. Kortix also mints one per session as `KORTIX_TOKEN`. |
| `setupLinks.requestSecret(input)` · `setupLinks.requestConnector(input)` | A link a person opens to enter a secret or connect an app, without full project access. |

#### Connectors and policies

| Method | What it does |
| --- | --- |
| `connector(slug).run(action, args, options?)` | The action's output. Throws `ConnectorCallError`, or `ConnectorApprovalPendingError` while the call waits for approval. |
| `connector(slug).call` · `.describe(action?)` · `.accounts()` · `.paginate(action, args, { next, maxPages? })` · `.uploadAttachment(content, input)` | The raw result · schemas · callable accounts · a page iterator (`ConnectorPageLimitError` past `maxPages`) · stage a file. |
| `connectors.call(tool, args?, { account? })` · `connectors.callAction(slug, action, args, options?)` | Call `<connector>.<action>` · the same, typed by `kortix connectors types`. |
| `connectors.catalog(options?)` · `.tools()` · `.search(query)` · `.describe(tool)` · `.accounts(slug)` · `.uploadAttachment(…)` | Find and inspect callable tools and accounts. |
| `connectors.list()` · `.config(slug)` · `.create(input)` · `.remove(slug)` · `.sync()` · `.auth.discover(input)` | Configure connectors. `auth.discover` reads auth from an OpenAPI spec, a Postman collection or an endpoint. |
| `connectors.setName` · `.setSensitive` · `.setCredentialMode` · `.setCredential` · `.deleteCredential` · `.setOAuth2ClientCredentials` | Connector settings and the stored credential of its shared account. |
| `connectors.policies.get(slug, { policySet? })` · `.policies.set(slug, policies, { policySet? })` | One connector's base rules, or the rules of one policy set. A rule is `{ match, action, conditions? }`. |
| `connectors.policySets.list(slug)` · `.upsert(slug, name, { description? })` · `.rename(slug, name, newName)` · `.remove(slug, name)` | Policy sets: each set with its rules and the agents that use it · create one or set its description · rename it and every agent reference · delete it. `remove` returns `409 policy_set_in_use` while an agent uses the set. Root exports: `listConnectorPolicySets`, `upsertConnectorPolicySet`, `renameConnectorPolicySet`, `deleteConnectorPolicySet`. |
| `connectors.agents(…)` · `connectors.rulesMatrix()` | Every declared agent on one connector, with its grant and rule set · every connector against every agent, in one request. |
| `connectors.connections.list()` · `.listAll()` · `.reconcile(…)` · `.reconcileMember(…)` · `.updateCredential(…)` · `.revoke(…)` · `.activate(…)` · `.setDefault(…)` · `.rename(…)` · `.share(…)` · `.addComputer(…)` | Connected accounts: shared or private, defaults, labels, sharing, paired computers. |
| `connectors.discover.list` · `.sections` · `.detail` · `.tools` · `.install` | The app catalog. `list` and `sections` take `q`, `category`, `kinds` (`mcp`, `composio`, `openapi`, `graphql`) and `oneClick`. `install({ id, way?, name?, slug? })` returns `next`: `connect`, `credential` or `ready`. |
| `connectors.listMoves()` · `connectors.move(slug, { connectionId? })` | Move a Pipedream or Composio connector to its Kortix MCP app, with the same slug. `listMoves` returns `ConnectorMove[]`. `move` returns `ConnectorMoveResult`: `status: 'sign_in'` with a `connection_id`, or `status: 'moved'`. Root exports: `listConnectorMoves(projectId)`, `moveConnector(projectId, slug, { connectionId? })`. |
| `connectors.pipedream.listApps` · `.listSections` · `.connect` · `.finalize` | Legacy Pipedream connect for OAuth apps. |
| `policies.list()` · `policies.set(policies)` | Project policies. |
| `agentIdentities()` · `setAgentScope(agentName, scope)` | The project's agents as principals · bind secrets, connectors and policy sets to one agent. `scope.policy_sets` is `{ <slug>: <name> \| null }`, merged per connector; `null` or `'base'` removes the entry. |

#### Triggers and reminders

| Method | What it does |
| --- | --- |
| `triggers.list()` | Every trigger, with parse errors and `triggers_paused`. |
| `triggers.create(input)` · `.update(slug, input)` · `.remove(slug)` | Write a trigger. `name` and `type` are required. `type` is `cron`, `webhook` or `app`. The API rejects `type: monitor` with `MONITOR_REMOVED` and names the replacement: an App and a `type: app` trigger. `prompt_template` is required unless `target` (`{ app, path?, method?, payload? }`) calls an App. A schedule takes `cron`, `every` or `run_at`. `type: 'app'` takes `app` and, optionally, `filter: { 'event.name': … }`. |
| `triggers.fire(slug, { payload?, dryRun? }?)` | Runs one trigger now, also while triggers are paused. `payload` deep-merges over the manual payload. `dryRun: true` writes nothing and returns `ProjectTriggerDryRunResult` (`rendered_prompt`, `filter_matches`, `session_key`, `payload`). A real fire returns `status` (`fired`, `queued` or `deduped`), `session_id`, `command_id` and `run_id`. |
| `triggers.runs(slug, { cursor?, limit? }?)` · `triggers.projectRuns({ status?, cursor?, limit? }?)` | One trigger's runs · every run of the project, newest first: `{ runs, next_cursor }`. `limit` is 1 to 100, default 25. A page can hold fewer than `limit` runs while `next_cursor` is set. |
| `triggers.replay(slug, runId, { confirmUnverified? }?)` | Fires again with the run's stored payload and the current spec. `409 run_in_progress` while the run is queued or running. `409 payload_unverified` for an unverified webhook payload without `confirmUnverified: true`. `422 payload_truncated` for a payload over 64 KiB. |
| `triggers.setActivation(paused)` | Pauses or resumes every trigger of the project. |
| `reminders.list()` · `reminders.updateMany(…)` · `reminders.removeMany(…)` | Reminders on every session the caller can open. One session's reminders are `s.reminders`. |

All trigger fields are in [Run triggers](/docs/developers#run-triggers) and [Automations](/docs/guide#automations).

#### Apps

| Method | What it does |
| --- | --- |
| `apps.list()` · `.get(appId)` · `.create(input)` · `.update(appId, input)` · `.remove(appId)` | App inventory and lifecycle. |
| `apps.artifacts.register(input)` · `.uploadArchive(…)` · `.finalize(…)` | Upload a build. |
| `apps.deployments.create(appId, …)` · `.list(appId)` · `.get(…)` · `.logs(…)` · `.remove(…)` | Releases and their logs. `remove` deletes one release that is not live. |
| `apps.rollback(appId, deploymentId)` · `.start(appId)` · `.stop(appId)` | Move traffic to an earlier release · start · stop. |
| `apps.machines.list(appId)` · `.start` · `.stop` · `.restart` · `.destroy(appId, machineId)` | Machines. `destroy` keeps the machine's volume. |
| `apps.scale(appId, { <group>: count })` | Machines per process group, 0 to 20. |
| `apps.exec(appId, input)` | Runs one command in a machine. Needs `project.app.admin`. Each call is audited. |
| `apps.volumes.list(appId)` · `.create` · `.update` · `.destroy(appId, volumeId, confirmName)` · `.snapshots` · `.snapshot` · `.restore(appId, volumeId, snapshotId)` · `.fork` | Persistent volumes and snapshots. |
| `apps.access.get(appId)` · `.update(appId, …)` · `.session(appId)` · `.agents(appId)` | Who can open the App · an access session · the agents whose `kortix.yaml` grant names the App (read-only). |
| `apps.token(appId)` | A 15-minute sign-in token for the App that names the caller. Needs the `member_tokens` capability. |
| `apps.authEnv(appId)` | `KORTIX_AUTH_ISSUER`, `KORTIX_AUTH_AUDIENCE` and `KORTIX_AUTH_JWKS`, to verify App tokens with no network call. |

Routes: `/projects/{projectId}/apps/…`. See [Deploy and operate Apps](/docs/developers#deploy-and-operate-apps) and the [Apps reference](/docs/developers#apps-reference).

#### Gateway and models

| Method | What it does |
| --- | --- |
| `gateway.logs(options?)` · `gateway.log(logId)` | Model request logs. |
| `gateway.overview(days?)` · `.series(days?)` · `.breakdown(days?)` · `.sessions(days?)` · `.errors(days?)` | Rollups over a window of days. |
| `gateway.budgets()` · `.setBudget(input)` · `.deleteBudget(budgetId)` | Budgets. |
| `gateway.keys()` · `.createKey(name)` · `.revokeKey(keyId)` | Gateway API keys for your own tools. |
| `gateway.routing.get()` · `.set(policy)` · `.reset()` · `.preview(input)` | The routing policy (default model, vision model, ordered fallbacks) · resolve a route with no model call. |
| `gateway.playground(prompt, models, system?)` | Runs one prompt against 1 to 6 models. |
| `modelDefaults.get()` · `.set(input)` · `.clear(params)` | Default models per account, agent and project. |
| `setDefaultAgent(agentName)` | Sets `default_agent` in `kortix.yaml`. The agent must be declared and enabled. |

See [Models](/docs/guide#models).

#### Channels

| Method | What it does |
| --- | --- |
| `channels.slack.installation()` · `.mode()` · `.manifest()` · `.connect(input)` · `.completeInstall(input)` · `.disconnect()` | The project's Slack app. |
| `channels.slack.getFile(url)` · `.uploadFile(input)` | Download or upload a Slack file through the server. The bot token stays on the server. |
| `channels.teams.completeInstall(input)` | Finishes a Microsoft Teams install as the signed-in user. |

See [Channels](/docs/guide#channels).

#### Access

| Method | What it does |
| --- | --- |
| `access.list()` · `access.invite(email, role, expiresAt?)` · `access.update(userId, role)` · `access.revoke(userId)` | Project members. Roles: `manager`, `member`. |
| `access.pendingInvites()` · `.resendInvite(inviteId)` · `.revokeInvite(inviteId)` | Pending invites. |
| `access.requests()` · `.approveRequest(id)` · `.rejectRequest(id)` | Access requests. |
| `access.groupGrants()` · `.attachGroupGrant(…)` · `.updateGroupGrant(…)` · `.detachGroupGrant(groupId)` | Groups with project access. |
| `access.resourceGrants.list()` · `.create(input)` · `.remove(grantId)` | Access to one object, such as one agent or skill, for a member or a group. |

See [Teams](/docs/guide#teams).

#### Sandbox

| Method | What it does |
| --- | --- |
| `sandbox.list()` · `sandbox.snapshots()` | The project's sandboxes · built snapshots. |
| `sandbox.rebuildSnapshot(slug?)` · `sandbox.fixWithAgent()` | Rebuild a snapshot · ask an agent to fix a failed build. |
| `sandbox.createTemplate(input)` · `.updateTemplate(…)` · `.removeTemplate(templateId)` · `.buildTemplate(templateId)` | Sandbox templates. |
| `sandbox.setProvider(provider \| null)` | Pins the sandbox provider, or `null` for the platform default. Returns `kind: 'project'` when the change applies at once, or `kind: 'preparation'` while the new provider's image builds. |

See [Computers](/docs/guide#computers).

### The session handle

`kortix.session(projectId, sessionId)` or `p.session(sessionId)` returns `s`. The `session_id` is also the sandbox id. Methods that reach the runtime call `ensureReady()` first. The guide is [Sessions from code](/docs/developers#sessions-from-code).

| Area | Method | What it does |
| --- | --- | --- |
| Row | `get({ showErrors? }?)` | The session row. |
| Row | `update({ name?, labels?, metadata? })` | Changes the name, labels and metadata. |
| Row | `delete()` · `restore()` | Delete · restore a deleted session inside its window (`restore_until`). |
| Row | `setPinned(pinned)` · `setArchived(archived)` | The caller's own pin and archive. Pinning removes the archive; archiving removes the pin. No one else's list changes. Needs a person's credential; an agent token gets `403`. |
| Lifecycle | `start(…)` · `startOrThrow(…)` | Start the runtime; a failed request yields `null` · the same, but a failed request rejects with the `ApiError` (`status`, `code`, and a `402` billing `detail`). |
| Lifecycle | `ensureReady({ readyTimeoutMs?, session? }?)` | Provisions or resumes the sandbox and waits until it is ready. Default `readyTimeoutMs`: `180_000`. Idempotent. |
| Lifecycle | `restart()` · `stop()` | Restart the runtime · stop it. Both reset the handle's readiness. |
| Lifecycle | `recover()` | Continues a lost session on a new computer. A session is lost when the provider removed its sandbox (`start` fails with reason `runtime_identity_unavailable`). Refusals: `409 session_not_lost`, `409 session_transcript_missing`, `409 session_sandbox_removal_pending` (retry in a minute), `503 session_log_restore_off`. |
| Lifecycle | `health(init?)` | The runtime probe, including `capabilities`. Never throws on a non-2xx status. |
| Prompts | `send(text, { model?, agent? }?)` | Queues a prompt. `model` is `{ providerID, modelID }`. Resolves when the prompt is durable, not when the turn ends. |
| Prompts | `abort()` | Stops the current turn. |
| Prompts | `prompts.create(input)` · `.list()` · `.remove(promptId)` · `.retry(promptId)` · `.edit(promptId, text)` · `.interrupt(promptId)` · `.hold(held)` | The prompt inbox. |
| Events | `stream({ onEvent, onGapRehydrate?, signal? })` | Opens the live event stream. Resolves to a handle with `close()`. Options: [openEventStream](#event-stream). |
| Conversation | `messages({ conversationId?, limit?, before?, signal? }?)` | One page of the transcript (`kortix.transcript.v1`). `conversationId` reads a sub-agent conversation. |
| Conversation | `pending()` | Conversation statuses and the permission requests and questions that wait for an answer. |
| Conversation | `answerPermission(requestId, 'once' \| 'always' \| 'reject', message?)` | Answers a permission request. |
| Conversation | `answerQuestion(requestId, answers \| null)` | Answers a question, one answer per question, or dismisses it with `null`. |
| Conversation | `compact({ providerID, modelID }?)` | Summarizes the context. Needs the `session.compact` capability. |
| Conversation | `rewind(messageId)` · `restoreRewind()` | Rewind to a message · undo the rewind. Needs the `session.rewind` capability; pi does not serve it. |
| Stored reads | `turn()` | The current turn, read on the server. |
| Stored reads | `log({ thread?, before_seq?, limit? }?)` | One page of the stored session log, or `null` when the session has none. It answers while the sandbox is stopped. |
| Stored reads | `messageAuthors()` · `modelUsage()` · `cost()` · `audit(limit?, options?)` | Who wrote each message · model usage · the session's usage record · the session's audit actions. |
| Model and agent | `setModel({ providerID, modelID } \| undefined)` · `setAgent(agent)` | The model or agent of every later `send` on this handle. `send(text, { model, agent })` overrides it for one prompt. |
| Model and agent | `changeModel('provider/model')` | Persists the model and restarts the runtime. |
| Scope | `scope()` · `rescope(scope)` | Read · change the session's scope. |
| Config | `configState()` · `reloadConfig(input?)` · `reloadConfigStream(…)` | Whether the session runs the config the manifest compiles to · reload it · reload with progress. |
| Files | `files.list(dir)` · `.read(path)` · `.readBlob(path)` · `.status()` · `.findFiles(query, { type?, limit? })` · `.findText(pattern)` | Read the sandbox file system. |
| Files | `files.upload(file, …)` · `.write(path, blob)` · `.create(path)` · `.copy(from, to)` · `.remove(path)` · `.mkdir(dir)` · `.rename(from, to)` | Write the sandbox file system. Behavior: [Files](#files). |
| Files | `attachments.upload(file, options?)` · `attachments.read(attachmentId, signal?)` | Session attachments. `read` returns a `Blob` and works while the sandbox is stopped. |
| Previews | `previews()` · `previewUrl(port, path = '/')` · `proxyUrl(url?)` · `sandboxPortUrl(port)` | Preview candidates · a reachable URL for a sandbox port · a proxy URL for an agent's `localhost` URL · the raw port URL. The last three throw `SessionNotReadyError` before `ensureReady()`. |
| People | `participants()` · `setSharing(intent)` | The session's people · who can open it. `intent` is `{ mode: 'private', ownerId }`, `{ mode: 'project' }` or `{ mode: 'members', memberIds?, groupIds? }`. Owner or project manager only. |
| People | `presence({ tab_id, active, alerts? }, { keepalive? }?)` | This tab's presence lease. Returns `{ ok, awake_until, awake_cap_at }`. |
| People | `publicShares.list()` · `.create(…)` · `.revoke(…)` | Public share links for a preview or a workspace file. A `{ transcript: true }` body returns `400`. |
| Git | `commit(input?)` | Commits and pushes the sandbox's pending changes to the session branch. |
| Reminders | `reminders.list()` · `.create(input)` · `.update(reminderId, input)` · `.remove(reminderId)` · `.run(reminderId)` | The session's reminders. `run` fires one now; the schedule does not move. |

`s.transcript()`, `s.transcriptSync()` and `s.runtime` are deprecated. See [Deprecated names](#deprecated-names).

### React hooks

Every hook is in `@kortix/sdk/react`. Most are React Query hooks: unless a row says otherwise, a hook returns the query result (`data`, `isLoading`, `error`, …) plus the mutations the row names. A write refreshes the matching reads. Set up the providers as in [React > Set up](/docs/developers#set-up).

#### Session hooks

| Hook | What it returns |
| --- | --- |
| `useSession(projectId, sessionId, options?)` | The whole session lifecycle: start, readiness, live stream, messages and actions. Tables: [React > useSession](/docs/developers#usesession). |
| `useSessionSend(sessionId, options?)` | A send function with `KortixSendError` classification. See [Send and handle errors](/docs/developers#send-and-handle-errors). |
| `useSessionPrompts(projectId, sessionId, { pollMs?, enabled? }?)` | The prompt inbox of one session. |
| `useSessionWorking(projectId, sessionId, { enabled?, runtimeSessionId? }?)` | Whether a turn is in progress, and why it ended. |
| `useRuntimeSupports(capability)` | `boolean`: whether the open session's runtime serves a capability. `true` until the runtime first answers. |
| `useSessionMessages(session, { throttleMs? }?)` | The messages of one conversation as `MessageWithParts[]`. |
| `useSessionLog(session)` · `useSessionLogThread(session, threadId?, { throttleMs? }?)` | The session log, or `null` · one thread of it. |
| `useChatTurns(messages)` | `TurnView[]`: each message classified with `classifyTurn`. |
| `useSessionChildren(projectId, parentSessionId, { q, limit, enabled })` | The sessions one parent started, paged. |
| `useSessionReminders(projectId, sessionId)` | Reminders, with `update` and `remove`. |

#### Project and account hooks

| Hook | What it returns |
| --- | --- |
| `useAccounts({ userId, enabled })` | The caller's accounts. Pass the signed-in person's `userId` in a host where the person can change without a reload. |
| `useProjects(accountId?, options?)` | The projects of one account. |
| `useProjectSessions(projectId, options)` | One project's sessions, paged, newest activity first. Read `sessions` (flat) and `fetchNextPage`. Filters as in [Sessions](#sessions). Adds `keepPreviousData` (keep the old rows while a new filter loads), `refetchInterval` and `pollFirstPageOnly` (experimental). |
| `useProjectModels(projectId)` | Selectable models as `FlatModel[]`. Empty while the list loads. |
| `useProjectModelPickerCatalog(projectId)` | The model-picker record per model (`reasoning_options`, `temperature`, `limit`). |
| `useModelAccess(projectId)` | Disabled providers and models, and `setEnabled({ target, id, enabled })`. |
| `useCustomLlmProviders(projectId)` | `{ providers, isLoading, error, save(providerId, input), remove(providerId), isSaving }`. |
| `useProjectConfig(projectId)` | The config summary (default agent, slash commands) as `ProjectConfigSummary`. |
| `useVisibleAgents({ projectId })` | The agents a person can pick, as `Agent[]`. |
| `useProjectSecrets(projectId)` | Secrets, with `upsert`, `remove`, `setPersonal` and `removePersonal`. |
| `useAccountSecretResources(accountId, projectId?)` | Account secret resources. |
| `useProjectTriggers(projectId)` | Triggers, with `create`, `update`, `remove`, `fire`, `dryRun` and `replay`. `fire.mutate(slug)` or `fire.mutate({ slug, payload? })` returns `FireProjectTriggerResponse`. `dryRun.mutate(slug \| { slug, payload? })` returns `ProjectTriggerDryRunResult` and writes nothing. `replay.mutate({ slug, runId, confirmUnverified? })`. A fire and a replay refresh the triggers and every run list. |
| `useTriggerRuns(projectId, slug, { enabled?, refetchInterval?, limit? }?)` · `useProjectTriggerRuns(projectId, { status? }?, { enabled?, refetchInterval? }?)` | Runs as an infinite query paged by `next_cursor`. Read `data.pages[].runs`; call `fetchNextPage()` while `hasNextPage`. No polling unless you pass `refetchInterval` (ms). `limit` is 1 to 100 (server default 25), and a set limit is its own cache entry. |
| `useProjectReminders(projectId)` | Reminders, with `update`, `remove`, `updateMany` and `removeMany`. |
| `useChangeRequests(projectId, status?)` | Change requests, with `open`, `merge`, `close` and `requestChanges`. |
| `useReviewItemPages(projectId, query)` · `useReviewSummary(projectId)` · `useSessionsNeedingInput(projectId)` | The review inbox as an infinite query (rows: `reviewItemsOf(query.data)`) · the summary, polled every 60 s · blocked sessions, polled every 30 s. |
| `useProjectApps(projectId)` | Apps, with `create`, `update`, `start`, `stop` and `remove`. |
| `useAppDeployments` · `useAppDeployment` | Deployments with `deploy` and `rollback` · one deployment with its build log. |
| `useAppMachines(projectId, appId)` · `useAppVolumes(projectId, appId)` · `useAppVolumeSnapshots(projectId, appId, volumeId)` · `useAppAccess` | Machines with `start`, `stop`, `restart`, `destroy`, `scale`, polled every 5 s · volumes with `snapshot`, `restore`, `fork`, `extend`, `destroy` · snapshots, newest first · `{ policy, session, update }`. |
| `useKortixAppViewer()` | Inside an App: `{ status: 'loading' \| 'anonymous' \| 'viewer', viewer }`. |
| `useConnectorQuery(projectId, slug, action, args, { account, enabled, staleTime })` | One connector action's output as a cached query. Use it only for read actions: every fetch runs the action. |
| `useConnectorSetup(token, { backendUrl, storage, openPopup, onOpened })` | The flow behind a connect link (`/connect/<token>`): `phase` (`loading`, `ready`, `starting`, `opened`, `connected`, `error`), `info`, `error`, `connectedAs`, `connect()`. You supply the popup opener. |
| `useFeatureFlag(projectId, key)` | `{ enabled, isLoading }`. Loading and errors read `false`. |
| `useCan(target, action)` · `useCans(target, actions)` | `{ allowed, reason, isLoading, isError }` · one request for several actions, a map keyed by action (keep `actions` stable across renders). `target` is `{ accountId }`, `{ accountId, resource }` or `{ projectId }`. Loading and errors read `allowed: false`. |
| `usePermissionsFor(accountId, memberUserId, probes)` | What another member can do. Needs `member.read`. |
| `useSetBillingPlan()` | A mutation over `billing.setPlan`. |
| `useGatewayOverview` · `useGatewaySeries` · `useGatewayBreakdown` · `useGatewaySessions` · `useGatewayErrors` | Gateway usage over `days` (default 30). |
| `useGatewayLogs` · `useGatewayLog` · `useGatewayBudgets` · `useSetGatewayBudget` · `useDeleteGatewayBudget` · `useGatewayKeys` · `useCreateGatewayKey` · `useRevokeGatewayKey` · `useGatewayRoutingPolicy(projectId)` | Gateway logs, budgets, keys and routing policy. |
| `useCaptureWorkspace` · `useSetCaptureEnabled` · `useCaptureDevices` · `useRevokeCaptureDevice` · `useCapturePolicy` · `useSetCapturePolicy` · `useCaptureDays` · `useCaptureTimeline` · `useCaptureTimelineItems` · `useCaptureSearch` · `useCaptureChunkMedia` · `useCaptureDeviceGrant` · `useApproveCaptureDevice` · `useDenyCaptureDevice` | Kortix Capture. Each account hook takes the account id first. A `null` id sends no request. `useCaptureDevices` polls every 10 s. |

Rules for several hooks:

- `useCan` and its siblings decide what to show. The API checks every write again. Probe the action the route checks, for example `PROJECT_ACTIONS.PROJECT_GITOPS_PUSH` (`project.gitops.push`), never a role. After a write that changes access, call `invalidatePermissionProbes(queryClient, { accountId, userId })`. A verdict stays cached for 5 minutes otherwise.
- After a runtime restart or a config reload, call `resetRuntimeQueries(queryClient)`.

#### Query keys and cache helpers

| Name | What it does |
| --- | --- |
| `qk` | The query key factory of every hook. Families: `qk.accounts`, `qk.projects`, `qk.project`, `qk.notifications`, `qk.drives`, `qk.capture`. Each family except `qk.capture` has `scope(…)`, the prefix of all its keys; `qk.capture.account(accountId)` is the prefix of one account's Capture keys. Example: `qk.project.detail(projectId)`, `qk.project.secrets(projectId)`. |
| `invalidateProject(queryClient, projectId)` | Invalidates every query under `qk.project.scope(projectId)`. |
| `invalidatePermissionProbes(queryClient, { accountId?, userId? })` | Invalidates cached `useCan` verdicts. |
| `resetRuntimeQueries(queryClient)` | Removes every runtime query, so the next read comes from the new runtime. |
| `resetIdentityState(…)` | Clears per-person state on sign-out. See [Sign-out and identity changes](/docs/developers#sign-out-and-identity-changes). |

```tsx
import { useQueryClient } from '@tanstack/react-query';
import { qk } from '@kortix/sdk/react';

function useRefreshSecrets(projectId: string) {
  const queryClient = useQueryClient();
  return () => queryClient.invalidateQueries({ queryKey: qk.project.secrets(projectId) });
}
```

### Framework-free modules

The root `@kortix/sdk` exports these modules next to the client.

| Module | Names | What it does |
| --- | --- | --- |
| Files | `files`, `FileExistsError`, `uploadNativeFile` | File operations on the process-wide active sandbox, with the methods of `s.files`. A host with more than one session uses `s.files`. |
| Session health | `getSessionHealth(runtimeUrl?, init?)`, `isRuntimeReady(health)`, `runtimeSupports(capabilities, name)` | Probe a runtime and read its capabilities. |
| Preview URLs | `detectLocalhostUrls`, `rewriteLocalhostUrl`, `proxyLocalhostUrl`, `parseLocalhostUrl`, `buildWebProxyUrl` | Rewrite an agent's `localhost` URLs into reachable proxy URLs. |
| Event stream | `openEventStream`, `narrowChatEvent`, `heartbeatGapEvent` | The live stream behind `s.stream`, and a narrower for chat events. |
| Auth | `authenticatedFetch`, `getAuthToken` | A `fetch` that adds the token from your `getToken`. |
| API client | `backendApi` | The typed HTTP client under every method. Use it only for a route with no method. |
| Errors | `ApiError`, `AuthError`, `BillingError`, `RequestTooLargeError`, `isAuthFailure`, `isFeatureDisabledError`, `featureDisabledKey`, `FEATURE_DISABLED_CODE`, `REPOSITORY_DISCONNECTED_CODE` | Error types and checks. Catalog: [Errors and retries](/docs/developers#errors-and-retries). |
| Classify | `classifyPart`, `classifyTurn`, `toolViewModel`, `toolInfo`, `humanizeToolName`, `toToolView`, `sessionLogTurns` and the other turn helpers | Turn session messages and session-log blocks into renderable parts. Guide: [Render messages yourself](/docs/developers#render-messages-yourself). |
| Composer lists | `projectConfigAgentsToRuntimeAgents`, `composerSelectableAgents`, `resolveComposerAgent`, `pickerProviderList`, `flattenModels`, `createModelVisibility`, `modelInDefaultView`, `resolveComposerModel`, `resolveModelDefault`, `sessionModelCandidates`, `latestPromptModel`, `modelRefToKey` | Build an agent picker and a model picker with the rules of the Kortix web app. Every function is pure. |
| Transcript export | `formatTranscript(session, messages, options?)` | One session as a Markdown string. |
| Message ids | `mintWireMessageId()`, `WIRE_MESSAGE_ID` | Mint a message id for a prompt · validate one. Never mint an id by hand. |
| Sandboxes by id | `restartSandbox(id)`, `stopSandbox(id)`, `getSandboxById(id)` | Act on a session's sandbox with only its `session_id`. One `GET /sessions/:id` finds the project, however old the session is. A provider external id still works, but only for a session on its project's first sessions page. With the project id, use `s.restart()`, `s.stop()` and `s.get()`. |
| Sandboxes, every project | `getSandbox()`, `ensureSandbox()`, `listSandboxes(id?)` | Read the first sessions page of each project only (default 50 rows). For older sessions, use `p.sessions.listPage()`. |

#### Files

```ts
import { FileExistsError, files } from '@kortix/sdk';

export async function saveNotes(file: File): Promise<void> {
  const { content } = await files.read('/workspace/README.md');
  console.log(content.length);

  await files.upload(file, '/workspace/uploads', file.name, {
    onProgress: ({ loadedBytes, totalBytes }) => console.log(loadedBytes, '/', totalBytes),
  });

  try {
    await files.create('/workspace/notes.md');
  } catch (error) {
    if (error instanceof FileExistsError) console.log('exists:', error.path);
    else throw error;
  }
}
```

| Method | Behavior |
| --- | --- |
| `upload(file, dir, name, { onProgress? })` | A file of 8 MiB or less goes in one request. A larger file goes in 8 MiB chunks, each checked on arrival. `onProgress` runs after each chunk. |
| `write(path, blob)` | Replaces the file at `path`. Fails when `path` is a folder. |
| `create(path)` | Makes an empty file and never overwrites. Throws `FileExistsError` (`status` `409`, `code` `FILE_EXISTS`) when anything exists at `path`. |
| `uploadNativeFile({ uri, name, type? }, targetPath?, baseUrl?)` | Uploads a React Native device file in one request. The bytes stream from `uri` and never enter the JS heap. |

#### Session health

`getSessionHealth(runtimeUrl?, init?)` sends `GET {runtimeUrl}/kortix/health`. It never throws on a non-2xx status. It returns `{ status, ok, health, body, hop, upstreamStatus }`. Without a runtime URL it returns `status: 0` and `ok: false`. `isRuntimeReady(health)` is `true` when the runtime accepts work. `s.health()` is the same probe for one session.

#### Event stream

`openEventStream(options)` returns `{ close() }` at once. `s.stream(options)` calls it with the session's runtime URL after `ensureReady()`. Streams to one URL share one connection.

| Option | Default | What it does |
| --- | --- | --- |
| `url` | — | The session runtime: `{backendUrl}/p/{externalId}/{port}`. |
| `onEvent(event)` | — | Called once per event, in order, after coalescing. Consecutive deltas of one part, or of one session-log block, in the same 16 ms flush arrive as one event: `properties.delta` is their joined text and `coalesced` lists the replaced events. A throw in the handler is caught and logged. |
| `onGapRehydrate(gapMs)` | — | Called when a reconnect is established, with the gap since the last frame. It fires when the dropped stream had delivered events, or when the gap exceeds 5 s. |
| `signal` | — | Stops the stream when aborted, like `close()`. |
| `connectTimeoutMs` | `20000` | Maximum wait for the connect call before a retry. |
| `heartbeatTimeoutMs` | `60000` | Maximum quiet time on an open stream before it reconnects. |
| `maxConsecutiveHardFailures` | `8` | Consecutive hard failures before the stream parks. Backoff grows from 1 s to a 30 s cap. |
| `onParked({ consecutiveFailures, lastError })` | — | Called once when the stream parks. A parked handle never reconnects: open a new stream. |
| `onConnectionChange(state)` | — | `connecting`, `open` or `lost`. |

`narrowChatEvent(event)` narrows a `RuntimeEvent` to the `KortixChatEvent` union. The event list is in [Stream events](/docs/developers#stream-events).

#### Workspace search

`createWorkspaceSearchClient(fileClient, getCacheKey?)` from `@kortix/sdk/workspace-search` returns ranked file and folder search over a session workspace, with a cached index. `fileClient` has `listFiles(path)` and `findFiles(query, options?)`, the same shape as `s.files`. `getCacheKey` partitions the cache, for example by session id. The entry also exports the ranking helpers `rankWorkspaceSearchEntry`, `searchIndexedWorkspaceEntries`, `normalizeSearchQuery` and `normalizeWorkspacePath`.

### Session log types

The root exports the types of the session log, the runtime-neutral record of a session, and its constants. It exports no validators. `s.log(query)` reads one page. In React, `useSessionLog` and `useSessionLogThread` return it.

| Name | What it is |
| --- | --- |
| `SESSION_LOG_SCHEMA` · `SESSION_LOG_MINOR` | `'kortix.session/2'` · `1`. |
| `SessionLog` | The record: threads, todos, pending questions and permissions, `harness` (the runtime that writes it, and the history of runtimes), `restore_grade`. |
| `SessionLogThread` · `SessionLogMessage` | One thread (the main thread or a sub-agent) · one message with `seq`, `role`, `kind`, `status`, `model`, `usage`, `blocks`. |
| `SessionLogBlock` | The union of `TextBlock`, `ReasoningBlock`, `AttachmentBlock`, `ToolCallBlock`, `CompactionBlock`, `SubtaskBlock`, `StepBlock`, `HarnessBlock`, keyed by `type`. |
| `ToolResultContent`, `CompactionLayoutEntry`, `ThreadInteraction`, `ModelRef` | Parts of the records above. |
| `KNOWN_TOOL_KINDS` · `KnownToolKind` · `SessionLogToolKind` | The tool kinds every reader renders: `shell`, `read`, `write`, `edit`, `patch`, `list`, `glob`, `grep`, `web_fetch`, `web_search`, `todo`, `task`, `question`, `plan`, `mcp`, `other` · a block's tool kind, any string. Render an unknown kind as `other`. |
| `SessionLogTodo` | One todo item. |
| `RestoreGrade` · `GradeCounts` | How completely the record restores (`native`, `converted` or `partial`) · what is missing. |
| `SessionLogPage` · `SessionLogPageQuery` | The answer of `s.log()` · its query `{ thread?, before_seq?, limit? }`. |

A text over 256 kB is stored by reference, in a `TextBlock` and in a `text` item of a tool result. Its `text` holds the first 16 kB, `bytes` holds the UTF-8 length of the full text, and `ref` is `kortix-attachment://<project_id>/<session_id>/<attachment_id>`. `s.attachments.read(attachmentId)` returns the full text as a `Blob`, also while the sandbox is stopped.

```ts
import type { SessionLog, SessionLogBlock } from '@kortix/sdk';

export function toolCalls(log: SessionLog): SessionLogBlock[] {
  return log.threads
    .flatMap((thread) => thread.messages.flatMap((message) => message.blocks))
    .filter((block) => block.type === 'tool_call');
}
```

A text over 256 kB is offered whole with the full-text helpers below. `fetchSessionAttachment(ref)` reads one by its `ref`.

#### Session log helpers

Pure functions over session-log blocks, from the root `@kortix/sdk`. They have no React and no DOM. The guide is [React > Render blocks from the session log](/docs/developers#render-blocks-from-the-session-log).

| Name | What it does |
| --- | --- |
| `sessionLogTurns(thread, options?)` | One `SessionLogTurn` per user message: `id`, `user`, `assistant` (the replies, by `reply_to` then order), `partial` (the prompt is not loaded), `blocks`, `status`, `error` (`code`, `message`, `details`, `raw`), `cost` (the replies' usage priced with the platform markup), `compaction` (`block`, `hasContent`, `inFlight`), `threads` (the sub-threads it spawned), `plan`, `shell` (the bash call of a shell turn, which needs `session.shell`), `response` (the last text block), `attachments`, `live`, `queued`, `retrying`. Options: `busy`, `liveMessageId`, `held` (the prompts the inbox holds, in queue order), `threads`, `pricing`, `hideOutOfContext`. `hideOutOfContext: true` leaves out every message with `in_context: false` that is not streaming. A turn keeps its identity while its messages and derived fields do. |
| `sessionLogTurnSegments(blocks, options?)` | The segments of a turn: `burst` (a run of non-text blocks), `text`, `standalone` (a `show`, `image_gen` or sub-agent call, or a call named in `standaloneCallIds`), `show-group` (adjacent `show` calls). |
| `splitSessionLogTurnWork(segments, { working })` | The settled-turn fold: `work`, `hoisted` (the deliverables: `show-group` segments and `show`, `show_user` and `image_gen` calls, in call order), `final` (the last text). `null` while the turn works or has no work. |
| `sessionLogBurstSteps(blocks)` | The rows of a burst: `thought` (a run of reasoning), `block`, `group` (same-family calls, with `family`, `label`, `status`). An answered question is its own row. |
| `sessionLogBurstSummary(blocks)` | A burst's step counts: `total` (a run of reasoning is one), `failed`, `completed`, `hasPlumbing`. |
| `sessionLogBurstRunning(blocks, { working, trailing? })` | Whether the burst is in progress. Never once the turn stops working. |
| `sessionLogShowGroupCall(calls)` | A `show-group` segment as one `show` call: `input.items` is the carousel; a failed call keeps its slot as an `error` item. |
| `sessionLogAnsweredQuestionCall(call, answers, input?)` | A `question` call shown as answered before the runtime confirms the answers. |
| `sessionLogRetrying(thread)` | Whether the thread's last reply is open with a retryable provider error, read without grouping turns. Pass it into `busy`. |
| `sessionLogActivityText(turn, childMessages?)` | The activity line of a live turn, for example `Reading files...`. A running task reads its sub-thread's messages. |
| `sessionLogThreadMetrics(thread, options?)` | One thread's `cost`, `messages` (`all`, `user`, `assistant`), `tokens` (`input`, `output`, `reasoning`, `cacheRead`, `cacheWrite`) and `context` (the last reply with tokens: its `usage` is the context size). Options: `pricing`. |
| `classifySessionLogMessage(message)` | `classifyTurn` of one session-log message: its blocks as `ClassifiedPart`s and its error as `TurnError`. For a renderer that takes `ClassifiedPart`s. |
| `sessionLogPromptMessage(prompt, threadId)` | The user message of a queued inbox prompt the thread has not placed: id `message_id`, else `queued-<prompt_id>`. Append it after the thread's messages and name it in `held`. |
| `isSessionLogRestoreNote(message)` | Whether the message is the note a partial restore leaves for the agent. Render it as a notice, not as a person's prompt. |
| `sessionLogRestoreNotice(session)` | What a partial restore could not bring back: `{ generation, missing }`, `missing` a list of `{ kind, count }` (`attachment`, `text`, `tool_input`, `tool_result`, `cut_point`, `other`). `null` unless the grade is `partial` and a restore wrote the record. Pass `useSessionLogRecord(session)`. |
| `sessionLogReferencedTexts(messages)` | The texts stored by reference that a transcript renders, once per `ref`: `{ ref, bytes }`. |
| `sessionLogWithFullTexts(thread, texts)` | `thread` with each text whose `ref` is a key of `texts` replaced by the whole text. |
| `sessionLogFullTextAction(refs)` | `'inline'` up to `SESSION_LOG_FULL_TEXT_INLINE_MAX_BYTES` (1 MiB) in all, else `'download'`. |
| `sessionLogFullTextPlan(refs)` | `{ inline, download }`: the smallest texts fill the 1 MiB inline budget first, the rest download. |
| `toolCallMetadata(call)` | Transitional. The runtime's display metadata of a tool call, for example bash `command` or task `sessionId`. One shared empty object when there is none. |

`toToolView`, `getChildSessionId`, `partOutcome`, `contextToolTrigger` and `shouldShowToolPart` accept a `ToolCallBlock` as well as a v1 tool part. `freshSendHint` and `fallbackBusyRowAfterTurnId` accept a `SessionLogTurn[]` as well as v1 turns, and `stepLabel` accepts any session-log block (a non-call block reads by its `type`). The closure rule: a call closed by the runtime (`result.synthetic`, or `result.error.code` `'interrupted'`) renders `running` or `pending` on a live turn and `interrupted` on any other.

```ts
import { sessionLogCallStatus, sessionLogTurnSegments, sessionLogTurns, type SessionLogThread } from '@kortix/sdk';

export function standaloneCalls(thread: SessionLogThread, busy: boolean) {
  return sessionLogTurns(thread, { busy }).flatMap((turn) =>
    sessionLogTurnSegments(turn.blocks)
      .filter((segment) => segment.kind === 'standalone')
      .map((segment) => ({ segment, status: sessionLogCallStatus(segment.block, turn.live) })),
  );
}
```

#### The image card

What a host draws an `image_gen` call with. Pure data, exported from `@kortix/sdk` and `@kortix/sdk/turns`. An `image_gen` call is a deliverable: it is a `standalone` segment and is hoisted above the final text like `show`.

| Name | What it does |
| --- | --- |
| `imageGenView(call, live?)` | The card of one `ToolCallBlock` as an `ImageGenView`. One call asks for one to four images. The call: `phase` (`ImageGenPhase`: `generating`, `ready`, `failed` or `stopped`), `action` (`generate` or `edit`), `prompts` (the call's `prompts`; a Phase 1 call's one `prompt`), `aspectRatio` (`"<w> / <h>"`: the ratio the call asked for, else `1 / 1`, the same in every phase, so a layout of several images can size its rectangle before they land), `startedAt` (epoch ms, from the call's start), `model` and `provider` (the model that made the images, such as `google/gemini-2.5-flash-image`, and the provider that answered, from the tool output; `null` until the call completed or when the output names none), `error` (the whole call's failure), `images`. A call that failed with an interrupt, a closure-rule result or an abort text is `stopped`. A completed call with no image path or URL is `failed`. An open call is `stopped` when `live` is `false`, else `generating`. |
| `ImageGenView['images']` | One frame per image: one per prompt while the call runs (one while its arguments stream), then each image the call made and each prompt that failed, in prompt order. Empty when the call `failed` or `stopped` as a whole. A frame has `prompt`, `phase` (`generating`, `ready` or `failed`), `aspectRatio` (`"<w> / <h>"`: the delivered size once the image came, else the requested `aspect_ratio` (`16:9` is `16 / 9`), else a legacy requested `size`; `auto` or neither is `1 / 1`), `imagePath` (a sandbox file: `.png`, `.jpg` or `.webp`), `directUrl` (legacy one-image outputs without a path; an http(s) URL only, never set beside a path) and `error` (this prompt's failure). An older one-image output (`{"path"}`, a Replicate URL or a bare path) is one frame. |
| `ImageGenView['error']` | `message`, `suggestion?` (the text after the first sentence), `code?` (the gateway's trailing `[code]`, such as `provider_not_connected` or `image_rate_limited`), `retryAfterSeconds?` (from `Retry after N s.`), `connectModel` (true only for `image_model_unavailable`, `provider_not_connected` or `provider_reauth_required`: connecting a model in the project's settings fixes the call). On the view, `null` unless `phase` is `failed`; on a frame, `null` unless its `phase` is `failed`. |
| `imageGenRetryDelay(seconds)` | `{ value, unit }`: whole seconds below 90 s (at least 1), minutes below 90 min, else hours, each rounded up. |
| `formatImageGenElapsed(ms)` | The elapsed time as `m:ss`, such as `0:42` or `2:05`. |
| `ImageGenView['images'][i].sha256` | Optional. The lowercase hex sha256 of the saved file's bytes, when the `image_gen` output names it. Older outputs have none; a malformed value is dropped. |
| `imagePathsShownByImageGen(blocks)` | The image paths the `image_gen` calls among `blocks` drew (every `ready` frame of every call), each without its `/workspace/` prefix. |
| `imageDigestsShownByImageGen(blocks)` | The `sha256` of every image the `image_gen` calls among `blocks` drew, when their output names it. |
| `showItemDigest(call, path)` | The `sha256` a `show` call's output gives the file at `path` (its single `entry` or one of its `items`), matched with or without the `/workspace/` prefix; `undefined` when the output names none. `sessionLogShowGroupCall` items carry it as `sha256`. |
| `withoutImageGenDuplicates(items, paths, digests?)` | `items` without each item whose `path` is in `paths` (with or without the `/workspace/` prefix) or whose `sha256` is in `digests` (the same bytes at another path, such as a Drive copy). Items without either stay. |
| `showDrawnByImageGen(call, paths, digests?)` | True when `call` is a `show` or `show_user` call and every file it names (`items`, else `path`) is in `paths` or, by the digest its output gives it, in `digests`: the host renders nothing for it. |
| `sessionLogImageGenRuns(segments, { paths, digests })` | The runs of `image_gen` calls that draw as one grid, as a `Map` from each member's block id to its run: two or more standalone `image_gen` calls with no visible non-image deliverable between them (a `show` the cards already drew, a burst or text does not split a run, since the settled turn hoists the deliverables together). The host draws the grid at `run[0]` and nothing for the others. |
| `imageGenGrid(views)` | The one grid of a run as an `ImageGenGrid`: `tiles` (`ImageGenGridTile`: `image`, the `call` index in the run, `first` for each call's first tile) in call order, and `rows`, each row's tile count. Up to four tiles is one bento; five and more are rows of three, or of four when that divides evenly, and one or two rows of two (5 = 3+2, 6 = 3+3, 7 = 3+2+2, 8 = 4+4). A call that failed or stopped as a whole keeps one slot per prompt. |
| `createImageCache({ maxEntries, maxBytes?, dispose? })` | A framework-free `ImageCache<V>` of loaded images (`V` is what the host draws from: an object URL, a native source). `get(ref)` (`ImageCacheRef`: `path?`, `sha256?`): with a `sha256`, only the entry of those bytes, never a path entry of other bytes; else the entry at `path`. `put(ref, value, bytes?)` (an existing entry for the digest wins and the new value is disposed), `link(path, entry)`, `setSize(entry, w, h)`, `retain(entry)` (held entries are never evicted; returns the release), `forget(path)`, `clear()`, `subscribe(listener)`. Past `maxEntries` or `maxBytes`, the least recently used entry nothing holds is evicted and handed to `dispose`. An `ImageCacheEntry` has `value`, `bytes`, `width`, `height`. |

```ts
import {
  imageDigestsShownByImageGen,
  imageGenGrid,
  imageGenView,
  imagePathsShownByImageGen,
  sessionLogImageGenRuns,
  showDrawnByImageGen,
} from '@kortix/sdk';

const drawn = imagePathsShownByImageGen(turn.blocks);
const view = imageGenView(call, turn.live);
for (const image of view.images) {
  if (image.phase === 'ready' && image.imagePath) renderImage(image.imagePath, image.aspectRatio);
  else if (image.phase === 'failed') renderNote(image.error?.message);
  else renderPlaceholder(image.aspectRatio, image.prompt);
}
if (showDrawnByImageGen(showCall, drawn, imageDigestsShownByImageGen(turn.blocks))) skip(showCall); // the image card already shows it

// Consecutive image_gen cards of a turn draw as one grid at the first call.
const runs = sessionLogImageGenRuns(segments, { paths: drawn, digests: imageDigestsShownByImageGen(turn.blocks) });
const run = runs.get(call.id);
if (run && run[0] === call) renderGrid(imageGenGrid(run.map((one) => imageGenView(one, turn.live))));
```

The chat behavior is on [The image card in the chat](/docs/developers#the-image-card-in-the-chat).

#### v1 turn helpers

Pure functions over v1 messages (`MessageWithParts`, `kortix.transcript.v1`, `KORTIX_TRANSCRIPT_SCHEMA`), the shape that `s.messages()` and `useSessionMessages` return.

| Names | What they do |
| --- | --- |
| `groupMessagesIntoTurns(messages)` · `collectTurnParts(turn)` | Pair each user message with the assistant messages that answered it · all parts of one turn. |
| `findLastTextPart(parts)` · `turnHasSteps(parts)` | The final answer text · whether the turn has tool, compaction, snapshot or patch parts. |
| `getTurnStatus(parts, childMessages?)` · `getWorkingState(…)` · `formatDuration(ms)` | A status line such as `Reading files...`. Pass `childMessages` to show a sub-agent's activity. |
| `getTurnError(turn)` · `getChildSessionError(messages)` · `unwrapError(error)` | Turn and sub-agent errors, normalized. |
| `getRetryInfo(status)` · `getRetryMessage(status)` | Retry state `{ attempt, message, next, details? }` · the full retry message. |
| `getChildSessionId(part)` · `getChildSessionToolParts(messages)` · `rootRuntimeSession` · `directSubsessions` · `runtimeSessionsOf` · `projectSessionForRuntimeId` | Connect a delegating tool call to its child conversation. |
| `getHiddenToolParts(…)` · `isToolPartHidden(…)` | Which tool parts a transcript hides. |
| `isTextPart`, `isToolPart`, `isReasoningPart`, `isFilePart`, `isAgentPart`, `isCompactionPart`, `isSnapshotPart`, `isPatchPart`, `isStepPart`, `getPartText` | Type guards. |
| `sortSessions(now)` · `childMapByParent(sessions)` · `allDescendantIds(map, id)` | Session list order and hierarchy. |
| `getFilename`, `getDirectory`, `relativizePath`, `stripAnsi`, `formatTokens` | Formatting. |

The grouping and status functions accept the structural types `PartLike`, `MessageInfoLike`, `TurnLike`, `ToolStateLike` and `SessionStatusLike`, so your own message shapes work.

### Deprecated names

Each name below carries `@deprecated` and resolves until the next major version. Your editor flags each use. The list is not complete: your editor flags every other deprecated name too, and its `@deprecated` tag names the replacement when one exists.

#### Subpaths

These subpaths re-export names that the root or `@kortix/sdk/react` already exports, or wrap internal stores.

| Subpaths | Use instead |
| --- | --- |
| `/config`, `/auth`, `/api-client`, `/projects-client`, `/feature-flags`, `/fresh-sessions`, `/instance-routes`, `/platform-client`, `/event-stream`, `/files`, `/session`, `/session/url`, `/turns` | The same names from the root `@kortix/sdk`. |
| `/sync-store`, `/server-store`, `/sandbox-connection-store`, `/idb-sync-cache`, `/internal/sync-store`, `/internal/server-store`, `/internal/sandbox-connection-store` | No replacement. Read session state with `useSession`. |
| `/message-queue` | `s.send`, or `useSession().send`. Kortix orders prompts on the server. |

#### Names

| Deprecated | Use instead |
| --- | --- |
| `claimNext`, `completeInFlight`, `createSessionQueue`, `editQueued`, `enqueue`, `failInFlight`, `removeQueued`, `reorderQueued`, `retryFailed`, `QueuedMessage`, `QueuedMessageInput`, `SessionQueue` | `s.send`, or `useSession().send`. |
| `wireIdClock`, `wireIdClockAt`, `wireIdClockDelta`, `maxWireIdClock`, `isWireIdAheadOf`, `newestWireIdClock`, `mintWireMessageIdAbove`, `MintWireMessageIdAboveInput`, `MintedWireMessageId`, `WIRE_ID_TIME_SCALE`, `WIRE_ID_TIME_MASK`, `WIRE_ID_CLOCK_TOLERANCE`, `WIRE_ID_BACKDATE_MS` | `mintWireMessageId` and `WIRE_MESSAGE_ID`, which stay current. Message ids are opaque. |
| `s.runtime` | The session methods: `messages`, `pending`, `answerPermission`, `answerQuestion`, `compact`, `send`, `abort`, `rewind`. |
| `s.transcript()` · `s.transcriptSync()` | `s.log(query)`. |
| `findActiveTranscriptShare`, `getPublicSessionShare`, `getPublicSessionShareMessages`, `PublicSessionShareError`, `PublicSessionShareMeta`, `PublicSessionTranscript`, `PublicSessionTranscriptMessage`, `PublicSessionTranscriptToolCall` | No replacement. The API removed public transcript sharing; every call returns `404`. |
| `clientSource` (a `createKortix` option) | Remove it. It is inert. Send `clientVersion` to identify a release. |
| `p.updateExperimentalFeature` · `updateExperimentalFeature` · `ExperimentalFeatureKey` · `ExperimentalFeatureView` | `p.updateFeatureFlag` · `updateFeatureFlag` · `FeatureFlagKey` · `FeatureFlagView` |
| `p.connectors.setAuthorizationStrategy` · `setConnectorAuthorizationStrategy` | Remove the call. It changes nothing. |
| `getConnectionPolicies` · `setConnectionPolicies` | `p.connectors.policies.get` · `p.connectors.policies.set` |
| `pipedreamConnect` · `pipedreamFinalize` · `p.connectors.connections.pipedreamConnect` · `.pipedreamFinalize` | `connectorConnect` · `connectorFinalize` |
| `listDiscoverIntegrations` · `getDiscoverIntegration` · `DiscoverIntegration*` types | `listDiscoverConnectors` · `getDiscoverConnector` · `DiscoverConnector*` types |
| `SessionScopeResult` · `toWorkspaceRelative` | `SessionScope` · `toDaemonPath` |
| `setCachedAuthToken` · `setBootstrapAuthToken` | Return the token from your own `getToken`. |
| `billing.checkout.createSession` · `billing.checkout.confirmSession` · `createPerSeatCheckout` · `claimPerSeatBilling` | `billing.setPlan(input)`. They reject with `ENDPOINT_RETIRED` and send no request. |
| `billing.subscription.scheduleDowngrade` · `.cancelScheduledChange` · `.prorationPreview` | `billing.setPlan(input)` or `billing.subscription.createPortalSession`. They reject with `ENDPOINT_RETIRED`. |

Other deprecated root names wrap routes that the API does not serve. They reject with `ENDPOINT_RETIRED`, resolve `null`, or return `404`, and have no replacement.
