Skip to content
Kortix
Esc
↑↓navigate↵open⌘Jpreview

Developers

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

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.

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

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.

Sign in

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.

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.

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.

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.

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

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:

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.

Run a session

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

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.

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:

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

Then review and merge:

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.

Check your setup

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

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:

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.

The git origin

Every project has one clone URL, whatever its kind:

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.

Clone a project

Install the CLI and sign in

Follow CLI → 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:
[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.

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.

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:

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.
Service account key (kortix_sa_…), OAuth token, gateway key, browser sign-in token Refused with 401.

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.

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.

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

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.

By default, agents land work through change requests. 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:

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

Get an agent’s work on your computer:

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

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

Open one from your computer

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, or from a terminal:

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.

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.

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

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.

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.

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

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.

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

// 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:

✓  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. 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.
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.
sandbox map the platform image Sandbox templates and the project’s default template. See sandbox keys.
triggers list — Schedules, webhooks and App events that start agent work. See triggers keys and Automations.
connectors list — Apps and APIs agents can call. See connectors keys and Connectors.
apps map — App slug → App block. See apps keys.
skills map the Kortix skills Skill name → repository folder. The Kortix skills (kortix-*) are always registered. See skills entries.
tools map the Kortix tools Tool name → repository module. The Kortix tools are always registered. See tools entries.
commands map — Slash command name → command. See commands entries.
extensions map — Extension name → package. See extensions entries.
policy map — The decision for a connector call that no rule matches. See policy keys.
policies list — Connector rules for the whole project. They run before every policy set and base rule. See policy keys.
approvals map — How long a held connector call waits for a decision. See 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

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

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.

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.

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.

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.

# kortix.yaml
kortix_version: 3
default_agent: assistant
imports:
  - triggers/          # every .yaml and .yml file below this folder
agents:
  assistant:
    prompt: You write reports.
# 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:

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

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:

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

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:

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.

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

Sign in to a host

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.

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 walks through it the first time. Git covers cloning, pushing from any git client, session branches and change requests.

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

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.

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

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

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

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:

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

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.

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

Sign in, per host

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

Account, within the host

Command What it does See
accounts <subcommand> List and switch accounts. Teams
members <subcommand> Invite and remove people, and change their roles. Teams
groups <subcommand> Groups of people that share a role. Teams
tokens <subcommand> Personal access keys, service accounts and connected apps. Security
billing <subcommand> The plan, credits, transactions, and costs per project and per session. 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
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

Session, within the project

Command What it does See
sessions <subcommand> Start, talk to, follow, stop and delete sessions. 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
tui [options] The whole product as a terminal app. kortix t and kortixt start it too. Experimental. 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
validate Check kortix.yaml and the sandbox template Dockerfiles. Check and validate
migrate [--write] Convert the project manifest to kortix_version: 3. It prints the plan; --write applies it. 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
schema --version 3 Print the kortix.yaml JSON Schema. 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
tools <subcommand> List the tools sessions get; eject copies a Kortix tool into the project so you can change it. Agents
models <subcommand> The models the project offers, and its default model. Models
gateway <subcommand> LLM gateway routing, budgets, keys, usage, logs and tests. Models
connectors <subcommand> Add, connect, call and govern connectors. moves and move move Pipedream and Composio connectors to Kortix. Connectors
secrets <subcommand> Project secrets, their exposure and who can use them. Security
providers <subcommand> Connect model providers with an API key or OAuth. Models
env <subcommand> Pull secret names into a .env file, or push a .env file as secrets. Security
channels <subcommand> Slack and Microsoft Teams connections, and the agent each channel runs. Channels
sandboxes <subcommand> Sandbox templates, their builds and their health. Computers
apps <subcommand> Deploy and manage Apps: sites and servers with stable Kortix URLs. Apps reference
marketplace <subcommand> Search, show, install and uninstall marketplace skills. 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
drive <subcommand> The project’s Drive: list, read, upload, move and delete files, and share folders. Drive
cr <subcommand> Change requests: list, show, diff, open, merge, request changes, close and reopen. Git
review <subcommand> The review inbox: change requests, held connector calls and submitted outputs. Git
triggers <subcommand> List, add, fire, enable, disable, pause and resume triggers. Automations
reminders <subcommand> Prompt one session later or on repeat. kortix remind "…" --in 24h adds one. Automations
capture <subcommand> Search the Capture timeline of the person you act for. Capture is off until an account admin turns it on. Capture

Access and permissions

Command What it does See
access <subcommand> Grant, list and revoke role assignments for people, groups and agents. Teams
roles <subcommand> System and custom roles, and what each one permits. Teams
permissions <subcommand> The permission catalog roles are built from. kortix perms is the same command. Teams
audit <subcommand> Read the account audit log. Teams
grants <subcommand> Assign agents to members or groups. 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
update Replace the binary with the latest release. Maintain the CLI
uninstall Remove the CLI from this machine. Maintain the CLI
help The command list. kortix help <command> prints one command’s help. Conventions
version Print the CLI version. 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.

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.

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.

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
SDK (@kortix/sdk) TypeScript and React. Sessions with live streaming, typed errors, automatic retries, and the session runtime. getToken returns a bearer token
CLI (kortix) Terminals, CI jobs, and agents inside a session sandbox. kortix login, or a token in CI
Kortix MCP server 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.
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:

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.

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

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:

{ "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.
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.
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
insufficient_credits 402 The account’s credit balance is empty. Billing
subscription_required 402 The account has no plan for Kortix-billed usage. Billing
no_account 402 The account has no credit account yet. Its setup is not complete. Billing
entitlement_required 402 The plan lacks sso, scim, rbac, auditAccess, or branding. The body names entitlement. Security
budget_exceeded 402 A model budget set to block reached its limit. 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
expiry_required, expiry_too_far 400 A new key breaks the account’s key rules. 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
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
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
session_not_deleted 409 A restore named a session that is not deleted. 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
session_purged 410 The session was deleted more than 30 days ago. Its messages, files and branch are deleted. 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
repository_push_rejected 409 The git host refused a push because of branch protection or repository rules. Git
CR_HEAD_NOT_AHEAD 422 The change request branch has no commits ahead of its base. Git
CR_SELF_MERGE_REFUSED 403 A session tried to merge its own change request without a project.gitops.merge grant. Git
MANIFEST_INVALID 422 The change request makes kortix.yaml invalid. The merge is blocked. issues lists the errors. kortix.yaml reference
PERSONAL_SECRET_REQUIRES_PRIVATE_SESSION 409 You tried to share a session that holds a secret shared only with you. Security
project_secret_write_limit 429 The project wrote secrets more than 100 times in one hour. Security
app_config_invalid 400 An App release config is not valid. issues lists the errors. Apps reference
app_auth_required 401 An App URL needs a viewer that may open it. 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. LLM provider and model failures are on 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:

{ "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.
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
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
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
The LLM gateway’s OpenAI- and Anthropic-compatible endpoints. They take a kortix_gw_ key. Models
The project git origin, https://api.kortix.com/v1/git/<project_id>.git. 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 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.

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:

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.

Deploy your first App

Build the site

npm run build

Deploy the output directory

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:

kortix apps access-link my-site

Ship the next release

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.

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

The full kortix.yaml reference is kortix.yaml 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.

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.

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

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

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:

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

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.

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.

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.

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.

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

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

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.

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.

Releases, rollback and deletion

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

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.

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:

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.

Kortix sign-in for the functions:

// 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',
  }],
};
// 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.

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:

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

apps:
  crm:
    path: apps/crm/dist
    type: static
    spa: true
    uses: [backend]

Build, deploy and open it to the project

(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 (@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:

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

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

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.

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.

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

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:

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:

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

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.

  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.

  6. Turn on email. Set one connection string:

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

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

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

    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.

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.

App hosting

Variable Default Effect
KORTIX_APPS_BASE_DOMAIN none The domain Apps 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:

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.

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

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.

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. --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 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 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 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 Both Nothing You only detect and split openui blocks in agent text. It loads no parser.
@kortix/sdk/genui 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 Browser react, zod, @openuidev/lang-core 0.3.1 You render blocks with your own React components.
@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, Sessions from code, Projects from code, React, Generative UI, Kortix UI. Exact signatures are in the SDK reference.

Install

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.

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:

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

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

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

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

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.

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:

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.

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.
  • A runtime does not serve every session control. Check a control with runtimeSupports before you show it: see Gate on runtime capabilities.
  • Changes per release are in the CHANGELOG. Runnable examples are in 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.

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

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). Creation, expiry, project binding and key rules for every credential are in Security. A valid credential without the needed permission gets 403. Grant the role in Teams.

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

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.

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.

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

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.

Create the auth kit on your server

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

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

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

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.

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.

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

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.

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.

// 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()}` },
});
// 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.

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.

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.

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.

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.

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.

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. A React app uses useSession instead of these calls. See React.

The session handle

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.

Create a session

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

require_connectors is accepted and ignored.

Change the name, labels or metadata later:

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.

Wait until ready

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.

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

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 shows the pattern.

Pick the model and agent per call or per handle:

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.

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():

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:

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

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.

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.

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:

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:

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.

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:

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:

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

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

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

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.

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.

Change the scope of a running session

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:

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.

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:

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.
  • Sign-in calls reject with HeadlessAuthError. See 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.

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.

Each sample assumes one client, made as in Create the client:

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.

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

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

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.

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.

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.

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.

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.

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

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

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.

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

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.

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

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

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

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

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.
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. App configuration in kortix.yaml is in 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.

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:

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:

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.

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 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. The prebuilt components are in 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.

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.

// 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. The sources of getToken are in 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.

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

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.

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

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

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.

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.

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 blocks, pending prompts, the send error and the composer. Setup, styles, props and tool-row overrides are in Kortix UI > 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.

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

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.

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.

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.

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

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

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

Set the flag from code with updateFeatureFlag:

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.

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.

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

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:

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.

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.

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 Badges; rating 0–5
Stat One key number with a trend
StatRow Key numbers in a row 2–4 Stats
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 CompareItems, up to 12 specs
RankedList A ranked recommendation, best first 1–10 RankedItems
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 Slices
Map Places and an optional route 1–25 Markers, zoom 1–18, route up to 500 points
Steps A process or journey in order 1–12 Steps
Checklist Items the reader ticks off 1–20 items, up to 120 characters each
Tabs Views the user switches between 2–5 Tabs, each with 1–8 blocks
Accordion Sections a reader may skip 1–10 AccordionItems, 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 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. @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

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. The module entries (@kortix/ui/session, /workspace, /theme and the others in Modules) ship from 0.14.4; 0.14.3 has the root, the component subpaths and the stylesheets. Inside the 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).
@openuidev/lang-core@0.3.1, zod The session thread, because it renders 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.

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.
@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.
@kortix/ui/theme KortixTheme, themeToCss, themeToCssVars, ThemeScope. See Theming.
@kortix/ui/tailwind.css, /tokens.css, /theme.css, /styles.css, /fonts.css The stylesheets. See 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.

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.

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

@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';

Import one prebuilt file. It holds the reset, the tokens and every class the components use.

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.

Mount the providers

Mount the providers once, at the root of your app, inside your router and inside one QueryClientProvider.

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. A custom host leaves it off, and no upgrade control renders.

Add a session

A session needs one 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.

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.

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

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.

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.

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

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

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

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. 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. In a browser, feed getToken from the headless session store (Sign users in headlessly). The store keeps the user’s tokens and refreshes them 60 s before they expire.

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.

Connect your router and theme

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',
};
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(),
};
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

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

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

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. ./shell-row is the row from Replace one tool row.

  • More data comes from the hooks in 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):
    • Lumen Workspace: 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: a static Kortix App over one account, signed in as the viewer.
    • 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, Sessions from code, Projects from code and 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 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.
  • 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.
@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).
@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.

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

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. /projects/{projectId}/…
session(projectId, sessionId) 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 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.

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.

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. Task guides for this handle are in 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, 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.

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.

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.

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

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.

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.

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.

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.

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

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.

Session hooks

Hook What it returns
useSession(projectId, sessionId, options?) The whole session lifecycle: start, readiness, live stream, messages and actions. Tables: React > useSession.
useSessionSend(sessionId, options?) A send function with KortixSendError classification. See 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. 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.
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.
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.
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

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.

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.

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.

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 ClassifiedParts and its error as TurnError. For a renderer that takes ClassifiedParts.
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.

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

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.

Was this page helpful?