# Kortix full public content corpus > The open-source AI command center for your company. Every agent, skill, and memory is a file in one versioned repo you own — a workforce of AI agents that does real work, shared across your whole team from Slack, Teams, the web, or the CLI. Self-hostable, any model, your keys. # Kortix – The AI Command Center for Your Company The open-source AI command center for your company. Every agent, skill, and memory is a file in one versioned repo you own — a workforce of AI agents that does real work, shared across your whole team from Slack, Teams, the web, or the CLI. Self-hostable, any model, your keys. Canonical page: https://kortix.com/ ## Official resources - [Documentation](https://kortix.com/docs) - [Blog](https://kortix.com/blog) - [Use cases](https://kortix.com/use-cases) - [GitHub](https://github.com/kortix-ai/suna) --- # About Kortix We build self-driving companies. Humans verify, steer, and govern while agent teams do work across engineering, product, operations, finance, support, and growth. Canonical page: https://kortix.com/about ## Official resources - [Documentation](https://kortix.com/docs) - [Blog](https://kortix.com/blog) - [Use cases](https://kortix.com/use-cases) - [GitHub](https://github.com/kortix-ai/suna) --- # Kortix for developers The open-source AI command center for your company. Every agent, skill, and memory is a file in one versioned repo you own — a workforce of AI agents that does real work, shared across your whole team from Slack, Teams, the web, or the CLI. Self-hostable, any model, your keys. Canonical page: https://kortix.com/developers ## Official resources - [Documentation](https://kortix.com/docs) - [Blog](https://kortix.com/blog) - [Use cases](https://kortix.com/use-cases) - [GitHub](https://github.com/kortix-ai/suna) --- # Kortix Enterprise Scale, security, and your deployment. Canonical page: https://kortix.com/enterprise ## Enterprise Custom Scale, security, and your deployment. - Everything in Team - SAML SSO + SCIM directory sync - Advanced RBAC + audit logs - Cloud, VPC, or on-prem - BYOK, ChatGPT subscription, and managed model controls - SLA, DPA & dedicated support --- # Kortix pricing Current plans and included features. Canonical page: https://kortix.com/pricing ## Free $0 Start with real sandbox credits. - 200 credits / month for sandbox compute - 3 projects - Bring your own API key for any premium model - Connect your ChatGPT subscription ## Team $40 / seat / mo For teams running real work on agents. - Everything in Free - 2,500 credits / month per seat, pooled - Access to the latest AI models - BYOK and ChatGPT subscription still supported - Up to 200 projects, up to 100 seats - Top up credits anytime - Support via email ## Enterprise Custom Scale, security, and your deployment. - Everything in Team - SAML SSO + SCIM directory sync - Advanced RBAC + audit logs - Cloud, VPC, or on-prem - BYOK, ChatGPT subscription, and managed model controls - SLA, DPA & dedicated support --- # Kortix vs Glean: search or an agent platform that runs work? Glean is the best permission-aware enterprise search. But search finds work — it doesn't do it. Here's where you outgrow it, and the open runtime alternative. Canonical page: https://kortix.com/blog/kortix-vs-glean Published: 2026-07-13 Author: team Tags: Comparisons, Enterprise, Open Source Glean is genuinely the best permission-aware enterprise search you can buy. It indexes your apps, respects your ACLs, and answers in plain language with citations. So this isn't a "they're bad, we're good" post. The honest question is a different one: once you can find anything in your company, what actually does the work with it? Compared here: - Glean (glean.com) ## What Glean is great at - **Permission-aware search done right** — it inherits your source-system ACLs, so a result you can see is a result you can act on. - **Mature connectors** — it reaches across the usual enterprise stack and keeps the index fresh. - **Serious compliance posture** — built for the security review that enterprise search has to survive. - **A clean assistant on top of retrieval** — ask a question, get a cited answer instead of ten blue links. ## Where it stops: search finds work, it doesn’t do it Glean’s center of gravity is the index. Agents are a layer on top of retrieval, not a workforce that runs your company. The moment the job is “open the tickets, enrich the accounts, draft and send the outreach, land the fix, close the book” — search has stopped being the bottleneck and a chat assistant over the index isn’t the answer either. You need a runtime that hands a task to agents and they return finished work. - **Retrieval-first, agents bolted on.** The product answers “where is it?” well; it is not built to run a fleet of agents that take real actions across your tools. - **Closed and vendor-hosted.** You query Glean; you don’t own it. It is SaaS or vendor-managed cloud — your company’s knowledge leaves your walls to be indexed somewhere else. - **Seat-priced and sales-led.** Public reporting puts Glean at roughly [$50–75 per user/month with a ~100-seat minimum](https://www.gosearch.ai/faqs/glean-enterprise-search-pricing-explained-costs-tiers-hidden-fees-gosearch-comparison) — about a $60k/year floor before infrastructure and implementation. That locks out the small team and the single-department pilot. - **Configured in a console, not as code.** Connectors, assistants, and prompts live in a vendor dashboard. There is no diff to review, no version to roll back, no repo to fork. None of that is a flaw in a search product. It is exactly the line you cross when “let me find it” becomes “let something do it.” If you want the broader framing, [beyond the chat box](/blog/beyond-the-chat-box) makes the same argument against chat assistants: input→output stops short of work. ## A runtime that does the work, not just retrieves it Kortix is an open agent runtime — the command center where a workforce of agents runs your company, not a search bar over it. Hand a task to a project and agents run in isolated sandboxes, take real actions through scoped connectors, and land durable change back to one shared `main` through a reviewed change request. The context they need is files in a repo you own, not an index someone else rents back to you. That is the real split. Glean makes your existing knowledge searchable; Kortix makes your company’s operating layer — agents, skills, memory, connectors, policies — into [files in one repo](/blog/introducing-kortix) that agents run against. One is a window onto work; the other is where the work happens. ## Own the data, pick the model, skip the seat tax Because Kortix is open-source and self-hostable, your data never has to leave your walls — your cloud, your VPC, on-prem, or your own GPUs. And because you bring your own key and run any model, the bill is not bundled into a per-seat license. An open-weight model like **GLM-5.2** runs about **5–7× cheaper** than Claude Opus or GPT on output (~$4.40 vs $25–30 per 1M tokens), and **DeepSeek** is **50×+ cheaper** on output. Route a cheap model for the bulk of the work and a frontier model only where it earns its keep. > No 100-seat floor, no sales process to start — [see the plans](/pricing). Open-source means you can run one project today and a whole company on it tomorrow — on infrastructure where the data, config, and model belong to you. ## Side by side | Dimension | Glean | Kortix | | --- | --- | --- | | Core job | Find & answer over company data | Build & run agents that do the work | | Runs a fleet of agents in parallel | Assistants bolted onto search | Thousands of agents, isolated sandboxes | | Self-hostable / own your data | No — SaaS or vendor-managed cloud | Yes — your cloud, VPC, on-prem | | Choose your models | Vendor-managed, bundled in seat | Any model — your keys | | Pricing model | ~$50–75/user/mo, ~100-seat min | Open-source; cloud or self-host, any size | | Accessible below 100 seats | No — sales-led, large-enterprise floor | Yes — start with one project | | Agents, skills & policies as code | Configured in a vendor console | Files in one repo you own | | Versioned, reviewable, roll-back-able | Console settings, no diff | Git history + change requests | | Multi-tenant governance | Enterprise permissions on search | Departments, roles, scoped connectors | ## When to pick which ### Choose Glean if you want the best permission-aware enterprise search and assistant, you’re fine with a closed SaaS and a sales-led ~100-seat contract, and “find the answer” is the job. ### Choose Kortix if you want to run agents that actually do the work — [across departments](/enterprise), any model, self-hosted, with everything versioned and owned by you. They can coexist, too. Plenty of companies will keep Glean as the search layer and run the work itself on Kortix — agents that read, decide, and act, with the operating layer they need to do it governed as code. If that operating layer is what you’re missing, the [company OS post](/blog/ai-transformation-company-os) and the [secure connector model](/blog/secure-ai-agent-tool-access) are the next reads. ## Don't just find the work. Run it. Connect your tools and hand a Kortix agent a real task. Free to start, free to self-host. --- # How to give AI agents tool access safely How to give AI agents production tool access without raw API keys: scoped connectors, approval policies, server-side credentials, and reviewed work. Canonical page: https://kortix.com/blog/secure-ai-agent-tool-access Published: 2026-07-07 Author: team Tags: Security, Connectors, Enterprise The moment an AI agent can use tools, it stops being a chat feature and becomes production infrastructure. It can read customer records, draft emails, open pull requests, query billing, post in Slack, or touch an internal API. At that point the hard question is not “can the model call the tool?” It is **who gave it access, how narrow is that access, what happens before a risky action runs, and what audit trail remains afterward?** Kortix was built around that boundary. Tool access does not belong in a prompt and raw credentials do not belong in an agent sandbox. In Kortix, connections are part of the project operating layer: declared as files, brokered server-side, granted per agent, governed by policy, and reviewed when durable work changes the company. If you want the larger architecture first, read [Introducing Kortix](/blog/introducing-kortix) or the [company OS post](/blog/ai-transformation-company-os). The rest of the market is converging on the same lesson. [Auth0](https://auth0.com/blog/api-key-security-for-ai-agents) calls out over-privileged tokens, prompt-injection exposure, and missing audit trails as common risks when teams hand API keys to agents. [WorkOS](https://workos.com/blog/ai-agent-credentials) argues agents need their own scoped, revocable credentials instead of borrowing a user’s full session. [Promptfoo’s OWASP Agentic AI summary](https://www.promptfoo.dev/docs/red-team/owasp-agentic-ai) lists Tool Misuse and Identity and Privilege Abuse as core agentic risks. The pattern is clear: agent security is mostly tool security. ## Chat is harmless until it touches systems A model drafting text in a window has a small blast radius. A model with connected tools has the blast radius of those tools. That is not a reason to keep agents powerless; powerless agents do not run companies. It is a reason to treat the connector layer as seriously as you treat IAM, secrets, and production deploys. - **A support agent** may need to read tickets and invoices, but should not be able to refund money without approval. - **A finance agent** may need to pull Stripe, bank, and warehouse data, but should not be able to send vendor payments from the same path. - **A recruiting agent** may need to enrich candidates and draft outreach, but should not send messages without a human approving the final copy. - **An engineering agent** may need GitHub, Linear, CI, and preview access, but should land work through a reviewed change request instead of mutating main directly. > The control plane cannot be “the prompt told the agent to be careful.” The control plane has to be outside the model. ## The five rules of safe tool access A production agent platform needs five layers before you can comfortably connect real company systems: - **Keep credentials out of the sandbox.** The agent should never receive a third-party API key unless the task truly requires direct process-level access. Connector credentials should be resolved server-side and injected into the upstream request, not into model context. - **Grant tools per agent.** Connecting Slack, Gmail, Stripe, or GitHub to a project is not the same as letting every agent call it. The support agent and release agent need different reach. - **Gate individual actions.** Read operations, write operations, deletes, sends, payments, and admin changes should not share one permission bit. Tool names need policy: always run, require approval, or block. - **Make risky calls human-reviewable.** A good agent can prepare the exact action and evidence. The platform should pause at the boundary where a human decision is required. - **Route durable change through review.** If the agent edits the operating layer — agents, skills, triggers, memory, policies, or code — that work should be a diff someone can review, merge, and roll back. ## How Kortix models a connector Kortix connections are documented in [Connecting your tools](/docs/guides/connecting-tools). A connector can be a one-click Pipedream app, a remote MCP server, an OpenAPI or GraphQL API, a raw HTTP API, a channel such as Slack, or a connected computer. The definition lives with the project; the credential lives on the platform. The agent sees a tool catalog, not a pile of secrets. ``` connectors: - slug: stripe provider: openapi spec: https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json policies: - match: "*.get*" action: always_run - match: "*.create*" action: require_approval - match: "*.delete*" action: block agents: support: connectors: [plain, stripe] secrets: none kortix_cli: none release-bot: connectors: [github, vercel] kortix_cli: [project.cr.open] ``` That example is deliberately boring. Boring is the point. You should be able to answer “what can this agent call?” by reading the project files, not by reverse-engineering a prompt or inspecting a live process. The [manifest reference](/docs/reference/manifest#connectors--connectors) defines connector policies and the [agent governance section](/docs/reference/manifest#agents-v2) defines per-agent grants. ## Server-side credentials change the failure mode When credentials sit in environment variables inside the agent runtime, every prompt-injection bug, logging bug, file-read bug, and subprocess bug becomes a possible credential leak. When credentials are brokered server-side, the agent can ask to call a tool, but the platform decides whether the call is allowed, resolves the credential, executes the upstream request, and records what happened. That is the model behind the Kortix Executor. Every session gets a scoped Executor token. The agent discovers tools, describes their schemas, and calls them through the Kortix API. The gateway enforces the project grant and connector policy, resolves credentials outside the sandbox, runs the request, and audits the call. The [connections guide](/docs/guides/connecting-tools) is explicit: the agent never holds third-party credentials. > A scoped tool token is not just safer than a raw API key. It also makes the audit trail meaningful: agent identity, tool name, input boundary, policy decision, approval state, and upstream result can all be tied together. ## The dangerous pattern to delete The common early pattern is understandable: put `STRIPE_SECRET_KEY`, `GITHUB_TOKEN`, `SLACK_BOT_TOKEN`, and a dozen other keys into `.env`, start the agent, and hope the prompt keeps it in bounds. That works for a demo. It is the wrong shape for a company. - **It is too broad.** The key usually carries every permission the integration owner had, not the minimum action the agent needs. - **It is hard to attribute.** Downstream systems see the shared key, not the agent, session, person, or approval that caused the call. - **It is hard to revoke safely.** Rotating a shared key breaks every workflow using it; leaving it in place keeps the blast radius large. - **It hides policy in code and prompts.** Security reviewers need declarative grants and logs, not “the agent instructions say don’t delete things.” ## A quick audit for your agent stack Before you connect agents to production systems, ask these questions: - Can I list every external system this agent can reach without opening the agent prompt? - Can I give a sales agent CRM read access without also giving it billing write access? - Can I block deletes, require approval for sends, and allow safe reads on the same connector? - Can I see which person, agent, session, and policy decision caused a tool call? - Can I revoke one agent’s reach without rotating a shared key that breaks other workflows? - Can the operating layer move from cloud to VPC or on-prem without rewriting the tool model? If the answer is no, you may still have a useful agent prototype. You do not yet have a secure AI command center. ## Why this is a company OS problem Safe tool access is not a standalone feature. It only works when it sits beside the rest of the company operating layer: memory, agents, skills, triggers, secrets, policies, sandboxes, and change requests. The connector grant says what the agent may touch. The sandbox limits where it runs. The policy gate decides which calls need approval. The change request records durable changes as a diff. The repo keeps the whole thing owned and reviewable. That is why Kortix frames the product as an Autonomous Company Operating System, not another assistant with more integrations. A company does not need one more place to paste keys. It needs a Git-backed AI command center where the tools, credentials, policies, and agent work are part of the same owned system. ## Connect the tools, keep the keys out of the agent. Start with one workflow, grant only the connectors it needs, gate risky actions, and run the work from a repo your company owns. --- # AI transformation needs a company OS Why consultancies and AI-transformation teams need one Git-backed workspace for agents, memory, connectors, policy, and auditable work. Canonical page: https://kortix.com/blog/ai-transformation-company-os Published: 2026-06-29 Author: team Tags: Enterprise, AI Transformation, Company OS AI transformation is past the demo phase. The hard part now is not proving that an agent can draft a report, inspect a spreadsheet, or update a CRM record. The hard part is giving every client, department, and delivery team a **repeatable operating layer** where agents, context, connectors, policy, and review live together. That is what a company OS is for. Kortix is the **Autonomous Company Operating System**: an AI command center where a workforce of agents does real work, and everything that defines the system is files in one Git repo you own. For consultancies and AI-transformation teams, that matters because the deliverable is no longer a single chatbot. The deliverable is a governed workspace the client can keep running after the pilot. If you want the full product spine first, read [Introducing Kortix](/blog/introducing-kortix). The market is already pointing this way. [Accenture AI Refinery](https://www.accenture.com/us-en/services/ai-data/ai-refinery) frames enterprise AI around agents, knowledge, models, and governance. [Deloitte](https://www.deloitte.com/in/en/services/consulting/services/engineering-ai-data/agentic-ai.html) describes multiagent systems that understand requests, plan workflows, coordinate role-specific agents, collaborate with humans, and validate outputs. The missing question is where all of that lives so it can be owned, reviewed, repeated, and ported into the tools people already use. ## The pilot is not the product Most AI-transformation work starts with a useful prototype: a support agent, a sales-research assistant, a finance close helper, a legal intake workflow, a marketing campaign planner. The prototype proves demand. Then the real work starts. - **Who owns the instructions?** If the prompt lives in one vendor dashboard, the client cannot audit or improve it like normal operational IP. - **Where does the context accumulate?** If every tool stores a different slice of memory, the organization never gets one shared brain. - **How are tools governed?** Reading a CRM, sending an email, querying Stripe, and posting in Slack should not have the same permission profile. - **How does the work become official?** A finished deliverable needs review, history, rollback, and a clear path into the client’s source of truth. - **How do you repeat it for the next department?** The second workspace should be a fork, not a rebuild. A proof of concept can avoid those questions. A production AI-transformation program cannot. The operating layer becomes the product because it decides whether the client gets a one-off demo or a system that keeps improving. ## One client, one repo In Kortix, a project is a repo. That repo contains the company’s agents, skills, memory, triggers, connector policy, sandbox definition, and operating instructions. One `kortix.yaml` defines how the workspace runs. Every session happens on an isolated branch. Every persistent change comes back through a change request. ``` acme-ai-workspace/ ├─ kortix.yaml # project, sandboxes, triggers, connectors, policy ├─ .kortix/opencode/ │ ├─ agents/ # role-specific agents: finance, support, sales, legal │ ├─ skills/ # repeatable client playbooks and workflows │ └─ commands/ # approved operating motions ├─ memory/ # durable company context and decisions ├─ artifacts/ # reports, briefs, packets, launch plans └─ docs/ # source-of-truth operating docs ``` That sounds technical because it is. It is also the reason the workspace can be handed to a client without trapping them in your service team forever. Files can be inspected. Diffs can be reviewed. A successful sales-ops workspace can be forked into a recruiting workspace. A regulated client can run the same pattern in their own VPC or on-prem environment. The [docs](/docs) walk through the project, session, and change request model in detail. ## The workspace needs five layers If you are leading AI transformation for a client, a serious agent workspace needs more than a chat UI. It needs at least five layers working together: - **Context.** The policies, playbooks, decisions, customer notes, docs, and memory the agents need to act like part of the company. - **Agents and skills.** Named roles and reusable workflows, not one giant prompt that tries to do everything. - **Connectors.** Access to the real systems of work — Slack, Gmail, HubSpot, Stripe, Linear, Notion, warehouses, internal APIs — brokered through scoped credentials instead of pasted keys. - **Policy.** Tool-level allow, ask, and block rules so a workspace can automate research freely and still pause before it sends, pays, deletes, or posts. - **Review.** A change request path for durable work: what changed, who requested it, what the agent touched, and what a human approved. > The unit of delivery is not “an agent.” The unit of delivery is a governed workspace where many agents can do real work safely. ## Governance belongs in the runtime Enterprise buyers do not just ask whether the model is good. They ask where secrets live, how access is scoped, what gets logged, how approvals work, how quickly a bad change can be reverted, and whether the system can run under their infrastructure constraints. Kortix was built around those constraints. Sessions run in disposable Linux sandboxes on their own branches. Connectors are brokered server-side through one scoped token. Secrets are encrypted and injected at runtime, not shown to the model. Work reaches `main` only through reviewed change requests. The same workspace can be used from the web, Slack, Teams, CLI, API, and MCP surfaces instead of forcing every employee into a new destination app. That is the difference between “we connected an LLM to your tools” and “we gave your organization a controlled workforce.” The first is exciting in a workshop. The second survives procurement, security review, and the third month of production use. ## Why consultancies feel this first Consultancies and systems integrators are where the repeatability pressure shows up fastest. They do not need one beautiful demo. They need a way to deploy the same architecture across many clients, many departments, and many compliance profiles without rebuilding the plumbing every time. - **For the AI-transformation partner:** one horizontal platform can become the delivery substrate for many vertical offerings. - **For the client CTO:** the workspace is Git-backed, self-hostable, and inspectable instead of a vendor-owned service wrapper. - **For the delivery team:** each department gets its own agents, memory, connectors, and policies without losing the shared pattern. - **For the end user:** the agent shows up where they already work — Slack, Teams, web, CLI, API — instead of asking the 99% of employees to adopt another AI portal. This is also where open matters. A consultancy cannot credibly tell a bank, manufacturer, or healthcare company that their future operating layer is a closed prompt stack nobody can inspect. The closer agents get to real work, the more the client needs to own the substrate. That is why Kortix is open, self-hostable, and built for enterprise deployment from the start. ## What to build first The best first workspace is narrow enough to ship and important enough to prove the operating model. Pick one workflow where the client already has documents, tools, approvals, and recurring pain. Then encode it as files. - **Sales renewal workspace:** read CRM context, summarize account risk, draft renewal plans, open human-reviewed follow-ups. - **Support triage workspace:** monitor tickets, classify urgency, draft replies from docs, escalate edge cases with evidence. - **Finance close workspace:** pull reconciliations, produce variance notes, flag missing evidence, create the close packet for review. - **Recruiting workspace:** source candidates, enrich profiles, draft Marko-style outreach, log every touch, never send without approval. - **Engineering review workspace:** review PRs, run checks, verify previews, and return concrete blockers instead of vague comments. Those are not abstract use cases for us. Kortix runs internal sweeps for production errors, PR review, docs maintenance, weekly briefs, outbound research, and this SEO/blog loop from the same project-native model: agents with skills, memory, tools, triggers, and a reviewed path for durable changes. ## A quick test for your stack Before you choose an AI-transformation platform, ask five questions: - Can the client clone or export the actual operating layer — agents, skills, memory, policy, and triggers — as files? - Can two hundred agents run in parallel without sharing one fragile machine or one user’s desktop state? - Can tool access be scoped per person, group, agent, and action? - Can a security reviewer see what happened after the fact: prompts, tool calls, commits, approvals, and diffs? - Can the same workspace move from cloud to VPC to on-prem without changing the basic model? If the answer is no, you may still have a good agent demo. You do not yet have a company OS. ## Build the client workspace as files, then run it with agents. Start with one department, connect the tools it already uses, and turn the workflow into a Git-backed AI command center the client can own. --- # Kortix vs Claude Cowork: a desktop assistant, or a company-wide agent platform? Claude Cowork is the best agent on the desktop. But it runs one assistant per person, on Anthropic's models, with your data on their cloud. Here's where you outgrow it — and what an open, company-wide agent platform looks like. Canonical page: https://kortix.com/blog/kortix-vs-claude-cowork Published: 2026-06-29 Author: marko Tags: Comparisons, Agents Claude Cowork is, hands down, one of the best agents you can put on a desktop today. It inherits Claude Code's engine, genuinely does multi-step work across your files and apps, and has a clean approval model. So this isn't a "they're bad, we're good" post. The honest question is what happens when one person's desktop assistant has to become a whole company's way of working. Compared here: - Claude Cowork (anthropic.com) ## What Claude Cowork is great at - **It does the work, not just the talking** — give it a goal and it returns a finished deliverable. - **A real permission model** — it shows a plan and waits for approval on consequential actions. - **Extensible with plugins** — teach it how you like work done and expose your own tools. ## Where it stops: one assistant, one machine, one lab - **One assistant per person** — not a fleet of agents running long jobs in parallel for the org. - **Nothing is shared.** Each person’s agents, skills, and context live on their own desktop — what one person teaches, the company never gets. - **Locked to Anthropic’s models** — no bring-your-own-key, so you pay frontier prices and can’t pick a cheaper model. - **Closed and vendor-hosted** — you can’t self-host it, and your data flows to Anthropic’s cloud. None of these are flaws for the product Cowork is. They’re exactly why it’s so good for one person — and exactly what you outgrow when "an agent on my laptop" needs to become "agents running our company." ## Shared across the company vs. siloed on a desktop It’s not just the machine that’s landlocked — it’s the knowledge. In Cowork, each person’s agents, skills, and context stay on their own desktop. In Kortix, your agents, skills, and memory are **files in one shared repo**: what one person teaches, every teammate — and every agent — gets, and it compounds over time instead of resetting person by person. ## The model lock-in tax Cowork only runs on Anthropic’s models, at Anthropic’s prices. Kortix lets you **bring your own key and run any model** — and the savings aren’t small. An open-weight model like **GLM-5.2** runs about **5–7× cheaper** than Claude Opus or GPT on output ($4.40 vs $25–30 per 1M tokens), and models like **DeepSeek** are **50×+ cheaper** on output. Route a cheap model for the bulk of the work and a frontier model only where it earns its keep. > Same agents, [a fraction of the bill](/pricing) — and you can run them on your own infrastructure, even your own GPUs, with your data never leaving your walls. ## Side by side | Dimension | Claude Cowork | Kortix | | --- | --- | --- | | Does real, multi-step work | Yes — on your desktop | Yes — in the cloud, at scale | | Runs a fleet of agents in parallel | One assistant per person | Thousands of agents in parallel | | Choose your models | Anthropic (Claude) only | Any model — your keys | | Model cost (per 1M output) | ~$25–30 — frontier only | ~$4.40 (GLM-5.2) to ~$0.30 (DeepSeek) | | Agents, skills & memory shared org-wide | Siloed on each desktop | Shared in one repo | | Open-source & self-hostable | No — closed, via Anthropic | Yes — your cloud, VPC, on-prem | | Your data stays with you | Processed by Anthropic's cloud | On your own infrastructure | | Multi-tenant — departments, roles | A per-user desktop app | Multi-tenant by default | | Everything as versioned code | Plugins customize one assistant | Agents, skills & policies as files | ## When to pick which ### Choose Claude Cowork if you want a brilliant agent on one person’s desktop, you’re happy on Anthropic’s models, and you don’t need to self-host or run a fleet. ### Choose Kortix if you want that same do-the-work power as a company platform — many agents [across departments](/enterprise), any model, self-hosted, with everything versioned and owned by you. They can even coexist: a power user keeps Cowork on their desktop while the company runs its shared, governed workforce on Kortix. ## Love agents that do the work? Run a whole fleet — on your own terms. Connect your tools and hand a Kortix agent a real task. Free to start, free to self-host. --- # Personal AI agents vs a company OS: Kortix, OpenClaw, and Hermes OpenClaw and Hermes are brilliant open-source personal agents — and we genuinely recommend them for individuals. But a personal "Jarvis" and a governed company platform are different things. Here is exactly where the line is. Canonical page: https://kortix.com/blog/personal-ai-agents-vs-company-os Published: 2026-06-28 Author: team Tags: Comparisons, Open Source If you’ve spent time in open-source AI lately, you’ve met **OpenClaw** and **Hermes**. Both are excellent: open-source, self-hosted, bring-your-own-model, living in the chat apps you already use. For an individual who wants a private, always-on agent on their own machine, they’re a joy — we mean that as a compliment. Compared here: - OpenClaw (github.com) - Hermes (nousresearch.com) They share Kortix’s core values: open, self-hosted, your models, your data. So why build Kortix? Because a **personal agent** and a **company operating system** are different problems — and stretching one into the other is where it gets painful. ## Single-operator is a design choice, not a gap - **OpenClaw** is explicit that it’s a personal assistant, not a shared multi-tenant system — and by default its tools run with broad access to the host machine. Fine on *your* laptop; a serious problem the moment several employees can steer a tool-enabled agent. - **Hermes** is a beautiful "agent that grows with you" — but team roles, tenant isolation, and org-wide audit aren’t what it’s documented for. You’d assemble that yourself. Neither is wrong. They optimized for the person. A company has to optimize for **many people, least privilege, and accountability** — and that changes the architecture from the ground up. ## Side by side | Dimension | OpenClaw / Hermes | Kortix | | --- | --- | --- | | Open-source & self-hosted | Yes — MIT, bring your own model | Yes — any model, your keys | | Designed for | One operator (personal use) | Teams and companies | | Multi-tenant — departments, roles | Single operator | Multi-tenant by default | | Scoped policies per connector | Largely DIY; broad access | Allow / ask / block per tool, as code | | Isolated sandbox per task | Optional / personal | microVM per session, egress-controlled | | Versioned, auditable, reversible | Limited | Git-backed — full history | ## When to pick which ### Choose OpenClaw or Hermes if you want a private, always-on agent for *yourself*, on your own machine. ### Choose Kortix if you want agents running across a *team or company* — with scoped control, isolation, roles, and audit — without giving up open-source and self-hosting. ## Love a great open-source agent? Get one built for your whole company. Same freedom, built for more than one person. Free to start, free to self-host. --- # Beyond the chat box: why ChatGPT, Claude, and Grok aren't an AI workforce Chat assistants answer; a workforce does the work. Why input-output tools — however brilliant — aren’t the same as a fleet of agents that run your company, own the data, and run on any model. Canonical page: https://kortix.com/blog/beyond-the-chat-box Published: 2026-06-27 Author: team Tags: Comparisons, Vision ChatGPT, Claude, and Grok are extraordinary, and you should keep using them. But it’s worth being precise about what they are: **chat assistants.** You give an input, you get an output, and the moment you close the tab, the work is yours to carry out. That’s a faster way to *think*. It isn’t a company *running* on AI. Compared here: - ChatGPT (openai.com) - Claude (anthropic.com) - Grok (x.ai) ## Input → output vs. hand-off → finished work With a chat assistant, you’re the runtime: you ask, it answers, and you copy-paste between the chat window and your real tools to get anything done. With Kortix, you hand off a task and an agent **goes and does it** — 30+ minutes of real, multi-step work across your connected tools, with full context on your company, returning a finished deliverable for review. ## The differences that matter at company scale | Dimension | Chat assistants | Kortix | | --- | --- | --- | | Finishes multi-step work end to end | Mostly answers; agent modes are supervised | Agents act across your tools, end to end | | Runs a fleet in parallel | One supervised session | Thousands of isolated agents at once | | Choose your models | Locked to the vendor's models | Any model — your keys | | Run cheaper models | Pay the vendor’s frontier price | GLM-5.2 ~5–7× cheaper; DeepSeek ~50×+ | | Own your data / self-host | On the vendor's cloud | Open-source — your infrastructure | | Company-wide memory | Per-user chat history | A shared, Git-backed brain | | No lock-in | Tied to one vendor's platform | Files in a repo you own | ## They’re complementary, not interchangeable This isn’t "stop using ChatGPT." Use a chat assistant for quick answers, drafting, and thinking out loud. Use Kortix for the work that has to actually get done — repeatedly, across your tools, owned by you, running while you sleep. One is a brilliant place to ask. The other is where your company’s work runs. ## Go from asking questions to running the work. Hand a Kortix agent a real task and get a finished result back. Free to start, free to self-host. --- # Introducing Kortix: the AI command center for your company A workforce of AI agents that do real work across your tools — defined as files in a git repo, run in isolated sandboxes, governed by review, and built enterprise-first. Here is the whole thing, A to Z. Canonical page: https://kortix.com/blog/introducing-kortix Published: 2026-06-06 Author: marko Tags: Product, Vision Every company is being told to "adopt AI." But most AI tools stop at the conversation. You ask a question, you get an answer, and the moment you close the tab the work is gone. That is a faster way to think. It is not a company running on AI. Kortix is the **command center for the AI agents that do your work** — one place to build a workforce of agents, connect them to your tools, run them on your terms, and keep every result accountable to a human. Underneath that is an idea we think is right for the next decade of software: **your company's AI operation should be files in a git repo.** Not a pile of settings in someone else's dashboard — actual files you own, version, review, and run. ## A company is a git repo In Kortix, a **project** is one git repository. The repo *is* the project: its files, its history, its agents, its automations, its settings — all of it lives in git. Start fresh with a private repo Kortix hosts for you, or bring an existing one on GitHub. - **Every change is reviewable.** A new automation, a tweak to an agent, a newly connected tool — each is a diff someone can read and approve before it goes live. - **Nothing drifts.** There is no separate database of settings to fall out of sync with reality. The repo is the truth. - **It is portable and yours.** Your whole setup is plain files. Read it, fork it, move it, run it on your own infrastructure. > A company that runs on AI shouldn’t be a dashboard you rent and can’t inspect. It should be a codebase you own. ## kortix.yaml: the single source of truth At the root of every project sits one file: `kortix.yaml`. Any repo with a valid manifest at its root *is* a Kortix project — that file defines what the project is, what it’s allowed to do, and how it runs. Here’s a real one: ``` # kortix.yaml — the one file that defines this project. kortix_version: 2 project: name: acme-ops description: Acme's operations command center. # Secrets your agents need: names here, encrypted values in the vault. env: required: [DATABASE_URL] optional: [STRIPE_API_KEY] # The sandbox every task boots into — your image, your hardware. sandbox: templates: - slug: ops dockerfile: .kortix/Dockerfile cpu: 4 memory: 8 # Run work on a schedule — nobody has to kick it off. triggers: - slug: weekly-health-report type: cron cron: "0 0 9 * * 1" prompt: Draft the weekly customer health report for review. # A tool the agent can use — credentials stay in the platform, never here. connectors: - slug: slack policies: - match: "*message*" action: require_approval ``` That’s a company’s operating setup in a few dozen lines. The scheduler reads `triggers:`, the sandbox builder reads `sandbox.templates:`, the connector layer reads `connectors:`. Edit it in the dashboard or from inside a session and changes round-trip through the same file — the diff stays clean either way. ## What happens when you hand off a task Day to day, you describe a task in plain language and get a finished result back. Here’s everything that happens underneath, from the moment you hit go: - **A branch is cut.** The control plane opens a **session** and cuts a fresh branch from main. Your main line is never touched directly. - **The sandbox boots.** An isolated sandbox comes up from a content-addressed snapshot of your image, clones the repo, and pulls git credentials on demand — no long-lived token sits in the environment. - **The agent works.** It reads and writes files, reaches your connected tools, and commits progress to the session branch. - **It proposes the work.** When done, the agent opens a **change request** — a summary plus the exact diff — and hands it to you. It does not merge its own work. The sandbox is disposable by design. When the session ends, the environment is thrown away — only committed, merged work survives. Because each session is fully isolated, any number can run at once: yours, your teammates’, and your automated ones, none stepping on each other. ## Review is the only way in The change request is the heart of the trust model. It’s the **only** path for a session’s work to reach your main line — for *everything* the agent touched: new code, a new skill, an edited automation, a change to the agent’s own instructions. You see the exact diff, with conflicts flagged up front. Until you merge, the work is proposed, not applied. > An agent can have real autonomy inside its sandbox while having zero ability to change your company without a human saying yes. That’s the combination that makes handing agents real work sane. ## Tools without handing over the keys Kortix connects your agents to the apps your team already uses — Slack, Gmail, Notion, Salesforce, and thousands more. When an agent uses a connected tool, **it never holds your credentials.** Each call is brokered server-side: the platform resolves the credential, runs the call, records it, and returns the result. The key never enters the sandbox. And you govern every action with policy — each tool can **run**, **require approval**, or be **blocked**, matched by name, so you can let an agent read freely and pause it before anything sends, posts, or pays. Every call is audited. ## Self-hostable, open, and yours When AI becomes how your company gets work done, the system running it stops being a tool and becomes infrastructure. Infrastructure you don’t own can be changed, repriced, or switched off without your say. So Kortix is **open-source and self-hostable**, and you can run the entire stack on your own infrastructure — one command brings up a production-style Kortix on your own machines, and the same CLI switches between our cloud and yours. Because it’s all open, you can read exactly how isolation, review, and credential brokering work — not trust a description. No lock-in: your projects are git repos, your config is plain files, and the platform running them is yours to host. ## It compounds Because your whole setup is version-controlled files, none of it resets tomorrow. Every agent you shape, every skill you teach, every tool you connect, every bit of memory your agents carry forward accumulates in the repo and gets more capable week over week. The routine work that used to fill calendars runs quietly in the background, 24/7, and your team spends its time on the decisions that need a human. ## Open the command center and hand an agent a real task. Connect your first tool and watch it come back with something you can use. Free to start, free to self-host. --- # Accounts and teams Your account holds your projects and the people you work with. Canonical page: https://kortix.com/docs/concepts/accounts An **account** holds your [projects](/docs/concepts/projects) and teammates. Signing up gives you a personal account; you can also create shared accounts for a company or team. ## People and roles Each person you invite has a role: - **Owner** — full control, including members and billing. - **Admin** — manage projects and members. - **Member** — work in the projects they're given access to. Within a project, access is one of three project roles: - **Member** — read the project and run sessions/chat and fire triggers. - **Editor** — everything a member can do, plus edit/customize the project and manage triggers. - **Manager** — everything an editor can do, plus invite/remove project members, change member roles, manage gateway keys, and delete the project. Those controls live in each project's access settings. ## Switching accounts If you belong to more than one account, switch between them from the account switcher. Each keeps its projects, members, and settings separate. ## Inviting your team Invite teammates by email from the account's members page; they get access as soon as they accept. Adding someone as **owner** or **admin** gives them manager access to every project (full control, including managing members and deleting the project); a plain account **member** sees only the projects they're granted a project role on. > **Under the hood** > An account is the top-level tenant; projects belong to accounts, and access is governed by account roles (owner, admin, member) plus per-project roles. Personal and team accounts use the same model. --- # Agents The agent runtime is OpenCode; the manifest's agents map is governance-only, agent behavior lives in .kortix/opencode. Canonical page: https://kortix.com/docs/concepts/agents The agent in every [session](/docs/concepts/sessions) is **[OpenCode](https://opencode.ai/docs/)**. The `kortix-agent` daemon runs it as `opencode serve` with its config dir pointed at the project's `.kortix/opencode/`. OpenCode-native behavior lives there, in OpenCode's standard layout: - **Agents** — a `.md` file per agent (frontmatter + prompt body) in `agents/` - **Skills** — on-demand `SKILL.md` know-how in `skills/` - **Commands, tools, plugins** - **Models, providers, MCP servers** — in `opencode.jsonc` `opencode.jsonc` stays the OpenCode-native registry for plugins, MCP servers, providers, model/provider settings, permissions, and default runtime behavior. Kortix does not duplicate those settings in the manifest. ## The manifest is governance, not behavior `kortix.yaml`'s `agents:` is a **map keyed by agent name** — one entry per agent, joined to that agent's `.kortix/opencode/agents/.md` by name. Each entry is governance only: `connectors`, `secrets`, `kortix_cli`, `skills`, `workspace`, `enabled`. Nothing about prompt, mode, tools, or permissions lives here — that's entirely the `.md` file's job. `default_agent` is required at the top level and must name a declared, enabled agent. The project default agent can be set (or changed) from the dashboard, API, or SDK — each writes `default_agent` in `kortix.yaml` — and sessions (UI, triggers, channels) inherit it unless the caller passes an explicit agent for that session. v2 is **deny-by-default**: an agent with no `connectors`/`secrets`/`kortix_cli`/ `skills` key gets none of that access. Give it `all` explicitly to grant full access (the starter's default `kortix` agent does this). Chat inputs, trigger pickers, and channel pickers list registered agents from the API, not from a sandbox-local OpenCode query — this doesn't mean every native file under `.kortix/opencode/agents/` must be registered; unregistered files can still exist for local experiments or runtime internals. > **Legacy kortix.toml** > v1 projects declare the same grants as an array, `[[agents]]`, with the secrets > field named `env` instead of `secrets`, and per-field defaults instead of > deny-all. A v1 project with no `[[agents]]` at all runs in legacy unrestricted > mode — an agent inherits the launching user's full role. Once `[[agents]]` > exists, an unlisted agent is default-denied, same as v2. Every project ships a default agent + the kortix-system skill, so it runs with no setup. Beyond reading/writing repo files, an agent can use external tools through connected integrations (per project — it only uses what's connected; see [Connecting your tools](/docs/guides/connecting-tools)). New agents, skills, or tools reach future sessions only after a [change request](/docs/concepts/change-requests) merges them. Authoring details: [opencode.ai/docs](https://opencode.ai/docs/) and [Kortix vs OpenCode config](/docs/reference/config-boundary). --- # Change requests The git merge layer that lands a session's branch on the default branch. Canonical page: https://kortix.com/docs/concepts/change-requests A **change request** merges one branch (`head_ref`, usually a session branch) into another (`base_ref`, usually the default branch). It is the **only** path for session work to reach the default branch. It's Kortix-native: the row is metadata; the git operations run in the Kortix API against any backend (GitHub, GitLab, plain git) — no per-host integration. - **Why** — sessions run on ephemeral branches; the sandbox is destroyed at the end, and everything else boots from the default branch. Until a change request merges, the work is invisible and unreviewed. This covers everything committed: code, skills, agents, the manifest (`kortix.yaml`/`kortix.toml`), `AGENTS.md` — no exceptions. - **Lifecycle** — `open → merged` (terminal) or `open → closed` (reopenable). A merged request can't be reopened or closed. - **Merge** — fast-forward when `head_ref` is ahead, else a three-way merge commit (default message `Merge CR #: `). A preview flags conflicts first; resolve on the branch and push. The agent opens the request (`kortix cr open`) but does not merge its own work — you merge from the dashboard or `kortix cr merge <n>`. Data model, diff semantics, and REST API: [change requests reference](/docs/reference/change-requests). --- <!-- /markdown/docs/concepts/channels.md --> # Channels Drive a project from chat — Slack and Microsoft Teams messages spawn sessions and the agent replies in-thread. Canonical page: https://kortix.com/docs/concepts/channels A **channel** connects a chat workspace to a project so you can run the agent from chat. Connect Slack or Microsoft Teams to a project, and a message or mention spawns a [session](/docs/concepts/sessions); the agent replies in the thread. Follow-up messages in that thread continue the same session. - **Connect Slack** with [`kortix channels connect`](/docs/reference/cli) or from the project's Channels page in the dashboard. Both offer the same two modes: one-click via the Kortix-managed Slack app (the CLI prints an "Add to Slack" install link — open it, pick the workspace, click Allow), or bring your own Slack app (`--manual`: a bot token + signing secret, via flags, `SLACK_BOT_TOKEN`/`SLACK_SIGNING_SECRET` env vars, or stdin). - **Connect Microsoft Teams** from the project's Channels page when Teams is enabled on your Kortix deployment. If a managed Kortix Teams bot is available, the dashboard gives you the app manifest, admin-consent link, and install steps; self-hosted deployments can bring their own multi-tenant Azure Bot with an app ID, client secret, and Azure AD tenant ID. - **Credentials are stored as project [secrets](/docs/concepts/secrets)** (e.g. `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, or Teams bot credentials for self-hosted installs) — never in the repo. - **The agent talks to chat through the Executor.** Connecting Slack auto-materializes the `kortix_slack` connector; connecting Teams auto-materializes `kortix_teams`. Both use provider `channel`: the agent's chat calls run through the gateway with the bot credential resolved **server-side**, so the token is never exposed inside the sandbox. The channel connector also shows up in the project's Connectors, where you control who can use it. > **Note** > Channels are configured through the dashboard, CLI, and secrets — **not** > through the manifest (`kortix.yaml`/`kortix.toml`). v2 removes channel > declarations from the schema outright; `kortix_slack` / `kortix_teams` > connectors auto-materialize on connect, you don't declare them. ## Who the agent runs as By default, every chat sender — the first message in a channel, a thread follow-up, or a button click — must be linked to a Kortix account that has access to the project before the agent will act for them. Link your identity once with Slack's `/kortix login` slash command, Teams' `/login` command, or the button on the prompt below; it opens a short-lived, private browser link that connects your chat user to a Kortix account. If there's no linked mapping yet, the agent doesn't run — it posts an ephemeral prompt nudging the sender to connect (or, if they're linked but not a member of the project's account, to request access) instead of replying. > **Legacy** > Deployments that disable this requirement fall back to the older behavior: > every message runs as a stand-in for the project account's owner, regardless > of who actually sent it. This is legacy, non-default behavior — the identity > requirement is on by default. --- <!-- /markdown/docs/concepts/computers.md --> # Computers Let the agent reach your own machine — files, shell, and desktop — over a permissioned tunnel, through the Executor. Canonical page: https://kortix.com/docs/concepts/computers A **computer** is one of your own machines (laptop, desktop, server) connected to Kortix over the **Agent Computer Tunnel** — a permissioned reverse tunnel. Once connected, the agent can read/write files, run shell commands, and drive the desktop **on that machine**, with you granting access per capability. - **Connect a machine** from the project's Computers page in the dashboard. It's an experimental feature — enable **Agent Computer Tunnel** in Customize → Experimental first, then the Computers page shows a one-line command to run on the target machine (`npx --yes @kortix/agent-tunnel@latest connect --api-url ...`) to pair it. - **Grant access per capability** — filesystem / shell / desktop, scoped to allowed paths, commands, and desktop features. The agent only gets what you grant; a call to something ungranted raises a **permission request** you approve in Computers. - **The agent reaches machines through the Executor.** Connecting a machine auto-materializes a single **`computer`** [connector](/docs/concepts/connections) that fronts **all** your connected machines. The agent calls `computer.list_computers`, then `computer.fs.read` / `shell.exec` / `desktop.cua.*` with a `computer` argument selecting the machine (optional when only one is online). The `computer` connector also shows up in the project's Connectors, where you control who can use it. - **There is no credential.** Unlike other connectors, `computer` has no token — the live tunnel connection *is* the credential, and per-machine auth is the tunnel permission layer. Nothing is exposed inside the sandbox. > **Note** > Computers are configured through the dashboard (Computers) — **not** through > the manifest (`kortix.yaml`/`kortix.toml`). The `computer` connector > auto-materializes when you connect a machine; you don't declare it. Calls run > through the same tunnel permission, approval, and audit model as the dedicated > Computers surface. --- <!-- /markdown/docs/concepts/connections.md --> # Connections Let the agent call your external tools, brokered server-side per project. Canonical page: https://kortix.com/docs/concepts/connections A **connection** (connector) lets a project's agent call an external tool or service — Slack, Gmail, a database, any HTTP/GraphQL API. Connections are per-project, and the agent can only use what you connect. - **Declared as `connectors` in the manifest** — provider is one of **Pipedream, MCP, OpenAPI, GraphQL, HTTP, Channel** (chat platforms like Slack and Microsoft Teams), or **Computer** (a connected machine). The definition lives in git; the **credentials live in the platform, never in the repo**. - **Channel + Computer connectors auto-materialize.** Connecting Slack or Microsoft Teams ([Channels](/docs/concepts/channels)) creates a `kortix_slack` or `kortix_teams` connector (provider `channel`); connecting a machine ([Computers](/docs/concepts/computers)) creates the `computer` connector — no manifest entry needed. The `kortix_slack` / `kortix_teams` slugs are reserved and blocked from user-declared connectors so they can't shadow the built-in channel. The chat credential is the install token; the `computer` connector has no credential at all (the live tunnel is the credential), and per-machine access is granted in Computers. - **Connection profiles choose the concrete identity behind a connector.** Each connector has a default project-owned profile, and the platform can also store additional project-scoped profiles owned by an agent, member, subject, or external identity (for example, several email inboxes). Profiles carry a credential only when the connector requires one; connectors such as `computer` use their live tunnel instead. A session can bind a specific profile per connector. Once a session binds any profile, every connector for that session must be bound explicitly (no silent fallback to the project default). (Legacy manifests can still say `credential = "per_user"` — per-member BYO credential — but that mode was removed; it's tolerated and silently resolves to `shared`.) - **Per-call policies** — each tool can be set to run, require approval, or be blocked. - **Set up in the dashboard** — the project's Connectors page offers Pipedream one-click connect plus a custom path for OpenAPI/GraphQL/MCP/HTTP. See [Connecting your tools](/docs/guides/connecting-tools). - **Which agent may use which connector** is a separate, per-agent grant — each agent's `connectors` field in the manifest (`all`, `none`, or a list of slugs; see [Agents](/docs/concepts/agents)). Connecting a tool to the project doesn't hand it to every agent automatically. > **Under the hood** > Every session gets a `kortix-executor` MCP server and a scoped Executor token. > The agent discovers tools through it (`connectors` → `discover` → `describe` → > `call`); each `call` is brokered by the Kortix API, which resolves the credential > server-side, enforces sharing and policies, runs it, and audits it. The agent > never holds third-party credentials. Pipedream's one-click connect requires > Pipedream API keys to be configured on the platform. --- <!-- /markdown/docs/concepts.md --> # How Kortix works The architecture — repo-as-project, the control plane, ephemeral sandboxes on branches, and change requests. Canonical page: https://kortix.com/docs/concepts A **project** is one git repository with a [manifest](/docs/reference/manifest) — `kortix.yaml` by default, or legacy `kortix.toml` — at its root. A **session** runs the agent in an isolated sandbox VM with the repo cloned onto a branch named after the session. The sandbox is disposable; the branch persists. Work reaches the default branch only via a merged **[change request](/docs/concepts/change-requests)**. ## The pieces - **[Projects](/docs/concepts/projects)** — a git repo (Kortix-managed or imported GitHub, pinned to one branch) plus its manifest. - **[Sessions](/docs/concepts/sessions)** — isolated sandbox VMs, each on its own branch. Many run at once without interfering. - **[Agents](/docs/concepts/agents)** — OpenCode, governed by the manifest's `agents:` map and implemented through `.kortix/opencode/`. - **[Change requests](/docs/concepts/change-requests)** — the git merge layer that lands a branch on the default branch. - **[Secrets](/docs/concepts/secrets)** — per-project encrypted values, given to sessions as environment variables. - **[Triggers](/docs/concepts/triggers)** — spawn a session on a schedule or from a webhook. - **[Connections](/docs/concepts/connections)** — let the agent call external tools, brokered server-side. - **[Channels](/docs/concepts/channels)** — drive a project from Slack. - **[Accounts](/docs/concepts/accounts)** — who owns projects and who can access them. ## When a session starts 1. The control plane (the Kortix API) inserts the session (`provisioning`) and cuts a branch from the default branch, named after the session id. 2. It resolves a content-addressed **snapshot** — the platform's default image (or your own Dockerfile, if the manifest declares a [`sandbox.templates`](/docs/reference/manifest) entry for it) plus the Kortix runtime layer, built by the configured sandbox provider. Unchanged projects reuse the cached image. 3. The sandbox provider boots the VM. The `kortix-agent` daemon clones the repo to `/workspace`, fetches git credentials just-in-time, and launches OpenCode. Status → `running`. 4. The agent works with [secrets](/docs/concepts/secrets) as env vars, committing and pushing to the session branch. 5. It opens a change request; you review and merge — the only path to the default branch. > **Git is the durable boundary** > Stop or idle hibernation preserves the same sandbox filesystem but drops memory > and processes; permanent deletion removes provider state. Only > committed-and-pushed branch work is provider-independent, and only a merged > change request makes it permanent on the default branch. ## Two config surfaces The manifest (`kortix.yaml`) + `.kortix/Dockerfile` are Kortix-owned; `.kortix/opencode/` is OpenCode-native runtime behavior. `agents:` is a name-keyed map that is governance only — `connectors`, `secrets`, `kortix_cli`, `skills`, which agent names are launchable, and what each may touch. Everything about how an agent behaves (prompt, mode, tools, permissions) lives in that agent's own `.kortix/opencode/agents/<name>.md`, a stock OpenCode agent file. `default_agent` is required and must name a declared, enabled agent. See [Kortix vs OpenCode config](/docs/reference/config-boundary). Drive any of it from a terminal with the [CLI](/docs/reference/cli) — it controls the cloud, it doesn't run the agent locally. > **Legacy kortix.toml** > Existing projects may still run on `kortix_version: 1` (`kortix.toml`, > `[[agents]]` as an array of tables, no required `default_agent`). Both formats > are read by the platform; migrate in-app via "Upgrade to kortix.yaml." See > [Projects](/docs/concepts/projects). --- <!-- /markdown/docs/concepts/projects.md --> # Projects A project is one git repository plus a kortix.yaml manifest. Canonical page: https://kortix.com/docs/concepts/projects A **project is one git repository** with a [manifest](/docs/reference/manifest) at its root — `kortix.yaml` by default. The repo is the project — files, history, agent config, and settings all live in git. No separate database to keep in sync. > **kortix.yaml vs legacy kortix.toml** > New projects get `kortix.yaml` (`kortix_version: 2`) from the starter template. > Existing projects may still run `kortix.toml` (`kortix_version: 1`) — the > platform reads both indefinitely. Migrate in-app via the Customize panel's > **Upgrades** section — run **Migrate manifest to v2 (kortix.yaml)**. > Full field-by-field comparison: [manifest reference](/docs/reference/manifest). Backed two ways: - **Kortix-managed repo** — Kortix creates and hosts a private repo (default). - **Imported GitHub repo** — link an existing repo; Kortix operates on it via the GitHub API. When importing GitHub, choose the branch that becomes this project's `default_branch` (the repo's default is just the default choice). Each branch of the same GitHub repo can be a separate Kortix project with its own sessions and change requests, so `main` and `dev` can be managed as independent workspaces. The SDK exposes branch discovery through `kortix.github.listRepositoryBranches` and imports via `kortix.github.linkRepository`. Either backing mode gives the project a `default_branch` that every [session](/docs/concepts/sessions) branches from and every [change request](/docs/concepts/change-requests) merges into. ## A project is a workspace, not your codebase Treat a project as a **standalone workspace** — the home for your agent and everything it needs to act: its instructions, its [connections](/docs/concepts/connections) to other tools, its [automations](/docs/concepts/triggers), and its memory. It can also hold things worth keeping around — documents, notes, generated files. Keep it small; it's cloned into every session. Your **code lives elsewhere**. When a task needs a codebase, the agent clones that repository on demand, does the work, and opens a pull request back to it. Large data and other systems are reached through [connections](/docs/concepts/connections), not copied in. The rule of thumb: if it's small and truly yours, keep it in the project; if it's large or lives somewhere else, reference it. So you don't turn an existing codebase into a project by dropping a `kortix.yaml` into it — least of all a large monorepo, which would be cloned into every session. You create a **dedicated** project for the agent and point it at the repositories and tools it should work with. ## What the control plane reads from the manifest - `project` — name, description - `env` — [secret](/docs/reference/secrets) names - `sandbox` + `.kortix/Dockerfile` — the [sandbox image](/docs/reference/sandbox-image) - `opencode.config_dir` — agent config location (default `.kortix/opencode`) - `agents` — [agent](/docs/concepts/agents) governance (connectors/secrets/CLI/skills grants) and `default_agent` - `triggers` — [automations](/docs/concepts/triggers) Unknown keys are ignored. Dashboard edits to triggers/env are read-modify-writes on the same file, so in-session and dashboard edits round-trip. > **v1 equivalents** > In legacy `kortix.toml` (`kortix_version: 1`) these are TOML tables: > `[project]`, `[env]`, `[[sandbox.templates]]`, `[opencode]`, `[[agents]]` (array, > grant field named `env` instead of `secrets`), `[[triggers]]`. v1 also > has `[[channels]]`, removed outright in v2 — channel↔agent routing is dashboard/CLI > managed, never committed. --- <!-- /markdown/docs/concepts/secrets.md --> # Secrets Per-project encrypted values, given to each session as environment variables. Canonical page: https://kortix.com/docs/concepts/secrets A **secret** is a per-project value (an API key, token, or connection string) that the agent needs but that must not live in the repo. Secrets are encrypted at rest and provided to each [session](/docs/concepts/sessions) as environment variables. - **Set values on the project's Environment variables page** (dashboard) or with [`kortix secrets set`](/docs/reference/cli). Names are uppercase (`STRIPE_API_KEY`); the `KORTIX_*` prefix is reserved for the platform. - **Optionally declare names in the manifest** under `env` (`required` / `optional` — same shape in `kortix.yaml` and legacy `kortix.toml`). This only documents what a project expects and flags missing ones in the UI — it is **advisory**, not enforced: a session still starts if a `required` secret is unset. - **Shared or personal** — a secret can be shared with the project or set as your own private override; you control who a shared secret is usable by. - **Which agents receive which secrets** is a separate, per-agent grant: each agent's `secrets` field in the manifest (`all`, `none`, or a list of names — called `env` on legacy `kortix.toml` agents). See [Agents](/docs/concepts/agents). - **Rotation propagates live.** Setting or removing a secret immediately pushes the new values to every currently running session for the project; for provider/model-affecting keys it also restarts the agent process in-sandbox so the change takes effect right away. Each prompt sent to a live session also re-syncs env before it's forwarded. > **Under the hood** > Values are encrypted with AES-256-GCM under a per-project key derived from the > platform master key (HKDF-SHA256), stored in `project_secrets`, and injected as > plain env vars at session provision — resolved as the launching user (personal > override wins, then a usable shared value). Connector credentials are a separate > scope, resolved server-side by the [Executor](/docs/concepts/connections) and > never injected. Details: [Secrets reference](/docs/reference/secrets). --- <!-- /markdown/docs/concepts/sessions.md --> # Sessions An isolated, disposable sandbox VM on a branch named after the session. Canonical page: https://kortix.com/docs/concepts/sessions A **session** runs the agent in its own isolated sandbox. The platform cuts a branch named after the session id, provisions a sandbox VM with the repo cloned onto it, and runs [OpenCode](/docs/concepts/agents) inside. - **Isolated** — each session has its own VM and branch, so any number run at once (yours, teammates', [automations](/docs/concepts/triggers)) without interfering. None touch the default branch directly. - **Status** — `provisioning → running → (stopped | failed)`. The branch is cut and the sandbox requested during `provisioning`; `stopped` on explicit stop or idle hibernation. - **Persistence** — stop/resume keeps the same provider identity and filesystem, but drops running processes and memory. Permanent deletion removes that provider state. Git remains the durable contract: only work the agent **commits and pushes** is portable and recoverable, and only a merged [change request](/docs/concepts/change-requests) lands it on the default branch. Inside the VM the `kortix-agent` daemon clones to `/workspace`, supervises OpenCode, and serves a control surface on port 8000 that the dashboard tunnels into. A trigger fire is just an ordinary session. Full runtime detail — status enum, injected env vars, daemon endpoints — is in [Session runtime](/docs/reference/session-runtime). > **Sandbox provider** > Kortix supports Daytona, Platinum, and E2B Cloud. A project can follow the > platform default or be pinned to any enabled provider. Every provider builds > and hosts the same layered image through the unified runtime contract — everything above > (branch-per-session, `/workspace`, the daemon, port 8000) behaves the same > regardless of provider. Pin or clear a project's provider with the SDK's > `p.sandbox.setProvider(provider)` (`null` follows the platform default); see > [The client](/docs/sdk/the-client). --- <!-- /markdown/docs/concepts/triggers.md --> # Triggers Spawn a session automatically — on a schedule or from a webhook. Canonical page: https://kortix.com/docs/concepts/triggers A **trigger** spawns a [session](/docs/concepts/sessions) on its own — this is how you automate recurring or event-driven work. Triggers are `triggers` entries in the project manifest (`kortix.yaml`; `[[triggers]]` in legacy `kortix.toml`); by default, each fire starts a **fresh** session that runs a templated prompt as its first message. - **`cron`** — runs on a schedule (6-field cron expression + IANA timezone). - **`webhook`** — runs on a **signed** `POST` to the project's webhook URL. No unauthenticated surface: a webhook trigger must name a signing secret. A fresh-mode fired session is an ordinary session — isolated sandbox, its own branch, work reviewed as a [change request](/docs/concepts/change-requests). The manifest holds the config; runtime state (e.g. `last_fired_at`) lives in the database, so a fire doesn't write a commit. A trigger can instead opt into `session_mode: reuse`: each fire re-prompts the most recent session that trigger already created — resuming its existing sandbox and branch instead of spawning a new one — so context accumulates across fires. This suits recurring cron triggers that should feel like one long-lived agent run rather than a new isolated task every time. If no reusable session exists yet (or the last one is dead or failed), the trigger falls back to creating a fresh session, which then becomes the canonical one for the next fire. Use `session_mode: pinned` with a `session_id` when a trigger must first try to loop a specific existing session. If that pinned session is gone or unavailable, Kortix gracefully falls back to the trigger's reusable session, then to a fresh session. The scheduler reads triggers from the default branch, so a new or edited trigger goes live only after its change request merges. Fields, signature format, and template variables: [Triggers](/docs/reference/triggers). --- <!-- /markdown/docs/development.md --> # Local development Run the Kortix stack on your machine and work with the encrypted API secrets. Canonical page: https://kortix.com/docs/development For contributors running this repository locally. Requires Docker, Node 22, and pnpm 8. ```sh pnpm install pnpm dev # full LOCAL stack (web + API + local Supabase + tunnel) ``` ## Environments There are **four environments for local development**, each a separate set of API secrets. They differ only in *which backend the API talks to* — same code, different DB / Stripe / keys: | Command | Env | API talks to | | --- | --- | --- | | `pnpm dev` | **local** | 100% local stack — local Supabase/Postgres in Docker, test Stripe. Also runs the web + tunnel. | | `pnpm dev:dev-env` | **dev** | the **dev** stack — dev Supabase DB, **test** Stripe, dev keys (mirrors `dev-api.kortix.com`). | | `pnpm dev:staging-env` | **staging** | the **staging** stack — staging Supabase DB and staging keys (mirrors `staging-api.kortix.com`). | | `pnpm dev:prod-env` | **prod** | the **prod** stack — prod Supabase DB, **LIVE** Stripe, prod keys (mirrors `api.kortix.com`). | `pnpm dev` is the full local stack you'll use day-to-day. `dev:dev-env` / `dev:staging-env` / `dev:prod-env` run the **API** locally against the remote dev/staging/prod backend (for debugging DB/billing/account flows) — they don't start local Supabase. > **Warn** > `pnpm dev:prod-env` connects your local API to **production** — DB writes, Stripe calls, etc. are **real**. Use it deliberately. Verify all profiles decrypt and point at the right stack: ```sh pnpm test:envs ``` ## How secrets work Each environment is a [dotenvx](https://dotenvx.com)-encrypted file, committed to the repo: | Env | File | Key (in `apps/api/.env.keys`) | | --- | --- | --- | | local | `apps/api/.env` | `DOTENV_PRIVATE_KEY` | | dev | `apps/api/.env.dev` | `DOTENV_PRIVATE_KEY_DEV` | | staging | `apps/api/.env.staging` | `DOTENV_PRIVATE_KEY_STAGING` | | prod | `apps/api/.env.prod` | `DOTENV_PRIVATE_KEY_PROD` | Every value is AES-encrypted (`KEY=encrypted:…`). The **public key** that encrypts sits in the file and is safe to commit; the **private key** that decrypts never touches git — it lives off your machine in [Dotenv Armor](https://dotenvx.com/armor). At runtime `dotenvx run --overload -f <file>` decrypts **in memory** and injects the vars — nothing plaintext is ever written to disk. `--overload` is intentional for env-specific runs so exported local cloud credentials cannot override the selected profile. > **Info** > These encrypted files are **only for local development**. The actual deployed **production** environment does **not** read them — the prod infra loads its env from **AWS Secrets Manager** at runtime. So `apps/api/.env.prod` is just for running locally against the prod backend; changing it does not change what production runs. **`apps/web` uses the same encrypted profiles** (`apps/web/.env` / `.env.dev` / `.env.staging` / `.env.prod`, own keys in `apps/web/.env.keys`). Most of it is public (`NEXT_PUBLIC_*`); only a few Vercel keys are secret. It's decrypted the same way — `pnpm dev` or `pnpm dev:web`. ## First time on a new machine ### Install the key manager and log in ```sh curl -sfS https://dotenvx.sh/armor | sh dotenvx-armor login ``` ### Pull the keys from the cloud ```sh for app in api web; do (cd "apps/$app" && for f in .env .env.dev .env.staging .env.prod; do dotenvx-armor pull -f "$f"; done) done ``` ### Enable the commit hooks + run ```sh git config core.hooksPath .githooks # auto-encrypts secrets on commit pnpm dev ``` The pre-commit hook **auto-encrypts** the profile files (`.env` / `.env.dev` / `.env.staging` / `.env.prod`) so a hand-typed plaintext value is sealed before it can be committed, and blocks the commit if any unencrypted `.env` slips through. ## Everyday | Task | Command | | --- | --- | | Run (local / dev / staging / prod) | `pnpm dev` · `pnpm dev:dev-env` · `pnpm dev:staging-env` · `pnpm dev:prod-env` | | Verify all envs decrypt | `pnpm test:envs` | | Read a secret | `dotenvx get KEY -f apps/api/.env` (or `.env.dev` / `.env.staging` / `.env.prod`) | | Add / change a secret | `dotenvx set KEY value -f apps/api/.env` (or `.env.dev` / `.env.staging` / `.env.prod`), then commit | | Share a new/rotated key with the team | `dotenvx-armor push -f <file>` (one file at a time) | > **Warn** > Never write plaintext into the profile files — they're encrypted and committed. Machine-local overrides go in the gitignored `apps/api/.env.local`, which Bun loads at higher precedence. --- <!-- /markdown/docs/guides/automating-work.md --> # Automating work How to run a session on a schedule or from an event using triggers. Canonical page: https://kortix.com/docs/guides/automating-work [Triggers](/docs/concepts/triggers) start a [session](/docs/concepts/sessions) on its own, with no one watching. A triggered session works like any other: the agent does the work, proposes a [change request](/docs/concepts/change-requests), and waits for review. ## Two ways to trigger a session - **On a schedule** — run at fixed times. Good for a Monday-morning summary or end-of-day report. - **From an event** — run when something happens elsewhere, delivered as a signed webhook. ## A scheduled example Triggers live in your project's manifest (`kortix.yaml`) as `triggers` entries. This one runs every weekday morning and writes a digest: ```yaml triggers: - slug: daily-digest name: Daily digest type: cron enabled: true cron: "0 0 9 * * 1-5" timezone: America/Los_Angeles prompt: | Summarize yesterday's activity and save it as a daily note. ``` (Legacy `kortix.toml` declares the same thing as `[[triggers]]`, an array of tables with the same fields.) When the schedule fires, Kortix starts a fresh session and gives the agent the `prompt` as its first message. ## Changes take effect after they're merged A trigger lives in the manifest, so adding or editing one is a change to your project. It goes live only once it's [merged](/docs/quickstart) onto your main line: edit the manifest, review and merge, then the trigger runs. > **Under the hood** > `cron` is a 6-field expression (`second minute hour day month weekday`); `timezone` is an IANA name. Webhook triggers fire on signed `POST` requests and must reference a signing secret by name (`secret_env`) — there's no unauthenticated webhook surface. By default (`session_mode: "fresh"`), each fire spawns a new session on its own branch; set `session_mode: "reuse"` to instead re-prompt the trigger's existing session, resuming its sandbox and branch so one long-lived session accumulates context across fires. Use `session_mode: "pinned"` plus `session_id` when a trigger should first try one exact session. See the [triggers reference](/docs/reference/triggers). --- <!-- /markdown/docs/guides/connecting-tools.md --> # Connecting your tools How to let your agent act in the external tools and services you already use. Canonical page: https://kortix.com/docs/guides/connecting-tools **Connections** (also called connectors) let an agent act in external tools and services on your behalf — Slack, Gmail, a database, any HTTP/GraphQL API. Setup depends on the tool, and you stay in control of what's connected. For the full model, see [Connections](/docs/concepts/connections). ## How a connection works - **Declared as `connectors` in the manifest.** The `provider` is one of `pipedream`, `mcp`, `openapi`, `graphql`, `http`, `channel` (chat platforms like Slack and Microsoft Teams), or `computer` (a connected machine). The definition lives in git; the credential lives on the platform, never in your repo. - **Connecting is per project**, like [secrets](/docs/guides/managing-secrets). Connecting a tool for one project doesn't make it available to another. - **A connector isn't automatically usable by every agent.** Each agent's `connectors` field in the manifest (`all`, `none`, or a list of slugs) grants — or withholds — access to what's connected. Connecting a tool to the project is a separate step from letting a given agent call it. - **Each tool call can be policed.** A connector's `policies` mark a tool `always_run`, `require_approval`, or `block`. ## Connect a tool ### Open the project's Connectors page From the dashboard, open the project's Connectors page. For apps Pipedream supports (Gmail, Slack, Stripe, and others — searchable from the same page), use **one-click connect** — it walks you through OAuth and finalizes the connection server-side; no manifest edit required. For anything else, add a connector by hand: an **OpenAPI** or **GraphQL** API by spec URL, a remote **MCP** server, or a raw **HTTP** API with declared routes. ### Declare it in the manifest (non-Pipedream connectors) A hand-added connector is a `[[connectors]]` entry: ```toml [[connectors]] slug = "stripe" name = "Stripe API" provider = "openapi" spec = "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json" [connectors.auth] type = "bearer" secret = "STRIPE_API_KEY" # project-secret NAME; value set on the Environment variables page [[connectors.policies]] match = "*.delete*" action = "block" ``` Pipedream connectors round-trip the same way once connected, but you rarely hand-write them — the dashboard's one-click flow creates the entry for you. See the [manifest reference](/docs/reference/manifest#connectors--connectors) for every field, and [Connections](/docs/concepts/connections) for how `channel` and `computer` connectors auto-materialize (connecting Slack, Microsoft Teams, or a machine creates their connector for you — no manifest entry needed). ### Set the credential Where a connector needs a credential (an `auth` block with `type = "bearer" | "basic" | "custom"`), the value is stored on the platform, not in the manifest — set it on the project's Environment variables page, same as any other [secret](/docs/guides/managing-secrets). Pipedream connectors skip this step entirely: they authenticate via the connected account from the one-click flow. ### Grant it to an agent Connecting a tool to the project doesn't hand it to every agent. In the manifest, each agent's `connectors` field controls what that agent may call: ```toml [[agents]] name = "kortix" connectors = "all" # every connector on the project [[agents]] name = "release-bot" connectors = ["github"] # only this one, by slug ``` (`kortix.yaml` v2 uses the same field under `agents.<name>.connectors` instead of `[[agents]]` — see the [manifest reference](/docs/reference/manifest#agents-v2).) A *declared* agent with `connectors = "none"` (the default when the field is omitted) can't call any connector, even ones the project has connected. In a v1 project with no `[[agents]]` section at all, this governance layer isn't opted into yet, and every session is capped only by the launching user's role instead. ## Per-call policies A connector's `policies` gate individual tool calls by name (glob match), independent of which agent is calling: ```toml [[connectors.policies]] match = "*.delete*" action = "block" # always_run | require_approval | block ``` Sensitive connectors (email, files, secrets-bearing) default every action to `require_approval` unless a policy explicitly opens it up. > **Under the hood** > Every session gets a `kortix-executor` MCP server and a scoped Executor token — the agent discovers tools through it (`connectors` → `discover` → `describe` → `call`), and each `call` is brokered by the Kortix API, which resolves connector identity server-side, enforces the grant and policies, runs it, and audits it. The agent never holds third-party credentials. **Connection profiles** are project-scoped identities behind a connector: one default project-owned profile, plus optional additional profiles that a session can bind explicitly per connector. Profiles hold credentials only for connectors that require them; the legacy manifest `credential = "per_user"` mode is tolerated but resolves to `shared`. --- <!-- /markdown/docs/guides.md --> # Guides Task-oriented walkthroughs for getting real work done with Kortix. Canonical page: https://kortix.com/docs/guides "How do I…" walkthroughs, one task each. For the bigger picture see [Core concepts](/docs/concepts); for exact fields and flags see the [Reference](/docs/reference). New here? Start with the [Quickstart](/docs/quickstart). - [Managing secrets](/docs/guides/managing-secrets): Store API keys and tokens safely. - [Connecting your tools](/docs/guides/connecting-tools): Let your agent act in external tools. - [Automating work](/docs/guides/automating-work): Run a session on a schedule or event. - [Using the command line](/docs/guides/using-the-cli): Drive everything from a terminal. --- <!-- /markdown/docs/guides/managing-secrets.md --> # Managing secrets How to give your agent the API keys and tokens it needs, stored safely per project. Canonical page: https://kortix.com/docs/guides/managing-secrets **Secrets** are private values — API keys, tokens, connection strings — that belong to a [project](/docs/concepts/projects) and are made available to your agent during a session. They're stored encrypted, never written into your project's files, and provided to each session as it starts. ## How a secret works A secret has two parts in different places: - **The name** is declared in your manifest (`kortix.yaml`, or legacy `kortix.toml`). Safe to commit — it's only a label like `STRIPE_API_KEY`. - **The value** is set separately on the project's Environment variables page. Encrypted, never in your repo. Anyone reading your project can see *which* secrets it expects, but no one can read the values out of the code. Under the hood, a stored secret is actually `{ identifier, key, value }`. The **identifier** is the stable handle you (and your manifest's agent grants) refer to; the **key** is the env-var name the agent sees, like `STRIPE_API_KEY`. For most projects the identifier and the key are the same string, so this distinction is invisible — it only matters if you keep more than one value under the same key (e.g. per-environment variants), since identifiers are unique per project but keys aren't. ## Which agents receive it Setting a secret on a project doesn't hand it to every agent automatically. Each agent block in your manifest has its own `secrets` grant (named `env` in legacy `kortix_version: 1` manifests — same mechanism, renamed in v2) that lists which secret *identifiers* it's allowed to receive as sandbox environment variables: ```yaml agents: reviewer: secrets: [STRIPE_API_KEY] # only this identifier ops: secrets: all # every project secret — the default when omitted sandboxed-demo: secrets: none # no secrets at all ``` `secrets` defaults to `all` when omitted, so most agents don't need to set it. But it's the sole authorization gate on agent secret access — a session running a scoped-down agent will not see a secret just because it's declared in `env: { required, optional }` and set on the project. ## Add a secret ### Declare the name in the manifest In `kortix.yaml`, add the name under `env`. List it as `required` if the agent can't work without it, or `optional` if it's nice to have: ```yaml env: required: [DATABASE_URL] optional: [STRIPE_API_KEY, WEBHOOK_SLACK_SECRET] ``` (Legacy `kortix.toml` uses the same shape as a `[env]` table with TOML arrays.) ### Set the value Open the project's Environment variables page and enter the value for that name. It's encrypted as soon as you save. ### Start a session The next session receives every secret its running agent is granted (`all` by default — see [Which agents receive it](#which-agents-receive-it)), read like any environment variable. ## Required vs optional - **Required** — flagged on the Environment variables page when missing. Use for values the agent genuinely can't work without. - **Optional** — shows up so you can fill it in, with no warning if empty. `required` is a contract with your team about what a project needs, not a lock. Keep the list to the values that truly matter. ## Rotating a secret Set the new value on the Environment variables page. Kortix hot-pushes it to every sandbox with an active session for that project — you don't need to end and restart a session for it to pick up the change. The sandbox's live environment refreshes immediately, and if the secret affects the model/gateway configuration, the agent process itself is restarted so it picks up the new credential too. > **Under the hood** > Secrets are encrypted at rest with AES-256-GCM and injected as plain environment variables into the sandbox at session boot (and hot-pushed to already-running sandboxes on rotation). Names must be env-var-shaped, and the `KORTIX_*` prefix is reserved for platform values. From the CLI: `kortix secrets set NAME=VALUE` upserts a value, `kortix secrets ls` lists what's declared and set, `kortix secrets unset NAME` removes one, `kortix secrets request NAME --scope runtime|connector --expires <min>` mints a short-lived link so someone else can enter a value without you ever seeing it, `kortix env push --from .env` bulk-uploads a dotenv file, and `kortix env pull [--out <path>] [--force]` exports a names-only skeleton `.env` (values are never exported). See the [secrets reference](/docs/reference/secrets). --- <!-- /markdown/docs/guides/using-the-cli.md --> # Using the command line How to drive Kortix from a terminal with the kortix CLI. Canonical page: https://kortix.com/docs/guides/using-the-cli The `kortix` CLI does everything the dashboard does, from a terminal. It's optional — if you're not technical, use the dashboard instead. ## Install and log in ### Install the CLI ```sh curl -fsSL https://kortix.com/install | bash ``` ### Log in ```sh kortix login ``` This opens your browser to authorize. Your token is then saved locally. ## The mental model The CLI controls the same things as the dashboard: projects, [sessions](/docs/concepts/sessions), [secrets](/docs/guides/managing-secrets), [triggers](/docs/guides/automating-work), and [change requests](/docs/quickstart). It's the control plane, not a replacement for `git`. ## The most useful commands Start a session, optionally with a first prompt: ```sh kortix sessions new --prompt "Audit the auth module and propose a fix" kortix sessions ls ``` Manage secrets: ```sh kortix secrets set STRIPE_API_KEY=sk_live_... kortix secrets ls ``` Push a local `.env`'s values up as secrets, or pull a names-only skeleton down: ```sh kortix env push --from .env kortix env pull ``` Work with triggers: ```sh kortix triggers ls kortix triggers fire daily-digest ``` Review and merge change requests: ```sh kortix cr ls kortix cr show 3 kortix cr merge 3 ``` ## Inside a session sandbox Inside a session's environment the CLI is already authenticated for that project — no login needed. A project-scoped token is injected automatically, so commands like `kortix secrets ls` or `kortix cr open` work right away. This is how an agent opens its own change request. > **Under the hood** > On your laptop the CLI uses a user-scoped token (saved at `~/.config/kortix/config.json`) that can see every project on accounts you belong to. Inside a sandbox, `KORTIX_CLI_TOKEN` (falling back to `KORTIX_EXECUTOR_TOKEN`) is pre-injected and scoped to that one project. `KORTIX_TOKEN` is also present, but it's the sandbox's own service key, not a CLI credential — the CLI never uses it to authenticate. The CLI resolves "which project" from a flag, an env var, or a linked directory, in that order. --- <!-- /markdown/docs/index.md --> # Kortix Run agents against your own git repos in disposable cloud sandboxes, and land their work through review. Canonical page: https://kortix.com/docs Kortix runs AI agents against your own git repositories in disposable cloud sandboxes, then lands their work through review. ``` project (git repo + kortix.yaml) └─ session ──> isolated sandbox VM on branch "<session-id>" └─ agent (OpenCode) commits + pushes └─ change request ──> merge ──> default branch ``` A **project** is one repo. A **session** is one agent run in an isolated sandbox on its own branch. A **change request** merges that work back. That's the system. - [Quickstart](/docs/quickstart): Create a project, run a session, keep the work — the everyday loop. - [Concepts](/docs/concepts): How it works — projects, sessions, change requests, agents, secrets, triggers, connections, channels, accounts. - [Guides](/docs/guides): Task how-tos — secrets, tools, automations, the CLI. - [Reference](/docs/reference): Exact detail — manifest, CLI, runtime, secrets, triggers, sandbox image. --- <!-- /markdown/docs/quickstart.md --> # Quickstart Set up your command center, run your first session, and make it your own. Canonical page: https://kortix.com/docs/quickstart Two parts: the loop you'll use every day, then the handful of things worth setting up once so your agents can do real work. ## The everyday loop ### Create a project A [project](/docs/concepts/projects) is one company, product, or idea. From your projects list, create one — start fresh, or bring in existing code (for developers). ### Run a session Open the project and start a [session](/docs/concepts/sessions). Describe the task like you would for a teammate. The agent works in its own private, disposable copy of the project — you can watch and reply as it goes. ### Review and keep it When the agent finishes, it opens a [change request](/docs/concepts/change-requests) — a summary and the exact changes. Review it and **merge** to keep the work, or ask for changes. Nothing touches your project until you merge. ## Set up your command center Do these once, early. Each one widens what your agents can do for you. ### Invite your team Bring teammates into the project so everyone can run sessions and review the work. Set who can do what from your [account](/docs/concepts/accounts). ### Connect your tools Plug in the apps your agent should reach — Slack, Gmail, Salesforce, Notion, and more. The more it can [connect to](/docs/concepts/connections), the more it can do without you in the loop. Add any [secrets](/docs/guides/managing-secrets) (API keys and tokens) it needs while you're there. ### Make it your own — agents and skills Every project ships with default [agents](/docs/concepts/agents) and skills, so it works out of the box. To make it truly yours, shape your own: - **Agents** — personas built around how your team actually works. - **Skills** — turn a workflow you repeat into a reusable shortcut your agents can use. All of it lives in your project as code, version-controlled, and compounds week over week. ### Automate the routine Once a task is worth repeating, [automate](/docs/guides/automating-work) it. Run work on a schedule or when something happens — no prompt needed. That's the whole picture. Start with a session today; add the rest as you go. --- <!-- /markdown/docs/reference/change-requests.md --> # Change requests The CR data model, lifecycle, diff/merge semantics, CLI surface, and REST API. Canonical page: https://kortix.com/docs/reference/change-requests Kortix's pull-request equivalent — the path session work takes to reach `main`. For the plain-language version see [Change requests](/docs/concepts/change-requests); for a review walkthrough see [Reviewing and merging](/docs/quickstart). A CR proposes merging one branch (`head_ref`) into another (`base_ref`) inside a single Kortix project. The layer is Kortix-native — it works on any git host (GitHub, GitLab, plain git) without per-host integration. The CR row is metadata; the git operations (fetch, diff, three-way merge, fast-forward) run inside the Kortix API against the project's `repo_url` backend. ## The agent mandate An agent in a session sandbox MUST open a CR to land any change on `main`. Sessions run on ephemeral branches (`session-<id>`); the sandbox dies at end-of-session and nothing reaches `main` unless a CR merges it. Future sessions boot from `main` — without merging, the work is invisible to every other agent, trigger fire, and collaborator. The contract: 1. **Commit on the session branch** (`$KORTIX_BRANCH_NAME`). Small, working commits. Don't rewrite or force-push. 2. **Push the branch** (`git push origin HEAD`). 3. **Open the CR** (`kortix cr open --title "…" --description "…"`). From inside the sandbox `--head` and `--session` are auto-detected from `$KORTIX_BRANCH_NAME` and `$KORTIX_SESSION_ID`; `--base` defaults to the project's default branch. 4. **Surface the CR number to the user** (`kortix cr ls`) so they can review. 5. **Stop. The agent does not merge its own CR.** Merging is the user's call, from the dashboard or `kortix cr merge <n>`. Applies to everything: code edits, new files (skills, agents, slash commands, tools, plugins), manifest edits (`kortix.yaml` or legacy `kortix.toml` — triggers, env/secrets, sandbox, agent grants), `AGENTS.md` changes, new MCP server configs — anything committed to the tree. There is no "small enough to skip the CR" exception. ### Anti-patterns - **Force-pushing to `main`.** Breaks the user's review contract even where the backend allows it. - **"I committed it on my branch, the user can pull it."** The session branch dies; they can't pull it once the sandbox shuts down unless it's merged. - **Bundling the change as a tarball / paste / gist.** A workaround for a problem the CR system already solves. ## Data model CRs live in the `change_requests` table. | Column | Type | Notes | | --- | --- | --- | | `cr_id` | uuid (PK) | Stable identifier. What the REST API uses. | | `account_id` | uuid | Tenant. | | `project_id` | uuid | Project the CR belongs to. Cascade-deleted with the project. | | `number` | integer | Short, per-project, monotonically-increasing display number. `#1`, `#2`, … Unique per project. | | `title` | text | Required. | | `description` | text | Defaults to empty string. | | `base_ref` | text | The branch being merged *into*. Usually `main`. | | `head_ref` | text | The branch being merged *from*. Usually `session-<id>`. | | `status` | enum | `open` \| `merged` \| `closed`. | | `head_commit_sha` | text (nullable) | Refreshed against the live `head_ref` tip on every read for open CRs. Captured at merge time for merged CRs. | | `base_commit_sha` | text (nullable) | Same idea. For merged CRs this is the base SHA *before* the merge commit was created. | | `origin_session_id` | text (nullable) | The session that opened the CR. FK to `project_sessions.session_id` with `ON DELETE SET NULL`, so closing the originating session orphans the link rather than deleting the CR. | | `created_by` | uuid | User who created the CR (or sandbox token's resolved user). | | `merged_at` | timestamptz (nullable) | When the merge ran. | | `merged_by` | uuid (nullable) | Who triggered the merge. | | `merge_commit_sha` | text (nullable) | The merge commit. For fast-forwards equals `head_commit_sha` at merge time. | | `closed_at` | timestamptz (nullable) | When the CR was closed without merging. | | `closed_by` | uuid (nullable) | Who closed it. | | `metadata` | jsonb | Free-form key/value. Defaults to `{}`. Holds `requested_changes: { text, by, at }[]` — the log of human "request changes" notes appended by `POST /:crId/request-changes` (CRs have no separate comment table). | | `created_at` | timestamptz | Defaults to now. | | `updated_at` | timestamptz | Updated on every status change or SHA refresh. | Indexes: - `idx_change_requests_account` on `account_id` - `idx_change_requests_project` on `project_id` - `idx_change_requests_project_status` on `(project_id, status)` - `idx_change_requests_project_number` (**unique**) on `(project_id, number)` The unique index on `(project_id, number)` lets the CLI accept `kortix cr show 3` — `3` resolves to the row with `number = 3` for the project. Numbers don't recycle on close; the counter keeps going. ## Lifecycle ``` open │ ├── kortix cr close ──▶ closed ──┐ │ │ │ │ kortix cr reopen │ │ │ │ ◀──────────────────────┘ │ │ │ ├── kortix cr merge ──▶ merged ─┘ (terminal) ``` - `open` is the starting state. - `closed` is reversible: `kortix cr reopen <n>` puts it back to `open`. The branch tips are re-resolved on the next read. - `merged` is **terminal**. You cannot reopen a merged CR — open a new one against the post-merge state if you need to. - You cannot close a merged CR — already final. When a CR is merged, the row captures the SHAs that were active at merge time: - `merge_commit_sha` ← the new merge commit (or fast-forward target). - `base_commit_sha` ← the base SHA *before* the merge. - `head_commit_sha` ← the head branch's tip at merge time. (For fast-forwards this equals `merge_commit_sha`; for three-way merges it stays at the original head tip, so the diff can re-render via `base...head`.) ## SHA refresh For open CRs, the API refreshes `head_commit_sha` and `base_commit_sha` against the live branches on every read. If the repo is unreachable or a branch is missing, the refresh is skipped silently — the row still serves its existing metadata, so the UI renders title / description / status even when the repo is temporarily down. ## Diff semantics `GET /v1/projects/:projectId/change-requests/:crId/diff` returns a unified patch with `files`, `additions`, `deletions`, and per-file status (`added`, `modified`, `deleted`). - For `open` and `closed` CRs: diff is **three-dot** between live `base_ref` and live `head_ref` — i.e. `base...head`, which excludes changes already on `base_ref` that aren't on `head_ref`. - For `merged` CRs: diff is computed from the **captured SHAs** (`base_commit_sha`, `head_commit_sha`), so the patch still renders even though `head_ref` is now reachable from `base_ref` post-merge. `kortix cr diff` falls back to no color when stdout isn't a TTY (or with `--no-color`). ## Merge mechanics `POST /v1/projects/:projectId/change-requests/:crId/merge` runs through `mergeBranches`: 0. **Manifest gate.** Before touching git, the route reads `kortix.yaml` (or legacy `kortix.toml`) from the CR's `head_ref` — the version about to be merged — and validates it against the canonical manifest schema (the same validator `kortix ship` runs pre-flight). A branch with no manifest at all is fine. A branch whose manifest fails to validate is not: 422 with `code: "MANIFEST_INVALID"` and an `issues` array, and the merge never runs. 1. Fast-forward if `head_ref` is strictly ahead of `base_ref`. 2. Otherwise create a merge commit (three-way merge). Default message `Merge CR #<n>: <title>`; override with `--message`. Author is `Kortix <noreply@kortix.ai>`. 3. On success: update the `change_requests` row to `merged`, capture SHAs, invalidate the project's mirror cache. 4. On conflict: 409 with `error: <specific message from the merge, e.g. "Merge conflicts detected — resolve before merging">` and the conflict list available via `GET /merge-preview`. ### Merge preview `GET /v1/projects/:projectId/change-requests/:crId/merge-preview` returns: ```ts { base_sha: string, // current tip of base_ref head_sha: string, // current tip of head_ref merge_base: string | null, // common ancestor (null if histories are unrelated) is_up_to_date: boolean, // head_ref is fully merged into base can_merge: boolean, // no conflicts can_fast_forward: boolean, // head is strictly ahead of base conflicts: string[], // file paths that would conflict } ``` `kortix cr show <cr>` calls this automatically for open CRs and renders it inline. Run `show` before `merge` to see what you're about to do. ### Conflicts If the merge preview lists conflicts, the agent does not merge anyway to auto-resolve. Standard recovery: 1. On the session branch, `git pull origin <base_ref>` (or merge `main` into the branch). 2. Resolve conflicts locally with `edit`. 3. Commit + push. 4. `kortix cr show <cr>` to confirm the preview is now clean. 5. Hand back to the user to merge. ## CLI surface The full surface is in the [CLI reference](/docs/reference/cli). Summary: | Command | What it does | | --- | --- | | `kortix cr ls [--status open\|merged\|closed\|all]` | List CRs on the project. Default: `open`. | | `kortix cr show <cr>` | Metadata + merge preview. | | `kortix cr diff <cr> [--no-color]` | Unified patch. | | `kortix cr open --title "..." [--description "..."] [--head <ref>] [--base <ref>]` | Open a CR. | | `kortix cr merge <cr> [--message "..."]` | Merge it (fast-forward or three-way). | | `kortix cr close <cr>` | Close without merging. | | `kortix cr reopen <cr>` | Reopen a closed CR. | `<cr>` is the per-project number (`3` or `#3`) or the UUID `cr_id`. ### Sandbox auto-detection When `kortix cr open` runs inside a session sandbox: - `--head` defaults to `$KORTIX_BRANCH_NAME` (or `$KORTIX_HEAD_REF`). - `--session` defaults to `$KORTIX_SESSION_ID`, which back-fills `origin_session_id` on the row so the dashboard can show which session opened the CR. - `--base` defaults to the project's default branch (from `projects.default_branch`, usually `main`). - `--project` defaults to the session's project (from `$KORTIX_PROJECT_ID`). - `--title` is the only required flag. Minimal viable invocation in a sandbox: ```sh kortix cr open --title "Add release-notes skill" \ --description "Drafts release notes from merged commits. Tested against the last 5 tags." ``` ## REST API All endpoints under `/v1/projects/:projectId/change-requests`. The CLI is a thin wrapper. | Method | Path | Notes | | --- | --- | --- | | GET | `/` | `?status=open\|merged\|closed\|all` (default `all`). | | POST | `/` | Body: `{ title, description?, head_ref, base_ref?, session_id? }`. Returns 201 + serialized CR. | | GET | `/:crId` | Returns `{ change_request: ... }`. Refreshes SHAs as a side effect. | | PATCH | `/:crId` | Edit `title` / `description`. | | GET | `/:crId/diff` | Unified patch + file list. | | GET | `/:crId/merge-preview` | Conflict + fast-forward analysis. | | POST | `/:crId/merge` | Body: `{ message?: string }`. 422 `MANIFEST_INVALID` if the head branch's manifest fails schema validation. 409 on conflict or non-`open` status. | | POST | `/:crId/close` | No body. 409 if already `merged`. | | POST | `/:crId/reopen` | No body. 409 if not `closed`. | | POST | `/:crId/request-changes` | Body: `{ feedback }`. Appends `{ text, by, at }` to `metadata.requested_changes` and delivers the feedback to the originating session's agent (best-effort, background — a sleeping sandbox is woken to receive it). 409 if not `open`. | Read endpoints (`GET /`, `GET /:crId`, `GET /:crId/diff`, `GET /:crId/merge-preview`) require **read** access to the project. Mutating endpoints (`POST /`, `PATCH /:crId`, `POST /:crId/merge`, `POST /:crId/close`, `POST /:crId/reopen`, `POST /:crId/request-changes`) require **write** access; `POST /` and `POST /:crId/request-changes` additionally require the `project.gitops.push` capability, and `POST /:crId/merge` additionally requires `project.gitops.merge`. Mismatched token → 403. Validation on `POST /`: - `title` required (non-empty). - `head_ref` required. - `base_ref` defaults to the project's `default_branch`. - `head_ref === base_ref` → 400 (must differ). - `session_id` is validated against `project_sessions`; unknown session IDs are silently dropped (`origin_session_id` becomes null). - Branch tips are resolved at create time, so the CR row has anchor SHAs from the moment it's opened. - `head_ref` must have at least one commit ahead of `base_ref` — otherwise 422 with `code: "CR_HEAD_NOT_AHEAD"` (an empty CR could never be applied — it would render "No changes detected" and `merge-preview` would report it un-mergeable). This covers both a committed-but-never-pushed session branch (head tip == base tip) and a stale branch behind an advanced base. The resolver force-refetches the mirror before concluding "not ahead," so a push that just landed doesn't bounce — this is the concrete error an agent sees if it skips step 2 of [the agent mandate](#the-agent-mandate). ## Composition with the rest of the system | Surface | How the CR composes | | --- | --- | | **Sessions** | A CR's `origin_session_id` is back-filled from `$KORTIX_SESSION_ID` so the dashboard shows the session that opened it. Cascade is `ON DELETE SET NULL` — closing the session orphans the link, doesn't delete the CR. | | **Skills** | New `.kortix/opencode/skills/<name>/SKILL.md` files reach future sessions **only** after a CR that contains them merges to `main`. Until then, only the originating session sees them. | | **Agents** | Same: new `.kortix/opencode/agents/<agent>.md` files need to land via CR. | | **Triggers** | Edits to `triggers:` (v2) or `[[triggers]]` (legacy v1) only reach the scheduler after the CR merges. The scheduler reads the manifest on `main`. | | **Secrets** | Decoupled. Secrets live in the Kortix Secrets Manager, not the manifest; CRs don't move secrets. | | **Dashboard** | Renders CR list / detail / diff / merge button. Same data as the CLI sees. | | **Triggers firing inside a session** | A trigger-spawned session can itself open a CR — same flow. | ## Gotchas - **Merge is `Kortix <noreply@kortix.ai>`-authored.** If you want the user's name on the merge commit, that's a dashboard-side option, not a CLI flag today. - **You cannot close a `merged` CR.** It's the terminal state. - **You cannot reopen a `merged` CR.** Open a new one against the post-merge tip. - **Branch deletion is not automatic.** After a CR merges, the head branch still exists in the git backend. If the project policy is to clean up session branches, that's a separate sweep — not part of the CR merge. - **The session-branch tip changes after the agent commits more.** CRs that read live tips will reflect new commits on the head branch even after the CR is opened — the diff updates. There's no freeze-on-open semantic. - **The `KORTIX_*` env vars expected at `cr open` time:** `KORTIX_CLI_TOKEN` (or its alias `KORTIX_EXECUTOR_TOKEN`), `KORTIX_API_URL`, `KORTIX_PROJECT_ID`, `KORTIX_BRANCH_NAME` (or `KORTIX_HEAD_REF`), `KORTIX_SESSION_ID`. All are pre-injected by the session bootstrap. Note `KORTIX_TOKEN` is a *different* credential — the sandbox daemon's own service key, not a CLI token — and is not what the CLI authenticates with. Running `kortix cr open` outside a session needs `--head` and `--project` explicitly, or a cwd linked via `kortix projects link`. --- <!-- /markdown/docs/reference/cli.md --> # CLI reference Every kortix command, the project-scoped token model, env vars, and exit codes. Canonical page: https://kortix.com/docs/reference/cli The `kortix` CLI drives everything the dashboard can, from a terminal — laptop, coding agent, or session sandbox. For common workflows, see [Using the CLI](/docs/guides/using-the-cli). Always available inside a session sandbox, no setup: - Binary on `PATH` (`/usr/local/bin/kortix`). - `KORTIX_CLI_TOKEN` pre-injected — the project-scoped token the CLI authenticates with. (`KORTIX_TOKEN` is also present, but it's the sandbox *service* key, not a CLI token.) - `KORTIX_API_URL` points at the platform you're running against. ## Quickstart inside a session ```sh kortix whoami # confirms what project + account this token has kortix projects info # the project you're running inside kortix secrets ls # encrypted env vars + manifest env spec kortix sessions ls # every session on this project (incl. you) kortix cr ls # open change requests kortix cr open --title "..." # propose merging your branch into main ``` The sandbox token is project-scoped: read+write anything on *this* project (secrets, sessions, triggers, and change requests), but it can't list other projects or touch account-level resources. See [Token scope](#token-scope). ## On your laptop ```sh curl -fsSL https://kortix.com/install | bash kortix login # opens browser, you click Authorize ``` The local CLI uses a user-scoped token at `~/.config/kortix/config.json` (mode 0600), which sees every project on every account you're a member of. ## Command surface ### Auth | Command | Effect | | --- | --- | | `kortix login [--token <pat>] [--host <name>] [--api <url>]` | Default: opens browser → click Authorize → token written. `--token` is the headless fallback. `--host` logs into a named host slot. | | `kortix logout [--host <name>]` | Remove the token for the active host (or named one). | | `kortix whoami [--host <name>] [--json] [--token-only]` | Print the user + active account on the chosen host. `--json` for machine-readable output; `--token-only` prints just the active token context. | | `kortix token [--host <name>]` | Shortcut for `whoami --token-only` — the active token's project/session/agent grants, script-friendly. | ### Hosts A host is one Kortix API endpoint. The built-ins `cloud` (Kortix Cloud), `selfhost` (your `kortix self-host` stack), `local-dev` (a local `pnpm dev` server), and `kortix-internal-dev` are always available, and you can add more endpoints. One host is active at a time; the CLI prints the active host before operational commands so Cloud/local actions are harder to mix up. | Command | Effect | | --- | --- | | `kortix hosts ls` | List configured hosts (`●` marks active). | | `kortix hosts use [<name>]` | Switch active host. No name → arrow-key picker. | | `kortix hosts add <name> --url <url> [--login]` | Register a new host. `--login` runs the browser flow right after. | | `kortix hosts rm <name>` | Remove a host (confirms when it's the last one). | | `kortix hosts info [<name>]` | Detailed view of one host. | | `kortix hosts current` | Print the active host name (script-friendly). | `--host <name>` overrides the active host for one invocation: `kortix projects ls --host local`. ### Accounts One Kortix login can belong to many accounts (personal, a company account, …). Exactly one is active at a time; every account-scoped command (`projects ls`, `ship`, …) operates on it unless overridden. | Command | Effect | | --- | --- | | `kortix accounts ls [--json]` | List the accounts you belong to. | | `kortix accounts use [<slug-or-id>]` | Switch the active account. No arg → arrow-key picker. | | `kortix accounts current [--json]` | Print the active account. | | `kortix accounts info [<slug-or-id>] [--json]` | Show one account (defaults to the active one). | ### Self-host `kortix self-host` is one generic Docker-native system — no separate "target" to pick. `init` generates a `docker-compose.yml` + `.env` (plus a `Caddyfile` and `updater.sh` when a domain is configured) into `~/.config/kortix/self-host/<instance>/`, and `start` pulls the images and runs `docker compose up`. It's identical on a laptop, any VPS, or a cloud VM — there is no AWS profile, no Terraform, no TUF/signing, no SSM. `start` creates config if needed, starts Supabase/API/gateway/frontend locally, and collects the Daytona sandbox key, managed GitHub access, and optional Pipedream connector integration via the `configure` wizard. Local ports, generated secrets, and image tags are handled automatically. A public domain + TLS is opt-in: set `KORTIX_DOMAIN` (and optionally `KORTIX_API_DOMAIN`, default `api.<domain>`) via `env set` to turn on the bundled Caddy reverse proxy, which terminates TLS via ACME HTTP-01 on ports 80/443 — leave it unset for the default loopback-port setup. An always-present in-compose `kortix-updater` service (gated by `KORTIX_AUTO_UPDATE`, on by default) pulls the configured channel's images on an interval (`KORTIX_UPDATE_INTERVAL`, default daily) and rolls the stack forward when anything actually changed — `update`/`reconcile` just runs that same logic once, immediately. The full operator runbook lives at `docs/runbooks/self-hosting.md` in the repository, and the architecture is documented at [Self-hosting architecture](/docs/reference/self-hosting-architecture). For a bare Linux box (VPS/EC2), `scripts/kortix-selfhost-up.sh` installs Docker + the CLI and runs `init`/`start` for you — see the runbook. | Command | Effect | | --- | --- | | `kortix self-host init` | Create or refresh the self-host config, without starting the stack. Non-mutating to a running stack. | | `kortix self-host configure` | Interactive wizard for integrations (Daytona, GitHub, Pipedream) and update policy (channel, auto-update, interval). | | `kortix self-host doctor` | Validate local Docker tooling and the rendered Compose config. Non-mutating. | | `kortix self-host start` | Create config if needed, start the stack, and register the `selfhost` host. | | `kortix self-host update [--tag <v> \| --channel stable\|latest]` | Pull the configured (or given) channel/tag's images and recreate the stack in place — migrates first, then rolls forward. | | `kortix self-host reconcile` | Alias for `update` — converge to the configured channel/version right now. | | `kortix self-host rollback --release <v>` | Roll back to an explicit older version (same mechanics as `update`, pinned). | | `kortix self-host version` | Show the running version, configured channel, and whether a newer release is available. | | `kortix self-host restart` | Restart the stack. | | `kortix self-host status` | Show Docker Compose service status. | | `kortix self-host open` | Open the dashboard in your browser. | | `kortix self-host env ls` | Show persistent self-host env values, masking populated secrets. | | `kortix self-host env set KEY=VALUE …` | Update persistent env values (domain, integrations, update policy, …) before the next start. | | `kortix self-host logs [service]` | Tail Docker Compose logs. | | `kortix self-host stop` | Stop the stack. | Common flags: `--instance <name>` (default `default`), `--tag <version>` / `--release <version>` (pin an explicit image tag), `--channel stable|latest` (default `stable`), `--auto-update on|off` (default `on`), `--update-interval <seconds>` (default `86400`), `--json`, and `--yes`. ### Projects | Command | Effect | | --- | --- | | `kortix projects ls [--all] [--json]` | Every project on the active account. `--all` spans every account you belong to, grouped. | | `kortix projects info [<id-or-slug>] [--json]` | Show one project (defaults to the linked/default one). | | `kortix projects use [<id>]` | Set the global **default** project (interactive picker if omitted). Switches the active account to that project's account. | | `kortix projects unset` | Clear the global default project. | | `kortix projects link [<id>]` | Bind cwd to a remote project. Writes `.kortix/link.json` with `project_id`, `account_id`, `host`, `host_url`. No arg → arrow-key picker. | | `kortix projects unlink` | Drop `.kortix/link.json`. | | `kortix projects rm [<id>] [--purge] [-y\|--yes]` | Archive a project (defaults to the linked one). `--purge` also deletes its managed git repo (irreversible). `-y` skips the confirmation. | | `kortix projects open [<id>]` | Open the dashboard URL for a project in your browser. | A directory link (`.kortix/link.json`) always wins over the default; the default (`kortix projects use`) is what commands use anywhere else on your machine. #### How a command finds "the project" In strict order: 1. `--project <id>` flag. 2. `KORTIX_PROJECT_ID` env var (this is also how a session sandbox's own project is picked up — one check, not a separate sandbox step). 3. `.kortix/link.json` in the exact cwd — no ancestor/parent-directory search. 4. The global default project set by `kortix projects use`. If none resolve, the command errors pointing to `projects link`. #### How a command finds "the host" 1. `--host <name>` flag. 2. Inside a session sandbox: the platform-injected `KORTIX_CLI_TOKEN` / `KORTIX_EXECUTOR_TOKEN` resolves the host directly — this wins over `.kortix/link.json`'s host field. 3. `host` field in `.kortix/link.json` (so a repo on your laptop hits its home Kortix instance). 4. The globally-active host. ### Secrets Encrypted env vars stored on the project, injected as plain env into every session sandbox at boot. | Command | Effect | | --- | --- | | `kortix secrets ls` | List secrets by identifier + manifest `env` spec (`env:` in `kortix.yaml`, `[env]` in legacy `kortix.toml`), showing `→ KEY` when the injected env key differs; marks required-but-missing. | | `kortix secrets set NAME=VALUE …` | Upsert one or more. `NAME=-` reads VALUE from stdin (so values never appear in shell history). Add `--identifier <id>` (alias `--id`) with exactly one `KEY=VALUE` pair to store a second value under the same env key. | | `kortix secrets request NAME [NAME …] [--scope runtime\|connector] [--expires <min>]` | Mint a short-lived link for a human to **enter** the value(s) directly — you never see or handle the raw value. Surface the URL (web: fill-in modal; Slack: tappable link). | | `kortix secrets unset NAME …` | Remove. | ### Env — dotenv ↔ secrets | Command | Effect | | --- | --- | | `kortix env pull [--out .env] [--force]` | Write a `.env` skeleton (names only — plaintext can't leave the cloud). | | `kortix env push --from <path>` | Upload every `NAME=VALUE` from a dotenv file as a secret. Supports quoted values, `export NAME=…`, comment lines. | ### Connectors Integrations agents call as tools (Pipedream apps, MCP servers, OpenAPI/GraphQL/HTTP endpoints) — mirrors the dashboard's Customize → Connectors. Connectors are project-wide visible; the access gate is which agents may call one (`[[agents]].connectors` in `kortix.yaml`, see [Grants](#grants)). `add` / `rm` / `policy set` edit the **local** `kortix.yaml` (the source of truth) — run `kortix ship` to apply, then `sync` to reconcile — unless you pass `--apply`, which skips ship/CR and applies on the cloud project immediately. | Command | Effect | | --- | --- | | `kortix connectors ls [--json]` | List connectors + status, auth. | | `kortix connectors show <slug> [--json]` | Show one connector's tools (actions). | | `kortix connectors add <slug> --provider <p> [options] [--apply]` | Add a `[[connectors]]` block to `kortix.yaml` (or apply instantly on the cloud project with `--apply`). | | `kortix connectors rm <slug> [--apply]` | Remove a `[[connectors]]` block. | | `kortix connectors rename <slug> <name…>` | Set a connector's display name (applies now). | | `kortix connectors mode <slug> shared` | Set the profile model — `shared` is currently the only mode (applies now + re-syncs). | | `kortix connectors sync` | Reconcile the catalog from the shipped `kortix.yaml`. | | `kortix connectors credential <slug> [value]` | Set a connector's credential (prompts if no value; reads stdin with `-`). | | `kortix connectors connect <slug>` | Start a Pipedream 1-click connect. | | `kortix connectors link <slug> [--expires <min>]` | Mint a durable shareable Quick Connect link to hand a human. Auto-finalizes. | | `kortix connectors finalize <slug>` | Confirm a Pipedream connection completed. | | `kortix connectors apps [<query>] [--json]` | Browse the Pipedream app catalog. | | `kortix connectors policy ls [--json]` | Show project-wide execution policies. | | `kortix connectors policy set --default <risk\|allow_all>` | Set the default execution mode. | | `kortix connectors policy <slug> ls [--json]` | Show one connector's tool-call rules. | | `kortix connectors policy <slug> set <match> <allow\|ask\|block>` | Set a rule for a tool name, glob (`send_*`), or `/regex/` (applies now). | | `kortix connectors policy <slug> rm <match>` | Remove a connector rule. | | `kortix connectors policy <slug> clear` | Remove all of a connector's rules. | `add` options: `--name <label>` (default: slug), `--provider <pipedream\|mcp\|openapi\|graphql\|http>`, `--app <slug>` (Pipedream), `--url <url>` (MCP), `--transport <http\|sse>` (MCP), `--endpoint <url>` (GraphQL), `--base-url <url>` (HTTP), `--spec <url\|path>` (OpenAPI/GraphQL/HTTP), `--auth-type <none\|bearer\|basic\|custom>`, `--credential shared`. ### Channels Manages the per-project Slack connection. Tokens are stored encrypted in the project's secrets manager; at session spawn they land in the sandbox as env vars, so the in-sandbox `slack` CLI can post back to your workspace. | Command | Effect | | --- | --- | | `kortix channels status` | Show the current Slack connection. `--json` for machine output. | | `kortix channels connect` | **Connect Slack in one step.** On Kortix Cloud (or any host with the shared Slack app configured) it prints a one-click "Add to Slack" install link — open it, pick the workspace, click Allow. Add `--wait` to poll until the install lands (`--timeout <sec>`, default 300). Hosts without the shared app fall back to manual token mode automatically. | | `kortix channels connect --manual [--bot-token <xoxb-…>] [--signing-secret <…>]` | Bring-your-own-app mode: save a bot token + signing secret to project secrets. Flags (which imply `--manual`), `SLACK_BOT_TOKEN`/`SLACK_SIGNING_SECRET` env vars, or `-` to read from stdin. | | `kortix channels disconnect` | Drop the project's Slack secrets. | | `kortix channels manifest` | Print the Slack app manifest JSON for the bring-your-own-app path — paste into `api.slack.com/apps` → "From a manifest". Not needed for the one-click install. | ### Sandboxes Manages the project's sandbox images — the same surface as the dashboard's Customize → Sandbox images. A template is a definition (image OR Dockerfile + resources); a build produces the actual snapshot the platform boots sessions from. Templates can also come from `[[sandbox.templates]]` in `kortix.yaml`. | Command | Effect | | --- | --- | | `kortix sandboxes ls [--json]` | List templates + live provider state. | | `kortix sandboxes builds [--json]` | Recent build log (last 25). | | `kortix sandboxes health [--json]` | Primary template readiness (quick check). | | `kortix sandboxes add <slug> (--image <i> \| --dockerfile <p>) [options]` | Create a custom template (kicks a build). | | `kortix sandboxes update <slug> [options]` | Update a UI-created template. | | `kortix sandboxes build <slug>` | Trigger a rebuild for a template. | | `kortix sandboxes rebuild <slug>` | Force-rebuild (delete existing snapshot first). | | `kortix sandboxes rm <slug>` | Delete a UI-created template. | | `kortix sandboxes fix` | Start a session seeded with the last failed build log so an agent can repair it. | Options for `add`/`update`: `--image <ref>` (mutually exclusive with `--dockerfile`), `--dockerfile <path>` (repo-relative), `--name <label>` (default: slug), `--cpu <n>`, `--memory <n>` (GiB), `--disk <n>` (GiB). ### Marketplace Browse and install items (skills, etc.) from the Kortix marketplace into a linked cloud project. | Command | Effect | | --- | --- | | `kortix marketplace search [query]` | Search marketplace items. | | `kortix marketplace list` | List marketplace items. | | `kortix marketplace show <id-or-name>` | Show one marketplace item. | | `kortix marketplace install <id-or-name> [--dry-run]` | Install into a linked cloud project. `--dry-run` shows what would be installed. | | `kortix marketplace status` | List installed marketplace items for a project. | | `kortix marketplace updates` | List installed items with available updates. | | `kortix marketplace update <name>` | Update one installed marketplace item. | | `kortix marketplace update --all` | Update all outdated items in one commit. | | `kortix marketplace remove <name>` | Remove one installed marketplace item. | Common options: `--query <text>` (same as `search [query]`), `--type <type>` (e.g. `skill`), `--source <source>` (e.g. `kortix`), `--project <id>`, `--json`. ### Executor The agent's primary interface to every configured connector — a thin client that never holds a third-party credential; every call goes to the Kortix Executor Gateway, which checks sharing, resolves the secret server-side, runs the call, and audits it. Auth comes from `KORTIX_EXECUTOR_TOKEN` + `KORTIX_API_URL`, injected at sandbox spawn. Machine-facing: emits JSON only. | Command | Effect | | --- | --- | | `kortix executor connectors` | List connectors + tools this session can use. | | `kortix executor discover "<intent>"` | Search tools by natural language. | | `kortix executor describe <connector>.<action>` | Show a tool's input schema. | | `kortix executor call <connector> <action> '<json-args>'` | Run a tool. | | `kortix executor add <slug> --provider pipedream --app <app>` | Add a connector now (no CR), then connect. | | `kortix executor rm <slug>` | Remove a connector from the project. | | `kortix executor connect <slug>` | Mint a Pipedream Quick Connect link to hand the human. | | `kortix executor mcp` | Run the optional stdio MCP compatibility server. | ### Sessions Each session is an isolated sandbox VM on its own ephemeral branch. | Command | Effect | | --- | --- | | `kortix sessions ls` | All sessions on the project. | | `kortix sessions status [--all] [--json]` | "Mission control": every session + what each agent is doing right now, live. Aliases: `overview`, `ps`. | | `kortix sessions info <id>` | Detail view: status, branch, base ref, agent, sandbox URL, errors. | | `kortix sessions new [--prompt "<text>"] [--wait] [--json]` | Start a new session, optionally with an initial prompt. `--wait` blocks until it's running; `--json` prints the session object. | | `kortix sessions chat [<id>] [--prompt "<text>"] [--new]` | Talk to a session's agent — interactive REPL, or one-shot with `--prompt`. Alias: `talk`. | | `kortix sessions connect [<id>] [-- <opencode attach args>]` | Attach your local OpenCode TUI to the OpenCode server already running inside the session sandbox. | | `kortix sessions log [<id>] [--limit <n>] [--json]` | Print a session's recent messages, read-only — peek at what an agent is doing without sending it anything. Alias: `messages`. | | `kortix sessions digest [--since <7d>] [--json]` | Compact multi-session review for reflection: metadata + compressed transcript snippets with tool outputs stripped. Aliases: `review`, `summary`. | | `kortix sessions preview <id> [port] [--port <n>] [--json]` | Print a clickable, root-served preview URL for a port in the session's sandbox (default `3000`). | | `kortix sessions restart <id>` | Restart the session's sandbox. An already materialized provider runtime is stop/started in place; a replacement is allocated only if the original sandbox never materialized. | | `kortix sessions rename <id> <name>` | Set a session's name. Pass `""` to clear it and revert to the automatic title. | | `kortix sessions rm <id>` | Stop + delete. | | `kortix sessions open <id>` | Open the dashboard URL for a session. | Inside a sandbox, `KORTIX_SESSION_ID` is your session; `kortix sessions info $KORTIX_SESSION_ID` is the live view of yourself. ### Files Browse the project's git repo — the same read-only view the dashboard shows (Files tab + version history). Operates on the default branch unless `--ref` selects another branch, tag, or commit sha. | Command | Effect | | --- | --- | | `kortix files ls [<path>]` | List files (recursive) under a path. | | `kortix files cat <path>` | Print a file's contents. | | `kortix files search <query> [--content]` | Search filenames, or file contents with `--content`. | | `kortix files history <path>` | Commit history for one file. | | `kortix files branches` | List branches (ahead/behind the default). | | `kortix files commits [--path <p>]` | List commits on `--ref`. | | `kortix files show <sha>` | Show one commit + its changed files. | | `kortix files diff <sha> [--path <p>]` | Print a commit's unified patch. | | `kortix files compare <from> <into>` | Summarize the diff between two refs. | Options: `--ref <ref>` (branch/tag/commit, default: the project's default branch), `--path <p>` (scope to a subtree or file), `--limit <n>`, `--json` (supported by every subcommand). ### Triggers Round-trip through the manifest's `triggers:` (v2 `kortix.yaml`) or `[[triggers]]` (legacy `kortix.toml`); the dashboard sees the same state. `add` / `rm` / `enable` / `disable` edit the **local** manifest — run `kortix ship` to apply. `pause` / `resume` are a separate **server-side** activation switch (cloud state, not the manifest) — use them to stop one of two deployments of the same repo from double-firing; manual `fire` still works while paused. | Command | Effect | | --- | --- | | `kortix triggers ls [--json]` | List triggers + runtime state (`last_fired_at`). | | `kortix triggers add <slug> [options]` | Append a `[[triggers]]` block (cron or webhook) to `kortix.yaml`. See options below. | | `kortix triggers rm <slug>` | Remove a trigger from `kortix.yaml`. | | `kortix triggers info <slug> [--json]` | Show one trigger in full. | | `kortix triggers fire <slug>` | Manually fire a trigger now. | | `kortix triggers enable <slug>` | Set `enabled = true`. | | `kortix triggers disable <slug>` | Set `enabled = false`. | | `kortix triggers pause` | Deactivate **all** of this project's triggers server-side (crons + webhooks stop auto-running). | | `kortix triggers resume` | Re-activate this project's triggers server-side. | `add` options: `--type <cron\|webhook>` (default `cron`), `--prompt <text>` (required — initial prompt for the spawned session), `--agent <name>` (default: the opencode default), `--cron <expr>` (6-field cron, e.g. `"0 0 9 * * 1-5"`), `--timezone <tz>` (default UTC), `--secret-env <NAME>` (HMAC secret env var, webhook type), `--name <label>` (default: slug), `--disabled` (create it disabled). ### Change requests (`cr`) Kortix-native PR layer for session work landing on `main` — merges one branch (`head_ref`) into another (`base_ref`) on any git host, no per-host integration. The only sanctioned way for an agent to land session-branch work on `main`. See [Change requests](/docs/reference/change-requests). | Command | Effect | | --- | --- | | `kortix cr ls [--status open\|merged\|closed\|all] [--project <id>]` | List CRs on the project. Default: `--status open`. | | `kortix cr show <cr> [--project <id>]` | Show one CR's metadata. Alias: `kortix cr info`. Includes the merge-preview (clean / fast-forward / conflicts) for open CRs. | | `kortix cr diff <cr> [--no-color] [--project <id>]` | Unified diff of the CR. Three-dot diff for open / closed CRs; for merged CRs it uses the SHAs captured at merge time. | | `kortix cr open --title "<text>" [--description "<text>"] [--head <ref>] [--base <ref>] [--session <id>] [--project <id>]` | Open a new CR. Aliases: `kortix cr new`, `kortix cr create`. Inside a sandbox, `--head` defaults to `$KORTIX_BRANCH_NAME` and `--session` defaults to `$KORTIX_SESSION_ID`. `--base` defaults to the project's default branch (usually `main`). `--title` is required. Aliases: `--from` (`--head`), `--into` (`--base`), `--body` (`--description`). | | `kortix cr merge <cr> [--message "<text>"] [--project <id>]` | Merge an open CR into its `base_ref`. Fast-forward when possible, three-way merge otherwise. Default message `Merge CR #<n>: <title>`. Fails with 409 if the CR is not `open` or there are conflicts. | | `kortix cr close <cr> [--project <id>]` | Close an open CR without merging. Cannot close a merged CR. | | `kortix cr reopen <cr> [--project <id>]` | Reopen a closed CR only — merged CRs are terminal. | `<cr>` accepts the short per-project number (`3`, `#3`) or the full UUID `cr_id`. Numbers are unique per project, monotonically increasing. #### Inside a sandbox — the typical agent flow ```sh # 1. Commit on the session branch git add . git commit -m "Add release-notes skill" # 2. Push the branch (KORTIX_BRANCH_NAME) git push origin HEAD # 3. Open the CR — head and session are auto-detected kortix cr open \ --title "Add release-notes skill" \ --description "Drafts release notes from merged commits. Tested against the last 5 tags." # 4. Confirm it's listed kortix cr ls # 5. (Optional) show the diff one more time kortix cr diff 3 ``` The agent does not merge its own CR — that's the user's call, in the dashboard or via `kortix cr merge <n>`. ### Agents Per-agent settings on the linked project. Today: which model each agent runs on — the dynamic gateway default, applied instantly with no `kortix.yaml` commit. A session that asks for the synthetic `auto` model resolves to this pick, falling back to the project → account → platform default. (The declarative default lives in `kortix.yaml` as `[[agents]].model`.) | Command | Effect | | --- | --- | | `kortix agents models [--json]` | Show every agent's pinned model + the fallback default. | | `kortix agents model <agent> <provider/model>` | Pin an agent to a model, e.g. `anthropic/claude-opus-4-8`. | | `kortix agents model <agent> --clear` | Clear the pin — the agent follows the default again. | ### Access Manage who can use the linked project — mirrors the dashboard's project sharing/access panel. Roles: `manager`, `editor`, `member`. | Command | Effect | | --- | --- | | `kortix access ls [--json]` | List members + effective project roles. | | `kortix access invite <email> --role <r>` | Invite someone to the project. | | `kortix access grant <user-id> --role <r>` | Set/grant a member's project role. | | `kortix access revoke <user-id>` | Remove a member's project access. | | `kortix access pending [--json]` | List pending project invitations. | | `kortix access cancel <invite-id>` | Cancel a pending invitation. | Options: `--role <manager\|editor\|member>`, `--expires <iso>` (optional auto-revoke timestamp for a grant). ### Roles Account-level custom IAM roles + policy assignments — the CLI face of the dashboard's Roles tab and a project's "Custom roles" card. Built-in roles (owner/admin/member, manager/editor/user) are read-only references; custom roles are yours to create, edit, and bind. `export` / `import` round-trip roles + bindings as TOML or JSON — "IAM as code". | Command | Effect | | --- | --- | | `kortix roles ls [--json]` | List roles (built-in + custom). | | `kortix roles show <role> [--json]` | Show a role's permissions + usage. | | `kortix roles actions [--json]` | List the permission catalog. | | `kortix roles create <key> --name <n> [options]` | Create a custom role. | | `kortix roles set-actions <role> --actions a,b` | Replace a custom role's permissions. | | `kortix roles rm <role>` | Delete a custom role. | | `kortix roles assignments [--project <id>] [--json]` | List policy bindings. | | `kortix roles assign <role> --to <type>:<id> [options]` | Bind a role to a principal (`member:<id>`, `group:<id>`, or `token:<id>`). | | `kortix roles unassign <policy-id>` | Remove a binding. | | `kortix roles export [--project <id>] [--out <file>] [--format toml\|json]` | Dump custom roles + bindings to a file (or stdout). | | `kortix roles import <file>` | Apply a roles/policies file — creates missing roles, then bulk-imports bindings. | A `<role>` may be its key (e.g. `support_agent`) or its role id. Options: `--name`, `--desc`, `--scope <account\|project>`, `--actions <list>`, `--to <type>:<id>`, `--project <id>`, `--expires <iso>`, `--account <id>`. ### Grants Assign project resources to people — the inheritance pyramid. Secrets and connectors live on **agents**; assigning an agent to a member (or group) grants them everything that agent declares. Mirrors the dashboard's "Members → Resource access" panel. `assign` only ever creates agent grants now; `ls` still lists any legacy skill/secret rows for visibility. | Command | Effect | | --- | --- | | `kortix grants ls [--json]` | List grants + grantable agents/skills/secrets. | | `kortix grants assign <agent-name> --to <who> [--group]` | Assign an agent to a member (or a group with `--group`). | | `kortix grants revoke <grant-id>` | Remove a grant. | Options: `--to <who>` (member email or user-id; group id with `--group`), `--expires <iso>`. ### Install / update / uninstall | Command | Effect | | --- | --- | | `kortix update` | Re-runs the install script to pull the latest binary. | | `kortix uninstall` | Removes the `kortix` binary and its `/usr/local/bin` shim, the `~/.kortix` home dir, and the stored auth token. `--keep-home` preserves `~/.kortix`; `--keep-auth` preserves the token. | | `kortix version` | Print the CLI version. | ### Project scaffold `kortix init` creates a **new, standalone** directory — like `create-next-app`, it never scaffolds into an existing folder or the current directory, and it refuses to run against a non-empty one. Scaffolding is explicit-only: `init` is the single command that creates a project. An unrecognized subcommand (`kortix use`, `kortix inti`, …) exits with an error and a did-you-mean suggestion — it never creates a directory. (Older CLI versions treated any unknown first argument as a project name to scaffold; that form is gone.) | Command | Effect | | --- | --- | | `kortix init [project-name] [options]` | Prompts for a project name if omitted, then creates that directory and scaffolds it: `kortix.yaml` (the v2 manifest), `.kortix/Dockerfile`, and the OpenCode config dir (default agent + the kortix-system skill + tools). Runs the wizard: pick coding agent(s) to wire up (symlinks `.opencode`/`.claude`/`.agents` + `AGENTS.md` into `.kortix/opencode` so skills/agents are shared), pick a starter template, pick marketplace skills to install. Does **not** write `.kortix/link.json` — that's created later by `projects link` or `ship`. | `kortix init` options: `--name <project>` (alias for the positional name), `--primary <agent>` (opencode\|claude\|codex\|cursor), `--agents <list>` (comma-separated extras to wire up alongside `--primary`), `--template <minimal\|general-knowledge-worker>` (default `minimal`; `general-knowledge-worker` includes the optional skill pack), `--marketplace <list>` (comma-separated marketplace skills, or `none`; interactive mode opens a preselected picker), `--no-git` (skip `git init`), `-y`/`--yes` (skip prompts — requires a project name). ### Dev Run OpenCode locally against this project's `.kortix/opencode` config — the same config the cloud sandbox uses — so you can test agents, skills, tools, and runtime config without spinning up a sandbox. Works from anywhere inside the project; no `cd` or `OPENCODE_CONFIG_DIR` needed. Scope: this exercises the OpenCode half of your config only — connectors/executor, triggers, and channels are injected by the platform at session start, so test those against a live session instead. | Command | Effect | | --- | --- | | `kortix dev` | Interactive session with your local config. | | `kortix dev run --agent <agent> "<prompt>"` | One-shot run of an agent. | | `kortix dev debug skill` | List the skills your config discovers. | | `kortix dev debug agent <agent>` | Show the resolved agent: model, tools, permissions. | ### Ship Stage everything, commit, and push your current branch to the project's git repo — in one command. Run it once to create the project, then run it again any time to sync. Alias: `kortix deploy`; it performs the same git sync as `kortix ship`. Every run: (1) verify `kortix.yaml` parses + validates (skip with `--no-verify`); (2) `git add -A` + commit (skipped if nothing changed); (3) offer to set any `[env]` secret not yet set (skip with `--no-env`); (4) push the branch you're on to the same-named branch on the project's repo; (5) connect any declared connector that still needs auth (skip with `--no-connect`). The **first** ship creates the cloud project + a git repo and links the folder (`.kortix/link.json`); every ship after that just commits + pushes. Origin is inferred, never asked: an existing GitHub `origin` links directly (GitHub-backed — prints a one-click App-install link if needed, or pass `--github-token`); another existing remote is registered + pushed; no `origin` creates a managed Kortix git repo. | Option | Effect | | --- | --- | | `--name <project>` | Display name for a new project (default: folder name). | | `--account <id\|slug>` | Account to create the project under (first ship only). | | `--origin <value>` | Override origin choice: `managed` (force a managed Kortix repo) or a git URL to register + push to. | | `--github-token <pat>` | Link a GitHub origin with this token instead of the GitHub App (needs repo Contents R/W). | | `-m, --message <msg>` | Commit message for the sync (default: `kortix: ship`). | | `--no-commit` | Don't commit — fails if the working tree is dirty. | | `--no-verify` | Skip the `kortix.yaml` validation check. | | `--no-env` | Skip the `[env]` secret check + prompts. | | `--no-connect` | Skip the connector connect/credential prompts. | | `-y, --yes` | Don't prompt; use the active account, skip secret prompts. | | `-n, --dry-run` | Print what would happen, do nothing. | | `--project <id>` | Operate on this project id (default: linked). | | `--host <name>` | Operate against a non-default Kortix host. | ### Manifest validation | Command | Effect | | --- | --- | | `kortix validate [--file <path>]` | Statically validate the project's manifest against the canonical schema. Resolves `kortix.yaml` first, falling back to legacy `kortix.toml`, unless `--file` names a specific path. Prints per-agent scope (connectors / `kortix_cli` / secrets) on success. `--json` for a machine-readable report; `--scopes` prints the full grantable `kortix_cli` action enum and exits. Exit codes: `0` valid, `1` validation errors, `2` file missing/unreadable. This is the same validator `kortix ship` runs as a pre-flight check and the CR-merge gate runs server-side — one schema, three call sites. | | `kortix schema [--version 1\|2] [--url]` | Print the canonical JSON Schema for the manifest — the same document served at `https://kortix.com/schema/kortix.v2.schema.json` (and the `v1` / combined variants). `--version 2` (or `1`) prints just that version; omit it for the combined document that dispatches on `kortix_version`. `--url` prints the schema URL instead of the body — paste it as a `kortix.yaml`'s first-line `# yaml-language-server: $schema=…` comment for editor validation + autocomplete. | ## Token scope Two token types, both with the `kortix_pat_…` prefix, distinguished by an internal `project_id` column on the token row. | Type | Scope | Issued by | Typical use | | --- | --- | --- | --- | | **User token** | All projects on accounts the user belongs to + account-level routes (`/v1/accounts/me`, billing, etc.) | `kortix login` browser flow → minted via `POST /v1/accounts/tokens` | The CLI on your laptop | | **Project token** | Read + write everything on one project — secrets, sessions, triggers, and change requests. Cannot list other projects or hit account-level routes. | Auto-minted at session create; surfaced via `POST /v1/projects/:id/cli-token` | The CLI inside a sandbox | Enforcement: every project route checks the token's `project_id` against the URL's `:projectId`. Mismatch → 403. Account routes (`/v1/accounts/*`) reject any project-scoped token outright. ### Inside a sandbox The session bootstrap injects (among other [runtime vars](/docs/reference/session-runtime#injected-environment)): ```sh KORTIX_CLI_TOKEN=… # project-scoped PAT — the CLI authenticates with this KORTIX_TOKEN=… # sandbox service key (daemon control surface) — NOT a CLI token KORTIX_API_URL=https://api.kortix.com KORTIX_PROJECT_ID=<uuid> KORTIX_SESSION_ID=<uuid> ``` The CLI reads `KORTIX_CLI_TOKEN` automatically (falling back to `KORTIX_EXECUTOR_TOKEN`, the same value) and uses `KORTIX_API_URL` as the host base. No config file needed. Don't reach for `KORTIX_TOKEN` — it's the daemon's service key, and the project-scoped API routes the CLI hits reject it. ### Rotating A sandbox's project token is minted for the session sandbox and remains the token for that sandbox across restart of an already materialized runtime. Restarting stop/starts that same provider runtime in place. A replacement runtime is allocated only if the original sandbox never materialized. ## Common workflows ### Spin up a fresh session with custom env ```sh kortix secrets set STRIPE_API_KEY=sk_live_… WEBHOOK_SLACK_SECRET=whsec_… kortix sessions new --prompt "Audit the auth module and propose a fix" ``` ### Inside a session: trigger another session ```sh kortix sessions new --prompt "Verify migration 0048 by running pnpm test + opening a CR if anything fails" ``` ### Run a trigger by hand for debugging ```sh kortix triggers ls # confirm the slug + status kortix triggers fire daily-digest # one-shot manual fire kortix sessions ls | head -3 # the new session that the trigger spawned ``` ### Pull current secrets into a local `.env` for development ```sh kortix env pull # names only, values left blank $EDITOR .env # fill in values locally # (don't push — local-only file) ``` ### Bulk-upload local `.env` to the cloud project ```sh kortix env push --from .env kortix secrets ls # confirm ``` ### Land session work on `main` (the CR flow) The agent opens the CR; the user reviews and merges. No other path to `main` from inside a session. ```sh # inside a session sandbox, on branch session-<id> git add . git commit -m "Add release-notes skill" git push origin HEAD kortix cr open \ --title "Add release-notes skill" \ --description "Drafts release notes from merged commits. Tested against the last 5 tags." kortix cr ls # confirm ``` The user then runs `kortix cr show 3`, `kortix cr diff 3`, and `kortix cr merge 3` (or `kortix cr close 3`). ## Environment variables the CLI reads | Variable | Purpose | | --- | --- | | `KORTIX_CLI_TOKEN` | The project-scoped PAT the CLI authenticates with in a sandbox (pre-injected). The primary token name the CLI reads. | | `KORTIX_EXECUTOR_TOKEN` | Same value as `KORTIX_CLI_TOKEN`; the CLI falls back to it if the former is unset. | | `KORTIX_API_URL` | Override the API base URL (default: `https://api.kortix.com`). | | `KORTIX_PROJECT_ID` | Override the linked project for one command. | | `KORTIX_CONFIG_FILE` | Override `~/.config/kortix/config.json` location (useful for tests). | | `KORTIX_DASHBOARD_URL` | Override the dashboard URL the `login` flow opens (default: derived from API URL). | The `KORTIX_*` prefix is reserved for platform-injected values. Don't declare project secrets with it — the secrets-manager API rejects them and the manifest validator warns. ## Exit codes | Code | Meaning | | --- | --- | | `0` | Success. | | `1` | Operation failed (API error, missing project, etc.). Diagnostics printed to stderr. | | `2` | Bad flag, unknown subcommand, missing required arg. | ## What the CLI is not - **Not a separate runtime.** Use `kortix self-host start` to run the generic Docker self-host stack (laptop, VPS, or cloud VM — all identical), then switch between Cloud and self-hosted APIs with `kortix hosts use`. - **Not a `git` replacement.** `kortix cr` composes with `git` rather than wrapping it. - **Not the runtime.** OpenCode executes the agent in the sandbox; the CLI is the control plane. See [Kortix vs OpenCode config](/docs/reference/config-boundary). --- <!-- /markdown/docs/reference/config-boundary.md --> # Kortix vs OpenCode config The strict ownership boundary between the manifest and the .kortix/opencode/ config directory. Canonical page: https://kortix.com/docs/reference/config-boundary A Kortix project's config has two owners, and the boundary is strict. - **Kortix owns** the manifest (`kortix.yaml`) and `.kortix/Dockerfile` — what a project is, its sandbox, secrets, triggers, and which agents the platform may launch/authorize. - **OpenCode owns** `.kortix/opencode/` — how the runtime behaves. `opencode.jsonc` remains the native registry for plugins, MCP servers, providers, models, permissions, and default OpenCode behavior. The config dir is declared via `opencode.config_dir` (default `.kortix/opencode`); the agent daemon launches OpenCode with `OPENCODE_CONFIG_DIR` pointed there. In `kortix.yaml`, `agents:` is a map keyed by agent name, and it is **governance only** — `connectors`, `secrets`, `kortix_cli`, `skills`, `workspace`, `enabled`. There is no `model`, `mode`, `description`, `permission`, or `prompt` field on the manifest side at all: every behavioral concern lives in that agent's own `.kortix/opencode/agents/<name>.md` frontmatter and body. The agent's *name* is the only join between the two files — the manifest key must match the `.md` filename. This is a stricter split than it sounds: there's no field on either side that can express the other's concern, so there's nothing to accidentally duplicate. `agents:` is also deny-by-default in `kortix.yaml`: an agent with no `connectors`/`secrets`/`kortix_cli`/`skills` key gets none of them. Any agent name not declared in the map is fully denied — no connectors, no `kortix_cli` powers, no secrets — though it still runs whatever its `.md` file says. > **Legacy: kortix.toml** > v1 projects (`kortix.toml`) express the same governance idea with `[[agents]]` — an array of tables instead of a name→block map, secrets are called `env` instead of `secrets`, and defaults run the other way: an omitted field on a declared agent falls back to permissive per-field defaults rather than "none," and a project with **no** `[[agents]]` section at all skips governance entirely (every agent inherits the launching user's full role). See [kortix.toml](/docs/reference/manifest) for the v1 field table. ## Ownership table | Concern | Lives in | Read by | | --- | --- | --- | | Project metadata (`name`, `description`) | `kortix.yaml` `project:` | Kortix | | Env / secrets spec | `kortix.yaml` `env:` | Kortix | | Sandbox base image | `kortix.yaml` `sandbox:` + `.kortix/Dockerfile` | Kortix | | Where the OpenCode config dir lives | `kortix.yaml` `opencode.config_dir` | Kortix | | Triggers (cron + webhook) | `kortix.yaml` `triggers:` | Kortix | | Platform-launchable agents and grants | `kortix.yaml` `agents:` (governance only) | Kortix | | Channels | Not in the manifest — dashboard/CLI-managed (`kortix channels`). The connector itself (`provider: channel`) is still declared under `connectors:` once you connect one. | Kortix (live routing is the `chat_channel_bindings` table) | | Agent personas | `.kortix/opencode/agents/<name>.md` | OpenCode | | Skills | `.kortix/opencode/skills/` | OpenCode | | Slash commands | `.kortix/opencode/` (commands) | OpenCode | | Custom tools | `.kortix/opencode/` (tools) | OpenCode | | Plugins | `.kortix/opencode/` (plugins) | OpenCode | | MCP servers | `.kortix/opencode/opencode.jsonc` | OpenCode | | Model / provider config | `.kortix/opencode/opencode.jsonc` | OpenCode runtime; product model pickers should come from the server / LLM gateway catalog | > **Rule of thumb** > If it describes the project, the sandbox, when work runs, or what the platform is allowed to launch/authorize, it belongs in `kortix.yaml`. If it describes how OpenCode executes that agent, it belongs under `.kortix/opencode/`. Full manifest field reference: [the manifest](/docs/reference/manifest). The `.kortix/opencode/` directory follows OpenCode's standard layout; see [opencode.ai/docs](https://opencode.ai/docs/) for each primitive. ## Where the agent runs The agent runtime is OpenCode, and **Kortix runs it in the cloud sandbox** — the sandbox agent server launches OpenCode with `OPENCODE_CONFIG_DIR` set to your `config_dir`. There is no Kortix command that runs the agent on your machine; the [CLI](/docs/reference/cli) is a cloud control plane, not a local runner. Because `.kortix/opencode/` uses OpenCode's standard layout, the personas, skills, and tools are portable: install the upstream `opencode` binary and point its config dir at that folder and it reads the same files. New files under `.kortix/opencode/` reach future sandbox sessions only after a [change request](/docs/reference/change-requests) merges them to `main`. --- <!-- /markdown/docs/reference.md --> # Reference The precise, technical reference for the Kortix manifest, CLI, runtime, and config boundary. Canonical page: https://kortix.com/docs/reference Every field, default, flag, endpoint, and validator. Plain-language versions live in [Core concepts](/docs/concepts) and the [Guides](/docs/guides). - [Manifest (kortix.yaml)](/docs/reference/manifest): Every field, default, and validation rule — v2 (`kortix.yaml`) and legacy v1 (`kortix.toml`). - [CLI reference](/docs/reference/cli): Every `kortix` command, token scopes, env vars, exit codes. - [Self-hosting](/docs/reference/cli#self-host): The `kortix self-host` command surface — one generic Docker system, laptop or any box. - [Self-hosting architecture](/docs/reference/self-hosting-architecture): How the Compose stack fits together: Caddy, the app containers, official Supabase Docker, the in-compose updater. - [Change requests](/docs/reference/change-requests): Data model, lifecycle, diff/merge semantics, CLI, REST API. - [Session runtime](/docs/reference/session-runtime): Lifecycle, branch model, sandbox layout, injected env, daemon. - [Secrets](/docs/reference/secrets): How `project_secrets` flow from the manifest to your sandbox. - [Triggers](/docs/reference/triggers): Cron and signed-webhook entries — fields, template vars. - [Sandbox image](/docs/reference/sandbox-image): The layered Docker image, what Kortix injects, rebuilds. - [Kortix vs OpenCode config](/docs/reference/config-boundary): The ownership boundary between the manifest and `.kortix/opencode/`. --- <!-- /markdown/docs/reference/manifest.md --> # Manifest reference The project manifest — every table, field, default, and validation rule, for both kortix.yaml (v2) and kortix.toml (v1, legacy). Canonical page: https://kortix.com/docs/reference/manifest The one file the platform treats as authoritative for a project. For the plain-language version, see [Projects](/docs/concepts/projects). The manifest lives at the repo root — `kortix.yaml` (v2, the current default for new projects), its `kortix.yml` short-extension sibling, or `kortix.toml` (v1, legacy, still fully supported for existing projects). Any repo with a valid manifest at the root is a Kortix project. When more than one candidate file is present, the platform resolves them in priority order — `.yaml` first, then `.yml`, then `.toml` — and reads the first one it finds. The parser is permissive — it never throws on a bad entry: bad triggers go into an `errors` list alongside the good ones, and unknown top-level keys are ignored (park your own metadata freely). The retired `apps:` section, if present, is rejected with an explicit "Delete this section." error. > **The canonical schema** > Either version, the always-current, machine-checkable spec is the public JSON Schema — generated straight from `@kortix/manifest-schema`, the same package behind `kortix validate` and the CR-merge gate, so it can't drift from what the platform actually enforces: > > - [`kortix.v2.schema.json`](/schema/kortix.v2.schema.json) — `kortix_version: 2` only (current default) > - [`kortix.v1.schema.json`](/schema/kortix.v1.schema.json) — `kortix_version: 1` only (legacy) > - [`kortix.schema.json`](/schema/kortix.schema.json) — both, dispatched by `kortix_version` > > Point a `kortix.yaml`'s first line at it for editor validation + autocomplete: > > ```yaml > # yaml-language-server: $schema=https://kortix.com/schema/kortix.v2.schema.json > kortix_version: 2 > ``` > > Or fetch it from the CLI: `kortix schema --version 2` (add `--url` for just the URL). ## Full example (v2, `kortix.yaml`) ```yaml # yaml-language-server: $schema=https://kortix.com/schema/kortix.v2.schema.json kortix_version: 2 default_agent: kortix project: name: my-project description: What this project is. env: required: [DATABASE_URL] optional: [STRIPE_API_KEY, WEBHOOK_SLACK_SECRET] sandbox: templates: - slug: ml name: ML Development dockerfile: .kortix/Dockerfile.ml # repo-relative; or `image: python:3.12-slim` cpu: 4 memory: 16 opencode: config_dir: .kortix/opencode agents: kortix: # governance ONLY — behavior lives in .kortix/opencode/agents/kortix.md connectors: all secrets: all kortix_cli: all skills: all release-bot: # a scoped specialist connectors: [github] kortix_cli: [project.cr.open] # may open a CR, but not merge it secrets: [GITHUB_AGENT_TOKEN] # only this secret, not every project secret triggers: - slug: daily-digest name: Daily digest type: cron agent: kortix enabled: true cron: "0 0 9 * * 1-5" # 09:00 Mon–Fri timezone: America/Los_Angeles prompt: | Summarize yesterday's commits across the repo. Save the result to notes/digest-{{ fired_at }}.md and open a CR against main. - slug: slack-hook name: Slack handler type: webhook agent: kortix enabled: true secret_env: WEBHOOK_SLACK_SECRET # add value via Secrets Manager prompt: | Slack event from {{ headers.user_agent }}. User said: {{ body.text }} ``` `agents:` is a governance-only map — see [`agents:`](#agents-v2) below. There's no `[[channels]]` in v2 (see [Channels](#channels-removed-in-v2)). ## Legacy example (v1, `kortix.toml`) ```toml # Pinned schema version. Lets the platform evolve safely. kortix_version = 1 [project] name = "my-project" description = "What this project is." [env] required = ["DATABASE_URL"] optional = ["STRIPE_API_KEY", "WEBHOOK_SLACK_SECRET"] [[sandbox.templates]] slug = "ml" # unique per project name = "ML Development" dockerfile = ".kortix/Dockerfile.ml" # repo-relative; or `image = "python:3.12-slim"` cpu = 4 memory = 16 [opencode] config_dir = ".kortix/opencode" [[triggers]] slug = "daily-digest" name = "Daily digest" type = "cron" agent = "kortix" enabled = true cron = "0 0 9 * * 1-5" # 09:00 Mon–Fri timezone = "America/Los_Angeles" prompt = """ Summarize yesterday's commits across the repo. Save the result to notes/digest-{{ cron.fired_at }}.md and open a CR against main. """ [[triggers]] slug = "slack-hook" name = "Slack handler" type = "webhook" agent = "kortix" enabled = true secret_env = "WEBHOOK_SLACK_SECRET" # add value via Secrets Manager prompt = """ Slack event from {{ headers.user_agent }}. User said: {{ body.text }} """ ``` ## Schema version ```yaml kortix_version: 2 ``` `kortix_version` is the required schema version. A manifest declaring a version higher than the platform knows about is rejected outright — the platform won't silently misread future fields. `kortix_version: 2` requires YAML (`.yaml`); a `.toml` file declaring `kortix_version: 2` is a validation error pointing at the migration flow. When the platform writes the manifest back after a dashboard edit, it ensures `kortix_version` is the first key. ### `runtime:` (v2 only) Optional top-level string, v2 only. Today the only legal value is `opencode` — the default when omitted — reserved so a future runtime (e.g. a hypothetical `runtime: claude`) is a one-line manifest change rather than a schema break. It has no other effect yet. ```yaml runtime: opencode ``` ## What's parsed where | Surface | v2 (`kortix.yaml`) | v1 (`kortix.toml`, legacy) | | ----------------- | ---------------------------------------------------------- | ------------------------------------------------------- | | Trigger sweep | `triggers:` | `[[triggers]]` | | Sandbox builder | `sandbox.templates` | `[[sandbox.templates]]` | | Sandbox runtime | `opencode:` (where to launch opencode with its config) | `[opencode]` | | Session bootstrap | `env:` (advisory — surfaced to dashboard, not enforced) | `[env]` | | Connector sync | `connectors:` | `[[connectors]]` | | Agent governance | `agents:` (governance-only name→block map — server-side launch roster + connector/`kortix_cli`/`secrets`/`skills` grants) | `[[agents]]` (array of tables, same grant fields plus dead `model`/`file`) | | Dashboard UI | All of the above + the raw manifest | Same | | *(removed)* | `channels:` does not exist in the v2 schema — see [below](#channels-removed-in-v2) | `[[channels]]` parses and round-trips but no runtime path reads it | ## `project:` / `[project]` Optional, human-facing metadata (`name`, `description`). The platform does **not** currently read this table — a project's display name comes from its own record, not the manifest — so treat it as a convention for people reading the repo. ## `env:` / `[env]` Declares the env var *names* your sessions need — same key in both versions. Values live in the dashboard's Environment variables page (still `project_secrets`/`secrets` under the hood), never inline; the platform decrypts and injects them as plain env vars at session start. (The per-agent *grant* field is what's renamed in v2 — `env` → `secrets` inside each `agents:` block — see [`agents:`](#agents-v2) below.) ```yaml env: required: [DATABASE_URL] optional: [STRIPE_API_KEY] ``` | Field | Type | Notes | | ---------- | ---------- | ---------------------------------------------------------------------- | | `required` | `string[]` | Advisory list — surfaced in the dashboard. Not enforced at session start today. | | `optional` | `string[]` | Available to sessions if set; absence is fine. | > **On enforcement** > The dashboard uses `required` to nag the user about secrets to set, but the session bootstrap does not currently block on missing values. Treat `required` as a contract with the user, not the platform. Manifest name validation is permissive — items match `^[A-Z_][A-Z0-9_]*$` (no length cap). The Secrets Manager API caps names at 64 chars (`^[A-Z_][A-Z0-9_]{0,63}$`), so a longer name is accepted in the manifest but can never get a value. Keep names ≤ 64 chars. The `KORTIX_*` prefix is reserved at the Secrets Manager surface, not at parse time: listing `KORTIX_FOO` in `[env]` parses fine but can never have a value. Don't. Full contract: [Secrets](/docs/reference/secrets). ## `sandbox.templates` / `[[sandbox.templates]]` A list of named, bootable sandbox images; the Kortix runtime layer (opencode CLI, agent daemon, entrypoint) is layered on top of it automatically. Optional — with no entries, every session boots the always-available platform default image. Declare as many templates as you like; a session picks one by `slug`. This is also where you size the sandbox hardware. The schema is provider-neutral: an image comes either from a **Dockerfile in your repo** or a **public Docker image reference**, and Kortix maps the `cpu` / `memory` / `disk` resource spec onto Daytona, Platinum, or E2B. ```yaml sandbox: templates: # From a repo Dockerfile - slug: ml # unique per project; not "default" (reserved) name: ML Development # optional display label dockerfile: .kortix/Dockerfile.ml # repo-relative cpu: 4 # vCPU cores memory: 16 # GiB disk: 50 # GiB # From a public image - slug: python name: Python 3.12 image: python:3.12-slim # must be tag- or digest-pinned cpu: 2 memory: 4 ``` > **v1 (`kortix.toml`, legacy)** > ```toml > [[sandbox.templates]] > slug = "ml" > name = "ML Development" > dockerfile = ".kortix/Dockerfile.ml" > cpu = 4 > memory = 16 > disk = 50 > > [[sandbox.templates]] > slug = "python" > name = "Python 3.12" > image = "python:3.12-slim" > cpu = 2 > memory = 4 > ``` | Field | Type | Default | Notes | | ------------ | ------ | ---------------- | --------------------------------------------------------------------- | | `slug` | string | — | **Required.** Unique per project. `default` is reserved. | | `name` | string | slug | Display label shown in the dashboard picker. | | `dockerfile` | string | — | Repo-relative path. **Mutually exclusive** with `image`. | | `image` | string | — | Public Docker image, tag- or digest-pinned (no bare `latest`). **Mutually exclusive** with `dockerfile`. | | `entrypoint` | string | — | Overrides the container entrypoint the snapshot boots with. Optional — when omitted, the Kortix runtime layer's own entrypoint is used. | | `cpu` | int | provider default | vCPU cores. | | `memory` | int | provider default | RAM in GiB. | | `disk` | int | provider default | Disk in GiB. | Exactly one of `image` or `dockerfile` is required per entry. A Dockerfile path must be repo-relative — absolute paths and `..` traversal are rejected. GPUs are not supported in this version. See [Sandbox image](/docs/reference/sandbox-image) for what the runtime layer injects and what your Dockerfile can do. ### `sandbox.default` — the project-wide default template By default every session boots the platform default image; a session opts into a template by passing its `slug`. Set `default` on `sandbox` to make one of your templates the project-wide default instead — then **every** session (the dashboard's "new session", triggers, channels) boots it without specifying a slug. ```yaml sandbox: default: dev # or "default" for the platform image (the implicit default) templates: - slug: dev dockerfile: .kortix/Dockerfile ``` `default` must name a template defined in this manifest (or the reserved `"default"`). It's the only key allowed alongside `templates` — image/build keys directly on `sandbox` are the removed legacy singular shape and are rejected. ### Hardware spec `cpu` / `memory` / `disk` size the sandbox. Each is independent and optional — leave one out and the runtime provider's default applies. Values are coerced to whole numbers; a value below 1 falls back to the default, and a value above the platform ceiling (cpu 32, memory 128 GiB, disk 500 GiB) is clamped down rather than rejected. > **A spec change rebuilds the snapshot** > The spec is baked into the project's snapshot at build time — sandboxes inherit their resources from the snapshot, so there's no per-session override. Changing any spec field is part of the snapshot's content hash, so it triggers one rebuild; the new size takes effect on the **next** session, the same way a Dockerfile edit does. Projects that don't declare a spec are unaffected — their snapshot hash is unchanged. ## `opencode:` / `[opencode]` Where OpenCode's config dir lives. Optional, with a default. The agent daemon launches opencode with `OPENCODE_CONFIG_DIR` pointed here. ```yaml opencode: config_dir: .kortix/opencode ``` | Field | Type | Default | Notes | | ------------ | ------ | ------------------ | ------------------------------------------------------ | | `config_dir` | string | `.kortix/opencode` | Repo-relative dir. Same silent-fallback behavior as `sandbox.templates` paths. | That directory holds agents, skills, slash commands, tools, plugins, and `opencode.jsonc` — the layout opencode reads by convention. OpenCode owns the runtime semantics; Kortix may inspect metadata to build server-side agent/model UI surfaces. See [Kortix vs OpenCode config](/docs/reference/config-boundary) and [opencode.ai/docs](https://opencode.ai/docs/). `opencode.jsonc` remains the OpenCode-native registry for plugins, MCP servers, providers, model/provider settings, permissions, and default runtime behavior. Do not duplicate those details in the manifest. Use `agents:` (v2) / `[[agents]]` (v1) for the Kortix-side decision of which agents are launchable and what server-side grants they receive — in v2, EVERY behavioral field (model, mode, tools, permission, the prompt itself) lives in the agent's own `.md` frontmatter instead, never in the manifest. Legacy v1 projects with no `[[agents]]` keep OpenCode-native discovery for backward compatibility. Projects on `agents:` (v2) or `[[agents]]` (v1) opt into declarative, server-side agent discovery: product UI should use the API's registered agents, while unregistered OpenCode files may still exist for local experiments or runtime internals. Model pickers should similarly come from the server / LLM-gateway catalog rather than a sandbox-local OpenCode provider list. ## `triggers:` / `[[triggers]]` Each entry fires a session that runs `prompt` as its initial message — a fresh session by default, the trigger's prior session when `session_mode: reuse`, or a specific session first when `session_mode: pinned` + `session_id` is set (see below). Sorted alphabetically by slug in the parsed output, so UI ordering is stable. Full reference: [Triggers](/docs/reference/triggers). ```yaml triggers: - slug: daily-digest type: cron cron: "0 0 9 * * 1-5" prompt: Summarize yesterday's commits. ``` ### Common fields | Field | Required | Type | Default | Notes | | -------------- | -------- | ------ | ----------- | -------------------------------------------------------------- | | `slug` | yes | string | — | `[a-z0-9][a-z0-9_-]{0,127}`, unique among triggers. | | `type` | yes | string | — | `"cron"` or `"webhook"`. | | `prompt` | yes | string | — | Mustache-style template. | | `name` | no | string | `slug` | Human label. | | `agent` | no | string | `"default"` | Agent name. Legacy projects resolve this through OpenCode discovery; declarative projects should use a registered `[[agents]]` name. | | `enabled` | no | bool | `true` | When `false`, the scheduler / receiver skip the entry. | | `model` | no | string | — | Wire form `provider/model` (e.g. `anthropic/claude-sonnet-5`). Pins the fired session to that model, taking precedence over the agent/account/platform default chain. Unlike the dead v1 `[[agents]].model`, this one is live. | | `session_mode` | no | string | `"fresh"` | `"fresh"`, `"reuse"`, or `"pinned"` (camelCase `sessionMode` also accepted). `"reuse"` continues the trigger's existing session on each fire; `"pinned"` first tries the exact `session_id`. | | `session_id` | for `pinned` | string | — | Exact project session to re-prompt when `session_mode` is `"pinned"` (camelCase `sessionId` also accepted). | Cron-only: `cron` (6-field croner `second minute hour day month weekday`) or a one-off `run_at` (camelCase `runAt`, an ISO-8601 datetime, e.g. `2026-06-01T09:00:00Z`) — exactly one of the two is required; `run_at` fires the trigger once at that instant instead of on a recurring schedule. `timezone` (IANA name, default `"UTC"`) applies to `cron`. Webhook-only: `secret_env` (name of a `project_secrets` entry holding the HMAC secret). The parser accepts only canonical trigger field names. Slug uniqueness is per-section — a trigger and an app may share a slug; two triggers may not. ## Channels — removed in v2 [#channels-removed-in-v2] **v2 removes `channels:` from the schema outright.** Channel↔agent routing (Slack and Microsoft Teams today; Discord/etc. later) is live operational state — the dashboard's Channels page or chat slash commands — not declarative config, the same boundary rule that keeps credentials out of git. The channel *integration* itself still shows up as a `connectors` entry with `provider: channel` once you connect one; only the routing table moved out of the manifest. ### `[[channels]]` (v1, legacy) Array of tables. Each entry documents a chat platform (Slack or Microsoft Teams today) connected to the project. **Validated but not wired to any runtime behavior** — declaring one, or editing `enabled`/`agent`/`events`/`prompt_prefix`, changes nothing about how the platform actually routes messages. The live channel→agent binding is the `chat_channel_bindings` database row, set from the chat app's commands or the dashboard's Channels page, not from this file. ```toml [[channels]] platform = "slack" enabled = true # optional, default true agent = "kortix" # optional — parsed, not consumed events = ["mention", "dm"] # optional, array of strings — parsed, not consumed prompt_prefix = """ You're working on {{ project.name }}. {{ message.text }} """ ``` | Field | Required | Type | Default | Notes | | --------------- | -------- | ---------- | ------- | ---------------------------------------------------------------------- | | `platform` | yes | string | — | e.g. `"slack"`. One entry per platform per project — a duplicate `platform` is a validation error. | | `enabled` | no | bool | `true` | Validated as a boolean. Not read by any runtime path. | | `agent` | no | string | — | Parsed, not validated, not consumed. | | `events` | no | `string[]` | — | Parsed as an array of strings. Not consumed. | | `prompt_prefix` | no | string | — | Parsed. Not consumed. | > **Declared but inert** > Connecting Slack or Microsoft Teams from the dashboard writes the install into the project's live channel state and creates the `chat_channel_bindings` row — that row, not this manifest block, decides which agent answers in which channel. A hand-written `[[channels]]` entry documents intent for readers of the repo but has no effect on running behavior. Manage channel routing from the chat app's commands or the dashboard's Channels page. ## `connectors:` / `[[connectors]]` Each entry connects an external tool the agent can call. The definition lives in git; credentials live in the platform, never here. Full model: [Connections](/docs/concepts/connections). ```yaml connectors: - slug: gmail-work provider: pipedream # pipedream | mcp | openapi | graphql | http | channel app: gmail # pipedream app slug credential: shared # shared is the only mode — see below policies: - match: "*" action: require_approval # always_run | require_approval | block ``` | Field | Required | Type | Notes | | ------------ | -------- | ------ | ------------------------------------------------------------------ | | `slug` | yes | string | `[a-z0-9][a-z0-9_-]{0,127}`, unique among connectors; the tool namespace. | | `provider` | yes | string | `pipedream` \| `mcp` \| `openapi` \| `graphql` \| `http` \| `channel`. | | `name` | no | string | Display name. Defaults to slug. | | `enabled` | no | bool | Defaults to `true`. | | `credential` | no | string | `shared` is the only mode and the default. `per_user` (per-member BYO credential) was removed 2026-07-05 — a legacy manifest that still says `credential: per_user` is tolerated and always resolves to `shared`. | Provider-specific fields: - **pipedream** — `app` (required), `account` (optional, defaults to slug). - **mcp** — `url` (required), `transport` = `http` (default) or `sse`. - **openapi** — `spec` (required: URL or repo-relative path). - **graphql** — `endpoint` (required), `spec` (optional SDL). - **http** — `base_url` (required), `spec` (optional). - **channel** — `platform` (required) = `slack` \| `teams` \| `email` \| `meet`. Chat platforms. You rarely declare these by hand — connecting one ([Channels](/docs/concepts/channels)) **auto-materializes** the connector (credential = the install token, resolved server-side), so the agent can call it through the Executor and you manage it in Connectors. - **computer** — connected machines over the Agent Computer Tunnel. **Synth-only: you cannot declare it by hand.** Connecting a machine ([Computers](/docs/concepts/computers)) auto-materializes a single `computer` connector that fronts all your machines (it has no credential — the live tunnel is the credential; per-machine access is granted in Computers). Reserved slugs: `kortix_slack`, `kortix_teams`, `kortix_email`, `kortix_meet` (each locked to `provider: channel`) and `computer` (locked to `provider: computer`) are platform-owned — declaring one of these slugs with any other provider is a validation error, the same rule that keeps `computer` synth-only above. ### `connectors.auth` Optional. `type` = `bearer | basic | custom | none` (default `none`); `in` = `header` (default) or `query`; `name` (required when `type = "custom"`); `prefix` (optional). The credential value is stored in the platform connector credential store, not in the manifest. Auth is not allowed with `provider: pipedream`. ### `connectors.policies` / `[[connectors.policies]]` Optional per-tool gates. `match` (glob over tool names, required) + `action` = `always_run | require_approval | block` (required). ## `agents:` (v2) [#agents-v2] A name→block **map**, keyed by agent name — **governance only**. There is no `model`/`mode`/`description`/`permission`/`prompt` here at all: every behavioral field lives in that agent's own OpenCode `.md` file under `.kortix/opencode/agents/<name>.md` (frontmatter + body, a stock OpenCode agent file — no Kortix-specific split). The map key is the join to that `.md`'s filename. `default_agent` (top-level, required in v2) must name a declared, enabled agent here. ```yaml default_agent: kortix agents: kortix: # the default general-purpose agent connectors: all # every connector profile secrets: all # every project secret kortix_cli: all # full Kortix CLI/API powers (∩ the launching user's role) skills: all release-bot: # a scoped specialist connectors: [github] kortix_cli: [project.cr.open] # may open a CR, but not merge it secrets: [GITHUB_AGENT_TOKEN] # only this secret, not every project secret ``` | Field | Required | Type | Default (v2) | Notes | | ------------ | -------- | ------------------------------ | --------------- | ---------------------------------------------------------------------- | | *(map key)* | yes | string | — | `[a-z0-9][a-z0-9_-]{0,127}`, unique per project. Matches the OpenCode `.md` filename it governs. | | `enabled` | no | bool | `true` | `false` treats the name as undeclared — default-deny. | | `connectors` | no | `string[] \| "all" \| "none"` | **`none`** | Which connector slugs this agent may call. `"all"` = every connector profile not scoped away from it. | | `secrets` | no | `string[] \| "all" \| "none"` | **`none`** | Project secret names this agent receives as sandbox env vars and may read via the secrets API. Renamed from v1's `env`. | | `kortix_cli` | no | `string[] \| "all" \| "none"` | **`none`** | Which Kortix CLI/API actions (open/merge CRs, manage triggers, spawn sessions, manage connectors, …) this agent may perform. `"all"` = everything the *launching user* can do. Effective grant is always `userRole ∩ agentGrant` — an agent can never exceed its launcher. Run `kortix validate --scopes` for the full grantable-action list. | | `skills` | no | `string[] \| "all" \| "none"` | **`none`** | Which skills this agent may load. Folds onto the compiled OpenCode `permission.skill`. | | `workspace` | no | — | — | Reserved for the git-boundary work (workspace/git powers) — not yet enforced. | v2 is **deny-by-default**: an omitted `connectors`/`secrets`/`kortix_cli`/`skills` on a declared agent resolves to `none`, the opposite of v1's per-field defaults. Give an agent every grant explicitly (as the starter's `kortix` agent does above) if it should keep full access. ## `[[agents]]` (v1, legacy) Array of tables. Optional — with no `[[agents]]` section, every session is capped only by the launching human's role (the project hasn't adopted per-agent governance). Adding **any** `[[agents]]` entry opts the whole project into declarative, server-side agent governance: any agent name not listed here is then default-denied — it still runs its OpenCode `.md` behavior, but with no connectors, no `kortix_cli` powers, and no project secrets. An agent's *behavior* (prompt, mode, tools, permission tree) still lives entirely in its OpenCode `.md` file under `.kortix/opencode/agents/`. `[[agents]]` is a governance overlay on top of that: which agent names the platform will launch, and what each one may touch. ```toml [[agents]] name = "kortix" # your default general-purpose agent kortix_cli = "all" # full Kortix CLI/API powers (∩ the launching user's role) connectors = "all" # every connector profile [[agents]] name = "release-bot" # a scoped specialist connectors = ["github"] kortix_cli = ["project.cr.open"] # may open a CR, but not merge it env = ["GITHUB_AGENT_TOKEN"] # only this secret, instead of every project secret ``` | Field | Required | Type | Default | Notes | | ------------ | -------- | ------------------------------ | --------------- | ---------------------------------------------------------------------- | | `name` | yes | string | — | `[a-z0-9][a-z0-9_-]{0,127}`, unique per project. Matches the OpenCode `.md` filename it governs. | | `enabled` | no | bool | `true` | `false` treats the name as undeclared — default-deny once the project has adopted `[[agents]]`. | | `connectors` | no | `string[] \| "all" \| "none"` | none (`[]`) | Which `[[connectors]]` slugs this agent may call. `"all"` = every connector profile not scoped away from it. | | `kortix_cli` | no | `string[] \| "all" \| "none"` | none (`[]`) | Which Kortix CLI/API actions this agent may perform. `"all"` = everything the *launching user* can do. Effective grant is always `userRole ∩ agentGrant`. Run `kortix validate --scopes` for the full grantable-action list. | | `env` | no | `string[] \| "all" \| "none"` | **`"all"`** | Project secret names this agent receives as sandbox env vars and may read via the secrets API. Unlike `connectors`/`kortix_cli`, omitting `env` defaults to **all** project secrets in v1 — the opposite of v2's deny-by-default. Renamed `secrets` in v2. | | `model` | no | string | — | Wire form `provider/model` (e.g. `anthropic/claude-sonnet-5`). **Dead — parsed and round-tripped, but never applied at runtime**; the effective model comes from session/trigger model preferences. Removed outright in v2 (lives in the agent's `.md` instead). | | `file` | no | string | conventional `.md` by name | Override path to the agent's OpenCode behavior file. **Dead — parsed but not currently used** during materialization. Removed outright in v2. | > **The `default` sentinel (v1 only)** > In v1, a trigger, channel, or session that names no agent (or names the literal `"default"`) does **not** resolve to a declared `[[agents]]` entry — no agent is ever named `default`. It resolves to OpenCode's own configured default agent (conventionally `kortix`) and is treated as non-binding: full access, capped only by the launching user's role, exactly as if the project had no `[[agents]]` at all. **v2 removes this ambiguity**: `default_agent` is a required top-level key that must name a declared, enabled agent — there's no unbound sentinel. > **Two v1 fields don't do anything** > `model` and `file` round-trip through a v1 manifest (dashboard edits preserve them) but neither is consulted by the runtime. Don't rely on them to change what a session actually does. Both are removed outright in v2 (that behavior lives in the agent's own `.md` file). ## Round-trip rules Dashboard manifest edits are a read-modify-write on the same file. To keep the diff clean across UI and in-session edits: - Keep `kortix_version` as the first key. - Inside a trigger entry (`triggers:` in v2, `[[triggers]]` in v1), write fields in this order: `slug`, `name`, `type`, `agent`, `enabled`, then type-specific fields, then `prompt` last. - If you add a webhook trigger before its secret is set, declare the secret name in `env.optional` so it shows up in the dashboard's Environment variables page, and leave the trigger `enabled: false` until the value is in. --- <!-- /markdown/docs/reference/sandbox-image.md --> # Sandbox image The layered Docker image, what Kortix injects, constraints, examples, and snapshot rebuilds. Canonical page: https://kortix.com/docs/reference/sandbox-image The sandbox base image. For how a session uses it at runtime, see [Session runtime](/docs/reference/session-runtime). Every session boots inside a Docker container, built in two parts: **your Dockerfile** defines the environment (languages, tools, system packages), and the **Kortix runtime layer** is auto-injected on top so the dashboard can connect. ``` ┌─────────────────────────────────────────┐ │ Kortix runtime layer (auto-injected) │ ← opencode + kortix-agent + ENTRYPOINT ├─────────────────────────────────────────┤ │ Your Dockerfile │ ← .kortix/Dockerfile └─────────────────────────────────────────┘ ``` Referenced from the manifest as a named template under `sandbox.templates` (v2 `kortix.yaml`) or `[[sandbox.templates]]` (legacy v1 `kortix.toml`): ```yaml # kortix.yaml (v2) sandbox: templates: - slug: dev # any slug except the reserved "default" name: Dev box # optional label dockerfile: .kortix/Dockerfile # OR image: python:3.12-slim cpu: 2 # optional — vCPU cores memory: 4 # optional — GiB disk: 20 # optional — GiB # Make a template the project-wide default so every session (UI, triggers, # channels) boots it without passing a slug. Omit → the platform default. default: dev ``` ```toml # kortix.toml (legacy v1) — same fields, TOML array of tables [[sandbox.templates]] slug = "dev" # any slug except the reserved "default" name = "Dev box" # optional label dockerfile = ".kortix/Dockerfile" # OR image = "python:3.12-slim" cpu = 2 # optional — vCPU cores memory = 4 # optional — GiB disk = 20 # optional — GiB [sandbox] default = "dev" ``` Each entry sets exactly one of `dockerfile` or `image`. Paths must be repo-relative — absolute paths and `..` traversal are rejected. A singular `sandbox` block with image/build keys on it directly (rather than under `templates`) is **legacy and rejected**; image definitions live under `sandbox.templates`, and `sandbox` itself otherwise only carries `default`. Field details: [the manifest reference](/docs/reference/manifest#sandboxtemplates--sandboxtemplates). ## What the Kortix layer injects On top of your Dockerfile's final stage, the snapshot builder appends: 1. `USER root` (the layer needs to install things). 2. `apt-get install` a system package floor: `ca-certificates curl git gzip nodejs npm unzip tmux iproute2 iputils-arping build-essential ffmpeg fonts-dejavu fonts-liberation fonts-noto fonts-noto-cjk latexmk libreoffice pandoc pkg-config poppler-utils qpdf tesseract-ocr python3 python3-dev python3-pip python3-venv texlive-bibtex-extra texlive-fonts-recommended texlive-latex-base texlive-latex-extra texlive-latex-recommended` — CLI/networking basics, a build toolchain, LibreOffice/LaTeX/Pandoc document conversion, OCR, and a Python 3 toolchain. 3. A Python package floor via `pip install --break-system-packages` with lower-bound (`>=`) version constraints: web/parsing (`beautifulsoup4`, `lxml`, `markdownify`, `markitdown[pptx]`, `requests`), data (`numpy`, `pandas`, `scipy`, `scikit-learn`), PDF/office (`pdf2docx`, `pdf2image`, `pdfplumber`, `pymupdf`, `pypdf`, `pypdfium2`, `python-docx`, `python-pptx`, `openpyxl`, `reportlab`), imaging/browser (`pillow`, `pytesseract`, `playwright`), plotting (`matplotlib`, `plotly`, `seaborn`), and media/transcripts (`youtube-transcript-api`). The build verifies representative imports from that floor before the layer succeeds. This backs the starter's document/data/PDF/presentation skills and is present even if your Dockerfile never touches Python. 4. `npm install -g opencode-ai@<pinned-version>`, plus the `bun` runtime and `agent-browser` (for the headless-browser tool). 5. A baked OpenCode tool-dependency cache under `/opt/kortix/` (so the boot-time `bun install` in your config dir is a no-op, not a network round-trip). 6. `COPY` the `kortix-agent` daemon, the `kortix` CLI, and the `kortix-entrypoint` script to `/usr/local/bin/`, plus the `slack-cli` and `executor-sdk` package trees under `/opt/kortix/`. 7. `ENV KORTIX_WORKSPACE=/workspace`, `WORKDIR /workspace`, `EXPOSE 8000`. 8. `ENTRYPOINT ["/usr/local/bin/kortix-entrypoint"]`. Everything you installed stays on `PATH`; the layer relocates and removes nothing. The exact package lists above are pinned to a runtime layer version and can change between platform releases — they're the floor guaranteed on every sandbox, not a ceiling on what you can add. ## Constraints These rules keep a sandbox connectable. They aren't enforced statically — your build succeeds even if you violate them — but the session won't behave correctly. | Rule | Why | | --- | --- | | Don't set `ENTRYPOINT`. | Kortix overrides it. Your `CMD` is also ignored. | | Don't claim port `8000`. | Reserved for the daemon's reverse proxy. Run your dev servers on other ports. | | `FROM` a Debian/Ubuntu-family base. | The layer assumes `apt-get`. Alpine, Fedora, Arch will fail at layer build. | | Don't `RUN apt-get clean` without `rm -rf /var/lib/apt/lists/*`. | The Kortix layer re-runs `apt-get update`; a broken lists cache breaks it. | | Don't bake credentials into the image. | Declare the name in `env:` (or legacy `[env]`) and set the value in the dashboard's Environment variables page — it's injected at session start. | Everything else is fair game: `RUN curl … | sh` for toolchains, COPY seed data, set `ENV` for non-secret config, install any apt/npm packages. ## Examples The starter ships no Dockerfile — `sandbox.templates` is a commented-out example in `kortix.yaml`, so a fresh project boots the platform's default image (bare Ubuntu plus the Kortix runtime layer above): ```dockerfile # syntax=docker/dockerfile:1.7 # Kortix platform default sandbox base. FROM ubuntu:24.04 WORKDIR /workspace ``` Add your own `.kortix/Dockerfile` and declare it under `sandbox.templates` once you need packages beyond the runtime floor. A minimal custom base might look like this: ```dockerfile # syntax=docker/dockerfile:1.7 FROM ubuntu:24.04 RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates curl git build-essential \ && rm -rf /var/lib/apt/lists/* WORKDIR /workspace ``` A heavier example with Python + Bun: ```dockerfile FROM ubuntu:24.04 RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl git python3.12 python3.12-venv unzip \ && rm -rf /var/lib/apt/lists/* \ && curl -fsSL https://bun.sh/install | bash ENV PATH="/root/.bun/bin:${PATH}" WORKDIR /workspace ``` ## Hardware spec `cpu`, `memory`, and `disk` on a `sandbox.templates` entry size the box your sessions run on. All three are optional; an omitted field takes the runtime provider's default. `cpu` is vCPU cores, `memory` and `disk` are GiB. GPUs are not supported in this version — a `gpu` key is rejected by the manifest validator. ```yaml # kortix.yaml (v2) sandbox: templates: - slug: big image: ubuntu:24.04 cpu: 4 memory: 8 disk: 50 ``` The spec is **baked into the snapshot**, not set per-session: the provider builds each sandbox from your project's snapshot and inherits its size from there. That has one practical consequence — changing the spec rebuilds the snapshot (it's part of the content hash, below) and the new size applies on the **next** session, exactly like a Dockerfile edit. Values are rounded to whole numbers; a non-positive value falls back to the default, and anything above the platform ceiling (cpu 32, memory 128 GiB, disk 500 GiB) is clamped down. See [the manifest → `sandbox.templates`](/docs/reference/manifest#hardware-spec) for the full field table and aliases. ## Snapshot rebuilds Snapshots are content-addressed: the platform hashes your Dockerfile's own bytes + the hardware spec + a platform runtime fingerprint (opencode version + `kortix-agent` binary). Unchanged hash reuses the snapshot; otherwise a new one builds. Rebuilds happen on any push that changes the Dockerfile's text, or any change to the `sandbox` spec — pure code commits, and edits to files your Dockerfile `COPY`s in without touching the Dockerfile itself, reuse the snapshot for free (the hash isn't computed over those copied files today). Builds run on the selected sandbox provider through the same adapter contract: Daytona snapshots, Platinum templates, or E2B templates. The first session on a new content hash triggers an inline build (a few minutes, shown as "preparing image"); later sessions on that provider and content hash hit its cache. Editing the Dockerfile inside a session takes effect on the **next** session; current ones keep their booted snapshot. The edit reaches `main` only once a [change request](/docs/reference/change-requests) merges it. --- <!-- /markdown/docs/reference/secrets.md --> # Secrets How project_secrets flow from the manifest to your sandbox. Canonical page: https://kortix.com/docs/reference/secrets The technical contract for secrets. For a walkthrough, see [Managing secrets](/docs/guides/managing-secrets). Secrets are per-project, encrypted at rest, and injected into every session as plain environment variables. They live in the platform's database (`project_secrets` table), never inline in your repo. Encryption: AES-256-GCM with HKDF-derived per-project keys rooted in the platform's master `API_KEY_SECRET`. Rotating the master key rotates every project's effective key without re-encrypting individual records. ## How they flow 1. **Declare a secret name** in the manifest: ```yaml # kortix.yaml (v2) env: required: [DATABASE_URL] optional: [STRIPE_API_KEY, WEBHOOK_SLACK_SECRET] ``` ```toml # kortix.toml (legacy v1) — same fields, TOML table [env] required = ["DATABASE_URL"] optional = ["STRIPE_API_KEY", "WEBHOOK_SLACK_SECRET"] ``` 2. **Set the value** in the Kortix dashboard's Environment variables page — or via `kortix secrets set` / `kortix env push`. 3. **When a session boots**, the platform decrypts every secret on the project and injects them as plain env vars into the sandbox. 4. **Your agent reads them** like any other env var (`process.env.DATABASE_URL`, `os.environ['DATABASE_URL']`, etc.). ## `required` vs `optional` | List | Effect | | --- | --- | | `required` | Advisory. The dashboard surfaces it to nag the user about secrets to set. The session bootstrap does not currently block on missing values. | | `optional` | Surfaced in the dashboard's Environment variables page so users can set them; missing is fine. | Treat `required` as a contract with the user, not a hard gate. Prefer `optional` for things with a sensible default-off behavior in your agent code. ## Identifier vs key Every secret row has two names: - **`identifier`** — the stable, unique-per-project handle. It's what you list in an agent's `secrets:` grant and what the dashboard shows as the secret's name. Format: `^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$` (letters, digits, `_`, `.`, `-`, starting with an alphanumeric, max 128 chars) — more permissive than the env-var key. - **`key`** (the manifest's `name`) — the actual env var injected into the sandbox (`process.env.KEY`). This is the env-var-shaped, ≤ 64-char name described in [Rules](#rules) below. For the common case (and every legacy/migrated secret) `identifier === key`, so the distinction is invisible — `secrets: [GITHUB_AGENT_TOKEN]` grants the identifier `GITHUB_AGENT_TOKEN`, which injects as `GITHUB_AGENT_TOKEN`. Identifiers only start to matter once a project has **multiple identifiers sharing one key** — e.g. `GMAPS-primary` and `GMAPS-backup` both resolving to `GOOGLE_MAPS_API_KEY`, so you can hold two candidate values under distinct handles while only one is ever live in the sandbox at a time: - If an agent's grant is `all` (or omitted entirely in a way that resolves to all), a collision picks a deterministic winner — identifier sort order — rather than erroring. - If an agent's grant is an **explicit list** that names two identifiers colliding on the same key, that's a configuration error: injection fails with an ambiguous-grant error naming the key and the conflicting identifiers, since there's no principled way to silently pick one for a deliberate list. ## Per-agent scoping (v2) In `kortix.yaml`, each entry in the `agents:` map has a `secrets` grant — which of the project's declared identifiers that agent receives as sandbox env vars. It's deny-by-default: an agent with no `secrets` key gets none, not every project secret. Give it `secrets: all` for the full set, or an explicit list to scope it down: ```yaml agents: kortix: secrets: all # every declared secret release-bot: secrets: [GITHUB_AGENT_TOKEN] # only this one ``` > **v1 default runs the other way** > In legacy `kortix.toml`, the equivalent field is named `env` (not `secrets`) and an **omitted** `env` on a declared `[[agents]]` entry defaults to **all** project secrets, not none — `env` was added after `connectors`/`kortix_cli` and had to default open so agents declared before it existed didn't silently lose access. Narrow it explicitly with a list or `"none"`. v2's `secrets` field starts closed instead. ## Rules - Names (keys) match `^[A-Z_][A-Z0-9_]{0,63}$` — env-var-shaped, capped at 64 chars. The manifest parser is looser (`^[A-Z_][A-Z0-9_]*$`, no length cap), so a name longer than 64 chars is accepted in `env:` (or `[env]`) but can never get a value. Keep names ≤ 64 chars. - Identifiers match `^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$` — see [Identifier vs key](#identifier-vs-key). Each identifier is unique per project (among shared, non-personal rows); re-creating an existing identifier with a different key is rejected rather than silently retargeting it. - The `KORTIX_*` prefix is reserved for platform variables; user secrets cannot use it. The secrets CRUD endpoint rejects it. The manifest parser does not enforce this, so declaring `KORTIX_FOO` as a secret name is accepted but no matching secret can be created. Don't. - Webhook triggers reference signing secrets by name only (`secret_env: WEBHOOK_FOO_SECRET`). The value is resolved at fire-time — the manifest never sees the plaintext. See [Triggers](/docs/reference/triggers). - Never commit a secret value into the repo. If you do by accident, rotate it via the dashboard and force-push a cleaned history. ## Rotation timing Secrets don't only take effect at session-create time. Creating, updating, or deleting a project secret (dashboard, `kortix secrets set`, or the API — including deletes and personal overrides) fires a live push to **every currently active sandbox on the project**, so a running session usually doesn't need to be restarted to pick up the change: 1. The platform re-resolves that sandbox's env snapshot (honoring the running agent's `secrets` grant) and POSTs it to the sandbox daemon's internal `/kortix/env` endpoint. 2. The daemon writes the full snapshot to the live agent env file immediately, which every new shell the agent spawns sources — so new tool calls see the new (or removed) value right away. 3. For gateway/provider-credential keys specifically — the ones that affect which LLM the sandbox talks to — the same push also restarts the in-sandbox `opencode` process, so the very next prompt in the session picks up the new credential without the user having to end the session and start a new one. Non-credential secrets update the shell env but don't force an opencode restart. > **Best-effort, not guaranteed** > The push is fire-and-forget: the API call that changed the secret returns before the sandbox push completes, and a failed push (sandbox unreachable, timeout, etc.) is only logged, not retried. If it fails, that sandbox keeps stale values until the next successful sync or until the session restarts. Treat propagation as fast (seconds) rather than instantaneous or guaranteed. ## Inspecting what's set The dashboard's Environment variables page (and `kortix secrets ls`) shows which secrets are declared in the manifest (`required` or `optional`) and which have a value set, marking required-but-missing names. --- <!-- /markdown/docs/reference/self-hosting-architecture.md --> # Self-hosting architecture How the generic Docker self-host stack fits together — one box, Caddy, the app containers, official Supabase Docker, and the in-compose updater. Canonical page: https://kortix.com/docs/reference/self-hosting-architecture **Kortix self-host is VPS-first.** The supported production deployment is your own VPS/server with a persistent domain pointed at it — that's the combination that gives you a stable, durable public URL for Caddy/ACME TLS and for agent sandboxes to call back to. A laptop can run the identical artifact for evaluation, but only via a Cloudflare tunnel (ephemeral URL) or loopback-only (no external reachability at all, agent sandboxes will not work) — neither is a deployment target. Architecturally, Kortix self-host is **one generic Docker-native system**, not a family of deployment targets. `kortix self-host init` renders a `docker-compose.yml` + `.env` (plus a `Caddyfile` and `updater.sh` when a domain is configured) into `~/.config/kortix/self-host/<instance>/`, and `kortix self-host start` runs `docker compose up`. The exact same artifact runs on a laptop, any VPS, or a cloud VM (EC2, Droplet, …) — a public domain is just an env var (`KORTIX_DOMAIN`) the same stack reacts to, not a different mechanism. See the [CLI reference](/docs/reference/cli#self-host) for the full command surface and the [self-hosting runbook](https://github.com/kortix-ai/suna/blob/main/docs/runbooks/self-hosting.md) for the VPS-first quickstart and day-2 operations. ## One box, one Compose stack ```mermaid flowchart TB subgraph internet["Internet"] user["Browser / API client"] end subgraph box["One host — laptop, VPS, or cloud VM"] subgraph compose["docker compose (one project per instance)"] caddy["Caddy\n(opt-in — only when\nKORTIX_DOMAIN is set)\nACME HTTP-01 on 80/443"] frontend["frontend"] api["kortix-api"] gateway["llm-gateway"] updater["kortix-updater\n(pull -> migrate -> up -d, on interval)"] subgraph supabase["official Supabase Docker"] kong["supabase-kong"] auth["supabase-auth"] rest["supabase-rest"] storage["supabase-storage"] db[("supabase-db\n(Postgres)")] end end vol_db[("bind mount:\nvolumes/db/data")] vol_storage[("bind mount:\nvolumes/storage")] end subgraph external["Outside the box — managed compute"] daytona["Daytona\n(agent sandboxes)"] registry["docker.io/kortix/*\n(image registry)"] end user -->|"80/443, TLS"| caddy user -.->|"no domain: loopback ports"| frontend caddy -->|"/v1/llm*"| gateway caddy -->|"else"| api caddy -->|"supabase data-plane paths"| kong caddy -->|"else"| frontend frontend --> api api --> gateway api --> kong kong --> auth kong --> rest kong --> storage auth --> db rest --> db storage --> db db --> vol_db storage --> vol_storage updater -->|"docker compose pull"| registry updater -->|"docker compose run migrate\nthen up -d"| compose api -->|"provision + run sessions"| daytona ``` ## What's on the box vs. outside it **On the box** (this Compose stack): - **Caddy** — reverse proxy + ACME TLS. Only rendered into the Compose file at all when `KORTIX_DOMAIN` is configured (`renderFullDockerCompose` in `apps/cli/src/self-host/compose-assets.ts` deletes the service entirely otherwise) — a domain-less instance never even opens 80/443. Routes: `api.<domain>` → `/v1/llm*` to the gateway, else to the API; `<domain>` → the Supabase data-plane path prefixes to Kong, else to the frontend. - **`kortix-api` / `llm-gateway` / `frontend`** — the three application images, `kortix/kortix-api`, `kortix/kortix-gateway`, `kortix/kortix-frontend`, all tracking the same moving tag (channel) or pinned version together. - **Official Supabase Docker** — Kong, Postgres, Auth (GoTrue), PostgREST, Storage, Realtime, Studio, imgproxy, meta, functions, the connection pooler. Vendored from the upstream Supabase self-hosting distribution and image-pinned by digest (see `SUPABASE_IMAGE_DIGESTS` / `image-lock.json` in the same file) — Kortix reviews and locks the Supabase images it ships, independent of the app-image channel. - **`kortix-updater`** — a small `docker:cli` container with the Docker socket mounted. On an interval it pulls this stack's configured image tags, and only if something actually changed: runs the `kortix-migrate` one-shot, then rolls the stack forward (`docker compose up -d --wait`). This is the entire update mechanism — there is no separate updater binary, no systemd timer, no SSM. A `flock` keeps overlapping cycles from racing each other. - **Data** — two bind-mounted directories under the instance directory (`volumes/db/data` for Postgres, `volumes/storage` for Supabase Storage), plus the `.env` holding every secret. See the runbook's Backups section. **Outside the box** (managed compute, unchanged from Kortix Cloud): - **Agent sandboxes** run on Daytona (or another configured `ALLOWED_SANDBOX_PROVIDERS`), reached over egress from `kortix-api`. Sandbox compute never runs on the self-host box itself — the box is light (API, gateway, frontend, Supabase), the heavy compute is external by design. Platinum (dedicated sandbox infrastructure) sits in this same "outside the box" category. - **The image registry** (`docker.io/kortix/*`) the updater and `start` pull from — publicly pullable by digest/tag, no credentials required. ## Channels and the update contract Every instance tracks one of two moving Docker tags — **`stable`** (default) or **`latest`** — or an explicit pinned version (`--tag <version>`). The `kortix-updater` service and `kortix self-host update`/`reconcile` both resolve the same way: an explicit pin wins, otherwise the configured channel. This is a **release-pipeline contract**, not a self-host CLI concern: the Kortix release flow must publish/repoint the moving `stable` tag on all three app images (`kortix-api`, `kortix-frontend`, `kortix-gateway`) on every production release, the same way it already republishes `latest` and the exact `X.Y.Z` tag. Self-host installs — laptop or production — consume whatever that pipeline publishes; they never build or sign anything themselves. ## What changed from the old enterprise-VPC design Earlier iterations of enterprise self-hosting (see `docs/specs/2026-07-13-enterprise-vpc-single-tenant-deployment.md`, `docs/specs/2026-07-14-enterprise-ecs-simplification.md`, and `docs/specs/2026-07-14-enterprise-appliance.md`, all now superseded) used a signed TUF release channel, a dedicated AWS VPC (EKS, then ECS, then a single EC2 appliance), Terraform-managed infrastructure, an on-box systemd updater binary, and SSM RunCommand for remote operation. All of that is gone. The generic Docker self-host system replaces it: no signing, no Terraform, no AWS-specific bootstrap, no SSM — one Compose stack, one CLI, one update mechanism, running the same way everywhere. --- <!-- /markdown/docs/reference/session-runtime.md --> # Session runtime Status model, branch creation, sandbox layout, injected environment, and the in-sandbox daemon. Canonical page: https://kortix.com/docs/reference/session-runtime The runtime mechanics of a session. For the technical model of what a session is, see [Sessions](/docs/concepts/sessions). A session is one conversation with the agent, run in an isolated sandbox VM on Daytona, Platinum, or E2B Cloud, with the project repo cloned and checked out on a branch named after the session id. The provider is an implementation detail of the same session-scoped API. Work reaches the default branch only through a [change request](/docs/reference/change-requests). ## Status model A session row (`project_sessions`) carries a `status`. The enum defines `queued`, `branching`, `provisioning`, `running`, `stopped`, `failed`, `completed`; the runtime only writes a subset: | Status | Set when | | --- | --- | | `provisioning` | At session create — the row is inserted directly as `provisioning`. The session branch is created and the sandbox is requested during this phase. | | `running` | Once the sandbox is live and reachable. | | `stopped` | On explicit stop, or by the idle sweep that hibernates inactive sandboxes. | | `failed` | If provisioning errors out. | `queued` and `branching` exist in the enum but are not part of the live session flow; `completed` is defined but not currently written. The sandbox itself is tracked in a separate `session_sandboxes` row with its own states (`provisioning → active → stopped | error | archived`). Concurrent-session caps are enforced per account; exceeding your tier returns `429`. ## Active-turn protection While any OpenCode root or subagent session is `busy` or `retrying`, the sandbox daemon renews a short execution lease with the API every 20 seconds. The lease vetoes the idle reaper, and each renewal touches the provider edge so the provider's native inactivity timer cannot hibernate the VM during a long tool-only phase. The daemon also caches that provider edge and continues the busy-only keep-alive if the Kortix API is temporarily unavailable. `session.idle` and `session.error` release the lease. An open dashboard, preview, SSE connection, or health poll does **not** create an execution lease. Passive tabs therefore cannot keep genuinely idle compute alive. ## Branch model - The session branch is **named after the session id** (a UUID). `KORTIX_SESSION_ID` and `KORTIX_BRANCH_NAME` are the same value. - It is cut from `base_ref`, which defaults to the project's `default_branch` (usually `main`), at create time. - Creation path depends on the backend: GitHub repos use the GitHub API (read the base tip, create the branch ref); generic git backends push `refs/heads/<base>:refs/heads/<session-id>`. - Triggers create their session branch the same way an interactive session does. Nothing writes the default branch directly except a change-request merge. ## What survives An in-place stop/resume preserves the same provider sandbox identity and its filesystem while dropping RAM and processes; the Kortix entrypoint and OpenCode restart on resume. E2B uses a filesystem-only pause (`keepMemory: false`), and Daytona and Platinum expose the same Kortix lifecycle contract through their native stop/resume primitives. Permanent deletion removes the provider sandbox. The session branch is the only durable, provider-independent record. Commit and push in small chunks so work survives provider deletion or recovery, and merge a [change request](/docs/reference/change-requests) to land it on the default branch. ## Layout inside the sandbox ``` /workspace ← WORKDIR. The project repo is cloned here. /workspace/.kortix/ ← Repo-internal Kortix folder (Dockerfile + opencode config dir). /usr/local/bin/kortix-agent ← The daemon (supervisor + reverse proxy). /usr/local/bin/kortix-entrypoint ← The container ENTRYPOINT (PID 1). /opt/kortix/home ← OpenCode's HOME — its object store lives here, off the repo. ``` OpenCode's `HOME` is `/opt/kortix/home`, not `/workspace`, so its store never lands among your repo files. ## Injected environment Injected at boot, alongside your project's [secrets](/docs/reference/secrets) (which arrive as plain env vars): | Variable | What | | --- | --- | | `KORTIX_PROJECT_ID` | UUID of this project. | | `KORTIX_SESSION_ID` | UUID of this session. Also the branch name. | | `KORTIX_BRANCH_NAME` | Same as `KORTIX_SESSION_ID` — the branch your work pushes to. | | `KORTIX_REPO_URL` | Clone URL for the project repo. | | `KORTIX_DEFAULT_BRANCH` | The base ref (the repo's default branch). | | `KORTIX_BASE_REF` | The ref this session branched from. | | `KORTIX_SERVICE_PORT` | `8000` — the daemon's external port. | | `KORTIX_API_URL` | The platform API base (`…/v1`). | | `KORTIX_AGENT_NAME` | The OpenCode agent the session was created with. | | `KORTIX_OPENCODE_MODEL` | The model to run, when set. | | `KORTIX_INITIAL_PROMPT` | The first prompt, when the session was spawned by a trigger or created with one. | | `KORTIX_PROJECT_AUTO_CLONE` | `1` — tells the daemon to clone the repo on boot. | | `KORTIX_PROJECT_SECRET_NAMES` | Comma-separated names of the project's secrets. | | `KORTIX_PROJECT_SECRETS_REVISION` | Revision marker for the secret set. | | `KORTIX_SANDBOX_TOKEN` | The sandbox **service key** — the daemon's own identity. It signs and validates its own control-surface requests with it (HMAC key for `X-Kortix-User-Context`) and is the bearer for the sandbox-identity routes (clone-credential / turn-stream, including execution leases / turn-question). It is *not* a project API token, and the `kortix` CLI does not read it — the project-scoped API routes reject it. `KORTIX_TOKEN` is injected with the same value as a back-compat alias for daemons baked before the rename to `KORTIX_SANDBOX_TOKEN`; a planned Phase 2 will drop the `KORTIX_TOKEN`/`KORTIX_EXECUTOR_TOKEN` aliases and repurpose `KORTIX_TOKEN` to mean the session token instead, so don't depend on `KORTIX_TOKEN` continuing to carry the sandbox identity long-term. | | `KORTIX_CLI_TOKEN` | The project-scoped PAT the in-sandbox `kortix` CLI authenticates with (same value as `KORTIX_EXECUTOR_TOKEN`). | | `KORTIX_EXECUTOR_TOKEN` | The same project-scoped PAT — acts as the launching user, scoped to the project. Also backs the `kortix-executor` MCP gateway. | | `KORTIX_BOOTSTRAP_OPENCODE_SESSION` | `1` — always set. The sandbox daemon owns OpenCode root creation on every cold boot; this tells it to create/open the OpenCode root at boot, and the API adopts whatever root the daemon creates rather than racing to create its own. | | `KORTIX_LLM_API_KEY` · `KORTIX_LLM_BASE_URL` | Managed LLM-gateway key + base URL, injected only on plans that use Kortix's bundled model access (operator availability + per-project opt-in + account entitlement all gate it). | Not injected: `KORTIX_WORKSPACE` (`/workspace`) is baked into the image, not per session. No `KORTIX_GITHUB_TOKEN` — git credentials are fetched just-in-time by the daemon (below). When a project brings its own model keys they travel as ordinary project secrets OpenCode reads at boot; the `KORTIX_LLM_*` pair above appears only on plans that use Kortix's managed model access. The `KORTIX_*` prefix is reserved for platform variables; a user secret using it is rejected ([Secrets](/docs/reference/secrets)). ## Pushing from a session The daemon fetches a short-lived clone credential (`GET /v1/projects/:id/git/clone-credential`) using `KORTIX_SANDBOX_TOKEN` — no static token in the environment. With that, `git push origin HEAD` sends commits to the session branch. Landing on the default branch goes through a [change request](/docs/reference/change-requests). ## The agent runtime The agent is **OpenCode**, launched by the daemon as `opencode serve --port 4096 --hostname 127.0.0.1` with `OPENCODE_CONFIG_DIR` at the project's config dir (`[opencode] config_dir`, default `.kortix/opencode`), resolved from the cloned repo. See [Kortix vs OpenCode config](/docs/reference/config-boundary). ## The daemon control surface The `kortix-agent` binary runs as PID 1's child and fronts OpenCode on `KORTIX_SERVICE_PORT` (`8000`). Everything outside `/kortix/*` requires the HMAC-signed `X-Kortix-User-Context` header (validated against `KORTIX_SANDBOX_TOKEN`). | Path | Purpose | | --- | --- | | `GET /kortix/health` | Liveness (auth-bypassed). Reports daemon + OpenCode state, repo, branch, commit. | | `POST /kortix/refresh` | Re-pull the session branch and restart OpenCode in place. | | `POST /kortix/abort` | Abort the current run. | | `POST /kortix/env` | Update the runtime environment. | | `/proxy/{port}/*` | Reverse-proxy to a port inside the sandbox (its own port is blocked). | | `*` | Catch-all reverse-proxy to OpenCode on `127.0.0.1:4096` (`503` while it boots). | `/kortix/refresh` is how the dashboard applies an out-of-band change (e.g. a manifest edit committed from a parallel session) without re-provisioning the sandbox. --- <!-- /markdown/docs/reference/triggers.md --> # Triggers Cron and signed-webhook entries that spawn, reuse, or pin sessions — fields, template variables, and gotchas. Canonical page: https://kortix.com/docs/reference/triggers Field-level reference for triggers. For the plain-language version, see [Automations](/docs/concepts/triggers). A trigger fires a session that runs the rendered prompt as its initial message. By default the platform creates a fresh session branch like an interactive session; `session_mode` can instead reuse that trigger's prior session or pin the fire to a specific session. The agent works, commits, pushes. Landing on `main` goes through a [change request](/docs/reference/change-requests). Triggers live in the manifest — `triggers:` (a YAML list) in v2 `kortix.yaml`, `[[triggers]]` (a TOML array of tables) in legacy v1 `kortix.toml`. Same fields either way, just a different container. The manifest is the source of truth for *config*; runtime state (`last_fired_at` only — there is no fire count) lives in `project_trigger_runtime` so a fire doesn't commit on every tick. For when a trigger last fired, check the dashboard, not the repo. ## Cron triggers ```yaml # kortix.yaml (v2) triggers: - slug: daily-digest name: Daily digest type: cron agent: kortix enabled: true cron: "0 0 9 * * 1-5" timezone: America/Los_Angeles prompt: | Summarize yesterday's commits across the repo. Save the result to notes/digest-{{ cron.fired_at }}.md and open a CR against main. ``` ```toml # kortix.toml (legacy v1) — same fields, TOML array of tables [[triggers]] slug = "daily-digest" name = "Daily digest" type = "cron" agent = "kortix" enabled = true cron = "0 0 9 * * 1-5" timezone = "America/Los_Angeles" prompt = """ Summarize yesterday's commits across the repo. Save the result to notes/digest-{{ cron.fired_at }}.md and open a CR against main. """ ``` `cron` is a **6-field croner expression**: `second minute hour day month weekday`. `timezone` is an IANA name (default `UTC`). The scheduler polls every 60 s (`KORTIX_TRIGGER_SCHEDULER_INTERVAL_MS`), so sub-minute precision is best-effort. ## Webhook triggers ```yaml # kortix.yaml (v2) triggers: - slug: slack-hook name: Slack handler type: webhook agent: kortix enabled: true secret_env: WEBHOOK_SLACK_SECRET prompt: "Slack event: {{ body.text }}" ``` ```toml # kortix.toml (legacy v1) [[triggers]] slug = "slack-hook" name = "Slack handler" type = "webhook" agent = "kortix" enabled = true secret_env = "WEBHOOK_SLACK_SECRET" prompt = "Slack event: {{ body.text }}" ``` Fires on signed `POST` requests to: ``` POST /v1/webhooks/projects/<project_id>/<slug> ``` The secret value lives in `project_secrets`; the manifest references it by name. Declare it in `env.optional` (v2) or `[env].optional` (v1 legacy) so it shows up in the dashboard's Environment variables page. ### Signature - Primary header: `X-Kortix-Signature: sha256=<hmac>`. The `sha256=` prefix is optional — the receiver strips it if present. - GitHub-compatible: `X-Hub-Signature-256` is also accepted, so GitHub webhooks point straight at this URL with no adapter. - Algorithm: HMAC-SHA256 over the **raw** request body using the secret named by `secret_env`. - Format: exactly 64 hex chars (mixed case accepted). - Compared with constant-time `timingSafeEqual`. **Fallback: static token auth.** If neither `X-Kortix-Signature` nor `X-Hub-Signature-256` is present on the request, the receiver falls back to a static shared-token check against the same `secret_env` value — for senders that can't HMAC-sign a body (e.g. Better Stack error webhooks, which only support custom headers or basic auth). Send the token as: - `X-Kortix-Token: <secret>`, or - `Authorization: Bearer <secret>`, or - `Authorization: Basic <base64(user:secret)>` — the password half is used as the token. The token is compared to `secret_env` with constant-time `timingSafeEqual`, same as the HMAC path. If a signature header is present, the token headers are ignored — signature auth always takes priority. ### Response codes | Status | Meaning | | --- | --- | | 202 | Signature or token valid — the session was fired or queued. The body is `{ "status": "fired", "session_id": … }` or `{ "status": "queued", "reason": … }` (queued when you're at a concurrency cap). | | 400 | Malformed project id or slug in the URL. | | 401 | Signature/token missing or mismatched. | | 404 | Trigger not found, disabled, or not a webhook (also when the project isn't active). | | 409 | `secret_env` value is not configured in the dashboard's Environment variables page. | | 500 | Auth succeeded but the session failed to fire. | ## Field reference ### Common fields | Field | Required | Type | Default | Notes | | --- | --- | --- | --- | --- | | `slug` | yes | string | — | `[a-z0-9][a-z0-9_-]{0,127}`, unique among triggers. | | `type` | yes | string | — | `"cron"` or `"webhook"`. | | `prompt` | yes | string | — | Mustache-templated body. May be multi-line via `"""…"""`. Alias: `prompt_template`. | | `name` | no | string | `slug` | Human label. | | `agent` | no | string | `"default"` | Agent name. In v2 (`kortix.yaml`), must name a key in `agents:` or be omitted to fall back to `default_agent`. Legacy v1 projects with no `[[agents]]` resolve it through OpenCode discovery instead. Alias: `agent_name`. | | `model` | no | string | — (resolves at fire time) | Per-trigger model override, wire form `provider/model` (e.g. `"anthropic/claude-sonnet-4-5"`). When unset, resolves through the chain agent → project → account → platform `auto` at fire time — catalog availability is validated when the trigger runs, not when the manifest is parsed. | | `session_mode` | no | string | `"fresh"` | `"fresh"`: every fire mints a brand-new session (new sandbox + branch). `"reuse"`: re-prompts the trigger's most recent session (resuming its sandbox) so one long-lived session accumulates context across fires. `"pinned"`: first tries the exact `session_id` below, then falls back to the trigger's reusable session or a fresh one if the pin is gone or unavailable. Case-insensitive. Alias: `sessionMode`. | | `session_id` | required only for `session_mode: "pinned"` | string | — | Exact project session to re-prompt for pinned mode. Alias: `sessionId`. | | `enabled` | no | bool | `true` | When `false`, the scheduler / receiver skip the entry. | ### Cron-only fields | Field | Required | Type | Default | Notes | | --- | --- | --- | --- | --- | | `cron` | one of `cron` / `run_at` | string | — | 6-field croner expression. Mutually exclusive with `run_at`. Alias: `schedule`. | | `run_at` | one of `cron` / `run_at` | string | — | ISO-8601 datetime for a one-off ("run once") schedule — the trigger fires once at/after this instant and then stays dormant. Mutually exclusive with `cron`. Alias: `runAt`. | | `timezone` | no | string | `"UTC"` | IANA name, e.g. `"America/Los_Angeles"`. | ### Webhook-only fields | Field | Required | Type | Notes | | --- | --- | --- | --- | | `secret_env` | yes | string | Name of a `project_secrets` entry holding the HMAC secret (and the static-token fallback secret — see [Signature](#signature)). Manifest-side regex is `^[A-Z_][A-Z0-9_]*$` (unbounded). Alias: `secretEnv`. | The parser also accepts the camelCase / alternate aliases noted above (`prompt_template`, `agent_name`, `sessionMode`, `schedule`, `runAt`, `secretEnv`) — the manifest-schema validator mirrors this same tolerance so a manifest that materializes fine never fails validation. ## Template variables `prompt` renders with a mustache-style engine: `{{ token.dotted.path }}`. Missing values render as empty strings — no error, no leftover `{{ x }}`. Objects and arrays render as JSON. The variables differ by **how the trigger fired** — there is no single combined set. On every fire you get `{{ trigger.slug }}`, `{{ trigger.type }}`, and `{{ trigger.kind }}` (always `"git"`). **Cron fires** — note there is no top-level `fired_at`; use `cron.fired_at`: | Variable | Source | | --- | --- | | `{{ cron.schedule }}` | The croner expression that fired. | | `{{ cron.timezone }}` | Configured tz (default `"UTC"`). | | `{{ cron.fired_at }}` | ISO-8601 timestamp of this fire. | | `{{ cron.last_fired_at }}` | Previous fire timestamp (or empty). | **Webhook fires:** | Variable | Source | | --- | --- | | `{{ fired_at }}` | ISO-8601 timestamp of this fire. | | `{{ body.* }}` | JSON-parsed request body (dotted access). Unparseable body → `{{ body.raw }}`. | | `{{ headers.content_type }}` · `{{ headers.user_agent }}` · `{{ headers.forwarded_for }}` | Request headers. | **Manual fires** (dashboard "fire now"): | Variable | Source | | --- | --- | | `{{ fired_at }}` | ISO-8601 timestamp. | | `{{ source }}` | `"manual"`. | | `{{ actor }}` | User id that fired it. | | `{{ message.text }}` · `{{ message.source }}` | Manual-fire context. | Missing values render as empty strings — no error, no leftover `{{ x }}`. Objects and arrays render as JSON. ## Common gotchas - In legacy `kortix.toml`, `[triggers]` (single brackets) is wrong — must be `[[triggers]]` (array of tables). The parser surfaces a clear error. - Slugs must be lowercase + URL-safe. Uppercase or spaces fail. - A webhook trigger without `secret_env` is rejected. There is no unauthenticated webhook surface — by design. - A cron trigger must declare either `cron` or a one-off `run_at` — having neither is rejected. - Bad entries surface in the listing's `errors` array next to the good ones — they don't break the whole manifest. --- <!-- /markdown/docs/sdk/auth.md --> # Authentication Two concepts only — an API key (account-wide or project-scoped) for anything programmatic, or a Supabase JWT for web apps. Canonical page: https://kortix.com/docs/sdk/auth Kortix has exactly two ways to authenticate: a **session** (you logged in) and an **API key** (everything programmatic — the SDK, the CLI, your backend, CI). The SDK takes one via `getToken`, which it sends as `Authorization: Bearer <token>`. ## API key — the one programmatic credential Create one in **User settings → API keys → Create API key**. Name it, choose its scope, copy it once (shown only at creation), and store it as a secret. An API key **acts as you**, so it can reach every project your account can. > **Warn** > Don't use a **Service account** here. Service accounts (`kortix_sa_…`) are a separate, advanced > IAM principal with **no project access until it's explicitly granted** — point the SDK at one and > every call returns `403 "You do not have access to this project"`. For the SDK, the CLI, and the > white-label demo, you want an **API key** from **Settings → API keys**. ```ts const kortix = createKortix({ backendUrl: 'https://api.kortix.com/v1', getToken: async () => process.env.KORTIX_API_KEY!, }); ``` ### Scope — account-wide or one project Scope is a property of the key, picked at creation: | scope | the key can touch | use it for | | --------------------- | ------------------------------------------- | ----------------------------------------- | | **Account** (default) | every project in your account | a backend managing your workspace | | **Project** | exactly one project — `403` everywhere else | CI/CD bound to one repo (least privilege) | Pick the narrowest scope that does the job. Rotate by creating a new key and revoking the old one. > **Note** > The **CLI** mints an API key for you — `kortix login` runs the browser authorize flow and stores > the key locally. Same credential, nothing special — there's no separate "CLI token." ## Supabase JWT — web apps on Kortix login Only if you're building a web app on Kortix's own auth. Return the user's live session token; it refreshes itself, so `getToken` re-reads it each call: ```ts getToken: async () => (await supabase.auth.getSession()).data.session?.access_token ?? null, ``` ## Which one? | you're building | use | | ------------------------- | ------------------------------------------------------- | | a backend, script, or CI | an **API key** (account-wide, or project-scoped for CI) | | the Kortix CLI | `kortix login` (mints an API key for you) | | a web app on Kortix login | a **Supabase JWT** | > **Note** > API keys are bearer credentials — treat them like passwords. Keep them in a secret manager, never > in client-side code or a committed file. `getToken` is called on demand, so the host owns storage, > caching, and rotation. --- <!-- /markdown/docs/sdk/distribution.md --> # Distribution How @kortix/sdk ships: npm, the CDN ESM bundle, the window.Kortix script global, version lockstep with platform releases, and the entry-point stability tiers. Canonical page: https://kortix.com/docs/sdk/distribution ## npm (the default) ```sh npm install @kortix/sdk ``` The published package is compiled ESM (`dist/`) with full type declarations. `react` and `@tanstack/react-query` are **optional peer dependencies** — install them only if you use `@kortix/sdk/react`. ```ts import { createKortix } from '@kortix/sdk'; // framework-free core import { useSession } from '@kortix/sdk/react'; // optional React layer import { createScopedKortix } from '@kortix/sdk/server'; // Node servers (async_hooks) ``` ## CDN — no build step Two browser bundles ship with the package, mirrored automatically by unpkg and jsDelivr: **ESM** (`dist/kortix.esm.min.js`, the package's `browser` field) — for `<script type="module">` and import maps: ```html <script type="module"> import { createKortix, classifyTurn } from 'https://unpkg.com/@kortix/sdk/dist/kortix.esm.min.js'; </script> ``` **IIFE global** (`dist/kortix.global.js` — what a bare `https://unpkg.com/@kortix/sdk` resolves to, via the package's `unpkg` / `jsdelivr` fields) — a single `<script>` tag that defines `window.Kortix`: ```html <script src="https://unpkg.com/@kortix/sdk/dist/kortix.global.js"></script> <script> const kortix = Kortix.createKortix({ backendUrl: 'https://api.kortix.com/v1', getToken: async () => KEY, }); </script> ``` `window.Kortix` **is** the root entry — `Kortix.createKortix`, `Kortix.classifyTurn`, `Kortix.ApiError`, no namespaces. Streaming works from the CDN bundles in any modern browser (Safari 16.4+). > **Warn** > Don't mix the ESM build and the IIFE global in one page — you'd get two copies of every class, and > `err instanceof ApiError` across the boundary silently returns `false`. Pick one. ## Versioning — lockstep with the platform The SDK version is stamped from the platform release at publish time; the SDK ships **in lockstep with the Kortix platform**. There is no separate SDK release train — a platform release publishes the matching SDK. Pin a version or use `^` as you prefer; the API surface follows semver, enforced by a committed public-surface snapshot asserted in CI (any rename or removal of an exported name must land as a reviewed, deliberate diff). ## Entry points and their stability tiers **The root entry is canonical.** Since v2, everything framework-free is exported from `@kortix/sdk` itself — one flat, discoverable surface. The supported entry points: | Entry | Tier | Contract | | ----------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `@kortix/sdk` | **canonical** | The full framework-free surface. Runs in browsers, Node ≥ 18, Bun, and edge runtimes. Never imports React or `node:*`. | | `@kortix/sdk/react` | **supported** | The React hooks layer. The only entry allowed to import React. | | `@kortix/sdk/server` | **supported** | Node/Bun-only per-request config isolation (`node:async_hooks`). Never bundle into a browser. | | Legacy subpaths (`/projects-client`, `/turns`, `/files`, …) | **deprecated aliases** | Still work — every legacy subpath re-exports from the root — but are frozen. Import from the root instead. Removal only in a major. | | Browser store subpaths (`/sync-store`, `/server-store`, …) | **internal** | Plumbing consumed by the Kortix web app. Not on `window.Kortix`, not designed API — don't build on them. | These guarantees are enforced mechanically, not by convention: an import-graph tripwire fails the build if a framework or `node:*` import ever reaches the core, and the public-surface snapshot catches accidental renames before they reach a release. ## What's in the tarball `npm pack` contains `dist/` (compiled ESM + `.d.ts` + CDN bundles), `src/` (readable source for go-to-definition), and the README — nothing else. Repo docs, examples, tests, and internal tooling never ship to consumers. The publish pipeline verifies the tarball installs and imports before anything goes out. --- <!-- /markdown/docs/sdk/error-handling.md --> # Error handling The typed error hierarchy — ApiError, AuthError, BillingError, SessionNotReadyError, the RUNTIME_UNAVAILABLE handshake — and how to catch, branch on, and format them for the UI. Canonical page: https://kortix.com/docs/sdk/error-handling Every REST call made through `createKortix(...)` or `backendApi` rejects with a real `Error` subclass — never a plain object. `catch` it, `instanceof` it, and branch on `.status`/`.code`. These are the **same classes** on every host: a server-side "Kortix as a backend" wrapper and the React UI both import from the one module. ```ts import { ApiError, BillingError } from '@kortix/sdk'; try { await kortix.project(projectId).sessions.create(); } catch (err) { if (err instanceof BillingError) { // 402 — out of credits / plan limit } else if (err instanceof ApiError) { // any other failed request — err.status, err.code, err.detail } else { throw err; } } ``` ## The classes | class | extends | when | key fields | | ---------------------- | ---------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | | `ApiError` | `Error` | the default for any failed request — bad status, network failure, timeout, or an aborted request | `status`, `code`, `details`/`data`, `detail`, `response`, `url`, `endpoint`, `timeout` | | `AuthError` | `ApiError` | `getToken` returned `null` — the request was never sent | `code` is always `'NO_SESSION'` | | `BillingError` | `Error` | HTTP `402` — the backend's only billing error today | `status` (`402`), `detail: { message, ... }` | | `RequestTooLargeError` | `Error` | HTTP `431` — typically too many files attached to one request | `status` (`431`), `detail: { message, suggestion }` | | `SessionNotReadyError` | `Error` | a runtime accessor (`previewUrl`, `proxyUrl`, `.runtime`) was called before `ensureReady()` — see [below](#session-readiness-errors) | `name` is `'SessionNotReadyError'` | > **Note** > `BillingError` used to be an 8-class hierarchy (`AGENT_RUN_LIMIT_EXCEEDED`, > `THREAD_LIMIT_EXCEEDED`, etc.). The backend now only ever returns a plain `402` with `{message}`, > so it's one class — legacy code that still switches on old error-code strings can delete that > branch. ### `ApiError` The class every failed `backendApi`/facade call produces. `name` defaults to `'ApiError'` but is overridden for specific failure modes you can match on: - `name: 'AbortError'`, `code: 'ABORTED'` — the request was aborted externally (navigation, React Query cancellation) — not a timeout, safe to ignore. - `code: 'TIMEOUT'` — the request's own timeout budget elapsed; `url`, `endpoint`, and `timeout` (ms) are set so you know what timed out. - Otherwise `status` is the HTTP status code and `code` is the backend's `error_code`/`detail.error_code` (falling back to the status as a string) when the body carried one. Transient gateway failures are retried before you see them: idempotent reads (`GET`/`HEAD`) that return `502`, `503`, or `504` retry up to two times with a 250ms → 500ms backoff. A single proxy/ALB blip is absorbed silently and never reaches `onError`; persistent failures surface as a normal `ApiError` with the last status. Mutations (`POST`/`PUT`/`PATCH`/`DELETE`) and deterministic `500`s are never retried. `message` is an **enumerable own property** (not just inherited from `Error`), so spreading the error or `JSON.stringify`-logging it includes the message — useful for error reporting. ### `AuthError` Thrown client-side, before any request goes out, when `getToken()` resolves to `null` — there's no way to sign the request. It's an `ApiError` subclass, so `err instanceof ApiError` still matches; check `err instanceof AuthError` (or `err.code === 'NO_SESSION'`) to special-case "not signed in" from a real backend failure. ### `BillingError` Thrown for HTTP `402` responses — out of credits, over a plan limit, or any other billing gate. `detail.message` is the human-readable reason from the backend; `detail` may carry additional fields the backend chose to include. ### `RequestTooLargeError` Thrown for HTTP `431` (Request Header Fields Too Large) — in practice, this means too many files were attached to a single request. `detail.suggestion` is a ready-to-show hint ("Try uploading files one at a time..."). ## Session readiness errors Two failure modes are about the **session's runtime not being there yet** — they're deliberate design, not bugs, and each has one correct response. ### `SessionNotReadyError` Thrown **synchronously, client-side** when you touch a runtime-scoped accessor — `session.previewUrl()`, `session.proxyUrl()`, `session.runtime` — before this handle has resolved its own runtime. This enforces the SDK's most important invariant: a session handle resolves *its own* sandbox or throws — it never silently falls back to whatever sandbox happens to be globally active (which could route your calls into another session's machine). ```ts import { SessionNotReadyError } from '@kortix/sdk'; const s = kortix.session(projectId, sessionId); try { const url = s.previewUrl(3000); // ← throws: nothing resolved yet } catch (err) { if (err instanceof SessionNotReadyError) { await s.ensureReady(); // resolve the runtime first } } ``` The fix is always the same: `await session.ensureReady()` (or `send()`, which readies internally) before the accessor. `session.health()` is the one exception — it never throws this, so a health poller can run before the session has ever booted. ### `ApiError` with `code: 'RUNTIME_UNAVAILABLE'` Thrown by `ensureReady()` when its single bounded `/start` long-poll returns while the sandbox is **still provisioning** — normal on a cold boot, which can take longer than one poll. It means *not ready yet*, not *failed*: retry, and each retry re-attaches to the same server-side provision. ```ts if (err instanceof ApiError && err.code === 'RUNTIME_UNAVAILABLE') { // still provisioning — wait a few seconds and call ensureReady() again } ``` The full retry-with-deadline pattern lives on the **[Streaming](/docs/sdk/streaming#readiness-is-a-handshake-not-a-one-liner)** page; in React, `useSession` handles all of this for you (surfacing it as the boot `phase` instead of an exception). > **Warn** > One `instanceof` trap: if a page somehow loads **two copies** of the SDK (the > ESM build *and* the CDN `window.Kortix` global), each copy has its own > `ApiError` class and `instanceof` across the boundary silently returns > `false`. Pick one distribution per page — see > [Distribution](/docs/sdk/distribution#cdn--no-build-step). ## Helpers | helper | signature | what | | -------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `parseBillingError(error)` | `(error: any) => Error` | If `error`'s status is `402`, wraps it into a `BillingError`; otherwise returns `error` unchanged. Called internally on every `402` response, so callers rarely need it directly. | | `isBillingError(error)` | `(error: any) => boolean` | `error instanceof BillingError` | | `formatBillingErrorForUI(error)` | `(error: any) => BillingErrorUI \| null` | Returns `null` for anything that isn't a `BillingError`; otherwise returns `{ alertTitle, alertSubtitle }` ready to render in an upgrade/pricing modal — with a dedicated "you ran out of credits" copy when the message mentions credits/balance/insufficient. | ```ts import { formatBillingErrorForUI } from '@kortix/sdk'; try { await kortix.session(projectId, sessionId).start(); } catch (err) { const ui = formatBillingErrorForUI(err); if (ui) showUpgradeModal(ui.alertTitle, ui.alertSubtitle); } ``` ## In `@kortix/sdk/react` `@kortix/sdk/react` re-exports `BillingError`, `RequestTooLargeError`, `parseBillingError`, `isBillingError`, and `formatBillingErrorForUI` — **not** `ApiError`/`AuthError`, which stay root-only (`@kortix/sdk`) since they're the generic REST-failure shape, not something a chat UI branches on. Import both from wherever you already import `@kortix/sdk`/`@kortix/sdk/react` — same classes either way. `useSession`'s `send`/`answerQuestion`/`answerPermission`/`rejectQuestion` classify every failure into a `KortixSendError` (`sendError` on the hook's return value) instead of making you `instanceof`-check by hand: ```ts interface KortixSendError { kind: 'billing' | 'runtime-not-ready' | 'runtime-error'; message: string; // already formatted for display billing?: BillingError; // present when kind === 'billing' cause: unknown; // the original thrown value } ``` ```tsx const s = useSession(projectId, sessionId); if (s.sendError?.kind === 'billing') { const ui = formatBillingErrorForUI(s.sendError.billing); // ... } ``` See **[React hooks](/docs/sdk/react)** for the rest of `useSession`'s surface. --- <!-- /markdown/docs/sdk/full-example.md --> # Full example Zero to a streaming agent reply in one framework-free file: list projects, create a session, ready the sandbox, stream events, send a prompt, render the transcript. Canonical page: https://kortix.com/docs/sdk/full-example This is the whole SDK in one runnable file — no framework, no build step beyond TypeScript. It exercises every core concept in order: the client, the platform REST surface, the readiness handshake, live streaming, sending with overrides, and turn classification. Each numbered section below maps to a concept page. ## The complete script ```ts import { ApiError, classifyTurn, createKortix, narrowChatEvent } from '@kortix/sdk'; import type { MessageWithParts } from '@kortix/sdk'; async function main() { // 1. One client, one auth seam. getToken returns your API key // (kortix_pat_…) or a logged-in user's Supabase JWT — nothing else. const kortix = createKortix({ backendUrl: 'https://api.kortix.com/v1', getToken: async () => process.env.KORTIX_API_KEY!, }); // 2. Platform REST: list projects, pick one (or provision your first). const projects = await kortix.projects.list(); const project = projects[0] ?? (await kortix.projects.provision({ name: 'sdk-quickstart' })); console.log(`using project ${project.name} (${project.project_id})`); // 3. Create a session — a cheap platform call. No sandbox exists yet. const created = await kortix.projects.createSession(project.project_id, { name: 'sdk full example', }); const session = kortix.session(project.project_id, created.session_id); // 4. Ready the session. This provisions (or resumes) the real cloud // sandbox. Each ensureReady() call is ONE bounded long-poll — on a // cold boot it throws a typed RUNTIME_UNAVAILABLE ApiError while the // sandbox is still coming up, so retry until it resolves. const { opencodeSessionId } = await retryUntilReady(() => session.ensureReady()); // 5. Connect the event stream BEFORE sending, so no early events are // missed. narrowChatEvent() collapses the wire events into a small // typed union you can switch over. let resolveIdle!: () => void; const idle = new Promise<void>((resolve) => (resolveIdle = resolve)); const stream = await session.stream({ onEvent: (event) => { const e = narrowChatEvent(event); if (!e) return; if (e.type === 'message.part.updated') process.stdout.write('.'); if (e.type === 'session.error') console.error('\nerror:', e.error); if (e.type === 'session.idle' && e.sessionID === opencodeSessionId) { resolveIdle(); // the turn is finished } }, }); // 6. Send. Per-send overrides let you pick the model and the agent for // just this prompt (ids come from projects.modelPicker() and // projects.detail().config.agents). await session.send('What files are in this repo?', { model: { providerID: 'kortix', modelID: 'claude-sonnet-4.6' }, }); // 7. Wait for the turn to finish — the session.idle event, not a sleep. await idle; stream.close(); // 8. Render the transcript. classifyTurn() collapses ~50 wire part // variants into a compile-time-exhaustive union, so a renderer can // switch (part.kind) and TypeScript proves no case is missed. const result = await session.runtime.session.messages({ sessionID: opencodeSessionId, }); for (const message of (result.data ?? []) as MessageWithParts[]) { for (const part of classifyTurn(message).parts) { if (part.kind === 'text') console.log(`\n[${message.info.role}] ${part.text}`); } } } /** Retry ensureReady() while the sandbox is provisioning (cold boots take a while). */ async function retryUntilReady<T>(ensure: () => Promise<T>): Promise<T> { const deadline = Date.now() + 300_000; for (;;) { try { return await ensure(); } catch (error) { const provisioning = error instanceof ApiError && error.code === 'RUNTIME_UNAVAILABLE'; if (!provisioning || Date.now() > deadline) throw error; await new Promise((r) => setTimeout(r, 3_000)); } } } main().catch((error) => { console.error(error); process.exit(1); }); ``` Run it with Node ≥ 18, Bun, or `tsx`: ```sh KORTIX_API_KEY=kortix_pat_... npx tsx full-example.ts ``` > **Note** > The first `ensureReady()` on a fresh session provisions a **real cloud sandbox** — expect the > ready step to take a while on the first run. Subsequent runs resume the same sandbox and are fast. ## What each step teaches | Step | Concept | Deep dive | | ---- | ----------------------------------------------------------- | ---------------------------------- | | 1 | One client, one token, one auth seam | [Authentication](/docs/sdk/auth) | | 2–3 | The platform REST surface: projects and sessions | [The client](/docs/sdk/the-client) | | 4 | Session readiness — the bridge between platform and runtime | [Sessions](/docs/sdk/sessions) | | 5, 7 | Live SSE events, `narrowChatEvent`, `session.idle` | [Streaming](/docs/sdk/streaming) | | 6 | Per-send `{ model, agent }` overrides | [Sessions](/docs/sdk/sessions) | | 8 | `classifyTurn` and the exhaustive `ClassifiedPart` union | [Turns](/docs/sdk/turns) | ## Going further from here The same client reaches the rest of the platform — a taste of the wider surface, all through the one facade: ```ts const project = kortix.project(projectId); // Workspace files inside the session's live sandbox const tree = await session.files.list('/workspace'); const readme = await session.files.read('/workspace/README.md'); // Project secrets — env vars every session's agent can read at runtime await project.secrets.upsert({ name: 'MY_API_KEY', value: 'sk-…' }); // The project's agents, skills, and slash commands (config files in the repo) const { config } = await kortix.projects.detail(projectId); console.log( config.agents.map((a) => a.name), config.skills.length, ); // LLM gateway observability — cost, latency, per-model breakdown const overview = await project.gateway.overview(7); const routing = await project.gateway.routing.get(); const preview = await project.gateway.routing.preview({ requestedModel: 'auto', imageInput: false }); // Channels: connect the project to Slack, email, or a meeting bot const slack = await project.channels.slack.installation(); ``` In-repo, the package ships **runnable examples** covering each of these — `packages/sdk/examples/` is the graded ladder (minimal client → streaming → server wrapper → transcript rendering → files & secrets → CDN `<script>` tag), and `packages/sdk/playground/` is a 35-script live-stack test suite you can run against your own deployment with `bun run playground/run-all.ts`. --- <!-- /markdown/docs/sdk/getting-started.md --> # Getting started Install @kortix/sdk, create a client, authenticate, and make your first calls. Canonical page: https://kortix.com/docs/sdk/getting-started ## Install ```sh pnpm add @kortix/sdk ``` `react` (≥18) and `@tanstack/react-query` (≥5) are **optional peers** — needed only if you use the `@kortix/sdk/react` hooks. The core client is framework-free and runs in Node ≥ 18, Bun, modern browsers, and edge runtimes such as Cloudflare Workers — see **[Streaming & runtime support](/docs/sdk/streaming)** for the full matrix (React Native is REST-only today; live streaming is not supported there). No build step? The SDK also ships **CDN bundles** — an ESM module and a `window.Kortix` `<script>` global — see **[Distribution](/docs/sdk/distribution)**. ## Create a client `createKortix(config)` wires the platform seam once and returns one client. The only required config is `backendUrl` and a `getToken` that returns your Kortix token. ```ts import { createKortix } from '@kortix/sdk'; export const kortix = createKortix({ backendUrl: 'https://api.kortix.com/v1', getToken: async () => process.env.KORTIX_API_KEY!, // your Kortix API key }); ``` ### Config | field | type | required | what | | ---------------------------------- | ------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | | `backendUrl` | `string` | yes | Kortix API base, e.g. `https://api.kortix.com/v1` | | `getToken` | `() => Promise<string \| null>` | yes | Returns the bearer token — a Kortix API key, a PAT (`kortix_pat_…`), or a Supabase JWT | | `getUserId` | `() => Promise<string \| null>` | no | Current user id, when the host knows it | | `billingEnabled` | `boolean` | no | Toggles billing-gated behaviour | | `onError` · `onToast` · `onNotify` | callbacks | no | Host sinks for errors, toasts, and notifications | | `sandboxId` | `string \| null` | no | Default sandbox id for local/single-sandbox hosts | | `featureFlags` | object | no | Per-flag overrides for `@kortix/sdk/feature-flags` — see **[Modules](/docs/sdk/modules#other-subpaths-internal-plumbing)** | > **Note** > `createKortix` calls `configureKortix` for you — the one global seam every SDK module reads. Call > it once at app startup. ## Authentication The SDK sends `Authorization: Bearer <token>` on every request; `getToken` supplies it. The default is an **API key** — open your avatar menu → **User settings** → **API keys** (under _Account_) → **Create API key**. Pick account-wide or a single project as the scope, copy the `kortix_pat_…` (it is shown once), store it as a secret, and return it: ```ts const kortix = createKortix({ backendUrl: 'https://api.kortix.com/v1', getToken: async () => process.env.KORTIX_API_KEY!, }); ``` The key acts only on that project (rejected `403` elsewhere). Building a web app on Kortix's own login instead? Return the user's Supabase session token. Full detail — scopes, rotation, web vs backend — is on the **[Authentication](/docs/sdk/auth)** page. > **Note** > Keys never live inside the SDK — `getToken` is called on demand, and the host owns storage, > caching, and refresh. ## Your first calls ```ts // list your projects const projects = await kortix.projects.list(); // open a project, read its detail const detail = await kortix.project(projectId).detail(); // create + start a session (one agent run, in its own sandbox on its own branch) const created = await kortix.project(projectId).sessions.create(); const s = kortix.session(projectId, created.session_id); await s.start(); // talk to the running agent — the typed opencode runtime, reached via the SDK await s.runtime.session.prompt({ sessionID: (await s.ensureReady()).opencodeSessionId, parts: [{ type: 'text', text: 'Add a README' }], }); ``` > **Warn** > Don't import `@opencode-ai/sdk`, `backendApi`, or `authenticatedFetch` in app code. Everything is > reachable through `createKortix` or a `@kortix/sdk/*` subpath. ## In a React app For a live UI, don't drive the runtime by hand — **one hook runs a session**: ```tsx import { useSession } from '@kortix/sdk/react'; function Chat({ projectId, sessionId }: { projectId: string; sessionId: string }) { const s = useSession(projectId, sessionId); if (s.phase !== 'ready') return <Booting stage={s.stage} onRetry={s.retry} />; return ( <> {s.messages.map(({ info, parts }) => ( <Message key={info.id} info={info} parts={parts} /> ))} <Composer busy={s.isBusy} onSend={(t) => s.send(t)} onStop={s.cancel} /> </> ); } ``` `useSession` owns `/start`, the runtime switch, the SSE stream, readiness, and the canonical id — no provider to mount, no poller, no server store. See **[React hooks](/docs/sdk/react)**. ## Next - [Full example](/docs/sdk/full-example) — zero to a streaming agent reply in one file. - [The client](/docs/sdk/the-client) — the full facade surface. - [Sessions](/docs/sdk/sessions) — lifecycle, runtime health, previews, and the opencode runtime. - [Streaming](/docs/sdk/streaming) — live events, the readiness handshake, and runtime support. - [React hooks](/docs/sdk/react) — live, reactive data for your UI. - [Distribution](/docs/sdk/distribution) — npm, CDN bundles, and the stability tiers. --- <!-- /markdown/docs/sdk.md --> # TypeScript SDK @kortix/sdk — one typed client for the Kortix platform: projects, sessions, and the live agent runtime, behind a single session-scoped facade. Canonical page: https://kortix.com/docs/sdk `@kortix/sdk` is the single, opinionated data layer for the Kortix platform. One typed client wraps both the **Kortix REST API** and the **OpenCode runtime**, so a host app — web, mobile, or your own — imports **only `@kortix/sdk`** and never touches the raw API, `authenticatedFetch`, or `@opencode-ai/sdk` directly. ```ts import { createKortix } from '@kortix/sdk'; const kortix = createKortix({ backendUrl: 'https://api.kortix.com/v1', getToken: async () => process.env.KORTIX_API_KEY!, // your Kortix API key }); const projects = await kortix.projects.list(); const s = kortix.session(projectId, sessionId); await s.ensureReady(); // provision/resume, long-poll until ready await s.health(); // is the runtime ready? s.previewUrl(3000, '/docs'); // proxy URL for a port the agent exposed ``` ## Philosophy - **One token.** Auth is a single Kortix token — a Supabase JWT or a `kortix_pat_…` — supplied once via `getToken`. Keys never leave the server. - **Session-scoped.** You hold a _session_ and ask it about its own runtime: `session.health()`, `session.previewUrl()`. The sandbox is an implementation detail you never name. - **Mutations own their side-effects** — server-side. You state intent; the SDK doesn't orchestrate side-effects client-side. - **One import.** No `@opencode-ai/sdk`, no raw `backendApi`, no `authenticatedFetch` in host code — everything is reachable through `createKortix` or a `@kortix/sdk/*` subpath. ## Two layers The SDK gives you an **imperative** client for actions and **reactive** hooks for live UI data: - `createKortix(config)` → the facade. Call methods: `kortix.projects.list()`, `kortix.session(pid, sid).start()`. - `@kortix/sdk/react` → hooks. **`useSession(projectId, sessionId)` runs a whole session** in one hook — messages, `send`, status, the boot `phase` — with the runtime (start, SSE, readiness) handled for you. Plus models, config, and PTY. - [Getting started](/docs/sdk/getting-started) Install, configure, authenticate, and make your first calls. - [Full example](/docs/sdk/full-example) Zero to a streaming agent reply in one framework-free file. - [Auth](/docs/sdk/auth) API keys vs Supabase JWTs — the two ways to authenticate the SDK. - [The client](/docs/sdk/the-client) The full `createKortix` facade — accounts, projects, and the project handle. - [Sessions](/docs/sdk/sessions) The session handle: lifecycle, runtime health, previews, and the typed opencode runtime. - [Streaming](/docs/sdk/streaming) Live events, the readiness handshake, and the runtime support matrix. - [React hooks](/docs/sdk/react) `@kortix/sdk/react` — reactive data: live messages, events, config, PTY. - [Modules](/docs/sdk/modules) The functional areas — files, session runtime, the opencode client, auth, turns — all on the root entry. - [Distribution](/docs/sdk/distribution) npm, the CDN bundles, version lockstep, and the entry-point stability tiers. > **Note** > The Kortix REST API also has an auto-generated reference at > [api.kortix.com/v1/docs](https://api.kortix.com/v1/docs) (OpenAPI). The SDK wraps it with types > and ergonomics — prefer the SDK in application code. --- <!-- /markdown/docs/sdk/modules.md --> # Modules The functional areas inside @kortix/sdk — files, session runtime, the opencode client, auth, REST, turns — all importable from the canonical root entry. Canonical page: https://kortix.com/docs/sdk/modules Most app code should use the [`createKortix`](/docs/sdk/the-client) facade and the [React hooks](/docs/sdk/react) — they cover the whole surface and keep auth, caching, and runtime resolution wired for you. The modules below are the **lower-level, mostly stateless** pieces those layers are built from. Reach for them when you need a single operation without the facade, a pure helper, or direct access to a store. > **Note** > **Since v2, the root entry is canonical.** Every framework-free name below is > importable straight from `@kortix/sdk` — `import { files, authenticatedFetch, > groupMessagesIntoTurns } from '@kortix/sdk'`. The old per-module subpaths > (`@kortix/sdk/files`, `@kortix/sdk/turns`, …) **still work but are deprecated > aliases** kept so no existing import breaks; new code should import from the > root. The only subpaths that remain first-class are `@kortix/sdk/react` (React > peer dep) and `@kortix/sdk/server` (`node:async_hooks`) — see > [Distribution](/docs/sdk/distribution#entry-points-and-their-stability-tiers) > for the full tier table. The functional areas, and where each lives: | module | what | import from | | -------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------- | | files | workspace file operations | root (was `/files`) | | session runtime | health probe + proxy/preview URL builders | root (was `/session`, `/session/url`) | | opencode client | the typed OpenCode v2 client + its full type surface | root (was `/opencode-client`) | | auth | `authenticatedFetch` + token accessors | root (was `/auth`) | | projects REST | the raw REST functions the facade wraps | root (was `/projects-client`) | | api client | `backendApi` — the low-level typed HTTP client | root (was `/api-client`) | | turns | turn-grouping, cost/token, and message-status helpers | root (was `/turns`) | | platform admin | instance lifecycle, members, backups, SSH | root (was `/platform-client`) | | **server isolation** | request-scoped config for multi-tenant backends | **`@kortix/sdk/server`** (first-class, Node/Bun-only) | | **React hooks** | the reactive layer | **`@kortix/sdk/react`** (first-class, needs React) | | browser stores | `sync-store`, `server-store`, and friends | their own subpaths — internal plumbing, see [below](#other-subpaths-internal-plumbing) | > **Warn** > A session is the unit of work and **owns its runtime** — there is no top-level "sandbox" object in > the app-facing API. `@kortix/sdk/session` *is* the runtime surface a host reaches for. The one > exception is internal: `sandbox-connection-store` (see [below](#other-subpaths-internal-plumbing)) > tracks low-level health/reconnect state for the sandbox process itself and uses "sandbox" in its > name — it's plumbing `useSession` consumes, not something host code should import directly. Host > code imports only from `@kortix/sdk/*` — never `@opencode-ai/sdk`, never raw `backendApi` / > `authenticatedFetch` from elsewhere. ## `@kortix/sdk/files` Workspace file operations against the active session's sandbox — read and write, search, and status. The `files` object groups every operation; the individual functions are exported too. ```ts import { files } from '@kortix/sdk'; const tree = await files.list('/workspace/src'); const { content } = await files.read('/workspace/README.md'); const changed = await files.status(); // git-style changed files const hits = await files.findText('TODO'); // ripgrep across the repo await files.upload(file, '/workspace/uploads'); await files.mkdir('/workspace/notes'); await files.rename('/workspace/a.txt', '/workspace/b.txt'); await files.remove('/workspace/old.txt'); ``` Also: `files.findFiles(query)`, `files.copy`, `files.create`, `files.readBlob` (for binary download), `files.toWorkspaceRelative(path)`, and `files.health` / `files.isReachable` for the daemon. **When to use:** one-off file operations in non-React code (uploads, exports, a file picker). In React, prefer the live data hooks; for repo files _outside_ a running session, use `kortix.project(id).files`. ## `@kortix/sdk/session` and `@kortix/sdk/session/url` A session's runtime surface: the liveness probe and the proxy/preview URL builders. Pure and stateless — the same logic the session handle's `.health()`, `.previewUrl()`, and `.proxyUrl()` use under the hood. ```ts import { getSessionHealth, isRuntimeReady } from '@kortix/sdk'; const result = await getSessionHealth(); // GET /kortix/health, never throws if (result.ok && isRuntimeReady(result.health)) { // the runtime is up and OpenCode is ready } ``` `getSessionHealth` returns `{ status, ok, health, body }` and never throws on a non-ok HTTP status — the caller decides what a status means. `isRuntimeReady` applies the one canonical rule for "runtime is ready" to a health payload. The `/url` entry holds the URL helpers for reaching ports the agent exposes: ```ts import { detectLocalhostUrls, rewriteLocalhostUrl, proxyLocalhostUrl } from '@kortix/sdk'; // the agent printed "running on http://localhost:3000" — find and rewrite it const found = detectLocalhostUrls(logLine); const proxied = proxyLocalhostUrl('http://localhost:3000/health', opts); const preview = rewriteLocalhostUrl(3000, '/docs', opts); // proxy URL for a port ``` Plus `parseLocalhostUrl`, `hasLocalhostUrls`, `isProxiableLocalhostUrl`, `buildWebProxyUrl` / `parseWebProxyUrl`, and `isPreviewUrl`. **When to use:** rendering agent output that mentions `localhost`, or building a preview iframe without going through the session handle. ## `@kortix/sdk/opencode-client` `getClient()` returns the typed [OpenCode](/docs/concepts/agents) v2 client for the **active** runtime, with auth injected. This module also re-exports the _entire_ OpenCode v2 type surface (`Session`, `Message`, `Part`, `Agent`, `Config`, … and the `OpencodeClient` type), so you have one place for runtime types. ```ts import { getClient, type Session, type Part } from '@kortix/sdk'; const client = getClient(); const { data } = await client.session.list({ limit: 100 }); ``` Also `getClientForUrl(url)` (a client pinned to a specific runtime), `dropClientForUrl(url)`, and `resetClient()` (used on a runtime switch). **When to use:** a typed runtime call the facade doesn't yet wrap. Prefer `kortix.session(pid, sid).runtime`, the same client scoped to a session. ## `@kortix/sdk/auth` The auth seam: a fetch that injects the bearer token, plus token accessors. The token comes from the `getToken` you passed to `createKortix` — **one token**, a Supabase JWT or a `kortix_pat_…`. ```ts import { authenticatedFetch, getAuthToken } from '@kortix/sdk'; const res = await authenticatedFetch(`${runtimeUrl}/kortix/health`); const token = await getAuthToken(); // the resolved bearer token, or null ``` Also `getSupabaseAccessToken`, `getAuthTokenWithRetry`, `invalidateTokenCache` (call after a 401), and `setBootstrapAuthToken` (seed a token during setup flows). **When to use:** calling a runtime endpoint the SDK doesn't wrap. You should not need this in normal app code — the file/session modules and the facade already authenticate for you. ## `@kortix/sdk/projects-client` The raw, flat REST functions for the Kortix platform API — `listProjects`, `getProjectDetail`, `createProjectSession`, `startProjectSession`, `listProjectSecrets`, `listChangeRequests`, and the rest. The facade's `kortix.projects` / `kortix.project(id)` / `kortix.session(...)` handles are thin id-binding wrappers over exactly these. ```ts import { listProjects, getProjectDetail } from '@kortix/sdk'; const projects = await listProjects(); const detail = await getProjectDetail(projectId); ``` `createProjectSession` accepts bounded non-secret scalar `runtime_context` for wrapper metadata. Kortix persists it across replacement runtimes and injects one JSON envelope (`KORTIX_SESSION_CONTEXT`), never one env var per key: ```ts await createProjectSession(projectId, { runtime_context: { workspace_id: 'org_123', locale: 'fr' }, }); ``` Credentials and dynamic MCP configuration are intentionally not accepted here. Session-specific identities use server-side connection profiles. A manager reconciles the profile through `kortix.project(projectId).connectors.profiles`, stores/rotates its credential there, then supplies only the profile id: ```ts await createProjectSession(projectId, { connector_bindings: { 'customer-data': { profile_id: profile.profile_id }, }, }); ``` The profile must belong to the same project and logical connector, be active, and remain within the agent's manifest connector grant. Revoking it denies the next Executor catalog/call request without exposing the credential to runtime. **When to use:** you want a single REST call without holding a facade instance — e.g. in a server action or a script. In app code, prefer the facade so ids are bound and the surface stays discoverable. ## `@kortix/sdk/api-client` `backendApi` is the low-level typed HTTP client every REST function is built on — `get` / `post` / `put` / `patch` / `delete` / `upload`, each prefixing the configured `backendUrl` and attaching auth. ```ts import { backendApi } from '@kortix/sdk'; const data = await backendApi.get<MyShape>('/some/endpoint'); await backendApi.post('/some/endpoint', { name: 'x' }); ``` **When to use:** a brand-new endpoint that doesn't have a typed wrapper yet. As a wrapper lands in `projects-client`, switch to it. This is the lowest rung — reach for it last. ## `@kortix/sdk/server-store` and `@kortix/sdk/sync-store` > **Warn** > **Internal plumbing — `useSession` composes these.** A normal host never imports them: > `useSession(projectId, sessionId)` owns runtime resolution and the live thread. They're documented > for the in-progress `apps/web` migration and advanced cases; new hosts should not reach for them. The two zustand stores the runtime layer keeps. `server-store` is a thin, read-only view over the **active runtime** — which sandbox the app is currently talking to and how to reach it, owned by the active session via `useSession`; `sync-store` holds the **live thread state** that SSE events stream into. ```ts import { useServerStore, getActiveOpenCodeUrl } from '@kortix/sdk/server-store'; import { useSyncStore } from '@kortix/sdk/sync-store'; const runtimeUrl = getActiveOpenCodeUrl(); // outside React const messages = useSyncStore((s) => s.messages[sessionId]); // in React ``` `server-store` also exports `getSandboxUrlForExternalId`, `getPublicShareUrlForToken`, `deriveSubdomainOpts`, `getActiveDbSandboxId`, `getActiveOpenCodeUrl`, `getActiveSandboxId`, and `getBackendPort` — the resolution helpers behind `getActiveServerUrl()`. There's no "active server" to switch between anymore: an older multi-instance registry and server-switching layer were removed, and a session's runtime is resolved fresh each time from `current-runtime`. `sync-store` exports `useSyncStore` plus the `ascendingId` id generator. **When to use:** reading runtime state outside React, or subscribing to raw store slices. In React UI, prefer the [hooks](/docs/sdk/react) (`useSessionSync`) — they derive render-stable views over `sync-store` for you. ## `@kortix/sdk/turns` Pure, framework-free helpers for turning a session's flat message list into turns (one user message + its assistant replies), plus the cost, token, and status math shared by web and mobile. No React, no store — just functions over plain message/part shapes. ```ts import { groupMessagesIntoTurns, getTurnCost, getSessionCost } from '@kortix/sdk'; const turns = groupMessagesIntoTurns(messages); // pairs users with their replies const cost = getTurnCost(turnParts, pricingLookup); // { cost, tokens } for one turn const total = getSessionCost(messages, pricingLookup); // billed cost for the whole session ``` Also: the `isTextPart` / `isToolPart` / `isFilePart` / … part-type guards, `computeStatusFromPart` and `getTurnStatus` (turn/session status derivation), `getToolInfo` and `getPermissionForTool` / `getQuestionForTool` (tool-call presentation and pending-request matching), `formatCost`, `formatTokens`, `formatDuration`, `stripAnsi`, and the session-tree helpers `childMapByParent` / `sortSessions` / `allDescendantIds`. **When to use:** rendering a chat transcript, a cost/usage summary, or a session tree outside the shape the React hooks already give you — most hosts get this for free through [`useSessionSync`](/docs/sdk/react). ## `@kortix/sdk/platform-client` The instance/sandbox admin surface: lifecycle (`ensureSandbox`, `restartSandbox`, `stopSandbox`, …), members and scopes, invites, backups, SSH access, and update management, plus the URL builders they share. This is the API the instance-admin UI is built on, not something a typical session-scoped host needs — several of its member/invite functions are now stubs that throw, pointing callers at the newer project-access surface instead (a legacy layer kept for older call sites, not something to build new code on). ```ts import { restartSandbox, listSandboxes } from '@kortix/sdk'; const sandboxes = await listSandboxes(); await restartSandbox(sandboxes[0].sandbox_id); ``` **When to use:** building or extending the instance/admin surface. Session and project code should use the [facade](/docs/sdk/the-client) instead. ## `@kortix/sdk/server` Node/Bun-only. Fixes a real hazard: `configureKortix()`/`createKortix()` store the platform config — including the bearer-token getter — in a single process-wide global, which is unsafe for a server handling concurrent requests for different users (two in-flight requests can clobber each other's token). `createScopedKortix` and `runWithKortix` isolate that config per request using Node's `AsyncLocalStorage`, for a third-party backend that wraps Kortix on behalf of multiple tenants at once ("Kortix as a Backend"). ```ts import { createScopedKortix } from '@kortix/sdk/server'; export async function handler(req: Request) { const kortix = createScopedKortix({ backendUrl, getToken: () => tokenFor(req) }); return kortix.projects.list(); } ``` > **Warn** > Never import this subpath from a browser bundle — it statically pulls in `node:async_hooks`. It's > unrelated to `@kortix/sdk/server-store`, which is a browser-side zustand store for the active > runtime. **When to use:** a Node/Bun server process proxying Kortix for multiple end users concurrently. A single-tenant server, a CLI, or a browser host should use the root `@kortix/sdk` config seam instead. ## Session transcripts `formatTranscript` is a root `@kortix/sdk` export (not a subpath) — a pure, DOM-free `SessionInfo`/`MessageWithParts[]` → Markdown formatter, ported from the OpenCode TUI, so any host (web, mobile, a CLI) can export the same transcript. ```ts import { formatTranscript, getTranscriptFilename, DEFAULT_TRANSCRIPT_OPTIONS } from '@kortix/sdk'; const markdown = formatTranscript(sessionInfo, messages, DEFAULT_TRANSCRIPT_OPTIONS); const filename = getTranscriptFilename(sessionInfo.id); // "session-<id>.md" ``` This formats messages you already have client-side. For the compact, **server-generated** transcript (no tool inputs/outputs), see [`s.transcript()`](/docs/sdk/sessions#audit--transcript) on the session handle — that one is callable with project-scoped session tokens. ## Other subpaths (internal plumbing) The remaining `@kortix/sdk/*` subpaths are small, mostly internal modules the layers above are built from. A normal host doesn't import these directly, but they're real, shipped exports: - **`@kortix/sdk/event-stream`** — `openEventStream`, the framework-free SSE connect/reconnect/coalesce primitive the SDK's live-update plumbing (the React event hooks, and ultimately `sync-store`'s updates) is built on. - **`@kortix/sdk/sandbox-connection-store`** — a zustand store tracking sandbox health/reconnect bookkeeping (`useSandboxConnectionStore`, `setSandboxStatus`, `incrementSandboxFail`, recovery-phase tracking). The one place "sandbox" appears as a concept in the public surface — see the callout above. - **`@kortix/sdk/opencode-pending-store`** — a zustand store of in-flight permission/question requests from the runtime (`useOpenCodePendingStore`), deduped against ones the user already answered. - **`@kortix/sdk/config`** — `configureKortix`, `platformConfig`, `isConfigured`; the same module the root `@kortix/sdk` entry re-exports, reachable directly. - **`@kortix/sdk/feature-flags`** — resolves `featureFlags` (e.g. `enableAutoModel`, `enableProjects`) from `configureKortix({ featureFlags })` overrides, then `NEXT_PUBLIC_*` env, then defaults. - **`@kortix/sdk/fresh-sessions`** — an in-memory `markSessionFresh` / `isSessionFresh` set so a just-created session renders the instant shell instead of the resume loader. - **`@kortix/sdk/instance-routes`** — the `kortix-active-instance` cookie and path helpers (`buildInstancePath`, `extractInstanceRoute`, …) for instance-scoped app routes. - **`@kortix/sdk/opencode-errors`** — normalizes OpenCode `ConfigInvalidError` payloads into a readable message (`formatOpenCodeRuntimeError`). - **`@kortix/sdk/idb-sync-cache`** — the IndexedDB persistence layer behind `sync-store`'s stale-while-revalidate cold-load cache. ## Next - [React hooks](/docs/sdk/react) — the reactive layer built on these modules. - [The client](/docs/sdk/the-client) — the imperative facade that wraps them. --- <!-- /markdown/docs/sdk/react.md --> # React hooks @kortix/sdk/react — one hook to run a session (useSession), plus reactive hooks for models, config, PTY, and the session list. Canonical page: https://kortix.com/docs/sdk/react `@kortix/sdk/react` is the **reactive** half of the SDK. Where `createKortix(...)` is imperative — you call a method, you get a promise — these hooks subscribe to **live** data and re-render as it changes. They're built on [React Query](https://tanstack.com/query) and [zustand](https://zustand-demo.pmnd.rs/), and kept current by OpenCode's SSE event stream. You almost never touch the runtime plumbing directly. **One hook runs a session:** ```tsx import { useSession } from '@kortix/sdk/react'; function Chat({ projectId, sessionId }: { projectId: string; sessionId: string }) { const s = useSession(projectId, sessionId); if (s.phase !== 'ready') return <Booting stage={s.stage} onRetry={s.retry} />; return ( <> {s.messages.map(({ info, parts }) => ( <Message key={info.id} info={info} parts={parts} /> ))} <Composer busy={s.isBusy} disabled={s.runtimePhase !== 'ready'} onSend={(text) => s.send(text)} onStop={s.cancel} /> </> ); } ``` That's the whole contract. No event provider to mount, no health poller, no "server store" to switch, no sandbox id, no canonical-session resolution — `useSession` owns all of it internally. ## `useSession(projectId, sessionId, options?)` The single hook for an open session. Internally it drives `POST /start` (the server long-polls), resolves the session's runtime, opens the SSE stream, resolves the canonical OpenCode id, and syncs messages — exposing one `phase` plus the thread, the actions, the interactive prompts, and the pre-runtime capabilities. **Readiness is server-truth:** the runtime is ready when `/start` returns `stage: 'ready'` (the backend only returns that once the daemon answered). There is **no client-side health poll** — so a fresh session's first turn streams immediately, and the old "stuck until you hard-refresh" failure mode cannot occur. Call it **once per session view** (like a provider) — it owns the SSE subscription and the `/start` poll for that `(projectId, sessionId)`. ### Returns | field | type | what | | ------------------------------------ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `phase` | `'starting' \| 'ready' \| 'error'` | top-level gate — render the boot screen until `ready` | | `stage` | `SessionStartStage \| null` | raw `/start` stage (`provisioning` / `starting` / `ready` / …) for boot UI | | `runtimePhase` | `'connecting' \| 'booting' \| 'ready' \| 'unreachable'` | granular live connection phase (for a mid-session reconnect pill) | | `messages` | `{ info, parts }[]` | the thread; parts stream in live | | `status` · `isBusy` | `SessionStatus` · `boolean` | session status · agent is generating | | `isLoading` · `isError` | `boolean` | first hydrate in flight · terminal `/start` state | | `questions` · `permissions` | `[]` | pending agent questions / approvals (from the live prompt store) | | `hasPending` | `boolean` | there are open questions/permissions | | `diffs` · `todos` | `[]` | live file diffs · todo items | | `pending` | `string \| null` | the optimistic in-flight user message text | | `isSending` · `sendError` | `boolean` · `KortixSendError \| null` | current `send` call in flight · its last failure (billing / runtime-not-ready / runtime-error), reset on every new `send` | | `opencodeSessionId` | `string \| null` | the resolved canonical OpenCode root id | | `models` · `agents` | `[]` | selectable models · agents (server-side, available pre-runtime) | | `defaultAgent` · `commands` | `string \| null` · `[]` | the project default agent · slash commands | | `picks` | `SessionPicks` | per-session model/agent selection (persisted) | | `sandbox` · `switched` · `retriable` | row · `boolean` · `boolean` | the `/start` sandbox row · runtime switched-in · can re-poll | | `startError` · `reason` | terminal `/start` failure · `string \| null` | render instead of spinning forever · latest `/start` reason (e.g. `'runtime_waking'`) for boot UI | ### Actions | action | what | | --------------------------------------- | ----------------------------------------------------------------------------------- | | `send(text, override?)` | send a prompt (optimistic). `override` = `{ model?, agent? }` for this message | | `cancel()` | abort the run **and** clear any pending prompt + open questions/permissions | | `runCommand(command, args)` | run a project slash-command (`/command`) | | `answerQuestion(id, answers)` | reply to a pending agent question through the server | | `rejectQuestion(id)` | reject a pending agent question through the server | | `answerPermission(id, reply, message?)` | reply to a pending permission request — `reply` is `'once' \| 'always' \| 'reject'` | | `retry()` | force a re-poll of `/start` (e.g. a boot-screen Retry button) | > **Warn** > `removeQuestion(id)` / `removePermission(id)` are also on the returned object but **deprecated**: > they drop the prompt from local state *without* replying to the server, so the agent run stays > blocked waiting on it. Use `answerQuestion` / `rejectQuestion` / `answerPermission` above instead. ### Options | option | default | what | | ------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `waitMs` | `15000` | long-poll budget requested on `/start` (the server clamps it) | | `replayStartStash` | `true` | replay a stashed first message (see [start-stash](#start-stash)) once ready + empty | | `enabled` | `true` | gate the whole hook — set false until a precondition resolves (e.g. a billing check) | | `chatEngine` | `true` | mount the chat-consumption engine (message sync + the question self-heal poll) on this call. Set `false` when the host mounts its _own_ chat surface for the same session (e.g. `apps/web`'s `SessionChat`) — avoids double-mounting the same pollers; `messages`/`diffs`/`todos` stay empty and `replayStartStash` is force-disabled in that mode | > **Note** > Sending is optimistic and fire-and-forget: `send` shows your message instantly, then SSE events > stream the agent's reply into the thread. `useSession` clears the optimistic bubble when your > message lands (or after a 30s backstop). ## `KortixProjectProvider` `useSession` itself needs no provider — but a few route-scoped hooks (like `useOpenCodeProviders()` under [More hooks](#more-hooks)) resolve "the project the user is looking at" from React context instead of a parameter, since the SDK is router-agnostic and can't call `useParams()` itself. A Next (or other router-based) host derives the project id once and mounts the provider near its root: ```tsx import { KortixProjectProvider } from '@kortix/sdk/react'; function ProjectLayout({ projectId, children }: { projectId: string; children: React.ReactNode }) { return <KortixProjectProvider projectId={projectId}>{children}</KortixProjectProvider>; } ``` `useKortixRouteProjectId()` is the read side — it's what those hooks call internally, and it's exported for a host that wants the same value. Outside a project scope it returns `null`. ## Capability hooks (available before the runtime) Model, agent, and command catalogs come from the **server**, so you can build a picker on a "new session" screen _before_ any sandbox exists. These are plain React Query reads — no runtime required. ```tsx import { useProjectModels, useVisibleAgents, useProjectConfig } from '@kortix/sdk/react'; const models = useProjectModels(projectId); // selectable model rows const agents = useVisibleAgents({ projectId }); // visible agents const config = useProjectConfig(projectId); // { open_code_default_agent, commands, … } ``` `useSession` already exposes these as `s.models` / `s.agents` / `s.defaultAgent` / `s.commands` for a session view; use the standalone hooks on screens that don't hold a session yet. ## Primitives Small building blocks the white-label reference uses — all owned by the SDK so every host shares one implementation. | primitive | what | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `useSessionPicks(sessionId)` | `{ model, agent, setModel, setAgent }` — per-session selection, persisted locally | | `useRuntimePhase()` | the granular `'connecting' \| 'booting' \| 'ready' \| 'unreachable'` phase (also `s.runtimePhase`) | | `generateSessionId()` | an RFC-4122 v4 id, with a fallback for non-secure contexts (http on a LAN) — imported from the **root** `@kortix/sdk` entry, not `/react` | **Start-stash** — the new-session → workbench hand-off. Your "what would you like to build?" screen collects a prompt + model + agent before the runtime exists; stash them under the new id, and `useSession` replays them as the first message once ready: ```tsx import { writeStartStash } from '@kortix/sdk/react'; import { generateSessionId } from '@kortix/sdk'; // root entry — not re-exported from /react // new-session screen: const sessionId = generateSessionId(); await kortix.project(projectId).sessions.create({ session_id: sessionId, name }); writeStartStash(sessionId, { prompt, model, agent }); router.push(`/projects/${projectId}/sessions/${sessionId}`); // the session page just calls useSession(...) — the stash replays automatically. ``` Also `readStartStash(sessionId)` / `clearStartStash(sessionId)` / `startStashKey`. ## Session list & CRUD The session list and per-session lifecycle, as React Query queries and mutations. ```tsx import { useOpenCodeSessions, useCreateOpenCodeSession, useUpdateOpenCodeSession, useDeleteOpenCodeSession, } from '@kortix/sdk/react'; function Sidebar() { const { data: sessions = [] } = useOpenCodeSessions(); const create = useCreateOpenCodeSession(); const rename = useUpdateOpenCodeSession(); const remove = useDeleteOpenCodeSession(); // … } ``` | hook | purpose | | ----------------------------------------------------------- | --------------------------------------------------- | | `useOpenCodeSessions()` · `useOpenCodeSession(id)` | the live session list · one record | | `useCreateOpenCodeSession()` | create `{ directory?, title? }` | | `useUpdateOpenCodeSession()` | rename / archive `{ sessionId, title?, archived? }` | | `useDeleteOpenCodeSession()` | delete | | `useSummarizeOpenCodeSession()` · `useInitSession()` | compact the thread · run `/init` | | `useOpenCodeSessionDiff(id)` · `useOpenCodeSessionTodo(id)` | working diff · todo list | ## Project data hooks Reactive counterparts of the imperative [`p.secrets`](/docs/sdk/the-client#p-secrets) / [`p.triggers`](/docs/sdk/the-client#p-triggers) / [`p.changeRequests`](/docs/sdk/the-client#p-changerequests) surfaces on [The client](/docs/sdk/the-client) — each a thin React Query binding with its own query-key factory, and mutations that invalidate the list so a write reflects without a manual refetch. ```tsx import { useProjectSecrets, useProjectTriggers, useChangeRequests } from '@kortix/sdk/react'; const secrets = useProjectSecrets(projectId); // { data, upsert, remove, setPersonal, removePersonal } const triggers = useProjectTriggers(projectId); // { data, create, update, remove, fire } const crs = useChangeRequests(projectId, 'open'); // { data, open, merge, close, requestChanges } ``` | hook | mirrors | query key | | --------------------------------------- | ------------------------------------------------------------------------ | ------------------------------- | | `useProjectSecrets(projectId)` | `p.secrets` — list + upsert/remove, personal-override set/remove | `projectSecretsKey(projectId)` | | `useProjectTriggers(projectId)` | `p.triggers` — list + create/update/remove/fire | `projectTriggersKey(projectId)` | | `useChangeRequests(projectId, status?)` | `p.changeRequests` — list (filterable) + open/merge/close/requestChanges | `changeRequestsKey(projectId)` | ## Runtime config Read and write OpenCode's runtime config (`opencode.jsonc` as the server sees it); updates are optimistic and roll back on error. ```tsx import { useOpenCodeConfig, useUpdateOpenCodeConfig } from '@kortix/sdk/react'; const { data: config } = useOpenCodeConfig(); const update = useUpdateOpenCodeConfig(); update.mutate({ permission: { edit: 'allow' } }); ``` ## MCP servers > **Warn** > **Being retired.** Tools/connectors are moving to the **Executor connectors** model — manage them > via [`kortix.project(id).connectors`](/docs/sdk/the-client#p-connectors), not these runtime MCP > hooks. Documented for the current runtime, but prefer connectors for new code. `useOpenCodeMcpStatus()`, `useAddMcpServer()`, `useConnectMcpServer()`, `useDisconnectMcpServer()`, and the OAuth trio `useMcpAuthStart()` / `useMcpAuthCallback()` / `useMcpAuthRemove()`. ## PTY terminal A live terminal into the session's sandbox — list/create/remove/resize PTYs, then open a WebSocket for the byte stream. Backed by Kortix's own PTY implementation in the sandbox daemon (`/kortix/pty`), independent of whatever agent runtime is running — the hook names/shapes below are unchanged from before that move. ```tsx import { useOpenCodePtyList, useCreatePty, useRemovePty, getPtyWebSocketUrl, } from '@kortix/sdk/react'; const { data: ptys = [] } = useOpenCodePtyList(); const create = useCreatePty(); const url = await getPtyWebSocketUrl(id); // wss://… with the auth token appended ``` Use `useUpdatePty()` to push a title or `{ rows, cols }` resize. ## Model store `useModelStore(allModels, opts?)` is a zustand-backed store of the user's model preferences — visibility, recents, per-agent and per-session selections, the global default, variants. Persisted client-side and pure UI state (it does **not** fetch — pass the flattened compact catalog from `kortix.project(id).modelPicker()` or `useProjectModels()`). ```tsx import { useModelStore, type ModelKey } from '@kortix/sdk/react'; const store = useModelStore(models, { connectedProviderIds }); store.setSessionModel(sessionId, model); ``` Helpers include `isVisible` / `setVisibility`, `recent` / `pushRecent`, `getVariant` / `setVariant`, `getSessionModel` / `setSessionModel`, `globalDefault` / `setGlobalDefault`, and `userPrefs`; `seedGlobalDefaultFromServer(model)` — a plain function, not a hook — seeds the global default from the server the first time it resolves. ## Lower-level hooks (what `useSession` composes) > **Warn** > **Internal plumbing.** These are the pieces `useSession` wraps — the SSE provider, message sync, > the prompt mutations, and the OpenCode-id resolver. A chat-focused host uses `useSession` and > never touches them. They stay exported for hosts that build **beyond chat** (a full IDE — file > tree, terminal, git) and for `apps/web`'s own `session-chat`; if you're just rendering a > conversation, reach for `useSession`. | hook | composed role | | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `useSessionSync(sessionId)` | fetch + live-merge the thread off the sync store | | `useSendOpenCodeMessage()` · `useAbortOpenCodeSession()` | prompt · abort — both take no arguments; the runtime is resolved through the single global client, not a per-session one | | `useExecuteOpenCodeCommand()` | run a slash-command | | `OpenCodeEventStreamProvider` / `useOpenCodeEventStream()` | open the SSE stream → the stores | | `useCanonicalOpenCodeSession({ projectId, sessionId, pinFromStart })` | resolve the OpenCode root id | | `useOpenCodePendingStore()` | the live questions/permissions store | | `useRuntimeReconnect()` | the independent liveness probe that detects a mid-session runtime death the SSE heartbeat can't see — polls `getSessionHealth`/`isRuntimeReady` and writes into the connection store | | `useQuestionSelfHeal()` / `hasRunningQuestionTool` | polls for a missed `question.asked` SSE event and repairs pending-question state | | `usePermissionSelfHeal()` / `findPermissionBlockedCandidate` / `hasActiveNonQuestionTool` | the same self-heal watchdog for missed permission requests | | `useGatewayCatalogSync(projectId)` | keeps the gateway model catalog cache warm | | `useGatewayRoutingPolicy(projectId)` | reads routing policy and exposes set, reset, and preview mutations | ## More hooks | hook | what | | ------------------------------------------------------------ | -------------------------------------------------- | | `useProjectModels` · `useVisibleAgents` · `useProjectConfig` | server-side models · agents · config (pre-runtime) | | `useOpenCodeProviders()` | configured providers + their models | | `useOpenCodeCommands()` · `useExecuteOpenCodeCommand()` | slash commands · run one | | `useOpenCodeToolIds()` · `useOpenCodeSkills()` | available tool ids · skills | | `useShareSession()` · `useUnshareSession()` | public share links | | `useBackgroundSessionPrefetch(sessions)` | warm message caches on hover | The `opencodeKeys` query-key factory is exported too, for targeted `invalidateQueries` / `setQueryData` against the same cache. ## Kortix Master (tasks, tickets, credentials, projects) `@kortix/sdk/react` also re-exports the full **Kortix Master** React Query layer — the hooks behind a project's task board, ticket/kanban system, credentials vault, and project/team management. This is the same hook set `apps/web`'s Kortix UI runs on end to end; it's exported from the SDK so any host can build the same surface. These hooks talk to the `/kortix/*` daemon routes and are otherwise plain React Query — no relation to `useSession`'s runtime plumbing. > **Note** > Most of these hooks take a **`KortixMasterIdentity`** as their first argument > instead of reading an auth hook internally: > > ```ts > interface KortixMasterIdentity { > userId: string | null; > handle: string; // stamped as actor_id / created_by_id on mutations > isLoading: boolean; > } > ``` > > This is the one seam between the SDK and a host's auth stack — derive it from > Supabase, a mobile auth SDK, a service-account token, whatever the host uses, > and pass it in. Hooks that don't need authorship (plain reads/writes scoped > only by project or id) skip the argument. > ### Credentials A project's secrets vault (`/kortix/projects/:id/credentials`) — values are never returned by the list call; `useRevealCredential` is a separate, audit-logged read. ```tsx import { useCredentials, useUpsertCredential, useRevealCredential } from '@kortix/sdk/react'; const { data: credentials = [] } = useCredentials(projectId); const upsert = useUpsertCredential(); upsert.mutate({ projectId, name: 'STRIPE_KEY', value: 'sk_...', description: 'Stripe secret key' }); ``` | hook | purpose | | -------------------------------------------------------------------- | ----------------------------------------------------------- | | `useCredentials(projectId)` · `useCredentialEvents(projectId, name)` | list a project's credentials · one credential's audit trail | | `useUpsertCredential()` | create or replace a credential's value | | `useRevealCredential()` | decrypt + return one value (audit-logged as a read) | | `useDeleteCredential()` | remove a credential | ### Kortix projects The project registry itself (`/kortix/projects`) — distinct from an SDK `Project` (a sandbox/session container); a Kortix project is the task/ticket/credentials workspace layered on top. ```tsx import { useKortixProjects, useKortixProject, usePatchProject } from '@kortix/sdk/react'; const { data: projects = [] } = useKortixProjects(identity); const { data: project } = useKortixProject(identity, projectId); ``` | hook | purpose | | ---------------------------------------------------------------- | ---------------------------------------------------------------- | | `useKortixProjects(identity)` · `useKortixProject(identity, id)` | list · one project | | `useKortixProjectForSession(identity, sessionId)` | resolve the Kortix project a session belongs to (`null` if none) | | `useKortixProjectSessions(identity, projectId)` | OpenCode sessions linked to a project | | `usePatchProject()` | update name / description / `user_handle` | | `useDeleteProject()` | delete a project | ### Tasks The async task queue (`/kortix/tasks`) — a task is a unit of work an agent picks up, with a `verification_condition` an approval step checks against. ```tsx import { useKortixTasks, useCreateKortixTask, useStartKortixTask, useApproveKortixTask, } from '@kortix/sdk/react'; const { data: tasks = [] } = useKortixTasks(projectId, 'in_progress'); const create = useCreateKortixTask(); create.mutate({ project_id: projectId, title: 'Fix the flaky test', verification_condition: 'CI green', }); ``` | hook | purpose | | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `useKortixTasks(projectId?, status?)` · `useKortixTask(id)` | list (optionally filtered) · one task, both poll every 3s | | `useKortixTaskEvents(id)` · `useKortixTaskStatus(id)` | a task's event log · live execution status | | `useCreateKortixTask()` · `useUpdateKortixTask()` | create · patch | | `useStartKortixTask()` | hand the task to an agent (`status → in_progress`) | | `useApproveKortixTask()` | approve a task sitting in `awaiting_review` | | `useDeleteKortixTask()` | delete | | `normalizeTask(raw)` | pure helper — coerces an unvalidated wire row to `KortixTask`, defaulting an unrecognized `status` to `'todo'` | `KortixTaskStatus` is one of `todo` · `in_progress` · `input_needed` · `awaiting_review` · `completed` · `cancelled`. ### Tickets & kanban The full ticket system (`/kortix/tickets`) plus a project's board configuration — columns, custom fields, templates, and the team of project agents tickets get assigned to. ```tsx import { useTickets, useCreateTicket, useAssignTicket, useCommentTicket } from '@kortix/sdk/react'; const { data: tickets = [] } = useTickets(projectId); const createTicket = useCreateTicket(identity); createTicket.mutate({ project_id: projectId, title: 'Ship the react.mdx page' }); ``` | hook | purpose | | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `useTickets(projectId?)` · `useTicket(id)` · `useTicketEvents(id)` | list (polls 3s) · one ticket · its comment/audit trail | | `useCreateTicket(identity)` · `useUpdateTicket(identity)` | create · patch (title, body, custom fields, template, milestone) | | `useUpdateTicketStatus(identity)` | move a ticket to another column | | `useAssignTicket(identity)` · `useUnassignTicket(identity)` | assign/unassign a user or agent | | `useCommentTicket(identity)` | post a comment (drives `@mention` notifications) | | `useDeleteTicket()` | delete | | `useColumns(projectId)` · `useReplaceColumns()` | board columns · replace the whole set (label, terminal/off-flow flags, default assignee) | | `useFields(projectId)` · `useReplaceFields()` | custom fields · replace the set (`text` / `number` / `date` / `select`) | | `useTemplates(projectId)` · `useReplaceTemplates()` | ticket templates · replace the set | | `useProjectAgents(projectId)` | the project's team of agents tickets can be assigned to | | `useCreateProjectAgent()` · `useUpdateProjectAgent()` · `useDeleteProjectAgent()` | manage a project agent (persona, execution mode, tool groups, model) | | `useAgentPersona(projectId, slug)` | one agent's full persona body | | `useEnsurePmSession()` | create-if-missing the project's Project-Manager chat session (idempotent) | **Activity & notifications** — derived client-side from the ticket event stream, no separate notifications endpoint: ```tsx import { useProjectActivity, computeNotifications, readLastSeen, writeLastSeen, } from '@kortix/sdk/react'; const { data: events = [] } = useProjectActivity(projectId); // polls 10s const notifications = computeNotifications( events, identity.handle, readLastSeen(projectId, identity.handle), ); ``` | hook / helper | purpose | | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `useProjectActivity(projectId)` | the project's raw ticket-event stream (200 most recent) | | `computeUnread(events, handle, sinceIso)` | pure — unread count assigned-to-me / @mentioning me, total + per-ticket | | `computeNotifications(events, handle, sinceIso)` | pure — the same rule set as a renderable `ProjectNotification[]` | | `readLastSeen(projectId, handle)` · `writeLastSeen(projectId, handle, iso)` | localStorage last-seen cursor (write emits `kortix:last-seen-changed` for same-tab listeners) | ### Milestones Grouping/tracking layer above tickets (`/kortix/projects/:id/milestones`) — a milestone has an acceptance note and closes with a summary. | hook | purpose | | --------------------------------------------------------------------- | --------------------------------------------------------------------------- | | `useMilestones(projectId, statusFilter?)` | list, filtered `'open' \| 'closed' \| 'all'` (polls 5s) | | `useMilestone(projectId, ref)` · `useMilestoneEvents(projectId, ref)` | one milestone (with progress) · its event log | | `useCreateMilestone()` · `useUpdateMilestone()` | create · patch | | `useCloseMilestone()` · `useReopenMilestone()` | close (with `summary_md`, optionally `cancelled`) · reopen | | `useDeleteMilestone()` | delete | | `useSetTicketMilestone()` | link/unlink a ticket's milestone (goes through `PATCH /kortix/tickets/:id`) | ### Sandbox services Long-running background processes inside the sandbox (`/kortix/services`) — dev servers, databases, workers — registered and supervised by the daemon. | hook | purpose | | -------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `useSandboxServices(identity, opts?)` | list registered services (polls 5s); `includeAll` also returns hidden/system ones | | `useSandboxServiceTemplates(identity)` | available service templates | | `useSandboxServiceLogs(identity, serviceId)` | a service's log tail (polls 3s while a `serviceId` is set) | | `useSandboxServiceAction()` | `{ serviceId, action }` — start/stop/restart a service | | `useSandboxServiceReconcile()` | re-sync the daemon's service state against what's actually running, optionally `reload` | | `useRegisterSandboxService()` | register a new service | | `useSandboxRuntimeReload()` | `{ mode }` — reload the OpenCode runtime itself (`systemReload`, shared with `../opencode/client`) | ## Next - [The client](/docs/sdk/the-client) — the imperative `createKortix` facade. - [Sessions](/docs/sdk/sessions) — the imperative session handle + the runtime. - [Modules](/docs/sdk/modules) — lower-level subpath imports. --- <!-- /markdown/docs/sdk/sessions.md --> # Sessions The session handle — lifecycle, the session-owned runtime (health & previews), and the typed opencode runtime. Canonical page: https://kortix.com/docs/sdk/sessions A **session** is one agent run, in its own disposable sandbox, on its own git branch. `kortix.session(projectId, sessionId)` binds both ids and is the single handle for everything a session does. ```ts const s = kortix.session(projectId, sessionId); ``` > **Note** > **Session-scoped, by design.** A session owns its runtime, so you ask the *session* about health > and previews — never a global "sandbox." The sandbox is plumbing the SDK resolves for you. ## Lifecycle ```ts await s.get(); // session detail await s.update({/* … */}); // rename, settings await s.start(); // provision + boot the runtime await s.restart(); // restart the same runtime in place await s.stop(); // stop the runtime without deleting the session await s.commit(); // commit the agent's work await s.setSharing(intent); // sharing / visibility await s.delete(); ``` `restart()` preserves the session's established sandbox identity and refreshes the handle's cached readiness state. If the original provider object is unavailable, restart fails explicitly instead of attaching an empty replacement. `delete()` removes the runtime; `stop()` clears readiness without deleting the session itself. ### Previews & public shares ```ts await s.previews(); // candidate preview ports the runtime exposes await s.publicShares.list(); await s.publicShares.create(input); await s.publicShares.revoke(shareId); ``` ### Audit & transcript ```ts await s.audit(limit?); // per-session audit trail of executor-gated agent actions await s.transcript(options?); // compact server-side transcript (text + tool calls, no tool inputs/outputs) ``` `transcript()` is callable with project-scoped session tokens, so it's the right read for a scoped/embedded host that only has a session token. ## The runtime The session owns its runtime, so these resolve the active sandbox for you — you pass a port or a URL, never a sandbox id. | method | returns | what | | --------------------------- | ------------------------------ | -------------------------------------------------------------------- | | `s.health(init?)` | `{ status, ok, health, body }` | runtime liveness + whether OpenCode is ready | | `s.previewUrl(port, path?)` | `string` | proxy/preview URL for a port the agent exposed | | `s.proxyUrl(url?)` | `string \| undefined` | rewrite a localhost URL the agent printed into a reachable proxy URL | ```ts const { ok, health } = await s.health(); // health?.runtimeReady · health?.status · health?.version … const url = s.previewUrl(3000, '/docs'); // → the live preview URL const fixed = s.proxyUrl('http://localhost:8080'); ``` `s.health()` never throws `SessionNotReadyError` — it's safe to poll before the session has ever resolved a runtime. `s.previewUrl()` and `s.proxyUrl()` do require a resolved runtime; call `s.ensureReady()` first (or `s.send()` / `s.abort()`, which call it internally). > **Note** > Stateless URL helpers — detecting localhost URLs in agent output, parsing preview URLs — live at > [`@kortix/sdk/session`](/docs/sdk/modules#kortix-sdk-session). The session handle wraps them with > the sandbox context already resolved. ## Talking to the agent These are the opinionated wrappers over the runtime — the right entry points for a script, server, worker, or any non-React host. Each auto-provisions the runtime via `ensureReady()` internally, resolves the OpenCode session id for you, and always acts against **this handle's own** resolved runtime — never whatever sandbox happens to be globally "active" — so two handles on two different sessions never cross wires. ```ts await s.ensureReady(); // provision/resume the runtime; idempotent s.setModel({ providerID, modelID }); // sticky model for subsequent send()s s.setAgent('build'); // sticky agent for subsequent send()s await s.send('Refactor the auth module'); // prompt the agent await s.send('One-off task', { model, agent }); // per-call override await s.abort(); // abort the current run ``` Call `s.ensureReady()` (or `send`/`abort`, which call it internally) before `.runtime`, `.previewUrl()`, or `.proxyUrl()` — those throw `SessionNotReadyError` if this handle hasn't resolved a runtime yet. `.health()` is the exception: it never throws and is safe to call anytime. ### Streaming events — `s.stream()` For **live** message / part / event streaming from a non-React host, use `s.stream()` — a framework-free facade over the same primitive [`useSession`](/docs/sdk/react#usesessionprojectid-sessionid-options) uses internally. It handles connect/reconnect/backoff and a 15s heartbeat watchdog. ```ts const handle = await s.stream({ onEvent: (event) => console.log(event), onGapRehydrate: (gapMs) => console.log('reconnected, gap:', gapMs), }); // later handle.close(); ``` In a React app, prefer [`useSession(projectId, sessionId)`](/docs/sdk/react#usesessionprojectid-sessionid-options) instead — the hook that owns the whole runtime (start, SSE, readiness) and returns the thread, `send`, and the boot `phase`. ### The typed runtime — `s.runtime` `s.runtime` is the typed **OpenCode v2 client**, scoped to this session and reached only through the SDK — the escape hatch for anything not covered by `send`/`abort`/`stream`. It requires a resolved runtime (call `s.ensureReady()` first, or use it after `send`/`abort`/`stream` have run). ```ts await s.ensureReady(); // send a prompt (equivalent to s.send(), shown for the raw client) await s.runtime.session.prompt({ sessionID: (await s.ensureReady()).opencodeSessionId, parts: [{ type: 'text', text: 'Refactor the auth module' }], }); ``` > **Warn** > The OpenCode `sessionID` the runtime expects is **not** the Kortix `sessionId` passed to > `kortix.session(projectId, sessionId)` — it's resolved server-side at `/start` and cached on the > handle. Prefer `s.send()` / `s.abort()`, which resolve it for you; only reach for raw `s.runtime` > calls when you need something those wrappers don't cover. > **Warn** > Never import `@opencode-ai/sdk` directly. `s.runtime` is the same client, owned by the SDK, with > the full opencode type surface re-exported from > [`@kortix/sdk/opencode-client`](/docs/sdk/modules#kortix-sdk-opencode-client). ## Files — `s.files` `s.files` is the session-scoped equivalent of the top-level `@kortix/sdk` `files` export: the same 12-op workspace surface, but every call auto-provisions via `ensureReady()` and always targets **this handle's own** resolved runtime — never the module-global "active" sandbox. This fixes a cross-session bleed bug: a host juggling multiple open sessions that called the global `files.list()` could silently read/write the wrong sandbox. ```ts await s.files.list(dirPath); await s.files.read(filePath); await s.files.readBlob(filePath); await s.files.status(); await s.files.findFiles(query, { type: 'file', limit }); await s.files.findText(pattern); await s.files.upload(file, targetPath?, filename?); await s.files.create(filePath); await s.files.copy(sourcePath, destPath); await s.files.remove(filePath); await s.files.mkdir(dirPath); await s.files.rename(from, to); ``` --- <!-- /markdown/docs/sdk/streaming.md --> # Streaming How live events flow — the readiness handshake, session.stream(), narrowChatEvent, reconnect machinery, and exactly which runtimes support streaming. Canonical page: https://kortix.com/docs/sdk/streaming Live updates are the SDK's most powerful surface and its most environment-sensitive one. This page covers how the stream works, the pattern for consuming it correctly, and precisely where it runs. ## The mental model A session's events come from the **OpenCode runtime inside its sandbox**, reached through the Kortix API as a proxy. There is no separate WebSocket endpoint or `EventSource` — the transport is **`fetch` with a streaming response body** (server-sent events read via `ReadableStream` + `TextDecoderStream`). The SDK owns everything above the wire: **reconnection, backoff, heartbeat detection, and event coalescing** live in the SDK's event-stream layer, so a dropped connection resumes without your code noticing. ## The pattern Three rules make streaming correct: 1. **Ready the session first.** The runtime doesn't exist until the sandbox is provisioned or resumed — that's `ensureReady()`. 2. **Connect the stream before sending**, so no early events are missed. 3. **End the turn on `session.idle`**, not on a timer. ```ts const session = kortix.session(projectId, sessionId); const { opencodeSessionId } = await session.ensureReady(); const stream = await session.stream({ onEvent: (event) => { const e = narrowChatEvent(event); if (!e) return; switch (e.type) { case 'message.part.updated': // a part grew — render incrementally case 'message.updated': // message metadata changed case 'session.status': // busy / building / … break; case 'question.asked': // the agent needs an answer break; case 'session.error': // the turn failed — surface e.error break; case 'session.idle': // the turn is DONE if (e.sessionID === opencodeSessionId) onTurnDone(); break; } }, }); await session.send('…'); // … await your idle signal … stream.close(); ``` `narrowChatEvent(event)` narrows the raw wire events to a small typed union — everything you need for a chat UI — and returns `null` for events you can ignore. In React, skip all of this: [`useSession`](/docs/sdk/react) owns the stream, readiness, and reconnect for you. ## Readiness is a handshake, not a one-liner `ensureReady()` issues **one bounded long-poll** against the session's `/start` endpoint. On a warm session it resolves immediately; on a **cold boot** — a sandbox being provisioned for the first time — the long-poll can return before the sandbox is up, and `ensureReady()` throws a typed `ApiError` with `code: 'RUNTIME_UNAVAILABLE'`. That error means _not ready yet_, not _failed_. Retry it — each retry re-attaches to the same server-side provision: ```ts async function retryUntilReady<T>(ensure: () => Promise<T>): Promise<T> { const deadline = Date.now() + 300_000; for (;;) { try { return await ensure(); } catch (error) { const provisioning = error instanceof ApiError && error.code === 'RUNTIME_UNAVAILABLE'; if (!provisioning || Date.now() > deadline) throw error; await new Promise((r) => setTimeout(r, 3_000)); } } } ``` `ensureReady()` is idempotent and deduplicates concurrent starts for the same session, so racing calls ride one `/start` instead of issuing several. ## Runtime support matrix Streaming needs `fetch` with a real `ReadableStream` response body **and** `TextDecoderStream`: | Runtime | Streams? | Notes | | ------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Modern browsers | ✅ | `TextDecoderStream` needs Safari 16.4+ | | Node ≥ 18 | ✅ | `fetch` and `TextDecoderStream` are global | | Bun | ✅ | | | Cloudflare Workers / edge | ✅ | | | **React Native / Expo** | ❌ | RN's `fetch` has no `response.body`, and Hermes has no `TextDecoderStream`. **REST works; live streaming does not.** Don't build RN streaming on the SDK today. | > **Warn** > Everything *except* streaming — projects, sessions, files, secrets, the whole REST surface — works > everywhere `fetch` does, including React Native. The matrix above is only about the live event > stream. ## What the stream delivers The narrowed union covers the full chat lifecycle: | `type` | When | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `message.updated` / `message.removed` | a message's metadata changed / it was deleted | | `message.part.updated` / `message.part.removed` | a part (text, tool call, file, reasoning) grew or was removed — this is the token-by-token signal | | `session.status` | the session's busy/working state changed | | `session.idle` | the turn finished — your "done" signal | | `session.error` | the turn failed (LLM error, tool crash) — carries the error payload | | `question.asked` / `question.answered` | the agent asked for permission/input, and the resolution | Render the growing transcript with [`classifyTurn`](/docs/sdk/turns), which turns raw messages + parts into an exhaustively-typed union of renderable parts. --- <!-- /markdown/docs/sdk/the-client.md --> # The client The createKortix facade — the complete method reference for accounts, projects, and the id-bound project handle. Canonical page: https://kortix.com/docs/sdk/the-client `createKortix(config)` returns one client. Every REST method is a direct, fully typed reference to the platform client; the `project()` and `session()` handles bind ids so you never repeat them. This page is the **complete** surface — every method, grouped by resource. ```ts const kortix = createKortix({ backendUrl, getToken }); kortix.accounts; // account / team operations kortix.accountInvites; // invite lifecycle by token alone (accept/decline) kortix.projects; // top-level project operations kortix.project(id); // id-bound project handle (every sub-resource) kortix.session(pid, sid); // id-bound session handle → see Sessions kortix.github; // GitHub App install + repo linking (account-scoped) kortix.billing; // credits / subscription / tier / transactions (read surface) kortix.sandboxShares; // public share links for a sandbox port kortix.transcribe; // speech-to-text kortix.connectStatus; // is easy-connect (Pipedream) configured? kortix.marketplace; // public marketplace catalog (browse, not project-scoped) kortix.validateToken; // pasted-API-key UX check kortix.config; // the platform config in effect (diagnostics) kortix.runtime(); // the opencode client for the active runtime (escape hatch) ``` ## Accounts — `kortix.accounts` | method | what | | ------------------------------------------- | ------------------------ | | `list()` | accounts you belong to | | `get(accountId)` | one account | | `create({ name })` | create an account / team | | `updateName(accountId, name)` | rename | | `members(accountId)` | list members | | `invite(accountId, input)` | invite a member | | `updateMemberRole(accountId, userId, role)` | change a member's role | | `removeMember(accountId, userId)` | remove a member | | `invites(accountId)` | pending invites | | `cancelInvite(accountId, inviteId)` | cancel a pending invite | | `resendInvite(accountId, inviteId)` | resend a pending invite | | `leave(accountId)` | leave the account | `accounts.tokens` mints account-scoped CLI PATs (`kortix_pat_...`): | method | what | | ------------------------------------ | ---------------------------------------------------------------- | | `tokens.list(accountId?)` | list personal access tokens | | `tokens.create(input)` | mint a new token — `{ accountId, name, expiresAt?, projectId? }` | | `tokens.revoke(tokenId, accountId?)` | revoke a token | `accounts.audit` is the enterprise audit log — events, export, and SIEM webhooks: | method | what | | ---------------------------------------------------- | ------------------------ | | `audit.log(accountId, params?)` | audit events | | `audit.export(accountId, params?)` | CSV/JSONL export | | `audit.webhooks.list(accountId)` | configured SIEM webhooks | | `audit.webhooks.create(accountId, input)` | add a webhook | | `audit.webhooks.update(accountId, webhookId, input)` | edit a webhook | | `audit.webhooks.remove(accountId, webhookId)` | delete a webhook | ## Account invites — `kortix.accountInvites` Reached by invite token alone — the invitee may not be an account member (or even signed in) yet, so these take only an `inviteId`, not an `accountId`. | method | what | | -------------------- | ---------------------------------- | | `describe(inviteId)` | preview an invite before accepting | | `accept(inviteId)` | accept it | | `decline(inviteId)` | decline it | ## Projects — `kortix.projects` | method | what | | -------------------------------------------- | ----------------------------------------------------------------- | | `list()` | your projects | | `listForAccount(accountId)` | projects in an account | | `get(id)` · `detail(id)` | summary · full detail | | `create(input)` | create a project | | `createRepo(input)` | create a project backed by a brand-new Kortix-managed GitHub repo | | `provision(id)` | provision its runtime | | `update(id, input)` · `archive(id)` | update settings · archive | | `llmCatalog(id)` | complete runtime model catalog | | `modelPicker(id)` | compact connected models for interactive selectors | | `sandboxTemplates(id)` | sandbox build templates (Dockerfile/image/warm-pool) | | `sandboxHealth(id)` | sandbox-image build health | | `sessions(id)` · `createSession(id, input?)` | list · create sessions | ## GitHub — `kortix.github` Account-scoped GitHub App installation and repository linking (not project-scoped). | method | what | | ----------------------------------------------------------------- | ------------------------------------------- | | `getInstallation(accountId)` | this account's GitHub App installation | | `listInstallations(accountId)` | installations reachable by the current user | | `saveInstallation(input)` | record an installation against the account | | `deleteInstallation(accountId, installationId?)` | unlink an installation | | `listRepositories(accountId, installationId?)` | repos the installation can see | | `listRepositoryBranches(accountId, installationId, repoFullName)` | existing branches and the GitHub default | | `linkRepository(input)` | import a repo as an isolated project | ## Billing — `kortix.billing` Reads for credits, subscription, tier, and transaction history — enough to drive entitlement-gating and a billing/usage UI. Checkout, the customer portal, and credit-purchase mutations are Stripe flows and stay app-owned. | method | what | | ------------------------------------------------------------------------------------ | ------------------------------- | | `accountState(accountId?)` · `accountStateMinimal(accountId?)` | full · minimal billing state | | `transactions(params?)` | transaction history | | `transactionsSummary(params?)` | summarized totals | | `creditBreakdown(accountId?)` | credit balance by source | | `usageHistory(params?)` | usage over time | | `tierConfigurations()` | available plan tiers | | `checkout.createSession(input)` | start a Stripe Checkout session | | `checkout.confirmSession(sessionId, accountId?)` | confirm it post-redirect | | `subscription.createPortalSession(returnUrl, accountId?)` | open the Stripe customer portal | | `subscription.cancel(feedback?, accountId?)` · `subscription.reactivate(accountId?)` | cancel · reactivate | | `subscription.scheduleDowngrade(targetTierKey, commitmentType?, accountId?)` | schedule a downgrade | | `subscription.cancelScheduledChange(accountId?)` | cancel a scheduled change | | `subscription.prorationPreview(newPriceId, accountId?)` | preview a plan-change proration | | `credits.purchase(input)` | one-off credit purchase | | `credits.autoTopupSettings(accountId?)` | current auto-topup config | | `credits.configureAutoTopup(input)` | configure recurring auto-topup | ## Sandbox shares — `kortix.sandboxShares` Public share links for a single exposed sandbox port (sandbox-scoped, not project-scoped). | method | what | | -------------------------- | --------------------------------------------------------- | | `list(sandboxId)` | active share links | | `create(input)` | create a share link — `{ sandboxId, port, ttl?, label? }` | | `revoke(sandboxId, token)` | revoke one | ## Transcription — `kortix.transcribe` `kortix.transcribe(audioFile)` — speech-to-text on an uploaded `File`. Not project-scoped. ## Connect status — `kortix.connectStatus` `kortix.connectStatus()` — deployment-wide flag for whether the easy-connect (Pipedream) provider is configured. Not project-scoped. ## Marketplace (catalog) — `kortix.marketplace` Public marketplace catalog browsing, plus the authed "add a source" surface. Top-level and read-only — distinct from `project(id).marketplace`, which installs an item onto a specific project's branch. | method | what | | -------------------- | ------------------------------ | | `items(options?)` | browse catalog items | | `item(id)` | one catalog item | | `itemFile(id, path)` | a file inside a catalog item | | `marketplaces()` | all marketplaces | | `featured()` | featured marketplaces | | `sources.list()` | configured marketplace sources | | `sources.add(input)` | add a source | | `sources.remove(id)` | remove a source | ## Validate token — `kortix.validateToken` `kortix.validateToken()` — the pasted-API-key UX check (`GET /accounts/me`, never throws). ## The project handle — `kortix.project(id)` Binds the project id; every sub-resource hangs off it. ```ts const p = kortix.project(projectId); await p.detail(); await p.update({ name }); await p.llmCatalog(); await p.modelPicker(); await p.onboardingComplete(); await p.archive(); ``` Direct methods: `get` · `detail` · `update` · `archive` · `llmCatalog` · `modelPicker` · `sandboxHealth` · `onboardingComplete` · `validateManifest(raw)` (validate a `kortix.yaml` or legacy `kortix.toml` manifest's raw text server-side — format is auto-resolved from the project's manifest path — the same schema `kortix ship`/CR-merge use) · `gitToken()` (mint a fresh scoped git push token; 409 for BYO repos) · `setAgentScope(agentName, scope)` (bind an agent's allowed secrets + connectors — the inheritance pyramid's declaration step). ### `p.tokens` — project-scoped CLI PATs Auto-minted at session-create as `KORTIX_TOKEN`; can also be minted by hand. | method | what | | ----------------- | ------------------ | | `list()` | project CLI tokens | | `create(input?)` | mint a new one | | `revoke(tokenId)` | revoke one | ### `p.setupLinks` — agent-minted setup links Hand a human a link to enter a secret value or 1-click connect an app, without giving them full project access. | method | what | | ------------------------- | ------------------------------ | | `requestSecret(input)` | link to collect a secret value | | `requestConnector(input)` | link to connect an app | ### `p.secrets` — project secrets / env | method | what | | -------------------------- | ------------------------- | | `list()` | all project secrets | | `upsert({ name, value })` | create / update a secret | | `remove(name)` | delete a secret | | `setPersonal(name, value)` | a per-user override value | | `removePersonal(name)` | remove the override | | `setGitCredential(input)` | git auth credential | ### `p.access` — members, invites, requests | method | what | | --------------------------------------------------- | ---------------------------------------- | | `list()` | members with access | | `invite(email, role)` | invite a user | | `update(userId, role)` | change a role | | `revoke(userId)` | remove access | | `pendingInvites()` | outstanding invites | | `resendInvite(inviteId)` · `revokeInvite(inviteId)` | resend · revoke an invite | | `requests()` | pending access requests | | `approveRequest(id)` · `rejectRequest(id)` | approve · reject a request | | `groupGrants()` | IAM group grants on the project | | `attachGroupGrant(input)` | grant an IAM group access to the project | | `updateGroupGrant(input)` | change a group grant's role | | `detachGroupGrant(groupId)` | remove a group grant | `p.access.resourceGrants` grants a member or group access to one specific resource (an agent, a skill, a secret) rather than the whole project: | method | what | | -------------------------------- | ----------------------------------------- | | `resourceGrants.list()` | per-resource grants | | `resourceGrants.create(input)` | grant a member/group access to a resource | | `resourceGrants.remove(grantId)` | revoke a resource grant | ### `p.connectors` — tool / MCP connectors | method | what | | ------------------------------------------------------------------- | --------------------------------------------------------------- | | `list()` | configured connectors | | `config(connectorId)` | one connector's config | | `create(input)` | add a connector | | `remove(connectorId)` | delete a connector | | `sync()` | re-sync connectors | | `setName(connectorId, name)` | rename a connector | | `setCredentialMode(connectorId, mode)` | switch how its credential is sourced | | `setCredential(connectorId, input)` | set its credential value | | `setSensitive(connectorId, sensitive)` | mark it sensitive (extra approval gating) | | `policies.get(connectorId)` · `policies.set(connectorId, policies)` | read · replace its tool policies | | `profiles.list()` | list concrete server-side identities (manager-only) | | `profiles.reconcile(input)` | idempotently create/update one external/member/subject identity | | `profiles.updateCredential(profileId, input)` | encrypt/rotate that profile's credential without returning it | | `profiles.revoke(profileId)` · `profiles.activate(profileId)` | deny or restore the identity live | `p.connectors.pipedream` drives the easy-connect (Pipedream) app-catalog handshake: | method | what | | ----------------------------- | ---------------------------- | | `pipedream.listApps(params?)` | browsable app catalog | | `pipedream.connect(input)` | start a connect flow | | `pipedream.finalize(input)` | finalize it into a connector | > **Note** > Connectors are how tools reach a session (the **Executor** surface). A logical connector remains > manifest/project configuration; `profiles` select the concrete identity for a session. Session > creation accepts `connector_bindings: { alias: { profile_id } }`. Credentials remain encrypted in > the control plane and are resolved for each Executor request, so profile revocation is immediate > and no credential enters the sandbox. ### `p.policies` — project policies | method | what | | --------------- | ---------------------- | | `list()` | the project's policies | | `set(policies)` | replace the policy set | ### `p.triggers` — automations (cron / webhook) A trigger fires an agent action on a schedule or an inbound webhook. | method | what | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `list()` | all triggers | | `create(input)` | create — `{ name, type: 'cron' \| 'webhook', prompt_template, slug?, agent?, model?, enabled?, session_mode?, session_id?, cron?, run_at?, timezone?, secret_env? }` | | `update(triggerId, input)` | edit a trigger | | `remove(triggerId)` | delete a trigger | | `fire(triggerId)` | run it now | | `setActivation(paused)` | pause / resume all of the project's triggers server-side; per-trigger enablement uses `update(triggerId, { enabled })` | `name` and `prompt_template` are required; `slug` is optional and auto-derived from `name` when omitted. `cron`/`run_at` (mutually exclusive) and `timezone` apply to `type: 'cron'`; `secret_env` (the `project_secrets` key holding the webhook HMAC secret) applies to `type: 'webhook'`. ### `p.marketplace` / `p.registry` — installed marketplace items Installs an item's files (+ lock) straight onto the project's default branch. `registry.*` is a compatibility alias of `marketplace.*` — identical handlers. | method | what | | -------------------------- | ---------------------- | | `marketplace.list()` | installed items | | `marketplace.install(id)` | install a catalog item | | `marketplace.updates()` | available updates | | `marketplace.update(name)` | update one item | | `marketplace.updateAll()` | update everything | | `marketplace.remove(name)` | uninstall an item | ### `p.files` — repo files (read) Read-only access to the project's git tree through the API. For read **and write** files _inside a running session's workspace_, use the session's file ops — see [Modules → files](/docs/sdk/modules#kortix-sdk-files). | method | what | | ------------------- | ---------------------------------------- | | `list(options?)` | the repo tree | | `read(path, ref?)` | a file's contents at an optional git ref | | `search(query)` | search the repo | | `archive(options?)` | download a tarball | | `history(path)` | a file's git history | ### `p.git` — history | method | what | | ----------------------- | --------------------- | | `commits()` | the commit log | | `commit(sha)` | one commit | | `commitDiff(sha)` | a commit's diff | | `branches()` | branches | | `versionDiff(from, to)` | diff between two refs | ### `p.changeRequests` — the review queue A change request is how a session's work merges back to the default branch. | method | what | | ----------------------------- | ------------------------------------------------------------------------------------------------------------- | | `list()` | open change requests | | `get(crId)` | one change request | | `diff(crId)` | its diff | | `mergePreview(crId)` | preview the merge result | | `open(input)` | open a change request | | `merge(crId, input?)` | merge it | | `close(crId, input?)` | close without merging | | `reopen(crId, input?)` | reopen a closed one | | `requestChanges(crId, input)` | request changes (Review Center) — records feedback and optionally delivers it back to the originating session | ### `p.sessions` — and the session handle | method | what | | ---------------- | -------------------------------------------------------- | | `list()` | the project's sessions | | `create(input?)` | create a session | | `session(sid)` | → the session handle (same as `kortix.session(id, sid)`) | The session handle is the heart of the runtime — see **[Sessions](/docs/sdk/sessions)**. ### `p.review` — the Review Center The per-project human-in-the-loop inbox: change requests, tool approvals, and agent outputs/decisions waiting on a person. | method | what | | -------------------------- | ------------------------------- | | `list(params?)` | review items | | `get(reviewItemId)` | one review item | | `submit(input)` | submit a new review item | | `act(reviewItemId, input)` | act on one (approve/reject/etc) | | `bulkAct(input)` | act on several at once | ### `p.approvals` — the executor approval inbox The manager inbox of executor-gated actions awaiting approve/deny — backs the permission-approval UX (`APPROVE` / `ASK` / `BLOCK`). | method | what | | ---------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `list(options?)` | pending approvals | | `resolve(executionId, decision, scope?)` | approve or deny one — `decision: 'approve' \| 'deny'`, `scope: 'once' \| 'session' \| 'session_all'` | | `sessionsNeedingInput(options?)` | sessions currently blocked on a decision | ### `p.gateway` — LLM observability Request logs, cost/latency rollups, budgets, and gateway API keys for this project's LLM traffic. | method | what | | -------------------------------------------------------- | -------------------------------------------------- | | `logs(opts?)` | request log entries | | `log(logId)` | one log entry | | `overview(days?)` · `series(days?)` · `breakdown(days?)` | rollups over a window | | `sessions(days?)` | per-session cost/usage | | `errors(days?)` | recent gateway errors | | `budgets()` | configured budgets | | `setBudget(input)` · `deleteBudget(budgetId)` | create/edit · remove a budget | | `keys()` | gateway API keys | | `createKey(name)` · `revokeKey(keyId)` | mint · revoke a key | | `playground(prompt, models)` | run one prompt against up to 6 models side by side | | `routing.get()` | project and effective routing policy | | `routing.set(policy)` · `routing.reset()` | replace · inherit the project policy | | `routing.preview(input)` | resolve a finite route without invoking a model | Routing policies contain a project default and vision model, an ordered default fallback chain, and exact-model overrides. Chains are bounded and each model is attempted at most once; `fallbackOn` is either `transient` or `any-error`. ### `p.channels` — Slack / email / Meet Integration surfaces that let an agent act as a Slack app, an email address, or a meeting bot. | method | what | | ----------------------------------------------------------- | ------------------------------------------------------ | | `slack.installation()` | current Slack installation | | `slack.connect(input)` · `slack.disconnect()` | connect · disconnect | | `slack.mode()` · `slack.manifest()` | current mode · app manifest | | `slack.getFile(url)` | download a Slack-hosted file via the server-side proxy | | `slack.uploadFile(input)` | upload a file to Slack via the server-side proxy | | `email.installation(connectorSlug?)` | current email installation | | `email.connect(input)` · `email.disconnect(connectorSlug?)` | connect · disconnect | | `email.mode()` | current mode | | `email.updatePolicy(input)` | update the send/reply policy | | `meet.voices()` | available bot voices | | `meet.setVoice(voice)` · `meet.previewVoice(voiceId)` | set · preview a voice | | `meet.setBotName(name)` | rename the meeting bot | | `meet.speak(botId, text, voice?)` | make the bot speak text in a live meeting | ### `p.updateExperimentalFeature` — feature flags `p.updateExperimentalFeature(feature, enabled)` — toggle an experimental feature (Customize → Settings → Experimental). Pass `enabled: null` to clear the override. ### `p.modelDefaults` — default model preferences Account/agent/project-scoped model defaults, resolved by the gateway. | method | what | | --------------- | --------------------------- | | `get()` | the resolved defaults | | `set(input)` | set a default at some scope | | `clear(params)` | clear an override | ### `p.setDefaultAgent` — project default agent `p.setDefaultAgent(agentName)` validates that the agent is declared and enabled, then commits it as `default_agent` in the project's v2 `kortix.yaml`. New project chats prefer this agent unless the user explicitly picks another one. ### `p.sandbox` — templates and snapshot builds Sandbox build config beyond `sandboxHealth`/`sandboxTemplates` on the project handle itself: Dockerfile/image/warm-pool templates and their snapshot builds. | method | what | | ----------------------------------- | -------------------------------------------------------------------------------- | | `list()` | sandboxes for this project | | `snapshots()` | built snapshots | | `rebuildSnapshot(slug?)` | rebuild a snapshot | | `fixWithAgent()` | ask an agent to fix a broken sandbox build | | `createTemplate(input)` | add a build template | | `updateTemplate(templateId, input)` | edit one | | `removeTemplate(templateId)` | delete one | | `buildTemplate(templateId)` | build it | | `setProvider(provider)` | pin/clear the per-project sandbox provider (`null` follows the platform default) | ## Escape hatch `kortix.runtime()` returns the typed opencode v2 client for the active runtime. Prefer `kortix.session(pid, sid).runtime`, the same client scoped to a session. You should never need `@opencode-ai/sdk` directly. --- <!-- /markdown/docs/sdk/turns.md --> # Turns Framework-free turn grouping, part classification, and view-model helpers for building a chat UI on raw session messages. Canonical page: https://kortix.com/docs/sdk/turns The turns module is the pure, framework-free engine behind the web and mobile chat renderers: grouping raw session messages into **turns**, classifying every message **part** into a typed shape, mapping tool parts to per-tool **view models**, and aggregating cost/token totals. No React, no DOM — every export is a plain function or type, safe to call from any host. ```ts import { classifyPart, classifyTurn, toolInfo, toolViewModel } from '@kortix/sdk'; ``` > **Note** > **When to use this.** The [React hooks](/docs/sdk/react) (`useSessionSync`, `useSession`) already > give you a live thread of messages. Reach for the turns helpers when you're **rendering** that > thread — grouping it into turns, deciding what a tool part means, or building a custom > message/tool renderer instead of using the reference one in `apps/web`. Since v2, **every turns export lives on the root `@kortix/sdk` entry** — classification, grouping, cost/token aggregation, formatting, and the session-list helpers alike. The old `@kortix/sdk/turns` subpath still works as a deprecated alias; new code imports from the root. See [Distribution](/docs/sdk/distribution#entry-points-and-their-stability-tiers). ## Part classification — `classifyPart` / `classifyTurn` The primary entry point. `@opencode-ai/sdk`'s wire `Part` union has 12 variants (`text`, `reasoning`, `tool`, `file`, `subtask`, `patch`, `snapshot`, `agent`, `retry`, `compaction`, `step-start`, `step-finish`). `classifyPart` normalizes any one of them into a `ClassifiedPart` — a discriminated union keyed by `kind`, with the fields a renderer actually needs already resolved (tool status, parsed JSON output, image/PDF detection, …). ```ts import { classifyPart, type ClassifiedPart } from '@kortix/sdk'; for (const part of message.parts) { const classified: ClassifiedPart = classifyPart(part); switch (classified.kind) { case 'text': render(classified.text); break; case 'tool': render(classified.tool.title, classified.tool.status); break; // 'reasoning' | 'file' | 'subtask' | 'patch' | 'snapshot' | 'agent' // | 'retry' | 'compaction' | 'step' | 'unknown' } } ``` | `kind` | shape | | ------------ | ------------------------------------------------------------------------------------------------------------- | | `text` | `{ id, text, synthetic }` — `synthetic` marks shell-mode's synthetic user prompt (skip rendering as a bubble) | | `reasoning` | `{ id, text }` | | `tool` | `{ id, tool: ToolView }` — see below | | `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 }` — forward-compat fallback for a part type this SDK version doesn't know about | The switch's `default` branch has a compile-time exhaustiveness check, so a new opencode part variant fails **this file's** build instead of silently rendering nothing; at runtime an unrecognized value degrades to `'unknown'` instead of throwing (e.g. an older client talking to a newer server). Tool parts classify into a normalized `ToolView`, independent of the wire's `pending`/`running`/`completed`/`error` status union: ```ts interface ToolView { name: string; title: string; status: 'pending' | 'running' | 'done' | 'error'; input?: Record<string, unknown>; output?: string; error?: string; outputParsed?: unknown; // JSON.parse(output) when it parses (capped at 256KB) outputText?: string; // the raw output text, always } ``` `classifyToolState` also detects **embedded failures**: some router/executor tools (`web_search`, `image_search`, connector calls) return `state.status: 'completed'` even when their JSON body carries `success: false` or a top-level `error` — a real failure that would otherwise render as a successful call with raw JSON inside. `ToolView.status` is `'error'` in that case too. ```ts import { classifyTurn, type ClassifiedTurn } from '@kortix/sdk'; const turn: ClassifiedTurn = classifyTurn(assistantMessage); // turn.parts — every part, classified // turn.error — { name, message } normalized from message.info.error, if any // turn.isEmpty — no error AND no part with visible content (the // "failed turn renders as silence" bug, solved once here) ``` ## Tool metadata — `toolInfo` vs `getToolInfo` > **Warn** > Two different lookups share the concept of "info about a tool" — don't > confuse them. `toolInfo` (from `tool-registry.ts`) is **icon-free**: a > `{ label, category }` pair, used internally by `classifyPart` and meant for > hosts that just need to know what kind of tool something is. `getToolInfo` > (from the module root, `index.ts`) is the richer, **icon-aware** lookup used > by the existing tool-card UI: `{ icon, title, subtitle }`, with the subtitle > derived from the tool's input (a file path, a query, an agent description, …). ```ts import { toolInfo, humanizeToolName, type ToolCategory } from '@kortix/sdk'; toolInfo('bash'); // { label: 'Shell', category: 'shell' } toolInfo('agent_spawn'); // prefix-matched: { label: 'Agent Spawn', category: 'task' } humanizeToolName('session_spawn'); // 'Session Spawn' ``` `ToolCategory` is `'shell' | 'files' | 'search' | 'edit' | 'web' | 'task' | 'other'`. Unknown tool names never throw — the built-in registry covers opencode's native tools plus Kortix's plugin tool families (`agent_*`, `session_*`, `task_*`, `trigger_*`, `project_*`, `pty_*`, matched by prefix so a new tool in an existing family is categorized correctly without a registry update); anything else falls back to `humanizeToolName(name)` with category `'other'`. ```ts import { getToolInfo, type ToolInfo } from '@kortix/sdk'; const info: ToolInfo = getToolInfo('write', { filePath: '/workspace/main.go' }); // { icon: 'file-pen', title: 'Write', subtitle: 'main.go /workspace' } ``` `getToolInfo(tool, input)` covers every built-in and plugin tool by name — `read`/`list`/`glob`/`grep`/`webfetch`/`bash`/`edit`/`write`/`task`/ `todowrite`/`question`, the `session_*`/`agent_*`/`project_*`/`trigger_*`/ `pty_*` families, and more — falling back to `{ icon: 'cpu', title: tool }` for anything it doesn't recognize by name. ## Tool view models — `toolViewModel` A layer on top of `ToolView` for product UIs that want to render specific tool families specially instead of a generic JSON blob: ```ts import { toolViewModel, type ToolViewModel } from '@kortix/sdk'; const vm: ToolViewModel = toolViewModel(classifiedTool); if (vm.kind === 'shell') { render(vm.command, vm.stdout, vm.exitCode); } ``` | `kind` | shape | tools | | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `web-search` | `{ query, results?, answer?, error? }` | `web_search`, `websearch`, `image_search` | | `shell` | `{ command, stdout?, exitCode? }` | `bash` — strips `<bash_metadata>`/ANSI tags | | `file-read` | `{ path, preview? }` | `read` | | `file-write` | `{ path, preview? }` | `write` | | `file-edit` | `{ path, diff?: DiffLine[] }` | `edit`, `morph_edit` — O(n) prefix/suffix line diff | | `search` | `{ pattern, matches?: SearchMatch[] }` | `grep`, `glob` | | `task` | `{ description, agent? }` | `task` | | `todo` | `{ items: TodoItem[] }` | `todowrite` | | `question` | `{ questions: QuestionItem[], answers? }` | `question`, `ask` | | `generic` | `{ label, inputPretty?, outputPretty? }` | everything else (`webfetch`, `image_gen`, `session_*`, `agent_*`, `project_*`, `trigger_*`, `pty_*`, …) — always safe to render | `DiffLine` is `{ type: 'added' | 'removed' | 'unchanged', text }`. All string fields are capped (4000 chars for pretty-printed JSON, 256KB before a diff is even attempted) so a huge tool output never blows up a render. ## Grouping messages into turns A **turn** pairs one user message with the assistant messages that answered it — the unit a chat UI actually renders as one exchange. ```ts import { groupMessagesIntoTurns, collectTurnParts, type TurnLike } from '@kortix/sdk'; const turns: TurnLike[] = groupMessagesIntoTurns(messages); for (const turn of turns) { const parts = collectTurnParts(turn); // every part across turn.assistantMessages } ``` `groupMessagesIntoTurns` links assistant messages to their parent user message via `parentID`, falling back to sequential ordering when `parentID` is absent; it dedupes user messages by id (an optimistic copy racing the real one) and attaches an orphan assistant message (no `parentID`, precedes every user message) to the first turn instead of the last, so it renders at its real chronological spot rather than under an unrelated later prompt. Also: `findLastTextPart(parts)` (the turn's "response" — the last non-empty text part), `turnHasSteps(parts)` (has a `tool`/`compaction`/`snapshot`/ `patch` part), `isShellMode(turn)` / `getShellModePart(turn)` (a turn that's entirely a synthetic user prompt driving exactly one `bash` tool call — the "shell mode" UI), and `splitUserParts(parts)` / `isAttachment(part)` (split a user message's parts into image/PDF attachments vs sticky text/other parts). ## Type guards Narrow a `PartLike` by `type`, generic over your own part union: ```ts import { isTextPart, isToolPart, getPartText } from '@kortix/sdk'; if (isTextPart(part)) { /* part.type narrowed to 'text' */ } const text = getPartText(part); // works for both 'text' and 'reasoning' parts ``` Also `isReasoningPart`, `isFilePart`, `isAgentPart`, `isCompactionPart`, `isSnapshotPart`, `isPatchPart`. ## Status & working state ```ts import { getWorkingState, isLastUserMessage, computeStatusFromPart, getTurnStatus, formatDuration, shouldHideResponsePart, } from '@kortix/sdk'; const working = getWorkingState(sessionStatus, isLastUserMessage(msg.info.id, allMessages)); const status = getTurnStatus(parts, childMessages); // "Running commands..." etc. formatDuration(4300); // "4s" — sub-second durations return '' (not worth a badge) ``` `getTurnStatus` scans a turn's parts in reverse for the last meaningful status line (`computeStatusFromPart` per-part, e.g. `bash` → `"Running commands..."`, `grep`/`glob` → `"Searching codebase..."`); when the last part is a **running** `task`/`agent_task` delegation tool, it drills into the child session's own messages (pass `childMessages`) to surface the sub-agent's real status instead of a generic "Delegating..." line. `shouldHideResponsePart(working, responsePartId)` is `true` once a turn is done and has a response part — the signal to pull that last text part out of the steps list and show it in a dedicated "Response" section. ## Errors ```ts import { getTurnError, getChildSessionError, unwrapError } from '@kortix/sdk'; getTurnError(turn); // the first assistant error in a turn, unwrapped getChildSessionError(childMessages); // newest-first scan of a sub-agent's own messages unwrapError(rawError); // deep JSON-unwrapping error normalizer both use ``` `unwrapError` handles the router/executor error shapes this SDK actually sees in practice: double-JSON-encoded strings, a JSON body embedded in an otherwise plain-text prefix (`Error: 402 Error: {"error":true,"message":"Insufficient credits"}`), and the common `{message}` / `{error}` / `{data:{message}}` object shapes — falling back to the raw string/value if none of those match. It's also what `classifyTurn`'s `TurnError` and `classifyRetry`'s retry `message` are built from. ## Cost & token aggregation ```ts import { getTurnCost, getSessionCost, formatCost, formatTokens, COST_MARKUP } from '@kortix/sdk'; const info = getTurnCost(partsWithMessage, modelPricingLookup); // undefined if no cost data // info?.cost · info?.tokens.{input,output,reasoning,cacheRead,cacheWrite} const sessionCost = getSessionCost(messages, modelPricingLookup); formatCost(0.0032); // "$0.003" formatTokens(12345); // "12k" ``` Both aggregate from `step-finish` parts' reported `cost`/`tokens`, falling back to estimating cost from token counts via a `ModelPricingLookup` when the wire reports zero (or there are no `step-finish` parts at all — some assistant messages only carry token totals on `info.tokens`). Every total is multiplied by `COST_MARKUP` (`1.2`) so the displayed number matches what's actually billed — this constant must stay in sync with `KORTIX_MARKUP` in `apps/api/src/config.ts`. ## Child session (sub-agent) helpers A `task`/`agent_spawn`/`session_spawn` tool call delegates to a **child session** — these helpers bridge a parent turn's rendering to that child's own message stream. ```ts import { getChildSessionId, getChildSessionToolParts, shouldShowToolPart } from '@kortix/sdk'; const childId = getChildSessionId(taskToolPart); // from metadata, title, or output text const steps = getChildSessionToolParts(childMessages); // child's tool parts, filtered ``` `getChildSessionToolParts` filters through `shouldShowToolPart`, which hides purely-internal tools (`todoread`, `context_info`) from the step list. ## Permission & question requests Match a pending permission/question request to the tool call it's about, and compute which tool parts to hide from the step list while one is active: ```ts import { getPermissionForTool, getQuestionForTool, getHiddenToolParts, isToolPartHidden, getAnsweredQuestionParts, } from '@kortix/sdk'; const permission = getPermissionForTool(permissions, callID); const hidden = getHiddenToolParts(activePermission, activeQuestion); if (isToolPartHidden(part, messageId, hidden)) { /* skip in the step list */ } ``` `getAnsweredQuestionParts(turn, stepsExpanded, hasActiveQuestion)` collects already-answered `question` tool parts to render standalone outside the step list (questions never render inside steps); it returns nothing while a question is actively pending, so an old answered question doesn't compete with the live one. ## Path & label formatting ```ts import { getFilename, getFileWithDir, getDirectory, relativizePath, stripAnsi } from '@kortix/sdk'; getFilename('/workspace/src/main.go'); // "main.go" getFileWithDir('/workspace/src/main.go'); // "main.go /src" getDirectory('/workspace/src/main.go'); // "/workspace/src" relativizePath('/workspace/src/main.go', '/workspace'); // "src/main.go" stripAnsi(rawTerminalOutput); // strips ANSI escape codes, linear-time ``` Also `getAgentCardLabel(input)` (a one-line label for a task/agent card, with graceful fallbacks across `description` → `title` → `message` → `prompt` → `agent_id`) and `getDiagnostics(diagnosticsByFile, filePath)` (LSP diagnostics for a file, errors only, capped at 3). ## Session list helpers Sidebar/tab helpers, not turn-scoped, but shipped here since they operate on the same session data shape: ```ts import { sortSessions, childMapByParent, allDescendantIds } from '@kortix/sdk'; sessions.sort(sortSessions(Date.now())); // pins sessions updated in the last 60s to the top const childMap = childMapByParent(sessions); // parentID -> child session ids const descendants = allDescendantIds(childMap, sessionId); // full sub-agent tree, recursive ``` ## Retry helpers ```ts import { getRetryInfo, getRetryMessage } from '@kortix/sdk'; getRetryInfo(sessionStatus); // { attempt, message, next } if status.type === 'retry', message capped to 60 chars getRetryMessage(sessionStatus); // the full, un-truncated retry error message ``` ## Structural types The grouping/status functions above (`groupMessagesIntoTurns`, `getWorkingState`, `getTurnCost`, …) are typed against minimal **structural** protocols — `PartLike`, `MessageInfoLike`, `MessageWithPartsLike`, `TurnLike`, `ToolStateLike`, `ToolPartLike`, `SessionStatusLike`, `RequestWithToolLike`, `TokenUsageLike` — instead of the concrete `@opencode-ai/sdk` wire types. That's deliberate: every host's own message/part union (web's rich type, mobile's leaner local mirror) flows through unchanged as long as it has the right shape, and TypeScript preserves your concrete type end to end instead of widening it to the SDK's own. `classifyPart` / `classifyTurn` are the exception — they're typed against the real `@opencode-ai/sdk` `Part` union on purpose, so the exhaustiveness check is meaningful. ## Everything is on the root entry Since v2 the root `@kortix/sdk` export **is** the turns surface — every function and type on this page imports from it directly: ```ts import { classifyTurn, // part classification groupMessagesIntoTurns, // turn grouping getTurnCost, // cost/token aggregation formatCost, // formatting sortSessions, // session-list helpers } from '@kortix/sdk'; ``` The legacy `@kortix/sdk/turns` subpath re-exports the same names and remains only as a deprecated compatibility alias. ## Next - [React hooks](/docs/sdk/react) — `useSessionSync` / `useSession` give you the live message list this module groups and classifies. - [Modules](/docs/sdk/modules) — the other subpath imports. --- <!-- /markdown/use-cases/access-requests.md --> # How we handle access requests An access request in Slack triggers an agent to check policy, gather context, and prepare a least-privilege grant, with every grant requiring a human approval and logged. Canonical page: https://kortix.com/use-cases/access-requests Access requests arrive constantly and informally. Someone needs a repo, a role in Okta, or a cloud IAM permission to finish a task, and they ask in Slack. Whoever holds the access has to check what the person's role should have, work out the narrowest grant that unblocks them, apply it, and remember to record it. Under time pressure the easy path is to grant broadly and move on, and the record of who has what drifts. We handle this by tying the grant to the request that starts it, and to the policy that governs it. This writes up how we run that on Kortix — the connections, the steps, and the guardrails — so you can set up the same flow. - **Team:** Kortix - **Trigger:** An access request in Slack - **Connected systems:** Okta · GitHub · Cloud IAM - **Mode:** Policy-checked · Approval-gated · Logged ## The problem The common approaches each fall short. A ticket queue routes the request to a person who still does all the lookup and grant work by hand. A standing broad role avoids the back-and-forth but hands out more than the task needs. And a self-serve grant with no policy check trades safety for speed. None of them keep a clean record of what was granted and why. We wanted each request checked against policy, scoped to the least privilege that unblocks the work, and granted only after a person signs off, with a log left behind. ## What we built On Kortix, an access request in Slack triggers an agent. Each request runs in its own isolated session — a cloud sandbox — with scoped access to Okta, GitHub, and cloud IAM. The agent reads the request, checks it against policy, gathers context on the person's role, team, and the least-privilege scope that fits, and prepares the grant. Every grant requires a human approval, and each one is logged. ## How it works ### Connect Slack as the trigger Slack is connected as a **channel**, so a request is the trigger. Post the access request and it spawns a fresh **session** in its own sandbox, seeded with who's asking and what they need. One request, one session, one disposable machine. ### Give the agent the access policy Which roles map to which grants, what least privilege means for each system, and which requests need extra scrutiny are stored as **skills** and **memory** that load into every session. The agent checks requests against that policy rather than improvising, and it updates as the policy changes. ### Connect what a grant can touch Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read Okta** — the requester's role, team, and current group memberships. - **Prepare a GitHub grant** — repo or team access scoped to what the task needs. - **Prepare a cloud IAM grant** — the narrowest role or permission set that unblocks the work. ### Set the guardrails Granting access is the step that changes who can do what, so every grant stops at a **human approval gate** — no access is applied until a person signs off. Each approved grant is logged with the request, the policy check, and the scope. Credentials are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let each request come back scoped and logged With that in place, a request in Slack comes back as a policy-checked, least-privilege grant ready for a person to approve, with a record attached. "I need access to this repo" becomes a scoped GitHub grant; "I need this cloud role" becomes the narrowest IAM permission that fits, both held for sign-off. > **The pattern** > Connect Slack via a **channel** trigger, give the agent scoped **connectors** > into Okta, GitHub, and cloud IAM, encode the access policy as **skills** and > **memory**, and gate every grant behind a human with a log left behind. ## Guardrails Giving an agent a hand in access decisions is a trust question. The relevant controls on Kortix: - **Isolation.** Every request runs in its own microVM sandbox on its own branch. The session can reach only the systems it's scoped to, and only what it's explicitly allowed to send leaves the sandbox. - **Scoped secrets.** Each credential is encrypted in the secrets manager, injected into the sandbox at runtime, and never exposed to the model or the logs. - **Human approval gate.** No grant is applied until a person approves it, and each approved grant is logged. - **Everything is code.** The agent's persona, skills, and permissions are files in the repo — versioned and changed through a reviewed **change request**, not a dashboard setting. ## The outcome - **Least privilege:** Every grant scoped to what the task needs - **Approval-gated:** No access applied without a person signing off - **Logged:** Every grant recorded with its request and scope Access requests that used to be granted broadly under time pressure now come back scoped, policy-checked, and ready for a person to approve, with a record left behind. Extending it to another system means connecting one more platform. --- <!-- /markdown/use-cases/ar-chaser.md --> # How we chase down overdue invoices An accounts-receivable agent we run on Kortix — connected to Stripe, email, and the accounting ledger. It finds overdue invoices, sends the right reminder, logs every touch, and reconciles when payment lands. Canonical page: https://kortix.com/use-cases/ar-chaser Chasing overdue invoices is steady, repetitive work that still needs judgment. Most reminders are routine: an invoice is a few days late, a polite note goes out, the customer pays. But the timing, the tone, and the escalation depend on how late the balance is and how large it is, and some accounts are sensitive enough that no automated email should go out without a person looking first. We run an accounts-receivable agent on Kortix that does this chasing on a daily schedule. This is how we collect on our own invoices, including the connections and guardrails involved. - **Team:** Kortix - **Runs on:** A daily cron - **Connected systems:** Stripe · Email · Accounting ledger - **Mode:** Read-mostly · reminders and status notes gate-able ## The problem Receivables slip because no one has time to work the list every day. An invoice goes a week late, then two, and the reminder that should have gone out on day one never does. The ones that need a firmer note get the same generic email as the ones that are barely late, and the sensitive accounts — a large balance, a disputed line, something heading to legal — get chased the same way as everything else. The common fixes are incomplete. Stripe's built-in reminders send on a fixed cadence regardless of balance size or account context. A spreadsheet and a calendar reminder depend on someone actually working it. A generic automation sends the same email to everyone and has no way to hold the risky ones back. ## What we built On Kortix, a daily cron triggers an agent. Each morning it spawns an isolated session (a cloud sandbox) with scoped access to Stripe, our email, and the accounting ledger. It finds every invoice that's overdue or coming due soon, decides the right reminder for each based on how late and how large the balance is, sends it, logs the touch, and reconciles the invoice when payment lands. Anything sensitive stops at a human approval gate before it sends. ## How it works ### Trigger the run on a daily cron A **cron trigger** fires once a day and spawns a fresh **session** in its own sandbox. Each run works the full receivables list from scratch, so nothing carries over between days and a missed morning is just the next run picking up where it left off. One run maps to one session on one disposable machine. ### Give the agent the collections playbook Our collections policy lives as **skills** and **memory** that travel with the agent: the reminder cadence by days overdue, the escalating tone from a first notice to a final notice, the balance thresholds that change the approach, and which accounts are flagged as disputed or sensitive. When we adjust the policy, we write it down and the agent applies it on the next run. ### Connect Stripe, email, and the ledger Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read invoices from Stripe** — which are overdue, which are coming due, the balance and age of each. - **Send reminders by email** — the right escalating notice for each invoice, from the agent's own address. - **Read the accounting ledger** — to confirm balances and reconcile against what Stripe reports. - **Log every touch and status note** — each reminder and reconciliation recorded against the invoice. ### Set the guardrails The agent is **read-mostly**: it reads Stripe and the ledger, and the only writes it makes are reminder emails and status notes. Anything sensitive — a large balance, a disputed invoice, or an account heading to legal — stops at a **human approval gate** before it sends. Credentials are encrypted in the Secrets Manager and injected at runtime, never shown to the model or written to logs. ### Let the list work itself each morning With that in place, the daily run finds the overdue and soon-due invoices, sends each account the reminder its timing and balance call for, and logs the touch. A routine day-three nudge goes out on its own. A large or disputed balance is held for a person to approve. When a payment lands, the agent matches it to the invoice and marks it settled. > **The pattern** > A daily **cron** spawns a session with scoped **connectors** into Stripe, email, > and the ledger. The collections policy is encoded as **skills** and **memory**. > The agent stays read-mostly, sensitive accounts wait for a human, and every > touch is logged. ## Guardrails The agent sends email on our behalf and touches invoice state, so the access is scoped and contained: - **Isolation.** Every run happens in its own per-task microVM sandbox. The session reads Stripe and the ledger and can send only the reminders and status notes it's scoped to; nothing else leaves the sandbox. - **Scoped secrets.** The Stripe, email, and ledger credentials are encrypted in the Secrets Manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gate.** A large balance, a disputed invoice, or an account heading to legal stops for a person to approve before any email goes out. - **Everything is code.** The agent's configuration, skills, and per-system permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Every morning:** The full receivables list worked on schedule - **Read-mostly:** Only reminders and status notes are written - **3 systems:** Stripe, email, and the ledger in one agent Overdue invoices now get the right reminder on the day they should, with each touch logged and each payment reconciled when it lands. Routine chases run on their own, the sensitive accounts wait for a person, and the receivables list no longer depends on someone finding time to work it. --- <!-- /markdown/use-cases/churn-risk.md --> # How we flag at-risk accounts A daily cron scores accounts on leading churn signals across product usage, support, and billing, then posts a ranked at-risk list to Slack with a reason and a suggested next step for each. Canonical page: https://kortix.com/use-cases/churn-risk Churn is usually visible before it happens. Usage tapers off, support threads get more frequent and more frustrated, a payment fails, a renewal approaches. The signals are there, but they sit in different systems, and no one is watching all of them at once. By the time an account cancels, the warning signs had been accumulating for weeks. We run a churn-risk agent on Kortix that reads those signals every day and posts a ranked at-risk list to our customer-success Slack channel. It only reads customer data; the single output is the Slack post. This is how we watch our own accounts. - **Team:** Kortix - **Runs on:** Daily cron - **Connected systems:** Postgres · Plain · Stripe · Slack - **Mode:** Read-only · one Slack post per day ## The problem The signals that predict churn live in separate systems: product usage in Postgres, support friction in Plain, payment health in Stripe, renewal dates in billing. Each one is a partial view. An account with declining usage might be fine; an account with declining usage, a rising support load, and a renewal next month is not. The common approaches don't combine them. A usage dashboard shows one signal and leaves the reader to correlate the rest. A health score baked into one tool only sees that tool's data. Manual account reviews are thorough but happen quarterly, long after the signals first appeared, and depend on someone remembering to look. ## What we built On Kortix, a daily cron triggers an agent. It spawns an isolated session (a cloud sandbox) with read-only access to Postgres, Plain, and Stripe, scores every account on leading churn signals — declining usage, rising support friction, missed or failed payments, an upcoming renewal — and posts a ranked at-risk list to the customer-success Slack channel, with the reason for each account and a suggested next step. It writes nothing back to customer systems. ## How it works ### Run on a daily cron A **cron trigger** fires the agent once a day. Each firing spawns a fresh **session** in its own sandbox. One day maps to one run on one disposable machine, so the score is recomputed from the current state every time and nothing carries over. ### Give the agent the scoring rules How we weigh the signals lives as **skills** and **memory** that travel with the agent: what counts as a usage decline, which support patterns matter, how a failed payment and an upcoming renewal combine, and what a good next step looks like for each kind of risk. When we learn which signals actually preceded a churn, we write it down and the scoring improves. ### Connect the signal sources read-only Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent reads: - **Product usage from Postgres** — activity trends per account, to catch a decline before it bottoms out. - **Support signals from Plain** — thread volume and tone, to catch rising friction. - **Billing from Stripe** — missed or failed payments and the upcoming renewal date. - **Posts to Slack** — the ranked at-risk list, with a reason and a suggested next step per account. ### Set the guardrails The agent is **read-only** across every customer system. It has no write access to Postgres, Plain, or Stripe, so it cannot change an account, a ticket, or a subscription. Its only output is the Slack post. Credentials are encrypted in the Secrets Manager and injected at runtime, never shown to the model or written to logs. ### Post the ranked list With that in place, each morning brings one Slack post: accounts ranked by risk, each with the signals that put it there — the usage drop, the support thread, the failed payment, the renewal date — and a suggested next step. The customer-success team reads it and decides what to do. Nothing is written back automatically. > **The pattern** > A daily **cron** spawns a session with read-only **connectors** into Postgres, > Plain, and Stripe. The scoring lives as **skills** and **memory**. The agent > reads everything and writes nothing but the Slack post. ## Guardrails The agent reads across every customer system, so its access is scoped and one-directional: - **Isolation.** Every run happens in its own microVM sandbox. The session can reach only the systems it's scoped to, and only the Slack post leaves the sandbox. - **Scoped secrets.** The Postgres, Plain, and Stripe credentials are encrypted in the Secrets Manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Read-only.** The connectors into customer data are read-only. The agent cannot change an account, a ticket, or a subscription; it can only report. - **Everything is code.** The agent's scoring rules, skills, and per-system permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Every day:** Accounts rescored on the current state of the data - **Read-only:** Nothing written back to any customer system - **4 signals:** Usage, support, billing, and renewal in one score Churn signals that used to sit in four separate systems now arrive as one ranked list in the channel where the team already works, each account carrying the reason it surfaced and a suggested next step. The agent only reads; the people decide what to do about each account. --- <!-- /markdown/use-cases/competitor-watch.md --> # How we monitor competitors A daily agent that checks competitor sites, changelogs, and pricing pages, diffs them against the last run, and posts a short summary of what changed to Slack. Canonical page: https://kortix.com/use-cases/competitor-watch Competitors ship changes quietly. A pricing tier moves, a feature lands in the changelog, a landing page gets rewritten — and unless someone happens to check that week, the team finds out late. Manually visiting a dozen sites every morning is the kind of task that gets done for a while and then quietly stops. We watch our market with an agent that runs every day on Kortix. It checks competitor sites, changelogs, and pricing pages, diffs them against the last run, and posts a short summary of what actually changed to a Slack channel. - **Team:** Kortix - **Control surface:** Slack - **Connected systems:** The web · Slack - **Mode:** Daily cron · diff against last run ## The problem Keeping up with competitors is a standing task with no natural owner. The information is public, but it's spread across pages that change on no schedule, and most days nothing moves — so a person checking manually spends most of their time confirming that nothing happened. The workarounds thin out. A page-change alerting tool fires on every edit, including cosmetic ones, and buries the real signal in noise. A shared doc of "things to check" depends on someone remembering to check it. The task needs to run daily, compare against what it saw last time, and only surface what's worth reading. ## What we built A cron trigger runs an agent every day. Each run spawns an isolated session that fetches the competitor pages we track, compares them against the previous run's snapshot, and writes a short summary of what changed to Slack. Cosmetic edits are filtered out; pricing moves, new features, and messaging changes are called out. ## How it works ### Run it on a daily cron A **cron trigger** fires the project once a day. Each firing spawns a fresh **session** in its own isolated sandbox with web access. One run, one sandbox, torn down when it's done. ### Give the agent the watch list and what matters The list of competitors, the specific pages to check, and what counts as a meaningful change live as **skills** and **memory**. Each run also reads the previous run's snapshot from memory so it can diff against it rather than re-reporting the same state. The list is updated as the market shifts. ### Connect the web and Slack Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Fetch competitor pages** — sites, changelogs, and pricing pages on the watch list. - **Compare against the last run** — diff today's content against the stored snapshot to find what actually changed. - **Post to Slack** — a short summary in the growth channel, or nothing when nothing moved. ### Set the guardrails The agent only reads public pages and writes to one Slack channel, so its scope is narrow by design. It has no access to internal systems and takes no action beyond posting. Any credentials it needs are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let each run report A run fetches the tracked pages, diffs them against yesterday, and posts what changed: a pricing tier that moved, a feature that shipped, a rewritten homepage. On a quiet day it says so briefly. The team reads one message instead of visiting a dozen sites. > **The pattern** > Run the check on a **cron trigger**, keep the watch list and last-run snapshot in > **skills** and **memory**, give the agent scoped **connectors** to the web and > Slack, and let each run diff and report. The team reads a summary instead of > browsing. ## Guardrails Even a read-only agent runs with the same controls as the rest of the platform: - **Isolation.** Each run executes in its own microVM sandbox, and only the Slack summary it's explicitly allowed to send leaves the sandbox. - **Scoped secrets.** Any credential the agent uses is encrypted in the secrets manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gates.** The agent's only external action is posting a summary; anything beyond that scope would require a person to approve. - **Everything is code.** The watch list, the pages to check, and the definition of a meaningful change are files in the repo — versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Daily:** The market is checked without anyone remembering to - **Diff-only:** Cosmetic edits filtered out, real changes surfaced - **One message:** A summary instead of a dozen tabs The team learns about a competitor's pricing move or feature launch the morning after it happens rather than whenever someone next looks. Because each run diffs against the last, the channel stays quiet on quiet days and speaks up only when something actually changed. --- <!-- /markdown/use-cases/compliance-monitoring.md --> # How we monitor for compliance drift A daily sweep checks resources against policy for public buckets, untagged resources, and over-broad roles, files findings, and proposes remediation as a reviewed change rather than applying it. Canonical page: https://kortix.com/use-cases/compliance-monitoring Infrastructure drifts out of compliance quietly. A bucket gets made public for a one-off, a resource ships without its tags, a role picks up a permission it no longer needs. Each change is small and reasonable in the moment, but they accumulate, and nobody notices until an audit or an incident surfaces them all at once. By then reconstructing when each drift happened is hard. We handle this by checking the infrastructure against policy every day, so drift surfaces the day it appears. This writes up how we run that on Kortix — the connections, the steps, and the guardrails — so you can set up the same sweep. - **Team:** Kortix - **Trigger:** A daily scheduled sweep - **Connected systems:** AWS · Audit logs · Slack - **Mode:** Cron-driven · Review-gated ## The problem The common approaches each have limits. A quarterly manual audit finds drift late and in bulk, when the context is hardest to recover. A fixed rules engine catches the checks it was configured for and nothing beyond them. And auto-remediation that fixes drift on its own can break a service that depended on the very configuration it "corrected." We wanted the infrastructure checked against policy daily, with findings filed where the team already works and fixes proposed as reviewed changes rather than applied silently. ## What we built On Kortix, a daily schedule triggers an agent. Each sweep runs in its own isolated session — a cloud sandbox — with scoped, read access to AWS and the audit logs. The agent checks resources against policy — public buckets, untagged resources, over-broad roles — and files what it finds to Slack. Remediation is proposed as a reviewed change, never applied automatically. ## How it works ### Connect the schedule as the trigger A **cron** trigger runs the sweep once a day. Each run spawns a fresh **session** in its own sandbox. One sweep, one session, one disposable machine. Nothing carries over between runs, so each day's check starts clean. ### Give the agent the compliance policy What counts as a violation — which buckets may be public, which tags are required, what a role should and shouldn't hold — is stored as **skills** and **memory** that load into every session. The agent checks against that policy rather than a fixed rule set, and it updates as the policy changes. ### Connect what the sweep can read Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read AWS resource state** — bucket policies, resource tags, and IAM roles. - **Read the audit logs** — when a configuration changed and what changed it. - **Post to Slack** — findings filed to the channel the team watches. ### Set the guardrails The sweep's access to AWS is **read-only** — it inspects state, it doesn't change it. Remediation is proposed as a reviewed **change request** and stops at a **human approval gate**; nothing is applied automatically. Credentials are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let each day surface the drift With that in place, the daily sweep checks the infrastructure against policy, files what drifted to Slack, and attaches a proposed fix for a person to review. A newly public bucket becomes a finding and a draft policy change. An untagged resource becomes a flag and a proposed tag set. An over-broad role becomes a narrower policy held for review. > **The pattern** > Run the sweep on a **cron** trigger, give the agent scoped read **connectors** > into AWS and the audit logs, encode the compliance policy as **skills** and > **memory**, and propose every fix as a reviewed **change request** rather than > applying it. ## Guardrails Giving an agent a standing view of the infrastructure is a trust question. The relevant controls on Kortix: - **Isolation.** Every sweep runs in its own microVM sandbox on its own branch. The session can reach only what it's scoped to read, and only the findings it files leave the sandbox. - **Scoped secrets.** Each credential is encrypted in the secrets manager, injected into the sandbox at runtime, and never exposed to the model or the logs. - **Human approval gate.** Remediation is proposed, never auto-applied; a person reviews and applies each change. - **Everything is code.** The agent's persona, skills, and permissions are files in the repo — versioned and changed through a reviewed **change request**, not a dashboard setting. ## The outcome - **Daily:** Infrastructure re-checked against policy every day - **Same-day:** Drift caught before it accumulates for an audit - **Review-gated:** Every fix proposed as a change, never auto-applied The drift that used to surface in bulk at audit time now arrives as small daily findings in Slack, each with a proposed fix a person reviews before it's applied. The team reviews a change instead of reconstructing months of drift, and the infrastructure stays close to policy. --- <!-- /markdown/use-cases/contract-review.md --> # How we do first-pass contract review A new contract in a Drive folder triggers an agent to summarize it, flag non-standard clauses against our playbook, and post the summary to the legal channel, drafting redlines for a lawyer to sign off. Canonical page: https://kortix.com/use-cases/contract-review Contracts arrive faster than they can be read closely. A vendor agreement, an NDA, or an order form lands in a shared Drive folder, and someone on the legal team has to open it, read it against what we consider standard, flag the clauses that deviate, and summarize it for whoever's driving the deal. The first pass is mechanical but time-consuming, and it's the step that stands between a contract arriving and a lawyer being able to focus on what actually matters in it. We handle this by tying the first pass to the event that starts it: a new file in the folder. This writes up how we run that on Kortix — the connections, the steps, and the guardrails — so you can set up the same review. - **Team:** Kortix - **Trigger:** A new contract in a Drive folder - **Connected systems:** Google Drive · Slack - **Mode:** Trigger-driven · Lawyer-gated ## The problem The common approaches each fall short. A shared inbox where contracts pile up means the first pass waits on whoever has time. A generic AI summarizer produces a readout that doesn't know our positions, so it flags nothing that matters to us. And skipping the first pass entirely puts a lawyer straight into a full read of every contract, including the routine ones. We wanted each contract summarized and checked against our own playbook the moment it lands, with the summary where the team works and the redlines left for a lawyer to sign off. ## What we built On Kortix, a new contract in a Drive folder triggers an agent. Each contract runs in its own isolated session — a cloud sandbox — with scoped access to Drive and Slack. The agent reads the contract, summarizes it, flags clauses that deviate from our playbook, and posts the summary to the legal channel. It drafts redlines on the non-standard clauses, but a lawyer signs off before anything goes back to the counterparty. ## How it works ### Connect the Drive folder as the trigger A signed webhook watches the contracts folder in Drive. A new file fires it, and each firing spawns a fresh **session** in its own sandbox, seeded with the document. One contract, one session, one disposable machine. Nothing carries over between runs. ### Give the agent our contract playbook Our standard positions — which clauses are routine, which terms we push back on, what an acceptable liability cap or notice period looks like — are stored as **skills** and **memory** that load into every session. The agent checks each contract against that playbook rather than a generic notion of "standard," and it updates as our positions change. ### Connect what a review can touch Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read the contract in Drive** — the full document, not just an excerpt. - **Draft redlines** — proposed edits on the clauses that deviate from the playbook. - **Post to Slack** — the summary and flags filed to the legal channel. ### Set the guardrails The agent produces a first pass, not a decision. Redlines are drafted, never sent — every one stops at a **human approval gate** for a lawyer to sign off before it reaches the counterparty. It reads the contract and writes its summary and drafts; it doesn't act on the deal. Credentials are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let each contract come back reviewed With that in place, a contract landing in the folder comes back as a summary in the legal channel, the non-standard clauses flagged against our playbook, and draft redlines attached for a lawyer to review. A vendor agreement becomes a readout and a set of proposed edits; the lawyer starts from the flags instead of a blank read. > **The pattern** > Connect the Drive folder via a **trigger**, give the agent scoped > **connectors** into Drive and Slack, encode the contract playbook as **skills** > and **memory**, and gate every redline behind a lawyer. ## Guardrails Giving an agent a first pass on contracts is a trust question. The relevant controls on Kortix: - **Isolation.** Every contract runs in its own microVM sandbox on its own branch. The session can reach only what it's scoped to, and only the summary and drafts it produces leave the sandbox. - **Scoped secrets.** Each credential is encrypted in the secrets manager, injected into the sandbox at runtime, and never exposed to the model or the logs. - **Human approval gate.** Redlines are drafted, never sent; a lawyer reviews and signs off before anything reaches the counterparty. - **Everything is code.** The agent's persona, skills, and permissions are files in the repo — versioned and changed through a reviewed **change request**, not a dashboard setting. ## The outcome - **Every contract:** Summarized and checked against the playbook on arrival - **Lawyer-gated:** Redlines drafted, never sent without sign-off - **2 systems:** Drive and Slack — one agent The first pass that used to wait on whoever had time now arrives as a summary and a set of playbook-checked flags the moment a contract lands, with draft redlines attached. The lawyer starts from the flags instead of a cold read, and routine contracts stop consuming the time the tricky ones deserve. --- <!-- /markdown/use-cases/crm-hygiene.md --> # How we keep our CRM clean A nightly agent that dedupes contacts in HubSpot, fills missing fields from enrichment data, flags stale deals, and posts a data-quality summary — with bulk updates held for approval. Canonical page: https://kortix.com/use-cases/crm-hygiene A CRM decays on its own. Duplicate contacts pile up from form fills and imports, records come in with missing fields, and deals sit untouched long after they've gone cold. Left alone, the pipeline reports drift away from reality and the sales team stops trusting the numbers. We keep our HubSpot clean with an agent that runs every night on Kortix. It dedupes contacts, fills missing fields from enrichment data, flags stale deals, and posts a short data-quality summary. Bulk field updates over a threshold wait for a person to approve. - **Team:** Kortix - **CRM:** HubSpot - **Connected systems:** HubSpot · Enrichment data · Slack - **Mode:** Nightly cron · human-gated ## The problem CRM hygiene is the work nobody schedules. Duplicate contacts split a company's history across two records, so the account owner sees half the story. Missing job titles, industries, and company sizes make segmentation unreliable. Deals that haven't moved in weeks still count toward the forecast. The usual fixes don't hold. A one-time cleanup helps until the next import undoes it. A paid dedupe add-on handles duplicates but not enrichment or stale deals. Asking reps to keep their own records tidy competes with selling and loses. The maintenance needs to run on its own, every night, without a person babysitting it. ## What we built A cron trigger runs an agent against HubSpot every night. Each run spawns its own isolated session with scoped access to HubSpot and our enrichment provider. It finds and merges duplicate contacts, fills missing fields from enrichment, flags deals that have gone stale, and posts a summary of what it changed. Any bulk field update above a set threshold stops for a person before it writes. ## How it works ### Run it on a nightly cron A **cron trigger** fires the project once a night. Each firing spawns a fresh **session** in its own isolated sandbox with the CRM and enrichment access a cleanup pass needs. One run, one sandbox, torn down when it finishes. ### Give the agent the hygiene rules Our data model lives as **skills** and **memory** loaded into every run: which fields are required, how we decide two contacts are the same record, what counts as a stale deal, and the merge rules to follow. The rules are updated as edge cases come up. ### Connect HubSpot and enrichment Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read and write HubSpot** — pull contacts and deals, merge duplicates, and update fields. - **Query the enrichment provider** — fill missing job titles, company sizes, and industries from external data. - **Post to Slack** — a short summary of every run in the RevOps channel. ### Set the guardrails The agent merges duplicates and fills individual gaps on its own, but a **bulk field update over a threshold** stops at a **human approval gate** before it writes. A sweeping change across hundreds of records is reviewed first. Credentials are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let each run report A run dedupes contacts, enriches what's missing, flags the deals that have gone quiet, and posts what it did: duplicates merged, fields filled, deals flagged. When a bulk change is pending, the summary says what's waiting and why. > **The pattern** > Run the cleanup on a **cron trigger**, give the agent scoped **connectors** into > HubSpot and enrichment, encode the hygiene rules as **skills** and **memory**, > and gate bulk changes behind a human. Each night's run then cleans and reports on > its own. ## Guardrails Giving an agent write access to the CRM is a data-integrity question. The controls on Kortix: - **Isolation.** Each run executes in its own microVM sandbox, and only the HubSpot and enrichment changes it's explicitly allowed to make leave the sandbox. - **Scoped secrets.** The HubSpot and enrichment credentials are encrypted in the secrets manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gates.** Bulk field updates over the threshold require a person to approve before they write. - **Everything is code.** The dedupe rules, required fields, and stale-deal definition are files in the repo — versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Nightly:** Cleanup runs without anyone scheduling it - **3 passes:** Dedupe, enrichment, and stale-deal flags in one run - **Threshold-gated:** Large bulk changes reviewed before they write The pipeline reports stay closer to reality because the records behind them are maintained every night instead of in occasional cleanups. When a change is large enough to matter, a person sees it first, and the RevOps channel has a running record of what changed and when. --- <!-- /markdown/use-cases/customer-support.md --> # How we run customer support with an AI agent The support agent we run on Kortix — connected to Plain, our codebase, and Stripe. It triages and resolves inbound threads, and stops for human approval on anything sensitive. Canonical page: https://kortix.com/use-cases/customer-support We run our own customer support on Kortix with an AI agent connected to Plain (our support tool), our codebase, and Stripe. Most support questions need product knowledge, engineering context, and billing data at the same time: a help-center chatbot can quote documentation but can't read the code behind a bug, confirm whether a subscription renewed, or take a scoped action on an account. This write-up covers how the setup works: the connections, the session model, and the guardrails. - **Team:** Kortix — the company behind this platform - **Support stack:** Plain.com - **Connected systems:** Codebase · Stripe · Plain - **Mode:** Trigger-driven · human-gated ## The problem Support volume grows faster than the team. The questions that matter most are the ones a canned macro can't answer: "why was I charged twice?", "this button does nothing", "does your API support X?" Each one needs someone who understands the product, can read the code, and can check the customer's account — usually a senior engineer. A help-desk bot handles the easy questions but not these. Hiring scales linearly with tickets. And giving a generic AI assistant real access to production systems is hard to justify in a security review. ## What we built Our support inbox in **Plain** is connected to an agent running on Kortix. Every inbound thread spawns its own isolated session with scoped access to the systems a support issue can touch: the product's knowledge, the codebase, and Stripe. It investigates, resolves what it can, and stops at a human for sensitive actions. ## How it works ### Connect Plain as the trigger A signed webhook from Plain points at the project. Every new thread or customer reply fires it, and each firing spawns a fresh **session** in its own isolated sandbox. One customer, one thread, one sandbox. Sessions don't share state, and a busy inbox means more sessions running in parallel. ### Give the agent the product's knowledge Product knowledge lives as **skills** and **memory** loaded into every session: how the product works, our support playbook, the reply tone, and resolutions that worked before. The knowledge is updated as threads are resolved. ### Connect the systems an issue can touch Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Search the codebase** — for a reported bug, find the relevant code path, check recent changes, and see whether it's already known. - **Look up Stripe** — plan, invoices, and subscription state, to answer billing questions from account data. - **Read and reply in Plain** — full thread context in, a reply out. ### Set the guardrails The agent is **read-mostly by default**: it investigates freely, but writes are scoped. Refunds, plan changes, and anything touching a customer's money or account stop at a **human approval gate**. Credentials are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let each thread run An inbound thread triages, investigates across the three systems, and either resolves directly or hands off to a human with the work done and context attached. "Why was I charged twice?" becomes a Stripe lookup with an answer. "This button does nothing" becomes a codebase search that either explains the behavior or files a bug report for engineering. > **Summary** > Connect the support platform via a **trigger**, give the agent scoped > **connectors** into the systems an issue can touch, encode product knowledge as > **skills** and **memory**, and gate sensitive actions behind a human. Each > inbound thread then runs in its own session. ## Guardrails Giving an agent access to the codebase, Stripe, and the support inbox is a security question as much as a product one. The controls on Kortix: - **Isolation.** Each thread runs in its own microVM sandbox on its own branch. A session can install, run, and experiment to reproduce a bug, and only what it's explicitly allowed to send leaves the sandbox. - **Scoped secrets.** The Plain, Stripe, and GitHub credentials are encrypted in the secrets manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gates.** Irreversible actions (money, account state) require a person to approve. - **Everything is code.** The agent's persona, skills, and permissions are files in the repo — versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **24/7:** Coverage without a night shift - **First-touch:** Many inbound threads resolved before a human opens them - **3 systems:** Product knowledge, Stripe, and the codebase in one agent The agent handles the investigation-heavy tickets that used to pull a senior engineer off their work, and when it escalates, it escalates with the diagnosis attached. Customers get an answer to the question at any hour. The setup relies on four pieces: sandbox isolation to contain each session, a secrets manager to broker tokens, human approval gates for irreversible actions, and memory that improves as threads are resolved. --- <!-- /markdown/use-cases/dependency-upgrades.md --> # How we keep dependencies up to date The upgrade agent we run on Kortix — a weekly cron that opens dependency PRs, runs the full suite in a sandbox, and only opens the PR when it's green. Canonical page: https://kortix.com/use-cases/dependency-upgrades Dependencies drift. Left alone, a project falls months behind, security patches pile up, and the eventual upgrade turns into a large, risky change nobody wants to own. The usual bots open a PR for every bump and leave a human to work out whether each one is safe, which mostly means the PRs sit unreviewed. We run an upgrade agent on Kortix that does the checking before it asks for a review. A weekly cron proposes upgrades, applies them in an isolated sandbox, runs the full suite, and only opens a PR when the change is green. This write-up covers how the setup works: the trigger, the session model, and the guardrails. - **Team:** Kortix - **Runs on:** A weekly cron - **Connected systems:** GitHub · CI - **Mode:** Cron-driven · PR opened only when green ## The problem Keeping dependencies current is work no one schedules. A version-bump bot opens a PR per package, but it can't tell whether the bump breaks anything — that check still falls to a person, so the PRs queue up and the project drifts anyway. The common fixes are incomplete. Ignoring upgrades until something forces the issue turns a routine bump into a migration. Merging bot PRs on green CI trusts whatever tests already exist, not that the upgrade is actually safe. Doing it by hand is reliable but slow, and it's the first thing dropped when the team is busy. ## What we built On Kortix, a weekly cron triggers an upgrade agent. Each run spawns an isolated session (a cloud sandbox) with scoped access to the repository and CI. The agent checks which dependencies are behind, applies the upgrades on a branch, installs clean, and runs the full suite inside the sandbox. It opens a PR only when the change is green; a human merges. ## How it works ### Connect a weekly cron as the trigger A scheduled **trigger** fires the project once a week. Each firing spawns a fresh **session** in its own sandbox, seeded with a clean checkout of the default branch. One run, one disposable machine, so nothing carries over between weeks and independent upgrade sets can run in parallel. ### Give the agent the upgrade playbook How we handle upgrades lives as **skills** and **memory** that travel with the agent: which packages are pinned on purpose, how to run the suite, the order to apply major versions in, and migrations that have bitten us before. When an upgrade needs a manual step, we write it down and the agent applies it on the next run. ### Connect the systems the upgrade needs Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read the manifests** — resolve which dependencies are behind and how far, separating patch and minor bumps from majors. - **Apply and install in the sandbox** — update the lockfile and install clean on a branch, with the resolution output captured in full. - **Run the full suite** — unit, integration, and e2e inside the sandbox, so a bump that breaks a path fails here rather than in review. - **Open a PR on GitHub** — the branch, the changelog for each bump, and the green result post as a pull request. ### Set the guardrails The agent opens a PR only when the suite passes; a failing upgrade is dropped or split, not pushed for a human to debug. It never merges — the merge is a **human approval gate**. Credentials are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let the weekly run happen With that in place, each week the agent finds what's behind, applies the upgrades, runs the suite in the sandbox, and opens a PR that's already been proven green — with the bumps grouped and the changelog attached. A bump that breaks a test never becomes a PR; it comes back flagged with the failure instead. > **The pattern** > A weekly **trigger** spawns a session with scoped **connectors** into the repo > and CI. The upgrade playbook is encoded as **skills** and **memory**. The agent > proves the change green in its sandbox and a human owns the merge. ## Guardrails The agent changes dependencies and runs code, so the access is scoped and contained: - **Isolation.** Every run happens in its own microVM sandbox on its own branch. The session can install, resolve, and run the suite to prove an upgrade; only the branch and result leave the sandbox. - **Scoped secrets.** The GitHub and CI credentials are encrypted in the secrets manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **PR-gated.** The agent opens a pull request and stops. It never merges and never pushes to the default branch; a human owns the merge. - **Everything is code.** The agent's configuration, skills, and permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Weekly:** Upgrades proposed on a schedule, not when something breaks - **Green-only:** PRs opened only after the full suite passes - **Human merge:** The agent proves the change; the team decides Dependencies stay current without anyone scheduling the work, and the upgrade PRs that land in review have already been run against the full suite. Reviewers see a green change with the changelog attached instead of a bump they have to check out and test by hand. --- <!-- /markdown/use-cases/docs-maintainer.md --> # How we keep our docs in sync with the code The docs agent we run on Kortix — connected to GitHub and our codebase. Once a day it checks the code that landed since its last run and updates the docs those changes affected, opening a PR for review. Canonical page: https://kortix.com/use-cases/docs-maintainer Documentation tends to fall behind the code. The README, the setup guide, the API reference, and architecture notes drift a release or two back while the code keeps changing. The person who changes the code is usually not the person who owns the page it affects, so the two rarely get updated together. A renamed environment variable, a new setup step, or a removed endpoint is a small code change and a docs change that often goes unmade. We handle this by running a docs sweep close to when the code changes: once a day, over everything that merged since the last run. This writes up how we run that on Kortix — the connections, the steps, and the guardrails — so you can set up the same thing for your own repo. - **Team:** Kortix - **Source of truth:** The codebase - **Connected systems:** GitHub · Codebase · Docs site - **Mode:** Daily sweep · PR-gated ## The problem The common fixes each have limits. "Docs are part of the PR" tends to get cut under deadline. A scheduled audit finds drift late and in bulk, when reconstructing what changed is hardest. And a generic AI writer pointed at the docs produces prose that doesn't match the code, because it never reads the code. We wanted the docs we already have to stay accurate to the code, updated on each merge rather than in periodic cleanups. ## What we built On Kortix, a docs agent runs once a day in the same persistent session — a cloud sandbox — with scoped access to what a docs update needs: the commits that landed since its last run, the codebase for context, and the docs. It picks up from a checkpoint it kept from the previous run, determines what changed, rewrites the affected pages, and opens a single docs PR for review. Nothing publishes without a human merge. ## How it works ### Connect GitHub as the trigger A **cron trigger** fires once a day and resumes the same persistent **session** rather than spinning up a new one per merge. The session reads a checkpoint left by the previous run, then pulls every commit that landed on the default branch since that checkpoint — however many merges that turns out to be. Each affected doc page is handled as its own unit of work, so a problem with one page never blocks the rest of the sweep. One sweep, one PR, and the checkpoint advances at the end whether or not anything changed. ### Give the agent the codebase and the docs standard Our writing conventions are stored as **skills** and **memory** that load into every session: how the docs are structured, the terminology we use, which page covers which subject, and fixes that worked before. The agent writes to that standard rather than inventing one, and it updates as docs PRs get merged. ### Connect what a docs update can touch Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read the diff and the codebase** — it sees what changed, then reads the surrounding code to understand intent, not just the delta. - **Search the docs** — it finds every page, README, and reference section that mentions the changed behavior. - **Open a PR on GitHub** — the rewritten docs come back as a reviewable pull request, linked to the change that prompted it. ### Set the guardrails The agent never pushes to a branch anyone reads from: every change lands as a **pull request** gated on a human merge. It edits only files under the docs and README paths; the code itself is read-only to it. Credentials are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let each merge update the affected docs With that in place, each day's sweep triages what changed since the last checkpoint, finds the pages that drifted, rewrites them to match, and opens a docs PR with its reasoning attached. A renamed env var becomes an update to the setup guide. A new endpoint becomes a reference entry drafted from the actual handler. A removed feature becomes a PR that strips the stale section. > **The pattern** > Connect the repo via a **trigger** on merge, give the agent scoped > **connectors** into the diff, the codebase, and the docs, encode the writing > standard as **skills** and **memory**, and gate every change behind a reviewed > **PR**. ## Guardrails Giving an agent write access to documentation is a trust question. The relevant controls on Kortix: - **Isolation.** The daily sweep runs in its own microVM sandbox, resuming the same session across runs via a durable checkpoint rather than persisting the raw repo state. The session can read the whole repo to understand a change, and only the docs PR it opens leaves the sandbox. - **Scoped secrets.** The GitHub credential is encrypted in the secrets manager, injected into the sandbox at runtime, and not exposed to the model or the logs. - **PR-gated.** No change reaches a branch anyone reads without a person reviewing the diff and merging it. - **Everything is code.** The agent's persona, skills, and permissions are files in the repo — versioned and changed through a reviewed **change request**, not a dashboard setting. ## The outcome - **Daily:** Docs re-checked against the code that landed since the last run - **Same-day:** Drift caught before it reaches a reader - **3 systems:** The diff, the codebase, and the docs — in one agent The backlog of "someone should update the README" changes now arrives as small, reviewable PRs within a day of the code landing, with the reasoning written down. The team reviews a diff instead of reconstructing months of drift, and readers stop hitting instructions that are no longer accurate. The setup relies on four pieces working together: sandbox isolation for the daily session, a secrets manager to broker the GitHub token, a PR gate on every change, and a durable checkpoint that carries the sweep forward run to run. --- <!-- /markdown/use-cases/employee-offboarding.md --> # How we offboard employees On a schedule, an agent checks for newly marked departures and runs the offboarding checklist across Okta, Google Workspace, Google Drive, and GitHub — revoking access, transferring ownership, and reclaiming licenses — holding the ownership transfer for approval and never deleting an account. Canonical page: https://kortix.com/use-cases/employee-offboarding Offboarding is a checklist that has to run to completion. When someone leaves, their access has to be revoked everywhere, their documents and repositories have to change hands, and their licenses have to come back. Miss a step and a former employee keeps a login, a shared drive loses its owner, or a paid seat sits unused. The steps are simple; the risk is in the ones that get skipped. We run offboarding through an agent on Kortix. On a schedule, the agent checks for newly marked departures and works the checklist across every connected tool, posting what it did and what is still pending. This is the security counterpart to how we onboard. - **Team:** Kortix - **Runs on:** Scheduled Okta departure check - **Connected systems:** Okta/SSO · Google Workspace · Google Drive · GitHub - **Mode:** Schedule-driven · ownership transfer gated ## The problem When an employee leaves, their access has to be pulled from every system they touched, their work has to be handed off, and their licenses have to be reclaimed. That means Okta, Google Workspace, Slack, and GitHub, each with its own steps, done in the right order, on the same day. The common approaches leave gaps. A written runbook depends on someone working it by hand under time pressure, and a missed line is a live account no one notices. A provisioning tool covers SSO but not document ownership or repository access. IT tickets spread the work across people and days, and the parts that are easy to forget are exactly the ones that matter for security. ## What we built On Kortix, an agent checks Okta on a schedule for newly marked departures. Each check spawns an isolated session (a cloud sandbox) with scoped access to Okta, Google Workspace, Google Drive, and GitHub, and works the offboarding checklist for every departure it finds: revoke SSO and app access, remove the person from the GitHub org, transfer document and drive ownership, and reclaim licenses. The ownership transfer waits at a human approval gate — account deletion is never attempted — and the agent posts a completed checklist showing what it did and what is still pending. ## How it works ### Check Okta on a schedule The agent checks the Okta group HR adds departing employees to on a schedule, so access comes down the same day a departure is marked. Each check spawns a fresh **session** in its own sandbox; if it finds more than one new departure, each is worked as its own independent case, and nothing carries over between runs. ### Give the agent the offboarding checklist Our offboarding policy lives as **skills** and **memory** that travel with the agent: the full list of systems, the order to work them in, which steps are reversible and which are not, and how ownership should be reassigned. When we add a tool or change a policy, we write it down and the agent picks it up on the next departure. ### Connect the systems access lives in Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Revoke SSO in Okta** — deactivate the account and pull the app assignments that hang off it. Access to Slack drops the moment SSO is revoked, where Slack is SSO-connected. - **Remove from GitHub** — remove the person from the GitHub org and its teams. - **Transfer ownership in Google Drive, then suspend in Google Workspace** — reassign document and shared-drive ownership so nothing is orphaned, then suspend the account. - **Reclaim licenses** — release paid seats across the connected tools so they return to the pool. ### Set the guardrails The reversible steps run on their own; transferring ownership away from a person stops at a **human approval gate** before it executes. Account deletion is out of scope for this agent entirely — it is never performed, gated or otherwise, and hands off to a human if one is ever genuinely needed. Credentials are encrypted in the Secrets Manager and injected at runtime, never shown to the model or written to logs. ### Run the checklist to completion With that in place, each departure the scheduled check finds becomes one worked case: SSO revoked, GitHub access removed, ownership transferred, and licenses reclaimed, with the ownership transfer held for approval. The agent posts a completed checklist showing every step it took and anything still pending a human, so nothing is left half-done. > **The pattern** > A scheduled **trigger** spawns a session with scoped **connectors** into Okta, > Google Workspace, Google Drive, and GitHub. The offboarding policy is encoded > as **skills** and **memory**. The agent runs the reversible steps on its own > and holds the ownership transfer behind a human. ## Guardrails The agent revokes access and transfers ownership across every system, so the access is scoped and contained: - **Isolation.** Every departure case runs in its own microVM sandbox. The session can reach only the systems it's scoped to, and only the checklist result leaves the sandbox. - **Scoped secrets.** The Okta, Google Workspace, Google Drive, and GitHub credentials are encrypted in the Secrets Manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gate.** Transferring ownership away from a person requires a person to approve before it runs. - **Never deletes.** Account deletion is out of scope for this agent entirely — it is never performed, gated or otherwise. It hands off to a human if a deletion is ever genuinely needed. - **Everything is code.** The agent's checklist, skills, and per-system permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Every departure:** The full checklist run across all systems - **Same day:** Access revoked once the scheduled check finds it - **4 systems:** Okta, Google Workspace, Google Drive, GitHub in one run Access that used to depend on someone working a runbook by hand now comes down the same day a departure is marked, with ownership transferred and licenses reclaimed in the same run. The ownership transfer waits for a person, and the completed checklist shows exactly what happened and what is still pending. --- <!-- /markdown/use-cases/employee-onboarding.md --> # How we onboard new hires A new-hire record triggers an agent to provision accounts, add the person to the right groups and channels, and file a first-week checklist, with account creation and group membership held for approval. Canonical page: https://kortix.com/use-cases/employee-onboarding Onboarding a new hire spans several systems on day one. There's a Google Workspace account to create, groups and aliases to add them to, Slack channels to invite them into, and a first-week checklist someone has to remember to file. Each step is small, but they live in different tools, and the person doing the setup is rarely the person who owns every system. Steps get missed, and a new hire waits on access they should have had on their first morning. We handle this by tying the setup to the event that starts it: a new record in our HR system. This writes up how we run that on Kortix — the connections, the steps, and the guardrails — so you can set up the same flow for your own team. - **Team:** Kortix - **Trigger:** A new-hire record - **Connected systems:** Google Workspace · Slack · Linear - **Mode:** Trigger-driven · Approval-gated ## The problem The common approaches each leave gaps. A written runbook depends on a person following every step by hand, and the steps drift as the tools change. A no-code automation handles the happy path it was built for but can't reason about which groups a given role needs. And handing each system to a different owner means the new hire's access lands piecemeal over their first few days. We wanted the accounts, memberships, and checklist a new hire needs to be prepared the moment their record exists, with the irreversible steps held for a person to confirm. ## What we built On Kortix, a new-hire record triggers an agent. Each new hire runs in its own isolated session — a cloud sandbox — with scoped access to what onboarding needs: Google Workspace, Slack, and Linear. The agent reads the role, prepares the account, the group and channel memberships, and a first-week onboarding checklist, and files them. Account creation and group membership stop at an approval gate before anything is provisioned. ## How it works ### Connect the HR record as the trigger A signed webhook from our HR system points at the project. A new-hire record fires it, and each firing spawns a fresh **session** in its own sandbox, seeded with the role, team, and start date. One hire, one session, one disposable machine. Nothing carries over between runs. ### Give the agent the onboarding standard Which groups a role belongs to, which channels a team joins, and what a good first week looks like are stored as **skills** and **memory** that load into every session. The agent works to that standard rather than guessing, and it updates as we refine the process. ### Connect what onboarding can touch Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Create the Google Workspace account** — mailbox, aliases, and the group memberships the role calls for. - **Add the hire to Slack** — the channels their team works in. - **File the checklist in Linear** — a first-week onboarding project with the tasks their role needs. ### Set the guardrails Account creation and group membership are the steps that grant access, so they stop at a **human approval gate** before anything is provisioned. A person confirms the account and the memberships in one place. Credentials are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let each new hire arrive set up With that in place, a new-hire record prepares the mailbox, the group and channel memberships, and the first-week checklist, and holds the access-granting steps for a person. A new engineer's record becomes a Workspace account, the right Slack channels, and a Linear onboarding project, ready on day one. > **The pattern** > Connect the HR record via a **trigger**, give the agent scoped **connectors** > into Google Workspace, Slack, and Linear, encode the onboarding standard as > **skills** and **memory**, and gate account creation and group membership > behind a human. ## Guardrails Giving an agent the ability to create accounts and grant access is a trust question. The relevant controls on Kortix: - **Isolation.** Every new hire runs in its own microVM sandbox on its own branch. The session can reach only the systems it's scoped to, and only what it's explicitly allowed to send leaves the sandbox. - **Scoped secrets.** Each credential is encrypted in the secrets manager, injected into the sandbox at runtime, and never exposed to the model or the logs. - **Human approval gate.** Account creation and group membership require a person to approve before anything is provisioned. - **Everything is code.** The agent's persona, skills, and per-system permissions are files in the repo — versioned and changed through a reviewed **change request**, not a dashboard setting. ## The outcome - **Day one:** Accounts and access prepared before the hire starts - **Approval-gated:** Every account and group membership confirmed by a person - **3 systems:** Google Workspace, Slack, and Linear — one agent The scramble to set up a new hire across several tools now arrives as one prepared, reviewable setup the moment their record exists, with the access-granting steps held for a person. Extending it to another system means connecting one more platform. --- <!-- /markdown/use-cases/expense-reconciliation.md --> # How we reconcile expenses every month The finance agent we run on Kortix — connected to Stripe, the bank feed, and Google Sheets. Each month it matches transactions against invoices, flags mismatches, and posts a summary, escalating anything it can't match. Canonical page: https://kortix.com/use-cases/expense-reconciliation Monthly reconciliation is the kind of task that's simple to describe and tedious to do: line up what came in and went out against what was supposed to, find the things that don't match, and explain them. On a small team it lands on one person for an afternoon, and it's the same afternoon every month. We handle this with a scheduled agent. Once a month it reconciles transactions against invoices, flags what doesn't line up, and posts a summary. This writes up how we run that on Kortix — the connections, the steps, and the guardrails. - **Team:** Kortix - **Connected systems:** Stripe · Bank feed · Google Sheets - **Trigger:** Monthly cron - **Mode:** Scheduled · human-escalated ## The problem Reconciliation is mechanical until it isn't. Most lines match cleanly, and the value is entirely in the handful that don't: a payment that never landed, a fee that doesn't tie out, an invoice with no matching deposit. Finding those means going through everything, which is slow and easy to get wrong when it's the same repetitive comparison hundreds of times. A spreadsheet formula catches the exact matches but not the near ones, and the near ones are where the problems hide. So the check either runs shallow and misses things, or runs deep and eats a person's day. Either way it only happens as often as someone has time for it. ## What we built On Kortix, a monthly **cron** triggers a reconciliation agent. Each run spawns its own isolated session — a cloud sandbox — with scoped access to what reconciliation needs: Stripe, the bank feed, and the Google Sheet we track in. It matches transactions against invoices, flags the mismatches, posts a summary to the sheet, and escalates anything it can't match to a person. ## How it works ### Connect a monthly cron as the trigger A **cron** trigger fires the project on a schedule — once a month, after the period closes. Each firing spawns a fresh **session** in its own sandbox. One run, one session, one disposable machine, with nothing carried over from the last month's run. ### Give the agent our reconciliation rules How we reconcile lives as **skills** and **memory** loaded into every session: what counts as a match, the tolerances we allow on fees and timing, the accounts we track, and mismatches we've explained before. The agent works to that standard rather than inventing one, and the memory updates as patterns recur. ### Connect what reconciliation can touch Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read Stripe** — charges, payouts, and fees for the period. - **Read the bank feed** — deposits and withdrawals to match against Stripe and invoices. - **Read and write the Google Sheet** — the ledger it reconciles against and the summary it posts back. ### Set the guardrails The agent reads the financial systems and writes only to the tracking sheet — it never moves money or touches Stripe or the bank beyond reading. Anything it can't match within tolerance is **escalated to a person** rather than force-fit or written off. Credentials are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let each month run With that in place, the monthly run produces a reconciled sheet and a summary without anyone starting it. Clean matches tie out silently. A payout that's short by a fee gets explained. An invoice with no matching deposit gets flagged and sent to a person to chase, with the two records it couldn't reconcile attached. > **The pattern** > Fire the project on a monthly **cron**, give the agent read access to Stripe > and the bank feed and write access to the sheet through scoped **connectors**, > encode the reconciliation rules as **skills** and **memory**, and escalate > anything it can't match to a person. ## Guardrails Giving an agent access to financial systems is a security question first. The relevant controls on Kortix: - **Isolation.** Each run happens in its own microVM sandbox. The session can read the accounts it needs, and only the summary it writes to the sheet leaves the sandbox. - **Scoped secrets.** The Stripe, bank, and Sheets credentials are encrypted in the secrets manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gate.** Anything the agent can't match within tolerance is escalated to a person rather than force-fit, and the agent never moves money. - **Everything is code.** The agent's persona, skills, and permissions are files in the repo — versioned and changed through a reviewed **change request**, not a dashboard setting. ## The outcome - **Every month:** Reconciliation run on schedule, not when there's time - **Mismatches only:** A person looks at the exceptions, not every line - **3 systems:** Stripe, the bank feed, and the sheet in one agent The afternoon that used to go to comparing hundreds of lines now goes to resolving the few the agent couldn't match. The reconciliation runs every month whether or not anyone has time, and the summary is waiting when the finance person opens the sheet. The setup relies on four pieces: sandbox isolation per run, a secrets manager to broker the Stripe, bank, and Sheets tokens, an escalation path for anything unmatched, and memory that carries recurring explanations forward month to month. --- <!-- /markdown/use-cases/inbox-triage.md --> # How we triage a shared inbox with an AI agent The inbox agent we run on Kortix — connected to Gmail, a help doc, and Linear. It labels every inbound email, drafts a reply, or files a task, and stops for approval before anything goes to a customer. Canonical page: https://kortix.com/use-cases/inbox-triage A shared inbox is where a small team's requests pile up: support questions, sales pings, bug reports, and the occasional invoice, all landing in one place with no owner. Sorting them by hand is the first thing that slips when the team is busy, and a message sitting unread for a day is a message the sender assumes was ignored. We handle this by putting an agent on the inbox. Every inbound email gets read, labelled, and either drafted a reply or turned into a task. This writes up how we run that on Kortix — the connections, the steps, and the guardrails. - **Team:** Kortix - **Connected systems:** Gmail · Help doc · Linear - **Trigger:** New inbound email - **Mode:** Trigger-driven · human-gated ## The problem The messages that need a fast reply are mixed in with the ones that can wait, and telling them apart takes a person reading each thread. On a small team that person is also doing three other jobs, so triage happens in bursts — everything looks urgent when the inbox is opened once a day, and nothing looks urgent in between. Filters and rules help with the obvious cases but not the judgment calls: whether a message is a real support issue or a sales lead, whether it needs a reply or a ticket, and what a reasonable answer would be. Those are the parts that actually take time. ## What we built Our shared inbox in **Gmail** is connected to an agent running on Kortix. Every inbound email spawns its own isolated session — a cloud sandbox — with scoped access to what triage needs: the message, our help doc, and Linear. It reads the email, applies a label, and then either drafts a reply in Gmail or files a task in Linear. Anything customer-facing waits for a person before it sends. ## How it works ### Connect Gmail as the trigger A signed webhook from Gmail points at the project. Every new inbound message fires it, and each firing spawns a fresh **session** in its own sandbox, seeded with the email. One message, one session, one disposable machine. Sessions don't share state, and a busy inbox means more sessions running in parallel. ### Give the agent our knowledge How we handle the inbox lives as **skills** and **memory** loaded into every session: our help doc, the categories we sort into, the reply tone, and answers that worked before. The agent triages to that standard rather than inventing one, and the memory updates as threads are resolved. ### Connect what triage can touch Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read the help doc** — it looks up the answer to a common question instead of guessing. - **Label and draft in Gmail** — it applies the right label and, where a reply fits, writes a draft on the thread. - **File a task in Linear** — a bug report or a request that needs follow-up becomes a ticket with the context attached. ### Set the guardrails The agent labels and drafts freely, but nothing sends on its own: every customer-facing reply stops at a **human approval gate** as a draft for a person to review and send. Credentials are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let each email run With that in place, an inbound email triages itself: it gets a label, and either a draft reply waiting for approval or a Linear ticket already filed. A common support question becomes a drafted answer pulled from the help doc. A bug report becomes a ticket. A sales ping becomes a label the right person can pick up. > **The pattern** > Connect the inbox via a **trigger** on new mail, give the agent scoped > **connectors** into the help doc, Gmail, and Linear, encode how we triage as > **skills** and **memory**, and gate every customer-facing reply behind a human. ## Guardrails Giving an agent access to the shared inbox is a trust question as much as a convenience one. The relevant controls on Kortix: - **Isolation.** Each email runs in its own microVM sandbox. The session can read the thread and the help doc it needs, and only the draft or ticket it produces leaves the sandbox. - **Scoped secrets.** The Gmail and Linear credentials are encrypted in the secrets manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gate.** Nothing customer-facing sends without a person reviewing the draft first — replies land as drafts, not sent mail. - **Everything is code.** The agent's persona, skills, and permissions are files in the repo — versioned and changed through a reviewed **change request**, not a dashboard setting. ## The outcome - **Every email:** Labelled and routed as it lands - **Drafted:** Common replies written and waiting for a send - **3 systems:** The help doc, Gmail, and Linear in one agent The inbox stops being a pile to sort and becomes a list already labelled, with replies drafted and tickets filed. The team reviews and sends instead of reading every thread cold, and no message sits unread waiting for someone to notice it. The setup relies on four pieces: sandbox isolation per email, a secrets manager to broker the Gmail and Linear tokens, a human approval gate before anything reaches a customer, and memory that improves as threads are resolved. --- <!-- /markdown/use-cases/incident-postmortem.md --> # How we draft incident postmortems When an incident resolves, an agent pulls the timeline from the incident channel, correlates deploys and log spikes, and drafts a structured postmortem as a doc PR for the team to review and edit. Canonical page: https://kortix.com/use-cases/incident-postmortem A postmortem is worth writing and easy to skip. Once an incident is resolved and the pressure is off, someone has to reconstruct the timeline from a scrolling channel, line it up against the deploys and log spikes, work out the root cause, and write it all up. It's an hour of careful work at the moment everyone most wants to move on, so it often gets a thin summary or nothing. We run an agent on Kortix that drafts the first version. When an incident resolves, it reads the incident channel, correlates the deploys and log spikes, and opens a structured postmortem as a doc PR. It drafts; humans review and finalize. This is how we write our own postmortems. - **Team:** Kortix - **Runs on:** Incident resolved, the agent drafts - **Connected systems:** Incident channel · Logs · GitHub - **Mode:** Drafts a doc PR, humans finalize ## The problem The facts of an incident are scattered across places that don't line up on their own: the back-and-forth in the incident channel, the deploy history, and the log spikes. Turning them into a timeline means reading the channel top to bottom, matching each moment against what shipped and what the logs did, and inferring where the root cause sits. The common workarounds are thin. A blank template gets filled in from memory, so the timeline drifts and details get rounded off. A resolved incident with no writeup means the same failure mode can recur with nothing to point back to. The information to do it properly exists; assembling it by hand is exactly the work no one wants right after an incident. ## What we built On Kortix, resolving an incident triggers an agent. It reads the incident channel for the timeline, pulls the deploy history and log spikes for the same window, correlates them, and drafts a structured postmortem, timeline, root cause, and action items, as a doc PR against the repo. The team reviews and edits the draft the way they'd review any change. The agent drafts; it never publishes a final postmortem on its own. ## How it works ### Trigger on the resolved incident The incident channel is connected as a **channel**, so marking an incident resolved is the trigger. That fires a fresh **session** in its own isolated sandbox, seeded with the incident. One incident maps to one session on one disposable machine, and the draft starts while the details are fresh. ### Give the agent the postmortem format What a good postmortem contains and how we structure it lives as **skills** and **memory** that travel with the agent: the section layout (timeline, root cause, impact, action items), the house style, and patterns from past incidents worth checking against. When we change how we write postmortems, we update the file and the next draft follows it. ### Connect the channel, the logs, and GitHub Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent reads: - **The incident channel** — the message timeline, who did what, and when the incident opened and resolved. - **The logs** — error-rate and latency spikes over the incident window, to line up against the timeline. - **GitHub** — the deploys and merges in the same window, to correlate what shipped with when things broke. The agent reads these to reconstruct the sequence; its one write is the doc PR. ### Set the guardrails The agent produces a **draft, not a decision**. Its output is a doc PR that a human reviews, edits, and merges: root cause and action items are proposed, never finalized by the agent. It doesn't publish a postmortem or assign owners on its own. Credentials are encrypted in the Secrets Manager and injected at runtime, never shown to the model or written to logs. ### Open the draft for review With that in place, a resolved incident produces a first draft on its own: a timeline built from the channel and lined up against the deploys and log spikes, a proposed root cause with the evidence behind it, and a list of candidate action items. The team opens the PR, corrects what the agent inferred, adds the context only they have, and merges the version they stand behind. > **The pattern** > A resolved incident on the **channel** trigger spawns a session with read-only > **connectors** into the channel, the logs, and GitHub. The format lives as > **skills** and **memory**. The agent correlates the timeline and opens a doc > PR; humans finalize. ## Guardrails The agent reads an incident channel, logs, and deploy history and writes a document others will act on, so the access is scoped and contained: - **Isolation.** Every incident runs in its own microVM sandbox. The session reads the timeline, correlates the deploys and logs, and only the drafted doc PR leaves the sandbox. - **Scoped secrets.** The channel, logs, and GitHub credentials are encrypted in the Secrets Manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **PR-gated.** The postmortem lands as a doc PR, not a published document. A human reviews the timeline, edits the root cause and action items, and owns the merge. - **Everything is code.** The postmortem format and the per-tool permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Every incident:** A first draft the moment it resolves - **PR-gated:** The agent drafts, the team reviews and finalizes - **3 systems:** Incident channel, logs, and GitHub in one agent Resolved incidents now come with a drafted postmortem waiting as a doc PR, its timeline already correlated against the deploys and log spikes. The team spends its time judging the root cause and deciding the action items rather than reconstructing what happened, and fewer incidents close with no writeup at all. --- <!-- /markdown/use-cases/investor-update.md --> # How we draft our monthly investor update A monthly agent we run on Kortix — connected to Postgres, Stripe, and last month's update. It pulls the core metrics, compares them to last month and to plan, and drafts the update in our format for a founder to finalize. Canonical page: https://kortix.com/use-cases/investor-update The monthly investor update is a small, recurring task that eats a founder's time. The numbers live in a few different places, they have to be pulled and compared to last month and to plan, and then the whole thing has to be written up in a consistent format. None of it is hard; it's just an hour or two of gathering and formatting that comes around every month. We run an agent on Kortix that does the gathering and the first draft. This is how we draft our own investor update, including the connections and guardrails involved. - **Team:** Kortix - **Runs on:** A monthly cron - **Connected systems:** Postgres · Stripe · Last month's update - **Mode:** Read-only · a founder finalizes and sends ## The problem Writing the update every month means pulling MRR and revenue from Stripe, active accounts and growth from the product database, and burn and runway from the finance numbers, then setting each against last month and against plan. The data is spread across systems, the comparisons are done by hand, and the write-up has to match the format investors are used to seeing. The common fixes are incomplete. A BI dashboard shows the current numbers but doesn't write the narrative or compare against plan. A saved template still needs someone to fill in every figure. Doing it by hand each month is reliable but it's the founder's time going into gathering and formatting rather than into the commentary that actually matters. ## What we built On Kortix, a monthly cron triggers an agent. It spawns an isolated session (a cloud sandbox) with read-only access to the product database, Stripe, and last month's update. It pulls the core metrics — MRR, growth, burn, runway, active accounts — compares them to last month and to plan, and drafts the update in our usual format as a document. A founder edits it and sends it. ## How it works ### Trigger the draft on a monthly cron A **cron trigger** fires once a month and spawns a fresh **session** in its own sandbox. Each run pulls the current month's numbers and produces one draft. One run maps to one session on one disposable machine, and nothing carries over between months except what's read from the source systems. ### Give the agent our format and what matters The shape of our update lives as **skills** and **memory** that travel with the agent: which metrics we report, how we define each one, the format and section order investors expect, and the plan targets to compare against. Last month's update is the reference for tone and structure. When we change how we report, we write it down and the agent follows it next month. ### Connect Postgres, Stripe, and last month's update Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Query Postgres** — active accounts, growth, and the product metrics we track. - **Read Stripe** — MRR and revenue for the month. - **Read last month's update** — the prior numbers to compare against and the format to match. - **Draft into a document** — the numbers and the narrative assembled in our format for a founder to edit. ### Set the guardrails The agent is **read-only** across every connected system: it reads the database, Stripe, and last month's update, and it writes nothing back to any of them. Its one output is a draft document. A founder finalizes and sends; the agent never sends anything. Credentials are encrypted in the Secrets Manager and injected at runtime, never shown to the model or written to logs. ### Hand a founder a finished first draft With that in place, the start of each month produces a draft with the metrics pulled, the month-over-month and against-plan comparisons filled in, and the narrative written in our format. The founder edits the commentary, checks the numbers, and sends it. The gathering and formatting are done; the judgment stays with a person. > **The pattern** > A monthly **cron** spawns a session with read-only **connectors** into Postgres, > Stripe, and last month's update. Our format and metric definitions are encoded as > **skills** and **memory**. The agent drafts; a founder finalizes and sends. ## Guardrails The agent reads revenue and product data to draft a document, so the access is scoped and contained: - **Isolation.** Every run happens in its own per-task microVM sandbox. The session reads only the systems it's scoped to, and only the draft document leaves the sandbox. - **Scoped secrets.** The Postgres and Stripe credentials are encrypted in the Secrets Manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Read-only.** The agent reads every source and writes back to none of them. Its only output is a draft, and it never sends. A founder owns the send. - **Everything is code.** The agent's configuration, skills, and per-system permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Every month:** A finished first draft ready at the start of the month - **Read-only:** Nothing written back to any source system - **3 systems:** Postgres, Stripe, and last month's update in one agent The monthly update now arrives as a draft with the numbers pulled, the comparisons done, and the narrative written in our format. The founder spends their time on the commentary and the send rather than on gathering and formatting, and the data is only ever read. --- <!-- /markdown/use-cases/lead-follow-up.md --> # How we follow up with every inbound lead The sales agent we run on Kortix — connected to HubSpot, email, and Google Calendar. It researches each new lead, drafts a personalized follow-up, and offers a call slot, with a human approving the send. Canonical page: https://kortix.com/use-cases/lead-follow-up Inbound leads have a short half-life. A form filled out on Tuesday is worth far more if the follow-up goes out Tuesday than if it goes out Friday, but a good follow-up takes research — who the company is, what they do, why they might have signed up — and that research is exactly what gets skipped when the pipeline is full. We handle this by putting an agent on every new lead. It researches the company, drafts a personalized follow-up, and offers a time to talk. This writes up how we run that on Kortix — the connections, the steps, and the guardrails. - **Team:** Kortix - **Connected systems:** HubSpot · Email · Google Calendar - **Trigger:** Scheduled HubSpot sweep (every 15 min) - **Mode:** Trigger-driven · human-gated ## The problem The follow-up that converts is the one that shows we read the form and understood the company. That takes a few minutes of research per lead, and a few minutes times every inbound is more time than a growing team has. So follow-ups either go out generic or go out late, and both lose deals. Templates make the sending fast but not the personalization, which is the part that matters. Booking the call adds another round of back-and-forth on top. The work that moves a lead forward is the work that keeps slipping. ## What we built Our lead pipeline in **HubSpot** is connected to an agent running on Kortix. A scheduled sweep spawns a fresh, isolated session — a cloud sandbox — with scoped access to what a follow-up needs: the lead record, the web for research, our email, and the team calendar. It researches each new lead, drafts a personalized message, and proposes a call slot. A human approves before it sends. ## How it works ### Connect HubSpot as the trigger A scheduled **trigger** sweeps HubSpot every 15 minutes for new leads, and each firing spawns a fresh **session** in its own sandbox. A sweep that turns up several new leads at once still runs as a single session, but each lead is handled as an independent unit — a research or drafting failure on one lead never blocks or corrupts the others. Sessions don't share state between sweeps: the marker written back onto each HubSpot record is what carries forward. ### Give the agent our playbook How we follow up lives as **skills** and **memory** loaded into every session: our positioning, the tone we write in, what a good qualifying question looks like, and follow-ups that landed before. The agent writes to that standard rather than inventing one, and the memory updates as deals move. ### Connect what a follow-up can touch Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Research the company** — it reads the lead's site and public information to understand what they do and why they signed up. - **Read and update HubSpot** — full lead context in, research notes back on the record. - **Draft the email** — a personalized follow-up written to our playbook, ready to send. - **Offer a slot on Google Calendar** — it checks real availability and proposes a specific time to talk. ### Set the guardrails The agent researches and drafts freely, but nothing goes out on its own: every follow-up stops at a **human approval gate** as a draft for a person to review and send. Credentials are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let each lead run With that in place, a new lead arrives already worked: the company researched, the record updated, a personalized email drafted, and a call slot proposed against real availability. The person on sales reviews the draft, adjusts if they want, and sends — instead of starting each follow-up from a blank page. > **The pattern** > Connect the CRM via a **trigger** on new leads, give the agent scoped > **connectors** into HubSpot, email, and the calendar, encode the sales playbook > as **skills** and **memory**, and gate every send behind a human. ## Guardrails Giving an agent the ability to reach out to leads is a brand question as much as a sales one. The relevant controls on Kortix: - **Isolation.** Each sweep runs in its own microVM sandbox — a fresh session that can cover several new leads at once, each handled as an independent unit so a failure on one never blocks the rest. Only the drafts it produces leave the sandbox. - **Scoped secrets.** The HubSpot, email, and Calendar credentials are encrypted in the secrets manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gate.** No message reaches a lead without a person reviewing the draft and sending it. - **Everything is code.** The agent's persona, skills, and permissions are files in the repo — versioned and changed through a reviewed **change request**, not a dashboard setting. ## The outcome - **Every lead:** Researched and followed up, not just the easy ones - **Same-day:** Personalized follow-up ready while the lead is still warm - **3 systems:** HubSpot, email, and the calendar in one agent Every inbound lead now arrives with the research done, a personalized draft written, and a call slot offered — waiting for a person to approve rather than sitting untouched. The team spends its time deciding which leads to push instead of doing the same research over and over. The setup relies on four pieces: sandbox isolation per lead, a secrets manager to broker the HubSpot, email, and Calendar tokens, a human approval gate before anything reaches a lead, and memory that improves as deals move. --- <!-- /markdown/use-cases/meeting-notes.md --> # How we turn meetings into notes and action items The meeting agent we run on Kortix — connected to Google Calendar, the call transcript, and Linear. After each call it writes structured notes and files action items as tickets assigned to the right people. Canonical page: https://kortix.com/use-cases/meeting-notes Most of what a meeting decides is lost within a day. The notes get taken by whoever remembers to, the action items live in someone's head, and the follow-up happens only if a person turns the conversation into tasks afterward — which is the step that gets skipped when the next meeting starts. We handle this by putting an agent on the calendar. After each call it writes structured notes and files the action items as tickets, assigned to the people who own them. This writes up how we run that on Kortix — the connections, the steps, and the guardrails. - **Team:** Kortix - **Connected systems:** Google Calendar · Transcript · Linear - **Trigger:** Meeting ends - **Mode:** Trigger-driven · assignee-routed ## The problem Turning a conversation into notes and tasks is real work, and it lands on whoever's least busy at the end of the call — which on a small team is nobody. Decisions get made, owners get named out loud, and then the meeting ends and none of it is written down. A week later the thing everyone agreed on hasn't started because it was never a task. Recording the call solves the record but not the follow-up: nobody rewatches an hour of video to find the three things they agreed to do. The gap is between the transcript and the tickets, and closing it by hand is exactly the chore that falls off. ## What we built Our calendar in **Google Calendar** is connected to an agent running on Kortix. When a call ends, it spawns its own isolated session — a cloud sandbox — with scoped access to what note-taking needs: the event, the transcript, and Linear. It writes structured notes, pulls out the action items, and files each one as a ticket assigned to the person who owns it. ## How it works ### Connect the calendar as the trigger A signed webhook tied to Google Calendar points at the project. When a meeting ends, it fires, and each firing spawns a fresh **session** in its own sandbox, seeded with the event and its transcript. One meeting, one session, one disposable machine. Sessions don't share state, and back-to-back calls run as parallel sessions. ### Give the agent our conventions How we take notes lives as **skills** and **memory** loaded into every session: our notes structure, how we phrase an action item, who owns which area, and past meetings for context. The agent writes to that standard rather than inventing one, and the memory updates as projects move. ### Connect what note-taking can touch Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read the event and transcript** — attendees, agenda, and the full record of what was said. - **Write structured notes** — decisions, discussion, and next steps in our format, attached where the team looks. - **File tickets in Linear** — each action item becomes a ticket assigned to its owner, linked back to the meeting. ### Set the guardrails The agent writes notes and files tickets, but it only creates — it doesn't close, reassign, or touch existing work. An action item it can't confidently assign gets flagged for a person rather than routed to a guess. Credentials are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let each meeting run With that in place, a finished call turns into a set of notes and a handful of tickets before the next meeting starts. "Sarah will handle the migration" becomes a ticket assigned to Sarah. "We decided to ship Friday" becomes a line in the notes. The follow-up exists as tasks instead of as a memory that fades. > **The pattern** > Connect the calendar via a **trigger** on meeting end, give the agent scoped > **connectors** into the transcript, the notes, and Linear, encode our notes > conventions as **skills** and **memory**, and route each action item to the > person who owns it. ## Guardrails Giving an agent the ability to file tickets and assign work is a trust question. The relevant controls on Kortix: - **Isolation.** Each meeting runs in its own microVM sandbox. The session can read the transcript and context it needs, and only the notes and tickets it produces leave the sandbox. - **Scoped secrets.** The Calendar and Linear credentials are encrypted in the secrets manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gate.** An action item the agent can't confidently assign is flagged for a person instead of routed to a guess. - **Everything is code.** The agent's persona, skills, and permissions are files in the repo — versioned and changed through a reviewed **change request**, not a dashboard setting. ## The outcome - **Every call:** Notes written and action items filed - **Assigned:** Tickets routed to the person who owns them - **3 systems:** The calendar, the transcript, and Linear in one agent The follow-up that used to depend on someone remembering now arrives as notes and tickets minutes after the call ends. The team reads a clean summary and finds their tasks already in Linear, instead of reconstructing the meeting from memory a week later. The setup relies on four pieces: sandbox isolation per meeting, a secrets manager to broker the Calendar and Linear tokens, a flag for anything the agent can't confidently assign, and memory that carries context forward across meetings. --- <!-- /markdown/use-cases/office-snacks.md --> # How we keep the office stocked People request snacks through a Slack shortcut, and an agent batches the requests weekly, prepares an order, and posts it for the office manager to approve before placing anything. Canonical page: https://kortix.com/use-cases/office-snacks Keeping an office stocked is a small recurring chore that never quite has an owner. Requests come in over Slack, hallway conversations, and sticky notes; the office manager reconciles them into an order; and half the time a request is forgotten by the time the order goes in. It's low-stakes, but it's steady work. We handle it with an agent on Kortix. People request snacks through a Slack shortcut, and once a week the agent batches every request, prepares an order, and posts it for the office manager to approve. Nothing is placed until a person signs off. - **Team:** Kortix - **Control surface:** Slack - **Connected systems:** Slack · Ordering account - **Mode:** Weekly batch · human-gated ## The problem Snack requests arrive one at a time and out of band. Someone asks in a channel, someone else mentions it in passing, and the office manager is left assembling a list from memory. Requests get dropped, duplicates slip through, and the order goes in later than it should. The task is simple but constant. It needs one place to collect requests, a way to batch them on a schedule, and a person's sign-off before any money is spent — none of which a shared spreadsheet or a recurring calendar reminder actually provides. ## What we built Requests come in through a Slack shortcut, so there's one place to submit them. Once a week a cron trigger spawns an isolated session that collects the week's requests, prepares an order against the ordering account, and posts the draft in Slack for the office manager to approve. Only after approval does it place anything. ## How it works ### Collect requests through a Slack shortcut Slack is connected as a **channel**, and a shortcut is the way people submit requests. Each submission is collected against the week's batch. There's one place to ask, and nobody has to track requests in their head. ### Batch them on a weekly cron A **cron trigger** fires once a week and spawns a fresh **session** in its own isolated sandbox. The run gathers every request submitted since the last order. One run, one sandbox, torn down when it's done. ### Give the agent the ordering rules The preferred vendor, the budget, standing staples to always include, and how to consolidate duplicate requests live as **skills** and **memory**. The rules are updated as preferences and the budget change. ### Connect Slack and the ordering account Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read requests from Slack** — the week's submissions from the shortcut. - **Prepare an order in the ordering account** — build the cart against the vendor, but not check out. - **Post to Slack** — the draft order for the office manager to review. ### Approve before it places anything The prepared order stops at a **human approval gate** in Slack. The office manager sees the full cart and the total, and nothing is purchased until they approve. Credentials for the ordering account are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. > **The pattern** > Collect requests through a Slack **channel** shortcut, batch them on a **cron > trigger**, keep the vendor and budget rules in **skills** and **memory**, and hold > the order at a **human approval gate** before it spends. One place in, one approved > order out. ## Guardrails Because the agent can prepare an order that spends money, the controls matter even for a small chore: - **Isolation.** Each weekly run executes in its own microVM sandbox, and only the prepared order and Slack post it's explicitly allowed to send leave the sandbox. - **Scoped secrets.** The ordering-account credential is encrypted in the secrets manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gates.** No purchase is placed until the office manager approves the draft order in Slack. - **Everything is code.** The vendor, the budget, the staples, and the batching schedule are files in the repo — versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Weekly:** Requests batched into one order on a schedule - **One place:** Every request comes in through a Slack shortcut - **Approve first:** Nothing is purchased without a person signing off Requests stop getting lost, duplicates get consolidated, and the office manager reviews a finished order instead of assembling one. The spending step stays behind a person, so the automation saves the busywork without ever placing an order on its own. --- <!-- /markdown/use-cases/oncall-triage.md --> # How we triage on-call alerts before paging a human The triage agent we run on Kortix — connected to Sentry, our logs, and GitHub. It works up a first-pass diagnosis on every alert and only pages a human when it can't resolve it. Canonical page: https://kortix.com/use-cases/oncall-triage An alert fires at 3am. Before anyone can act on it, someone has to wake up, pull the stack trace, check what deployed recently, grep the logs, and work out whether this is a real incident or noise. Most of that is mechanical, and most of it happens before the human has any context — which is the worst time to be doing it. We run a triage agent on Kortix that does the first pass. When an alert fires, the agent gathers the stack trace, the recent deploys, and the correlated logs, posts a first-pass diagnosis to the incident channel, and only pages a human when it can't resolve the alert or the severity is high. This write-up covers how the setup works: the trigger, the session model, and the guardrails. - **Team:** Kortix - **Runs on:** Every alert, as it fires - **Connected systems:** Sentry · Logs · GitHub - **Mode:** Trigger-driven · pages a human only when needed ## The problem The first minutes of an incident are spent gathering context, not fixing anything. Which error is this, when did it start, what shipped just before, is it one user or all of them — the on-call engineer answers these by hand, half-awake, before they can even judge whether the page was warranted. The common fixes are incomplete. Paging on every alert burns the on-call rotation on noise and trains people to ignore the pager. Tuning thresholds cuts the noise but also hides real regressions. A runbook helps, but someone still has to be awake to follow it. None of it does the gathering for you. ## What we built On Kortix, each alert triggers an agent. When Sentry fires, the alert spawns an isolated session (a cloud sandbox) with scoped, read-only access to Sentry, the logs, and GitHub. The agent pulls the stack trace, lists the deploys since the error first appeared, correlates the logs around the spike, and posts a first-pass diagnosis to the incident channel. It pages a human only when it can't resolve the alert or the severity is high. ## How it works ### Connect the alert as the trigger A signed webhook from Sentry points at the project. Every alert fires it, and each firing spawns a fresh **session** in its own sandbox, seeded with the alert payload. One alert maps to one session on one disposable machine, so nothing carries over between incidents and concurrent alerts triage in parallel. ### Give the agent the triage playbook How we triage lives as **skills** and **memory** that travel with the agent: which services are noisy, what a real regression looks like versus a known flake, the severity rules for when to page, and past incidents with their root causes. When an alert turns out to be benign, we write it down and the agent recognizes it next time. ### Connect the systems triage needs Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read the Sentry issue** — the stack trace, the frequency, and the first-seen timestamp, to place the error in time. - **Correlate the logs** — pull the log lines around the spike and line them up against the trace. - **Check recent deploys on GitHub** — the commits and PRs that shipped just before the error appeared, to surface a likely cause. - **Post to the incident channel** — the diagnosis, the suspected deploy, and the evidence, as a single message. ### Set the guardrails The agent is **read-only**: it investigates across Sentry, the logs, and GitHub, but it does not deploy, roll back, or change anything. High-severity alerts and anything it can't resolve page a human straight away — the diagnosis is attached, not a substitute for the page. Credentials are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let each alert triage itself With that in place, an alert gathers its own context: the trace, the deploy window, the correlated logs, and a first-pass diagnosis land in the channel within the first minute. A known-benign spike is closed with the reasoning attached. A high-severity or unresolved alert pages the on-call engineer with the work already done, so they open the page to context instead of a blank terminal. > **The pattern** > A **trigger** on every alert spawns a session with scoped, read-only > **connectors** into Sentry, the logs, and GitHub. The triage playbook is encoded > as **skills** and **memory**. The agent diagnoses first and pages a human only > when it can't resolve the alert or the severity is high. ## Guardrails The agent reads production telemetry, so the access is scoped and contained: - **Isolation.** Every alert runs in its own microVM sandbox. The session can pull traces, logs, and deploy history to build the diagnosis; only the posted message and any page leave the sandbox. - **Scoped secrets.** The Sentry, logging, and GitHub credentials are encrypted in the secrets manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gate.** The agent never deploys or rolls back. High-severity and unresolved alerts page a human, who owns any action taken. - **Everything is code.** The agent's configuration, skills, and permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **First-pass:** Diagnosis in the channel before the pager fires - **Less noise:** Benign alerts closed with reasoning, not paged - **3 systems:** Sentry, logs, and deploy history in one agent The mechanical first minutes of an incident happen before anyone is paged, and when the agent does page, it pages with the trace, the suspect deploy, and the correlated logs attached. The on-call engineer spends their time deciding and fixing rather than gathering context half-awake. --- <!-- /markdown/use-cases/outbound-outreach.md --> # How we run personalized outbound An agent connected to our CRM, enrichment, and email that researches each lead, drafts a genuinely personalized sequence, logs it to the CRM, and holds every send behind human approval with a daily cap. Canonical page: https://kortix.com/use-cases/outbound-outreach Personalized outbound doesn't scale by hand, so most teams give up the personalization: a mail-merge template with a first name swapped in, sent to a list. It's fast and it's ignored. Real personalization means researching each account and writing to its actual context, which is exactly the part that doesn't scale. We run an agent on Kortix that does the research and the writing per contact, then holds every send behind a person. It enriches each lead, drafts a first-touch and follow-up grounded in the account's real context, logs every draft to the CRM, and sends nothing without approval, capped daily. This is how we run our own outbound. - **Team:** Kortix - **Runs on:** A new segment or lead list - **Connected systems:** CRM · Enrichment · Email - **Mode:** Every batch approved by a person · daily cap ## The problem Outbound has a tradeoff most teams resolve the wrong way. Personalized messages land, but researching and writing each one doesn't scale, so the list wins: a template, a merge field, a send button. The result is volume without relevance, and the reply rate shows it. The usual tools don't fix it. A sequencing tool blasts the same template on a schedule. An AI writer produces fluent copy with no real account context, which reads as personalized but isn't. And any tool that can mass-send is one wrong segment away from putting unreviewed email in front of thousands of people. ## What we built On Kortix, a periodic sweep triggers the agent in a fresh session (a cloud sandbox) with scoped access to enrichment, the CRM, and email. The agent works through the batch of new contacts one by one, researches each account, drafts a first-touch and a follow-up sequence grounded in what it found, and logs every draft to the CRM. No message sends on its own: every batch waits for a person to approve, under a daily cap. ## How it works ### Connect the lead list as the trigger A new segment or lead list in the CRM is the trigger. On a schedule, the agent fires a fresh **session** in its own sandbox and works through the batch of contacts that list contains. Each contact is handled as an independent unit — a research or drafting failure on one contact is logged and skipped, and never blocks the rest of the batch — with the run capped so a sweep always stays a size a person can review. ### Give the agent the outreach playbook What genuine personalization looks like lives as **skills** and **memory** that travel with the agent: our angles, the proof points that resonate, what to avoid, and which signals in an account are worth writing to. When a message pattern works, we write it down and the agent reuses it on the next batch. ### Connect enrichment, the CRM, and email Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Enrich each contact** — role, company, recent signals, and the real context that makes a message specific. - **Draft a genuine sequence** — a first-touch and follow-ups grounded in the account's context, not a template with a name swapped in. - **Log every draft to the CRM** — each message written back against the contact, so the history is complete. - **Send on approval** — the email goes out only after a person clears the batch. ### Set the guardrails Nothing sends without a person approving it, and it never mass-sends unreviewed. Every batch stops at a **human approval gate**, and a **daily cap** limits how many messages can go out even once approved. A wrong segment can't turn into thousands of unreviewed emails, because the send is gated and capped regardless of how big the list is. Credentials are encrypted in the Secrets Manager and injected at runtime, never shown to the model or written to logs. ### Run personalized outbound at a safe rate With that in place, loading a segment produces a batch of researched, account- specific drafts logged to the CRM and waiting for review. A person reads the batch, approves what's ready, and the daily cap meters the sends. The personalization is real because each message is written to a researched account, and nothing leaves without a person clearing it. > **The pattern** > A **trigger** on a new list fires a fresh session with scoped > **connectors** into enrichment, the CRM, and email. The outreach approach is > encoded as **skills** and **memory**. Every batch is approved by a person and > the daily cap meters the sends — nothing mass-sends unreviewed. ## Guardrails The agent can draft and send email, so the send is the tightly controlled step: - **Isolation.** Each sweep runs in its own fresh microVM sandbox, torn down when it finishes. The session reaches only enrichment, the CRM, and email, and only drafted messages leave it; nothing sends from inside the sandbox. A failure on one contact is logged and skipped — it never blocks or corrupts the rest of the batch. - **Scoped secrets.** The enrichment, CRM, and email credentials are encrypted in the Secrets Manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gate.** Nothing sends without a person approving it. Every batch is reviewed before send, and a **daily cap** limits volume even after approval, so a wrong list can never mass-send unreviewed. - **Everything is code.** The agent's configuration, skills, and permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Every send:** Approved by a person and metered by a daily cap - **Per account:** Research and copy grounded in real context, not a merge field - **3 systems:** Enrichment, CRM, and email in one agent Outbound is now personalized per account and sent at a safe rate: each contact is researched, each message is written to its context and logged to the CRM, and every batch is cleared by a person under a daily cap. The personalization scales because the research does, and nothing mass-sends without review. --- <!-- /markdown/use-cases/qa-agent.md --> # How we QA every pull request automatically The QA agent we run on Kortix — connected to GitHub and our test environment. It checks out each PR, runs the suite, exercises the change, and posts the result. Canonical page: https://kortix.com/use-cases/qa-agent Code review catches problems a person can find by reading a diff. It misses the ones you only find by running the change: a test that passes locally but flakes in CI, a path that works in the happy case and 500s on an edge case, a migration that reads fine but locks a table under load. These tend to surface in staging or production, after the PR is approved. We run a QA agent on Kortix that does this checking when the PR opens. This is how we QA our own changes, including the connections and guardrails involved. - **Team:** Kortix - **Runs on:** Every PR, on open and on push - **Connected systems:** GitHub · Test environment · Cloudflare - **Mode:** Trigger-driven · result posted on the PR ## The problem Some bugs don't show up in a diff. A reviewer reads the code and approves, but no one has checked out the branch, run the suite, hit the new endpoint, and watched what happens. CI runs the tests the author wrote; it doesn't cover the paths they missed. The common fixes are incomplete. Green CI shows the existing tests pass, not that the change is correct. A manual QA pass is thorough but slow and lands after the review. A generic AI reviewer only sees the diff; it can't run the branch, so it misses failures that only appear at runtime. ## What we built On Kortix, each PR triggers an agent. On open and on every push, the PR spawns an isolated session (a cloud sandbox) with scoped access to the branch, the test suite, a deployable test environment, and the Cloudflare edge in front of it. The agent checks out the change, runs the suite, exercises the new behavior on a live deploy, and posts a pass/fail result on the PR. ## How it works ### Connect GitHub as the trigger A signed GitHub webhook points at the project. Every PR opened or updated fires it, and each firing spawns a fresh **session** in its own sandbox, seeded with the branch under test. One PR maps to one session on one disposable machine, so nothing carries over between runs and concurrent PRs run in parallel. ### Give the agent the branch and the test playbook The session checks out the branch and installs it clean. Our QA conventions live as **skills** and **memory** that travel with the agent: how to run the suite, which flows are critical, edge cases that have caused problems before, and what a result should contain. When a bug slips through, we write it down and the agent picks it up on the next run. ### Connect the systems QA needs Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Run the full suite** — unit, integration, and e2e inside the sandbox, with the failure output captured in full. - **Deploy to the test environment** — it stands the change up on an ephemeral deploy and exercises it end-to-end against the new paths. - **Reach the edge via Cloudflare** — it checks behavior through the edge (routing, headers, caching, redirects), not just localhost. - **Report on GitHub** — the pass/fail result and any reproduction post as a check and a comment on the PR. ### Set the guardrails The agent operates against the **test environment only**; production is out of scope. It does not merge or deploy to prod. Its output is a result, and a human owns the merge. Credentials are encrypted in the Secrets Manager and injected at runtime, never shown to the model or written to logs. ### Let each PR arrive pre-QA'd With that in place, a new PR checks itself out, runs the suite, deploys to the test environment, exercises the change through Cloudflare, and posts a result: green when it passes, or a red check with the failing command, the logs, and steps to reproduce. A flaky test is flagged with the evidence. A broken edge route is caught before staging. > **Summary** > A **trigger** on every PR spawns a session with scoped **connectors** into the > branch, the test suite, the test environment, and Cloudflare. The QA playbook is > encoded as **skills** and **memory**. The agent stays on the test environment > and a human owns the merge. ## Guardrails The agent has access to the test environment and the edge, so the access is scoped and contained: - **Isolation.** Every PR runs in its own microVM sandbox on its own branch. The session can install, deploy, and exercise the change to reproduce a failure; only the reported result leaves the sandbox. - **Scoped secrets.** The GitHub, test-environment, and Cloudflare credentials are encrypted in the Secrets Manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Test environment only.** The agent's access stops at the test plane: no production access, no prod deploy, no merge. It reports; the team decides. - **Everything is code.** The agent's configuration, skills, and permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Every PR:** Run, deployed, and exercised before human review - **Pre-merge:** Runtime failures caught before they reach staging - **4 systems:** Branch, suite, test environment, and edge in one agent Runtime failures that a diff can't show now surface on the PR when it opens, with the failing command, the logs, and the repro attached. Reviewers spend their time on design rather than checking out branches by hand, and the changes that reach staging have already been run against the test environment. --- <!-- /markdown/use-cases/release-notes.md --> # How we generate release notes from merged PRs The release-notes agent we run on Kortix — connected to GitHub. On each release it reads the merged PRs since the last one, groups them, writes the notes, and opens a changelog PR. Canonical page: https://kortix.com/use-cases/release-notes Release notes are the first thing that gets skipped under a deadline. The information is all there in the merged PRs, but turning it into something a reader can follow — grouped by area, in plain language, with the noise dropped — is manual work that lands after the release is already out. We run a release-notes agent on Kortix that does it from the PR history. On each release, the agent reads every PR merged since the last release, groups them by area, writes human-readable notes, and opens a PR to the changelog for review. This write-up covers how the setup works: the trigger, the session model, and the guardrails. - **Team:** Kortix - **Runs on:** Every tag or release - **Connected systems:** GitHub - **Mode:** Trigger-driven · changelog PR opened for review ## The problem Writing release notes means reading back through everything that merged since the last release, deciding what a reader cares about, and phrasing it for someone who wasn't in the PRs. It's the kind of task that's easy to defer, so the changelog falls behind or gets a one-line summary that helps no one. The common fixes are incomplete. An auto-generated list of PR titles is accurate but unreadable — internal wording, no grouping, every dependency bump included. Writing them by hand is better but slow, and it competes with actually shipping. Either way the notes arrive after the release, if they arrive at all. ## What we built On Kortix, each release triggers an agent. When a tag is pushed, the release spawns an isolated session (a cloud sandbox) with scoped access to the repository. The agent finds the previous release, reads every PR merged in between, groups them by area, writes notes in plain language, and opens a PR to the changelog. A human reviews and merges. ## How it works ### Connect the release as the trigger A signed GitHub webhook points at the project. A pushed tag or published release fires it, and each firing spawns a fresh **session** in its own sandbox, seeded with the new tag. One release maps to one session on one disposable machine, so nothing carries over between releases. ### Give the agent the notes playbook How we write release notes lives as **skills** and **memory** that travel with the agent: how to group changes by area, which labels mark internal-only work to drop, the voice the changelog is written in, and the format the file expects. When we adjust how a section reads, we write it down and the agent follows it on the next release. ### Connect the systems the notes need Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Find the range** — locate the previous release tag and the commit range up to the new one. - **Read the merged PRs** — titles, descriptions, labels, and authors for every PR merged in that range, to understand what actually changed. - **Group and write** — cluster the PRs by area and write plain-language notes, dropping internal churn and dependency noise. - **Open a changelog PR on GitHub** — the drafted notes land as a pull request against the changelog file. ### Set the guardrails The agent's output is a **PR against the changelog**, nothing more — it does not publish, tag, or announce. A human reviews the wording and merges. Credentials are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let each release draft its own notes With that in place, a pushed tag reads its own history: the agent finds the range, reads the merged PRs, groups them by area, writes the notes, and opens a changelog PR — grouped, readable, with internal churn dropped. The team reviews the wording instead of assembling the notes from scratch. > **The pattern** > A **trigger** on every release spawns a session with a scoped **connector** into > the repo. The notes playbook is encoded as **skills** and **memory**. The agent > drafts a changelog PR and a human owns the wording and the merge. ## Guardrails The agent reads the repository and writes a draft, so the access is scoped and contained: - **Isolation.** Every release runs in its own microVM sandbox. The session reads the PR history and drafts the notes; only the changelog branch leaves the sandbox. - **Scoped secrets.** The GitHub credentials are encrypted in the secrets manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **PR-gated.** The agent opens a pull request against the changelog and stops. It never publishes or announces the release; a human reviews the wording and merges. - **Everything is code.** The agent's configuration, skills, and permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Every release:** Notes drafted from the PR history automatically - **Grouped:** Changes clustered by area with internal churn dropped - **Human review:** The agent drafts; the team owns the wording The changelog stops falling behind, and the notes that reach review are already grouped and readable instead of a raw list of PR titles. Reviewers edit wording rather than reconstructing what shipped from the git history. --- <!-- /markdown/use-cases/resume-triage.md --> # How we screen inbound applicants An agent connected to our applications inbox, the role's written rubric, and Google Calendar that scores each resume against the rubric with evidence and proposes interview slots for strong matches. Canonical page: https://kortix.com/use-cases/resume-triage Every open role brings in more applications than anyone can read carefully, so screening gets rushed: a few seconds per resume, inconsistent from one reviewer to the next, and easy to drift from the criteria the role was actually written against. Good candidates get skimmed past and the standard moves depending on who is reading. We run an agent on Kortix that does the first read consistently. Each application triggers a session that scores the resume against the role's written rubric, writes a structured screen with evidence, and proposes interview slots for strong matches. A person makes every advance-or-reject call; the agent never rejects anyone. This is how we screen our own inbound applicants. - **Team:** Kortix - **Runs on:** Every inbound application - **Connected systems:** Applications inbox / ATS · Role rubric · Google Calendar - **Mode:** Trigger-driven · a person makes every decision ## The problem The first read of a resume is where screening gets inconsistent. Volume forces it to be fast, and fast reads drift: the same resume gets a different verdict from a different reviewer, or against a criterion the role never listed. Strong candidates get missed and the bar moves with whoever is reading. The usual fixes trade one problem for another. Keyword filters reject on the wrong signal and quietly drop good people. A rushed human pass is inconsistent by the afternoon. An AI screener that scores against its own idea of "good" is a fairness problem: opaque, unaccountable, and impossible to check. ## What we built On Kortix, each inbound application triggers an agent. The application spawns an isolated session (a cloud sandbox) with the role's written rubric and scoped access to Google Calendar. The agent reads the resume against the rubric, writes a structured screen — strengths, gaps, a score, and supporting quotes as evidence — and for strong matches proposes interview slots on the hiring manager's calendar. A person decides every case. ## How it works ### Connect the applications inbox as the trigger The applications inbox, or the ATS, is connected so a new application is the trigger. Each one fires a fresh **session** in its own sandbox, seeded with that resume. One application maps to one session on one disposable machine, so screens are independent and the pipeline processes in parallel. ### Give the agent the role's rubric The role's written rubric lives as **skills** and **memory** that travel with the agent: the required and preferred criteria, what strong evidence looks like for each, and how to score. The agent scores against this rubric and nothing else. When the rubric changes, we update the file and the agent screens against the new version. ### Connect the calendar Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read the resume** — the full application, so the screen quotes what the candidate actually wrote. - **Score against the rubric** — strengths, gaps, and a score, each tied to supporting quotes as evidence. - **Propose interview slots** — for strong matches, open times on the hiring manager's Google Calendar, offered for a person to confirm. ### Set the guardrails The agent scores only against the written rubric and always surfaces the evidence behind every strength, gap, and score, so a decision can be checked. It never auto-rejects: a person makes every advance-or-reject decision. The agent produces the screen and the proposed slots; the hiring manager decides. Credentials are encrypted in the Secrets Manager and injected at runtime, never shown to the model or written to logs. ### Let each application arrive pre-screened With that in place, an inbound application arrives already read against the rubric: a structured screen with a score and the quotes behind it, and for strong matches a set of proposed interview times. The hiring manager reviews the evidence, decides, and confirms a slot. The first read is consistent, and the decision stays with a person. > **The pattern** > A **trigger** on every application spawns a session with the role's rubric as > **skills** and **memory** and a scoped **connector** into Google Calendar. The > agent scores only against the written rubric and surfaces the evidence; it never > auto-rejects, and a person makes every decision. ## Guardrails The agent reads candidate applications and its output shapes hiring, so fairness and human judgment are built into the controls: - **Isolation.** Every application runs in its own microVM sandbox. The session reads only the resume it's seeded with and reaches only the calendar; only the screen and proposed slots leave the sandbox. - **Scoped secrets.** The inbox, ATS, and calendar credentials are encrypted in the Secrets Manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gate.** The agent never auto-rejects. It scores only against the written rubric and always surfaces the evidence behind every score, and a person makes every advance-or-reject decision. - **Everything is code.** The rubric, the agent's configuration, and its permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Every application:** Read against the same written rubric, with evidence - **A person decides:** No candidate is ever auto-rejected by the agent - **3 systems:** Applications inbox, rubric, and calendar in one agent The first read is now consistent and checkable: every application is scored against the same written rubric with the quotes that support each score, and strong matches arrive with interview times already proposed. The hiring manager spends their time deciding on evidence rather than skimming resumes, and every advance-or-reject call stays with a person. --- <!-- /markdown/use-cases/sales-call-followup.md --> # How we follow up after sales calls An agent connected to our call transcripts, HubSpot, and Linear that drafts the recap, updates the deal, and files the follow-up tasks, then holds the email for the rep to send. Canonical page: https://kortix.com/use-cases/sales-call-followup After a sales call ends, someone has to write the recap email, answer the questions that came up, update the CRM, and file whatever the team committed to. It's an hour of work that usually happens late, half-remembered, or not at all. The deal stalls not because the call went badly but because the follow-up slipped. We run an agent on Kortix that does the follow-up the moment a call ends. It reads the transcript, writes the recap, updates the HubSpot deal, and files the tasks in Linear, then holds the outbound email at an approval gate so the rep reviews and sends it. This is how we handle our own post-call follow-up. - **Team:** Kortix - **Runs on:** Every sales call, when the recording ends - **Connected systems:** Call transcript · HubSpot · Linear - **Mode:** Trigger-driven · email held for the rep ## The problem The follow-up after a call is where deals leak. The rep just ran the meeting, so the context is in their head, but the writing-up is manual: recap the discussion, answer the open questions, restate the next steps, log it in the CRM, and file the tasks. Done well it takes an hour; done late it's vague; skipped, the deal goes quiet. The usual fixes don't close the gap. A CRM reminder tells you to follow up but doesn't do any of it. A note-taker that dumps a transcript into the deal leaves the recap, the CRM update, and the tasks for the rep to write by hand. A template email is fast but generic, and it still needs the account context filled in. ## What we built On Kortix, the end of a call triggers an agent. When the recording finishes, the transcript spawns an isolated session (a cloud sandbox) with scoped access to HubSpot and Linear. The agent reads the transcript, drafts the recap email with the answers and next steps, updates the deal stage, notes, and contacts, and files the follow-up tasks. The recap email waits at an approval gate for the rep to review and send. ## How it works ### Connect the call as the trigger The meeting recorder is connected so the end of a call is the trigger. When a recording finishes, the transcript fires a fresh **session** in its own sandbox, seeded with the call. One call maps to one session on one disposable machine, so nothing carries between deals and concurrent calls process in parallel. ### Give the agent the recap playbook How we write a follow-up lives as **skills** and **memory** that travel with the agent: the shape of a good recap, how we phrase next steps, which objections need a considered answer, and the account's own history. When a rep improves a recap, we write it down and the agent applies it on the next call. ### Connect HubSpot and Linear Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read the transcript** — the full call, including the questions raised and what was committed to. - **Update the HubSpot deal** — stage, notes, and contacts, written back from what the call actually covered. - **File follow-up tasks in Linear** — the concrete next steps, assigned and dated. - **Draft the recap email** — summary, answers to the open questions, and clear next steps, ready for the rep. ### Set the guardrails The agent reads the transcript and writes to the CRM and the task tracker on its own, but the outbound email never sends itself. Every recap stops at a **human approval gate**, so the rep reads it, edits if needed, and sends. Credentials are encrypted in the Secrets Manager and injected at runtime, never shown to the model or written to logs. ### Let the follow-up be ready when the call ends With that in place, hanging up is the whole task. By the time the rep looks, the deal is updated, the tasks are filed, and a recap email is drafted and waiting: the summary, the answers, and the next steps. The rep reviews and sends. The follow-up happens while the call is still fresh instead of days later. > **The pattern** > A **trigger** on the end of each call spawns a session with scoped > **connectors** into HubSpot and Linear. How we write a recap is encoded as > **skills** and **memory**. The CRM and tasks update automatically; the outbound > email waits for the rep at an approval gate. ## Guardrails The agent reads customer conversations and writes to the CRM, so the access is scoped and the outbound step stays with a person: - **Isolation.** Every call runs in its own microVM sandbox. The session reads only the transcript it's seeded with and reaches only HubSpot and Linear; only the updates and the drafted email leave the sandbox. - **Scoped secrets.** The transcript, HubSpot, and Linear credentials are encrypted in the Secrets Manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gate.** The recap email never sends on its own. Each one is held for the rep to review and send; the agent writes to the CRM and tasks, the rep owns the outbound. - **Everything is code.** The agent's configuration, skills, and permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Every call:** Recap, CRM update, and tasks ready when it ends - **Rep sends:** The outbound email is always reviewed by a person - **3 systems:** Transcript, HubSpot, and Linear in one agent The follow-up now happens while the call is still fresh: the deal is updated, the tasks are filed, and a recap email is drafted and waiting. The rep spends a minute reviewing instead of an hour writing, and no email goes out without a person sending it. --- <!-- /markdown/use-cases/security-questionnaire.md --> # How we answer security questionnaires An agent we run on Kortix — connected to the inbound questionnaire and our knowledge base of vetted answers and policies. It parses each question, drafts responses in the vendor's format, and flags anything it can't answer confidently. Canonical page: https://kortix.com/use-cases/security-questionnaire Security questionnaires arrive in the middle of a sales cycle and hold the deal up until they're answered. The questions are mostly ones we've answered before — about our encryption, access controls, data handling, and policies — but they come in different formats each time, a SIG one deal, a CAIQ the next, a custom spreadsheet after that, and each has to be filled out in its own layout. We run an agent on Kortix that drafts the answers from our vetted knowledge base. This is how we answer our own security questionnaires, including the connections and guardrails involved. - **Team:** Kortix - **Runs on:** Each inbound questionnaire - **Connected systems:** Inbound questionnaire · Knowledge base - **Mode:** Drafts only · security reviews before it's sent ## The problem Most of a questionnaire is answers we already have. The same questions about encryption at rest, SSO, incident response, and data retention come up on nearly every one, and we've written vetted answers for them. But each questionnaire uses a different format, so someone has to read every question, find the matching approved answer, and paste it into the vendor's own layout — a SIG workbook, a CAIQ, or a custom spreadsheet. The common fixes are incomplete. A shared answer library still needs a person to match each question to it by hand. Copying last deal's responses risks pulling an answer that no longer fits. A generic AI drafter with no grounding will write confident answers that aren't the vetted ones, which is exactly what can't happen on a security document. ## What we built On Kortix, each inbound questionnaire triggers an agent. It spawns an isolated session (a cloud sandbox) with access to our knowledge base of vetted answers and policy docs. It parses each question, matches it to our approved answers and policies, drafts responses in the vendor's own format — SIG, CAIQ, or a custom spreadsheet — and flags anything it can't answer confidently for a human. It returns a filled draft for security to review before it goes back. ## How it works ### Trigger on the inbound questionnaire A questionnaire arriving as an email, a spreadsheet, or a portal link is the **trigger**, and each one spawns a fresh **session** in its own sandbox. The agent parses the incoming document, whatever its format, into a list of questions to answer. One questionnaire maps to one session on one disposable machine. ### Ground the agent in our vetted answers Our approved answers and policy docs live as **skills** and **memory** that travel with the agent: the vetted response to each common question, the policies behind them, and the standards we map to. The agent answers only from this grounded set, so a drafted answer is one we've already approved rather than one the model invented. When a policy changes, we update it and the agent uses the new wording. ### Connect the questionnaire and the knowledge base Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read the inbound questionnaire** — from the email, spreadsheet, or portal it arrived in, parsed into individual questions. - **Search the knowledge base** — the vetted answers and policy docs to match each question against. - **Draft in the vendor's format** — writing each response back into the SIG, CAIQ, or custom layout it came in. - **Flag low-confidence questions** — anything without a confident match marked for a person to answer. ### Set the guardrails The agent **drafts only**: it fills the questionnaire and flags what it's unsure of, and it never sends. Anything it can't answer confidently from the vetted set is left for a person rather than guessed. The completed draft stops at a **human approval gate** — security reviews it before it goes back to the prospect. Credentials are encrypted in the Secrets Manager and injected at runtime, never shown to the model or written to logs. ### Return a filled draft for review With that in place, an inbound questionnaire comes back as a filled draft in the vendor's own format, with each answer drawn from our vetted set and the low-confidence rows flagged. Security reviews the draft, answers the flagged questions, and sends it. The matching and formatting are done; the sign-off stays with a person. > **The pattern** > An inbound questionnaire is the **trigger** that spawns a session with scoped > **connectors** into the document and our knowledge base. The vetted answers and > policies are encoded as **skills** and **memory**. The agent drafts and flags; > security approves before anything is sent. ## Guardrails The agent drafts a security document from our own vetted answers, so the access is scoped and contained: - **Isolation.** Every questionnaire runs in its own per-task microVM sandbox. The session reads the inbound document and the knowledge base it's scoped to, and only the filled draft leaves the sandbox. - **Scoped secrets.** The credentials for the questionnaire source and the knowledge base are encrypted in the Secrets Manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gate.** The completed draft is reviewed by security before it goes back to the prospect, and any low-confidence question is left for a person rather than guessed. - **Everything is code.** The agent's configuration, skills, and permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Every questionnaire:** Parsed, matched, and drafted in the vendor's format - **Grounded:** Answers drawn only from our vetted set - **Drafts only:** Security reviews before anything is sent Inbound questionnaires now come back as filled drafts in whatever format they arrived, with each answer drawn from our vetted knowledge base and the uncertain ones flagged. Security spends its time reviewing and answering the hard questions rather than matching and pasting, and nothing goes back to a prospect without a person's sign-off. --- <!-- /markdown/use-cases/slack-control-pane.md --> # How we run operations from Slack A single agent reachable from Slack, with scoped access to our database, Stripe, Linear, and GitHub. Ask in a thread and it runs the task across whatever platforms it needs. Canonical page: https://kortix.com/use-cases/slack-control-pane Operations work usually spans several platforms: the database, Stripe, the sandbox providers, Linear, and GitHub. Onboarding a customer means provisioning, setting up billing, and filing a tracking issue. Shipping a fix means reviewing a PR, cutting a branch, and updating a ticket. No single tool covers the whole task, so the coordination falls to a person moving between several tabs. We run these tasks through a single agent reachable from Slack, with scoped access to the platforms we've connected. This is how we run our own operations on Kortix, and the same setup works for any team. - **Team:** Kortix - **Control surface:** Slack - **Connected systems:** Database · Stripe · Sandboxes · Linear · GitHub - **Setup per platform:** Connect once, then it can act ## The problem The cross-platform tasks are the ones no single tool owns. "Onboard this enterprise account" touches the database, Stripe, and Linear. "Get this fix out" touches GitHub, the sandbox that reproduces the bug, and the ticket that tracks it. Each step is simple; stitching them together means switching between tabs, copying IDs, and keeping the order straight. The common workarounds each fall short. Internal scripts each do one thing and break when an API changes. A no-code automation tool handles the flow it was built for and nothing beyond it. A chatbot that integrates with Slack can answer questions but can't run a multi-step task, because it has no safe way to hold credentials and take actions across systems. ## What we built Our Slack is wired to an agent with scoped access to every connected system. You @-mention it in a thread and describe the task in plain language. It spawns an isolated session, a cloud sandbox, with scoped access to the platforms we've connected, works out the steps, runs them, and replies in the thread as it goes. Adding a capability means connecting one more platform. ## How it works ### Make Slack the control surface Slack is connected as a **channel**, so a message is the trigger. Mention the agent in any thread and the request spawns a fresh **session** in its own isolated sandbox. The agent stays in the thread and replies as it works. One request, one session, one disposable machine. ### Connect a platform To give the agent a new capability, you **connect the platform to Kortix once**. The database, Stripe, the sandbox providers, Linear, and GitHub are each a scoped **connector**, brokered server-side so no raw token reaches the model. Once a platform is connected, the agent can act on it. There's no integration to write or script to maintain. ### Run tasks across those platforms With the platforms connected, the agent handles cross-platform tasks: - **Invite a member to Linear**, create the project, and file the tracking issues. - **Review an open GitHub PR**, and open its own PRs to the codebase when a change is warranted. - **Query the database** to answer questions like how many accounts are on a given plan. - **Look up and adjust Stripe** state: plan, invoice, subscription. - **Spin up a sandbox** to reproduce a bug or run a one-off job. Whatever the task spans, it runs in one thread through one agent. ### Set the guardrails Because the agent has access to every connected platform, its scope is kept tight. Access is **read-mostly by default**. Actions that touch money, production data, or account state (a Stripe change, a merge, a destructive query) stop at a **human approval gate** in the thread. Credentials are encrypted in the Secrets Manager and injected at runtime, never shown to the model or written to logs. ### Run operations from a thread With that in place, "onboard this account" is one message that provisions in the database, sets up billing in Stripe, and files the Linear project, with the irreversible steps held for approval. "Review this PR" is a code review plus, when needed, a follow-up PR. The work spans one thread instead of several tools. > **The setup** > Connect Slack as the **channel**, connect each platform to Kortix as a scoped > **connector** (the only per-platform setup), and gate every sensitive action > behind a human. One agent then runs cross-platform tasks from a thread. ## Guardrails Giving one agent access to the database, Stripe, sandboxes, Linear, and GitHub is a security question. The controls that make it workable: - **Isolation.** Every request runs in its own microVM sandbox on its own branch. A session can reach only the platforms it's scoped to, and only what it's explicitly allowed to send leaves the sandbox. - **Scoped secrets.** Each platform credential is encrypted in the Secrets Manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Human approval gates.** Irreversible actions (money, production data, merges) require a person to approve in the thread. - **Everything is code.** The agent's persona, skills, and per-platform permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **One thread:** Any cross-platform task, asked in plain language - **Connect once:** A new platform is a new capability, no build - **5+ systems:** Database, Stripe, sandboxes, Linear, GitHub — one agent Cross-platform tasks such as onboarding, reviews, provisioning, and billing changes now run in the Slack thread where they're already discussed, with the risky steps held for approval. Extending the system means connecting the next platform. --- <!-- /markdown/use-cases/standup-summary.md --> # How we run async standups in Slack A daily agent that collects each person's Linear and GitHub activity, prompts anyone with nothing recorded, and posts a concise team standup to Slack. Read-only across every tool. Canonical page: https://kortix.com/use-cases/standup-summary A standup is supposed to answer one question: what did everyone do, and what's next. In practice it costs a meeting, or a thread where half the team pastes a summary and the other half forgets. The information already exists in Linear and GitHub; someone just has to gather it, format it, and chase the people who didn't post. We run a daily agent on Kortix that does the gathering. It reads each person's Linear and GitHub activity, nudges anyone with nothing recorded, and posts a concise standup to a Slack channel. This is how we run our own standups, and the same setup works for any team. - **Team:** Kortix - **Runs on:** A daily cron, one post per weekday - **Connected systems:** Linear · GitHub · Slack - **Mode:** Read-only across the tools, no writes ## The problem A live standup takes a slot on everyone's calendar to relay information that's already recorded somewhere. An async thread saves the meeting but shifts the work onto people: each person has to remember to write, summarize their own day, and post it, and the ones who don't leave gaps. The common workarounds are partial. A recurring bot that asks "what did you do yesterday?" still depends on everyone answering. A dashboard shows activity but doesn't summarize it or tell you who's quiet. Neither reads the tools where the work actually happened, so the summary is only as complete as the people filling it in. ## What we built On Kortix, a cron fires once each weekday morning and spawns an agent. It reads each team member's recent Linear issues and GitHub activity, builds a short per-person summary, checks who has nothing recorded, and posts the standup to a Slack channel. Anyone with a quiet day gets a light prompt so they can add context the tools can't see. The agent only reads; it never writes to Linear or GitHub. ## How it works ### Trigger it on a daily cron The standup runs on a **cron trigger**: once every weekday morning, the schedule spawns a fresh **session** in its own isolated sandbox. One run, one disposable machine, nothing carried over from the day before. No one has to start it and there's no bot sitting idle waiting for messages. ### Give the agent the team and the format Who's on the team, which repos and Linear projects to look at, and what a good standup entry looks like live as **skills** and **memory** that travel with the agent: the roster, the mapping from a person to their Linear and GitHub handles, and the house style for a summary. When the format changes, we update the file and the next run picks it up. ### Connect Linear and GitHub, read-only Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent reads: - **Linear** — each person's recently updated and closed issues, and what's in progress. - **GitHub** — commits, opened and merged PRs, and reviews since the last standup. Both connectors are scoped to read only. The agent can see the activity; it cannot change an issue or touch a branch. ### Set the guardrails The agent is **read-only** across Linear and GitHub by design: its only write is the Slack post. It doesn't move tickets, comment on PRs, or edit anyone's work. Credentials are encrypted in the Secrets Manager and injected at runtime, never shown to the model or written to logs. ### Post the standup and prompt the gaps With that in place, the morning post writes itself: a per-person summary drawn from Linear and GitHub, grouped so the team can scan it in one read. Anyone with no recorded activity is flagged with a gentle prompt to add what the tools missed, meetings, planning, anything off-platform, so a quiet day shows context rather than a blank line. > **The pattern** > A **cron trigger** spawns a session each morning with read-only **connectors** > into Linear and GitHub. The roster and format live as **skills** and > **memory**. The agent summarizes, prompts the gaps, and posts to Slack, its > only write. ## Guardrails The agent reads everyone's activity across two tools and posts to a shared channel, so the access is scoped and contained: - **Isolation.** Every run happens in its own microVM sandbox. The session reads the day's activity, builds the summary, and only the Slack post leaves the sandbox. - **Scoped secrets.** The Linear, GitHub, and Slack credentials are encrypted in the Secrets Manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Read-only across the tools.** The Linear and GitHub connectors are scoped to read. The agent's only write is the standup post; it changes nothing in the source tools. - **Everything is code.** The roster, the format, and the per-tool permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **No meeting:** The standup is a post, not a slot on the calendar - **Read-only:** No writes to Linear or GitHub, only the Slack post - **3 systems:** Linear, GitHub, and Slack in one daily agent The standup now arrives each morning as a Slack post built from the work people already recorded, with quiet days flagged instead of skipped. No one spends a meeting relaying status, and no one has to write their own summary for the activity the tools already have. --- <!-- /markdown/use-cases/user-feedback.md --> # How we turn feedback into a roadmap On a schedule, an agent gathers feedback from support, public reviews, and a Slack channel, clusters it into themes with representative quotes and counts, and creates or updates a Linear issue per theme. Canonical page: https://kortix.com/use-cases/user-feedback Product feedback arrives everywhere: support threads, public reviews, a Slack channel where people drop what they hear. The same request shows up in all three, worded differently each time, and never gets counted. A theme that a hundred people asked for looks the same as a one-off, because nothing pulls the mentions together. We run a feedback agent on Kortix that gathers those sources on a schedule, clusters them into themes, and keeps a Linear issue per theme up to date. It reads the feedback and writes to Linear; people still own prioritization. This is how we keep our own roadmap grounded in what users actually ask for. - **Team:** Kortix - **Runs on:** Scheduled cron - **Connected systems:** Plain · G2 / app stores · Slack · Linear - **Mode:** Reads feedback · writes Linear issues ## The problem Feedback is scattered across support threads in Plain, public reviews on G2 and the app stores, and an internal Slack feedback channel. The same underlying request appears in all of them, phrased differently, so it never gets counted. A theme that keeps recurring is indistinguishable from a single loud comment. The common approaches don't add up to a roadmap. Reading each source by hand is slow and inconsistent, and whoever reads it weighs it differently. A tag in the support tool captures support but not reviews or Slack. A spreadsheet of feature requests goes stale the moment someone stops maintaining it, and it still doesn't tell you how many people asked for the same thing. ## What we built On Kortix, a scheduled cron triggers an agent. It spawns an isolated session (a cloud sandbox) with read access to Plain, the public review sources, and the Slack feedback channel, and write access to Linear. It gathers the feedback, clusters it into themes with representative quotes and counts, and creates or updates a Linear issue per theme, so the same request is deduplicated and quantified instead of scattered. ## How it works ### Run on a schedule A **cron trigger** fires the agent on a schedule. Each firing spawns a fresh **session** in its own sandbox. One run pulls the current feedback, reconciles it against the existing themes in Linear, and updates them. Nothing carries over between runs except what's written to Linear. ### Give the agent the clustering rules How we group feedback lives as **skills** and **memory** that travel with the agent: what makes two differently-worded requests the same theme, how to pick a representative quote, how to title an issue, and how to match new feedback to an existing theme instead of creating a duplicate. As our themes evolve, we write it down and the clustering stays consistent. ### Connect the sources and Linear Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Read support from Plain** — recent threads, for the requests and complaints users raise directly. - **Read public reviews** — G2 and the app stores, for what users say in the open. - **Read the Slack feedback channel** — the internal channel where the team drops what they hear. - **Write to Linear** — create a new issue for a new theme, or update the count and quotes on an existing one. ### Set the guardrails The agent is **read-only** on every source and its only write is to Linear, where it creates and updates issues. It does not set priority, assign owners, or close issues — those stay with people. Credentials are encrypted in the Secrets Manager and injected at runtime, never shown to the model or written to logs. ### Keep one issue per theme With that in place, each run gathers the latest feedback, clusters it, and keeps one Linear issue per theme current: representative quotes, a running count, and the sources it came from. The same request stops being scattered across three systems and becomes one quantified issue the team can weigh against the rest. > **The pattern** > A scheduled **cron** spawns a session with read **connectors** into Plain, the > public reviews, and Slack, and a write connector into Linear. The clustering > lives as **skills** and **memory**. The agent quantifies the themes; people own > what to build. ## Guardrails The agent reads several feedback sources and writes to Linear, so its access is scoped to exactly that: - **Isolation.** Every run happens in its own microVM sandbox. The session can reach only the sources it's scoped to, and only the Linear writes leave the sandbox. - **Scoped secrets.** The Plain, review-source, Slack, and Linear credentials are encrypted in the Secrets Manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Read-only sources, scoped writes.** The feedback sources are read-only; the only write is creating and updating Linear issues. The agent does not prioritize, assign, or close. - **Everything is code.** The agent's clustering rules, skills, and per-system permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Every run:** Feedback gathered, clustered, and reconciled to Linear - **One per theme:** The same request deduplicated instead of scattered - **4 sources:** Support, reviews, and Slack into one issue tracker Feedback that used to sit unread across support, reviews, and Slack now arrives as a set of quantified themes, each a single Linear issue with quotes and a count. The agent does the gathering and counting; the team still owns which themes become roadmap. --- <!-- /markdown/use-cases/weekly-report.md --> # How we auto-generate the weekly metrics report The reporting agent we run on Kortix — connected to our Postgres database and Slack. Every Monday it queries the metrics, writes the report with commentary on what moved, and posts it. Canonical page: https://kortix.com/use-cases/weekly-report Every team has a weekly metrics report, and someone spends part of their Monday building it: running the same queries, dropping the numbers into a template, and writing a line or two about what changed. It's routine, it's on a schedule, and it's exactly the kind of thing that gets skipped the week it's most needed. We run a reporting agent on Kortix that builds it. A Monday cron queries the metrics from our Postgres database, writes the weekly report with commentary on what moved and why, and posts it to Slack. Access to the database is read-only. This write-up covers how the setup works: the trigger, the session model, and the guardrails. - **Team:** Kortix - **Runs on:** A Monday cron - **Connected systems:** Postgres · Slack - **Mode:** Cron-driven · read-only database access ## The problem The weekly report is low-skill, high-consistency work: the same queries, the same layout, every week. Done by hand it eats an hour of someone's Monday, and the week things are busiest is the week it's most likely to slip — which is usually the week the numbers most needed a look. The common fixes are incomplete. A static dashboard shows the numbers but doesn't say what moved or why, so someone still has to read it and write the summary. A scheduled SQL job can post the figures but not the commentary. The interpretation — what changed, whether it matters — is the part that takes a person, and it's the part that gets dropped. ## What we built On Kortix, a Monday cron triggers a reporting agent. Each run spawns an isolated session (a cloud sandbox) with scoped, read-only access to the Postgres database and permission to post to one Slack channel. The agent runs the metric queries, compares them against the prior weeks, writes commentary on what moved, and posts the report to Slack. It cannot write to the database. ## How it works ### Connect a Monday cron as the trigger A scheduled **trigger** fires the project every Monday morning. Each firing spawns a fresh **session** in its own sandbox. One run, one disposable machine, so nothing carries over between weeks and the report is built from scratch each time. ### Give the agent the report playbook What the report contains lives as **skills** and **memory** that travel with the agent: the metric definitions, the queries, the layout, and what counts as a notable move worth calling out. As the metrics we care about change, we write it down and the agent picks it up on the next run. ### Connect the systems the report needs Through scoped **connectors**, brokered server-side so no raw token reaches the model, the agent can: - **Query Postgres read-only** — run the metric queries against a read-only role, pulling this week's numbers and the prior weeks for comparison. - **Compute the deltas** — compare against recent history to find what moved and by how much, inside the sandbox. - **Write the commentary** — turn the deltas into plain-language notes on what changed and whether it's worth attention. - **Post to Slack** — the report and its commentary land in the team channel as a single message. ### Set the guardrails The database connector is **read-only**: the agent queries but cannot insert, update, or delete, and its role is scoped to the metrics tables. It posts to one Slack channel and nothing else. Credentials are encrypted in the secrets manager and injected at runtime, never shown to the model or written to logs. ### Let each Monday build its own report With that in place, every Monday the agent runs the queries, computes the deltas against recent weeks, writes commentary on what moved, and posts the report to Slack before the team logs on — the numbers and a plain-language read of them in one message. No one spends their Monday assembling it. > **The pattern** > A Monday **trigger** spawns a session with scoped **connectors**: a read-only > role into Postgres and a single Slack channel out. The report playbook is encoded > as **skills** and **memory**. The agent queries, interprets, and posts — with no > path to write to the database. ## Guardrails The agent reads production data, so the access is scoped and contained: - **Isolation.** Every run happens in its own microVM sandbox. The session queries the metrics and drafts the report; only the Slack message leaves the sandbox. - **Scoped secrets.** The Postgres and Slack credentials are encrypted in the secrets manager and injected into the sandbox at runtime, never exposed to the model or the logs. - **Read-only database access.** The database role can select and nothing else — no insert, update, or delete — and it's scoped to the metrics tables. The report cannot change the data it reports on. - **Everything is code.** The agent's configuration, skills, and permissions are files in the repo, versioned and changed through a reviewed **change request** rather than a dashboard setting. ## The outcome - **Every Monday:** The report built and posted before the team logs on - **With commentary:** What moved and whether it matters, not just numbers - **Read-only:** Production metrics queried, never written The weekly report stops depending on someone having a free Monday, and it arrives with a read of what changed rather than a table to interpret. The team starts the week looking at the movement that matters instead of assembling the numbers by hand.