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 shipkortix 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-agentkortix 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
kortixis the default agent. Every grant it has isall.harness-reflectorrefines the agents’ prompts, skills and tools from recent sessions, through one change request. Its daily trigger is off until you setenabled: true.session-revieweris a subagent ofharness-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. Theskillskey inkortix.yamllists 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
Agents
Connectors
Automations
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, fieldgit_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.cloneand each push asgit.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:
- It runs
git clonewith your token in the process environment. The token is never in the remote URL or in.git/config. - It writes
.kortix/link.json, which binds the folder to the project forkortixcommands. It adds the file to.git/info/exclude, so the link stays on your computer. - 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:
- It runs the
kortix validatechecks.--no-verifyskips them. - It runs
git add -Aand commits, with the message from-m. The default message iskortix: ship.--no-commitfails when the working tree has changes. - It offers to set each
envsecret that has no value.--no-envskips this. - It pushes the current branch to the branch of the same name.
- It connects each declared connector that still needs sign-in.
--no-connectskips 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.deleteto 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.anyin the agent’skortix_permissions. Deleting a branch needsproject.gitops.ref.delete. - A push to the default branch also needs
project.agent.write,project.trigger.create,project.trigger.updateandproject.trigger.delete. - The agent’s
kortix_permissionsmust list the permission, or beall, 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 nokortix_permissionskey 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 brancheslists the branches with commits ahead of and behind the default branch.kortix sessions info <session_id>showsbranchandbase_ref.- To start a session on another branch, pass
base_refwhen you create it, inPOST /v1/projects/<project_id>/sessionsor insessions.create()of the SDK.kortix sessions newhas 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:
- Commit on
$KORTIX_BRANCH_NAME. - Push:
git push origin HEAD. - Open:
kortix cr open --title "…" --description "…". Inside a sandbox,--headand--sessionfill themselves from$KORTIX_BRANCH_NAMEand$KORTIX_SESSION_ID. - 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:
- Kortix validates
kortix.yamlon the head branch, with itsimports:. An invalid manifest stops the merge with422MANIFEST_INVALID. A head with no manifest passes. - Kortix fast-forwards the base when the head is strictly ahead.
- Otherwise it creates a merge commit,
Merge CR #<n>: <title>, authored byKortix <noreply@kortix.ai>.--messagesets another message. - A conflict stops the merge with
409and 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_permissionsmerges only when the grant includesproject.gitops.merge. An agent withoutkortix_permissionscannot merge. - A session cannot merge a change request it opened unless its grant in
kortix.yamlincludesproject.gitops.merge. Without it, the merge returns403CR_SELF_MERGE_REFUSED. - A merge that changes
agents,default_agentortriggersalso needs the permission the direct edit needs:project.agent.write, orproject.trigger.create,.updateor.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
/workspaceor 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.--forcereloads during a turn and ends that turn; it asks first unless you pass--yes.--no-reposkips the git pull in/workspace.--statusonly reports whether the session is behind. - To test config before a merge, create a session with
base_refset 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
400AGENT_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
409withcode: repository_disconnected, thereason, and the same message. The project detail still loads: itsconfig.manifest_version.unknown_reasonisrepository_disconnectedandgit_connection.statusiserror. - 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,fetchandpushthrough the Kortix git origin print the message asremote: ….- The web app shows GitHub disconnected in the sidebar, in Git repo and on a session that cannot start.
kortix projects infoprints 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 validateexits0with no errors,1with one or more errors, and2when the file is missing or unreadable.kortix shipruns the same checks before it commits.- The change request merge runs the schema check on the merged manifest. An error blocks the merge with
422MANIFEST_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, triggeronandrun, and imported files. The message names the key that replaces it, when one exists. An unknown key underenvis 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_filethat does not exist; - a
skillsfolder with noSKILL.md, or aSKILL.mdwhose frontmattername:is not the skill name; - a
toolsmodule or anextensions.<name>.pirepository path that does not exist; - an agent
connectorsorconnectors_requiredslug thatconnectorsdoes not declare (kortix_slack,kortix_teams,kortix_emailandcomputeralways count as declared); - an agent
secretsname or a triggersecret_envthatenvdoes 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,appsorkortix_permissionsgrants nothing. - Three keys are open by default:
toolsisall,permissionisallow, andcodemodeison. workspace.project_repositorydefaults tofalse. Settrueon 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-memoryand the otherkortix-*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 itsskillsgrant names it or isall. - 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,askordeny. 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.
writefollowsedit.memoryfollowsedit, orreadwhen it only views.image_genfollowsedit.web_searchandimage_searchfollowwebsearch.scrape_webpagefollowswebfetch. - Action only.
question,webfetchandwebsearchtake an action, not a glob map. bashglobs match each command of a line. Ina; b,a | bor$(b), the strictest answer wins:deny, thenask, thenallow.- Path globs match the file, relative to
/workspace. A path outside/workspacealso answers toexternal_directory. A path rule does not governbash. - Precedence. The most specific matching rule wins, whatever the key order. The longest pattern is the most specific.
permission.skillandpermission.taskare errors. Useskillsandsubagents.
Connector rules and approvals
Kortix decides each connector call in this order. In each list, the first rule that matches decides.
- The project
policies. - 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. - The connector’s own
policies, the base rules. - No rule matched: a
sensitiveconnector holds the call for approval. Otherwisepolicy.default_modedecides.
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_setsentry that names a set the connector does not define is an error. An entry for a connector the agent is not granted, or thatconnectorsdoes not declare, is a warning: it does nothing. kortix validatedoes not checkpolicyorpolicies. 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.ttlexpires. Approving one is on Connectors.
Sandbox templates
A session picks its sandbox template in this order:
- The template the session request names.
- The
sandboxof the agent the session runs. sandbox.default.- 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:
/workspaceholds 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.
- A path is relative to the repository root.
.., an absolute path and a glob are errors. A path that matches no.yamlor.ymlfile is an error. - A folder import takes every
.yamland.ymlfile below it, at any depth, sorted by path. - An imported file holds only
agents,apps,commands,connectors,extensions,skills,tools,triggersandimports. Any other key in it is an error. - Imports nest up to 8 levels and 200 files. A cycle is an error. A file reached twice merges once.
- 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.
- Entries merge in order: the importing file first, then its imports as listed.
- 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 lsfrom the project root. - A
kortix_version: 2project selects Kortix tools undertools:as<name>: kortix:<name>. With notools:key, in the root file or in any imported file, it gets all six. With atools:key, even an empty one, it gets the Kortix tools the key lists, plusimage_gen. Deleting a line removes that tool from every agent, exceptimage_gen, which loads when its line is missing. - Own a Kortix tool:
kortix tools eject <name>writes the source the sandbox runs totools/<name>.tsand points the tool at it. The copy imports onlynode:*modules.web_search,image_search, andscrape_webpagecall the Kortix API withKORTIX_API_URLandKORTIX_TOKENfromcontext.env.image_gencalls the Kortix LLM gateway withKORTIX_LLM_BASE_URLandKORTIX_TOKEN.memoryandshowwork on files in the project checkout. In a v2 project with notools:key, the eject creates one and also lists the other five Kortix tools askortix:<name>, so it removes nothing. context.turnIdis optional in thecontexta tool’sexecute(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 acodemodescript or a subagent.context.signalcan differ per call. Key a per-turn limit onturnId. 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 withimage_model_unavailable. - The output is paths, not images. The model receives
{ action, model, provider, images: [{ path, size, prompt, sha256 }], failed: [{ prompt, error }] }.modelandprovidername what made the first saved image.sizeisWxH, read from the saved file when its header is readable, else the size the provider reported.sha256is the lowercase hex SHA-256 of the saved file’s bytes.failedis 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. |
qualityandtransparent_backgroundreach ChatGPT and OpenAI models only. Other models do not receive them.- ChatGPT and OpenAI models receive a size, not a ratio.
autoisauto.1:1is1024x1024.3:2,4:3, and16:9are1536x1024.2:3,3:4, and9:16are1024x1536. xAI models receive no aspect ratio. Other models receive the ratio as it is, and no ratio forauto. - 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
codemodescript, 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: askto approve each call. image_genwrites a file, soedit: denyalso denies it, unless the agent sets a rule forimage_gen. The same applies tomemory, on both harnesses.- On OpenCode (deprecated), these tools cannot ask:
edit: askor aneditpattern 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.
codex/gpt-image-2, when a ChatGPT account is connected.- 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.
- OpenAI key:
- 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 isauto, such asopenrouter/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.autonever picks one. The model must be in the endpoint’s model list; a model missing from it fails withmodel_not_found. It works when the endpoint answers/chat/completionswith the image inchoices[0].message.images[0].image_url.url, as adata:URL. Otherwise the call fails withimage_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
generateContentfor 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.mdbecomes an agent. A grant v1 omitted is written asall, because v1 meant all.envbecomessecrets.default_agentcomes from the old harness config, else the first agent.kortix.tomlis deleted. - It moves each agent’s
.mdfrontmatter into its block. The.mdkeeps the prompt alone and becomesprompt_file. - It copies the project-wide
permissionof the old harness config onto every agent that sets none, and deletes that config file. - It turns
permission.taskintosubagents. - It turns the
skills/<name>/folders into theskillsregistry. - It turns the harness folder’s command files into the
commandsregistry. Each command is enabled on the agent its frontmatter names, else ondefault_agent. - It turns plugins and
harnesses.pi.packagesintoextensions, enabled on every agent that loaded them, andharnesses.pi.codemodeintocodemode. - It sets
workspace: { project_repository: true }on every agent that had the repository, because v3 defaults tofalse. - 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:
- Downloads the release binary for your system from GitHub Releases.
- Checks it against the release’s
SHA256SUMS. A missing or wrong checksum stops the install. Whencosignis installed, it also checks the Sigstore signature ofSHA256SUMS. - Puts the binary in
~/.kortix/kortix. - Links
kortixandkortixt(the TUI) into/usr/local/bin. If that directory is not writable, it uses~/.local/binwhen it exists, elsesudo.
| 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:
- Opens your browser. You approve the CLI there, and the token comes back on a local callback.
- Selects your account. With one account it picks it. With several it asks.
--account <slug>skips the question. - Sets a default project for the host.
--no-projectskips 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:
--project <id>on the command..kortix/link.jsonin the current directory. The CLI does not look in parent directories.- The
KORTIX_PROJECT_IDenvironment variable. - 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 initwrites akortix_version: 3project. Its fields are on kortix.yaml reference.kortix projects clone <project_id> my-agentgets an existing project instead. It clones with your Kortix login and installs a git credential helper, so plaingit pullandgit pushwork in the clone.kortix shipcommits 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 deployis the same command.1is the change request number fromkortix 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 doctorkortix 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-systemThe 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 --forceIt 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" ;;
esacExit 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 newandrmkortix 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 helpandkortix --helpprint 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.--debugprints the resolved host, account and credential source to stderr.- A mistyped command exits
2and, when a command name is within two edits, prints it as a suggestion. It never scaffolds a project directory. Onlykortix initdoes 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. Itsserversentry 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. Returnsstatus,service,version,commit,environment,timestamp, andstarted_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
urlandexternal_urlare the site.capabilitiesis["preview"]. - The site decides whether it may be framed. Kortix reads its
X-Frame-Optionsand CSPframe-ancestorsheaders when you add it. A site that refuses a frame getscapabilities: []and the warningapp_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 answer409 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 buildDeploy the output directory
kortix apps deploy dist --slug my-site --type static --spaThe 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-siteShip the next release
npm run build && kortix apps deploy dist --app my-siteUse --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
staticfor every UI you can build ahead of time. Build locally and deploy the output. A Next.js site withoutput: 'export'deploysout/asstatic. - Pick
dockerfilefor server rendering, native packages or any service. dockerfileandoci_imageneed--command(a JSON argv array is unambiguous) and--port, orhttp_service.internal_portinkortix.yaml. A worker-only App (processesand 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,.dockerignoreand.kortixignore. - It never uploads
.git/,.kortix/or.env*. It leaves outnode_modulesunless 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 deploywith no arguments deploys every block that has apathor animage: each App after the Apps it uses, then by name. It skips the other blocks.kortix apps deploy <path>uses the block whosepathis that path.--manifest-app <slug>names a block. A flag you pass wins over the block.kortix validateapplies the API’s release config rules. The API refuses a bad config with400 app_config_invalidandissues: [{ path, message }]. It checks the machine size at deploy: see Run mode, machines and scale.- Any other key is an error.
backendsis an error: list used Apps inuses.kindis ignored, with a warning. - A static App ignores the run mode, the machine size,
env,secretsand 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.
processesmaps a group name to a shell command.""runs the App’scommand. A group name is lowercase letters, digits,-or_, and starts with a letter. - Primary service.
http_serviceanswers on the App’s own host. Its fields:internal_port,access,auto_stop_machines(off,stop,suspend;suspendacts likestop),auto_start_machines,min_machines_running(0–100),processes,checks. - More services.
serviceslists at most 10 more HTTP services, each{ name, internal_port, access, processes, checks }. A service answers on<app host>--<name>.httpis 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_portis 1–65535. Ports7331and8080belong to the App supervisor and are refused. Two services cannot share a port. - HTTP only. A service with
protocol: tcpis 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 countsets 0–20 per group and applies in the background. - A group with a mount runs one machine.
scale countabove 1 answers409 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-volumesaccepts separate volumes, for software that replicates its own data. - Destroying a machine keeps its volume. The group runs one machine fewer.
kortix apps stopsuspends a server App now. The next authorized request wakes it.kortix apps startwakes it before traffic arrives. A static App answers409 static_app_no_runtimeto both.execneedsproject.app.adminand 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--timeoutseconds (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 answers409 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 }
sourceis the volume name: 1–30 lowercase letters, digits or_.destinationis an absolute path. It cannot be/, end in/, or sit under/bin,/boot,/dev,/etc,/lib,/lib64,/proc,/root,/run,/sbin,/sys,/usr,/varor/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 volumesshows 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, never403. - 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
readAppViewerverifies the signed header withKORTIX_APP_VIEWER_SECRET, which Kortix injects. A forged header fails.createAppViewerKortixbuilds 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)andrequireGroup(request, groupIds)return{ viewer }or{ response }, never null. A group check keeps working when the App’s access mode changes.- A viewer with the
memberrole needs an agent grant to start sessions on a project with agent permissions. Without it, the API answers403 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
envvalues and 128secretsmappings. A value is at most 32 KiB. - A name is uppercase letters, digits and
_(^[A-Z_][A-Z0-9_]*$), at most 128 characters. kortix validateand the deploy refuse reserved names and say why. Reserved: every name that starts withKORTIX_;PORT,PATH,HOME,PWD,USER,LOGNAME,SHELL,HOSTNAME,TERM,TMPDIR,NODE_ENV,NODE_OPTIONS,LD_PRELOAD,LD_LIBRARY_PATH; andSLACK_SIGNING_SECRET,SLACK_BOT_TOKEN.- A name cannot be in both
envandsecrets. secretsmaps a runtime name to a project secret identifier. The secret must have theenvironmentexposure. 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 underassets/orstatic/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 your404.htmlwith status404. /docsredirects308to/docs/whendocs/index.htmlexists.- Responses are
privateto shared caches unless the App ispublic.
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
readyanswers409 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) orcaddy(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 needsproject.app.admin. Each volume keeps afinalsnapshot 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-dashboardprints 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 withkortix 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 backendprints 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 projectNever 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-apicreates it withPLATINUM_API_KEY. The sandbox calls back to the instance’s public API URL,KORTIX_URL. - What a session keeps.
/workspaceholds 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-apiandkortix/kortix-gatewayon 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>andapi.<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, anddoctorfails.*.<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
-
Sign up as the admin. Open
https://<domain>and sign up with an address inKORTIX_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. -
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. -
Create the team account. Your sign-up account is personal. It cannot hold SAML SSO (
403sso_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. -
Create a project in the team account. After Create an account…, Kortix opens
/newwith the new account selected. -
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. -
Turn on email. Set one connection string:
kortix self-host env set EMAIL_URL=smtp://user:password@smtp.example.com:587Kortix then sends sign-in and invitation mail through it, requires email confirmation for new accounts, and adds magic-link sign-in.
EMAIL_FROMdefaults toKortix <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. -
Close sign-up. When your team has signed up, run
kortix self-host env set DISABLE_SIGNUP=true, thenkortix self-host startso 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. .envanddocker-compose.ymlare written with mode0600.- Connectors refuse private and internal addresses with
connector_egress_blocked. List the internal hosts they may reach inKORTIX_CONNECTOR_EGRESS_ALLOW_HOSTS. kortix-updaterholds 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.
-
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).
-
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>"}}' -
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:
- Checks free disk space. It stops below
KORTIX_MIN_FREE_DISK_MB(default2048). - Pulls the configured tags. When no image changed, it stops.
- Runs
kortix-migrate. When a migration fails, nothing is swapped and the previous version keeps serving. - Rolls
kortix-api,llm-gatewayandfrontend. With 2 replicas, new containers must pass their health check withinKORTIX_ROLLOUT_TIMEOUT(default300seconds) 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. - 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, defaultwrite) converts every old session, oldest first, on the leader API container. It logs[session-log] backfill tickwith 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 answers503session_log_convertingwithRetry-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 lslists every value by group and masks secrets.--showreveals them.kortix self-host env set KEY=VALUE ...writes values and restarts the services that read them. A bareKEYprompts for the value. A newAPI_KEY_SECRETmakes every stored credential undecryptable, soenv setwarns first. At a terminal it asks you to confirm. A script must pass--yes: without it the command exits2and writes nothing.kortix self-host env rotate <KEY>or--all-generatedregenerates a generated secret. RotatingSUPABASE_JWT_SECRETalso regenerates the anon and service-role keys. The CLI refusesAPI_KEY_SECRET,POSTGRES_PASSWORDandSAML_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 examplekortix self-host logs kortix-api. Each container keeps 3 log files of 10 MB.kortix self-host pslists the containers.statusadds 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 askortixwithDASHBOARD_PASSWORDfromenv 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:
sessions.create()returns the session row with itssession_id. The sandbox starts to provision in the background.ensureReady()resolves when the runtime accepts work. It returnsruntimeSessionId, the id of the root conversation. It waits up to 180 seconds by default.stream()opens beforesend(), so the script misses no early event.send()resolves when the prompt is stored, not when the turn ends.narrowChatEvent()reduces raw runtime events to the chat events. Eachmessage.part.updatedevent carries the whole part, so the map keeps the latest text of each part.- The turn ends on
session.idleforruntimeSessionId, after an assistant message exists.session.errorends 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/sdkpublishes 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-searchand/capture-rules. - Exported names, types included, are the public contract. A renamed export keeps a
@deprecatedalias 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/turnsand@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
runtimeSupportsbefore 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.
getTokenreturnsnull: the SDK sends no request. A typed call such askortix.projects.list()throwsAuthErrorwithcode: 'NO_SESSION'.kortix.validateToken()resolves{ valid: false, error }instead.- The SDK sends the token as
Authorization: Bearer <token>. WithcredentialHeaderset (for example'x-app-reader-token', for a proxy that reads a custom header), it sends the raw token in that header and sends noAuthorization. The401replay still callsgetTokenagain. A header of the same name that a call sets itself wins. - The API answers
401: the SDK callsgetToken.invalidate(rejectedToken)when it exists, callsgetTokenagain, and replays the request once. A second401goes to the caller. - Parallel
401s on one token share oneinvalidatecall and onegetTokencall. 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 ownAuthorizationheader.
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.
Consent and revocation
The first sign-in shows a Kortix consent screen with your app’s name and
scopes. Kortix remembers the approval per person and app. Later sign-ins
return straight to your app. A person revokes an app with
kortix.accounts.connectedApps.list() and .revoke(clientId), or on the web
and in the CLI as Security 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_idis the id of the sandbox and the name of the session’s git branch.ensureReady()returnssandboxId. That value is the provider’s external id of the sandbox, notsession_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'] });
labelsreplaces the whole list.[]clears it.metadatamerges keys. Anullvalue removes a key.name: nullorname: ''returns the session to the automatic title.listPage({ labels })returns only sessions that carry every label. Passnext_cursorback ascursorfor 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 .../startin long polls of up to 30 s, with a 1 s pause, while the answer isprovisioningorstartingwithretriable: true. - The default deadline is
180_000ms. PassreadyTimeoutMsto change it. - At the deadline, or at once on a
failedorstoppedanswer, it throws anApiErrorwithcode: 'RUNTIME_UNAVAILABLE'. - A
4xxanswer other than408or429rejects at once with aSessionStartError(status,code,terminal: true). A402is one example. - Concurrent calls for one session share one start request. A call on a ready handle returns at once.
runtimeSessionIdis the id of the root conversation in the runtime. Events carry it assessionID.
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 froms.messages().send()throws anApiErrorwithcode: '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 listsession.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
usePromptAttachmentsfrom@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.deltaevents for one part field in one batch arrive as one event:properties.deltaholds the joined text,idis the last event’s id, andcoalescedlists 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 akind:text,reasoning,tool,fileand more.erroris the turn’s normalized error.s.turn()answers without the stream:turns(empty when idle),last_ended,recent_failuresandcan_stop. Poll it when you cannot hold a stream open.- An agent reply can hold generative UI blocks (
openuifences). For plain text, pass the text throughgenuiToMarkdown(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.askedandquestion.asked. TheirrequestIDis theidto 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
501feature_not_supported. runtimeSupportsreturnstruebefore a health answer arrives, and for a list with nosession.*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,failedorcompletedsession it returns409SESSION_NOT_RUNNING. Only the session owner or a project manager may call it; anyone else gets403. - 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/sdkis 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.
- 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. - Create the client with
createScopedKortixfrom@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 neverbackend. - On a scoped client,
kortix.runtime()throws. Reach a session throughkortix.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
201with 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
409IDEMPOTENCY_KEY_CONFLICT. Put your own unique ids in the key. - A replay while the first create still runs answers
202withstatusandsession_id. - A replay with different
connector_bindings,secretsorruntime_contextreturns409IDEMPOTENCY_BINDING_CONFLICT,IDEMPOTENCY_SECRETS_CONFLICTorIDEMPOTENCY_CONTEXT_CONFLICT. - A key whose session was deleted returns
409IDEMPOTENCY_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 astoken,secret,password,credential,api_key,private_key,authorizationorcookieis 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: truelets 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 meantnullblocks 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
403with 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. |
codeis the API’s error code, or the status as a string.- An aborted request has
name: 'AbortError'andcode: 'ABORTED'. It is not a failure. - The default timeout is
30000ms per request. A request past its timeout hascode: 'TIMEOUT'. - A
401is sent once more aftergetToken()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-queryImport 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.
hasErroristruewhen the transcript shows an error on that turn.isAbortistruewhen that error is an abort.turnEndNoticereturns{ kind: 'sandbox-memory', usedPct, detail },{ kind: 'cause', name, message },{ kind: 'unexplained' }ornull.nullmeans: show what the transcript shows. A non-abort transcript error always returnsnull.useSessionTurnOutcomemakes no request. It reads the/turncache thatuseSessionWorkingkeeps fresh.turnEndCause(outcome, messageId)returns the raw recorded cause, ornull.
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:
- The watch listens for
kortix.control.turnframes on the session’s control stream. A hidden window that throttles its timers still receives the end. - While that stream is down, it reads
GET …/turnevery 5 s, and every 15 s after 5 minutes. It stops after 12 hours. - When no turn is open, it raises
task-complete, orsession-errorwith the recorded cause. A stop that a person asked for raises nothing. - These events also carry
projectId.task-completealso carrieswatchedSinceMs, the watch start in the host’sDate.now(). A host that relays completions between tabs skips one it already showed after that time. - 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>;
persistwrites at most once per second. Callflush()onpagehide, so a reload keeps the last change.- On React Native, pass AsyncStorage as
storage.restorethen 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.
isPersistableQueryKeynever 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-genuiby 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
skillsgrant inkortix.yamlisallor nameskortix-genui. The default grant isnone. The starter agents grantall. 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,Mutationand$statestatements 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 absolutehttporhttpsURL, ornull. 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_NOTEis'Response was cut off.'. It shows under a settled block whose stream ended inside a statement.GENUI_UNSUPPORTED_NOTEis'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/uiintranspilePackages. - 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
darkclass on<html>.useIsDark()returns the current state. - Both paths style the page:
bodypaints--backgroundand--foreground, borders default to--border, native controls followcolor-scheme, text selection reads--selection, and interactive elements show the pointer cursor. You add no base rules. @kortix/ui/fonts.cssloads the Roobert font. It is for Kortix-owned surfaces only. Every other host uses the system font stack (--font-sans-system) or setsfonts.sansin 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,SessionWorkspaceandProjectHomemount the project scope andSessionIdsProviderthemselves.- With no side panel on the page, a file or preview the thread opens shows in a dialog.
SessionThreadrenders Generative UI blocks by default. You write no code for them.ModelSelectorandAgentSelector(@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
nullfor a page you do not serve. Every link to it is hidden and no navigation goes there. projectandsessioncannot returnnull.
| 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. Atransform,filter,backdrop-filter,containoroverflow: hiddenancestor of the scope does not clip or offset the overlays. - The scope sets
font-familyandcoloron its element. It paints no background: addbg-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:andlast:on the children behave as without the scope. - Toasts do not portal. To theme toasts, mount
<Toaster />inside theThemeScope, 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 bareborder*anddivide-*followborderWidth. - Font sizes with no step stay fixed: the 10px, 11px and 12px captions and the
0.8reminline code chip. - Durations with no step stay fixed:
duration-80,duration-0, the sidebar slide (200/240/260msoncubic-bezier(0.32, 0.72, 0, 1)), the command palette (140/180ms) and the enter and exit keyframes (200/250ms). Everyduration-100/150/200/300/500readsduration-fast…duration-slower. - Chat surfaces have no tokens of their own. They read
muted,card,borderandmutedForeground. - Decorative values stay fixed: the dialog scrims, the
inverseButtonvariant, theTagstatus 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
useBrandoption ofUiProvider. - Do not load
fonts.css. Setfonts.sans. - Plain CSS works after the
@kortix/uiimport. Put light values in:root:not(.dark)and dark values in.dark. A bare:rootrule also matches<html class="dark">and overrides the dark value.themeToCsswrites 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:
Modalfor a dialog,Hintfor a tooltip,Loadingfor a spinner,Badgefor a chip,ConfirmDialogbefore a destructive action.DialogandTooltipare banned in feature code. The root exports them becauseModalandHintbuild on them, and both carry@deprecated.- Never call the
toast()function ofsonnerdirectly. Use the helpers in@kortix/ui/toast. - Never use an icon with
animate-spinas 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,useProjectAppsanduseProjectTriggers. - Never
fetchthe 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:
kortixis the client fromcreateKortix.piskortix.project(projectId).siskortix.session(projectId, sessionId).- Route paths are relative to
backendUrl, which ends in/v1. The REST reference lists each route. - A failed call throws
ApiError.statusis the HTTP status,codeis the API’s error code,detailsis the parsed response body anddetailis 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. |
Secrets, tokens and setup links
| Method | What it does |
|---|---|
secrets.list() |
Secret metadata, never values. status is the shared value’s state and mine.status the caller’s own value’s state. 'undecryptable' means the server cannot decrypt it: write the value again. |
secrets.upsert(input) · secrets.remove(name) |
Create or update a write-only value with its delivery policy · delete. |
secrets.setStrategy(identifier, 'runtime' | 'egress' | 'broker' | 'denied', options?) |
The exposure: runtime is an environment variable, egress is injected at the network edge, broker keeps the value out of the session, denied is none. |
secrets.broker(identifier, request) |
Sends one policy-bound HTTPS request with the secret. Needs a session-scoped token. |
secrets.setPersonal(name, { value?, active? }) · secrets.removePersonal(name) |
The caller’s own value, used in the caller’s sessions. active: false keeps it but uses the shared value. |
secrets.setGitCredential(input) |
The credential for a bring-your-own git repository. |
secrets.listProviderOAuth() · .startProviderOAuth(…) · .pollProviderOAuth(…) · .removeProviderOAuth(provider) |
Model provider subscription logins, through a device-code flow. |
tokens.list() · tokens.create(input?) · tokens.revoke(tokenId) |
Project API keys. Kortix also mints one per session as KORTIX_TOKEN. |
setupLinks.requestSecret(input) · setupLinks.requestConnector(input) |
A link a person opens to enter a secret or connect an app, without full project access. |
Connectors and policies
| Method | What it does |
|---|---|
connector(slug).run(action, args, options?) |
The action’s output. Throws ConnectorCallError, or ConnectorApprovalPendingError while the call waits for approval. |
connector(slug).call · .describe(action?) · .accounts() · .paginate(action, args, { next, maxPages? }) · .uploadAttachment(content, input) |
The raw result · schemas · callable accounts · a page iterator (ConnectorPageLimitError past maxPages) · stage a file. |
connectors.call(tool, args?, { account? }) · connectors.callAction(slug, action, args, options?) |
Call <connector>.<action> · the same, typed by kortix connectors types. |
connectors.catalog(options?) · .tools() · .search(query) · .describe(tool) · .accounts(slug) · .uploadAttachment(…) |
Find and inspect callable tools and accounts. |
connectors.list() · .config(slug) · .create(input) · .remove(slug) · .sync() · .auth.discover(input) |
Configure connectors. auth.discover reads auth from an OpenAPI spec, a Postman collection or an endpoint. |
connectors.setName · .setSensitive · .setCredentialMode · .setCredential · .deleteCredential · .setOAuth2ClientCredentials |
Connector settings and the stored credential of its shared account. |
connectors.policies.get(slug, { policySet? }) · .policies.set(slug, policies, { policySet? }) |
One connector’s base rules, or the rules of one policy set. A rule is { match, action, conditions? }. |
connectors.policySets.list(slug) · .upsert(slug, name, { description? }) · .rename(slug, name, newName) · .remove(slug, name) |
Policy sets: each set with its rules and the agents that use it · create one or set its description · rename it and every agent reference · delete it. remove returns 409 policy_set_in_use while an agent uses the set. Root exports: listConnectorPolicySets, upsertConnectorPolicySet, renameConnectorPolicySet, deleteConnectorPolicySet. |
connectors.agents(…) · connectors.rulesMatrix() |
Every declared agent on one connector, with its grant and rule set · every connector against every agent, in one request. |
connectors.connections.list() · .listAll() · .reconcile(…) · .reconcileMember(…) · .updateCredential(…) · .revoke(…) · .activate(…) · .setDefault(…) · .rename(…) · .share(…) · .addComputer(…) |
Connected accounts: shared or private, defaults, labels, sharing, paired computers. |
connectors.discover.list · .sections · .detail · .tools · .install |
The app catalog. list and sections take q, category, kinds (mcp, composio, openapi, graphql) and oneClick. install({ id, way?, name?, slug? }) returns next: connect, credential or ready. |
connectors.listMoves() · connectors.move(slug, { connectionId? }) |
Move a Pipedream or Composio connector to its Kortix MCP app, with the same slug. listMoves returns ConnectorMove[]. move returns ConnectorMoveResult: status: 'sign_in' with a connection_id, or status: 'moved'. Root exports: listConnectorMoves(projectId), moveConnector(projectId, slug, { connectionId? }). |
connectors.pipedream.listApps · .listSections · .connect · .finalize |
Legacy Pipedream connect for OAuth apps. |
policies.list() · policies.set(policies) |
Project policies. |
agentIdentities() · setAgentScope(agentName, scope) |
The project’s agents as principals · bind secrets, connectors and policy sets to one agent. scope.policy_sets is { <slug>: <name> | null }, merged per connector; null or 'base' removes the entry. |
Triggers and reminders
| Method | What it does |
|---|---|
triggers.list() |
Every trigger, with parse errors and triggers_paused. |
triggers.create(input) · .update(slug, input) · .remove(slug) |
Write a trigger. name and type are required. type is cron, webhook or app. The API rejects type: monitor with MONITOR_REMOVED and names the replacement: an App and a type: app trigger. prompt_template is required unless target ({ app, path?, method?, payload? }) calls an App. A schedule takes cron, every or run_at. type: 'app' takes app and, optionally, filter: { 'event.name': … }. |
triggers.fire(slug, { payload?, dryRun? }?) |
Runs one trigger now, also while triggers are paused. payload deep-merges over the manual payload. dryRun: true writes nothing and returns ProjectTriggerDryRunResult (rendered_prompt, filter_matches, session_key, payload). A real fire returns status (fired, queued or deduped), session_id, command_id and run_id. |
triggers.runs(slug, { cursor?, limit? }?) · triggers.projectRuns({ status?, cursor?, limit? }?) |
One trigger’s runs · every run of the project, newest first: { runs, next_cursor }. limit is 1 to 100, default 25. A page can hold fewer than limit runs while next_cursor is set. |
triggers.replay(slug, runId, { confirmUnverified? }?) |
Fires again with the run’s stored payload and the current spec. 409 run_in_progress while the run is queued or running. 409 payload_unverified for an unverified webhook payload without confirmUnverified: true. 422 payload_truncated for a payload over 64 KiB. |
triggers.setActivation(paused) |
Pauses or resumes every trigger of the project. |
reminders.list() · reminders.updateMany(…) · reminders.removeMany(…) |
Reminders on every session the caller can open. One session’s reminders are s.reminders. |
All trigger fields are in Run triggers 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:
useCanand its siblings decide what to show. The API checks every write again. Probe the action the route checks, for examplePROJECT_ACTIONS.PROJECT_GITOPS_PUSH(project.gitops.push), never a role. After a write that changes access, callinvalidatePermissionProbes(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.
Workspace search
createWorkspaceSearchClient(fileClient, getCacheKey?) from @kortix/sdk/workspace-search returns ranked file and folder search over a session workspace, with a cached index. fileClient has listFiles(path) and findFiles(query, options?), the same shape as s.files. getCacheKey partitions the cache, for example by session id. The entry also exports the ranking helpers rankWorkspaceSearchEntry, searchIndexedWorkspaceEntries, normalizeSearchQuery and normalizeWorkspacePath.
Session log types
The root exports the types of the session log, the runtime-neutral record of a session, and its constants. It exports no validators. s.log(query) reads one page. In React, useSessionLog and useSessionLogThread return it.
| Name | What it is |
|---|---|
SESSION_LOG_SCHEMA · SESSION_LOG_MINOR |
'kortix.session/2' · 1. |
SessionLog |
The record: threads, todos, pending questions and permissions, harness (the runtime that writes it, and the history of runtimes), restore_grade. |
SessionLogThread · SessionLogMessage |
One thread (the main thread or a sub-agent) · one message with seq, role, kind, status, model, usage, blocks. |
SessionLogBlock |
The union of TextBlock, ReasoningBlock, AttachmentBlock, ToolCallBlock, CompactionBlock, SubtaskBlock, StepBlock, HarnessBlock, keyed by type. |
ToolResultContent, CompactionLayoutEntry, ThreadInteraction, ModelRef |
Parts of the records above. |
KNOWN_TOOL_KINDS · KnownToolKind · SessionLogToolKind |
The tool kinds every reader renders: shell, read, write, edit, patch, list, glob, grep, web_fetch, web_search, todo, task, question, plan, mcp, other · a block’s tool kind, any string. Render an unknown kind as other. |
SessionLogTodo |
One todo item. |
RestoreGrade · GradeCounts |
How completely the record restores (native, converted or partial) · what is missing. |
SessionLogPage · SessionLogPageQuery |
The answer of s.log() · its query { thread?, before_seq?, limit? }. |
A text over 256 kB is stored by reference, in a TextBlock and in a text item of a tool result. Its text holds the first 16 kB, bytes holds the UTF-8 length of the full text, and ref is kortix-attachment://<project_id>/<session_id>/<attachment_id>. s.attachments.read(attachmentId) returns the full text as a Blob, also while the sandbox is stopped.
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.