kortix.toml
The project manifest — every table, field, default, and validation rule.
The one file the platform treats as authoritative for a project. For the plain-language version, see Projects.
kortix.toml lives at the repo root. Any repo with a valid kortix.toml at the root is a Kortix project. The parser is permissive — it never throws on a bad entry: bad triggers and apps go into an errors list alongside the good ones, and unknown top-level tables are ignored (park your own metadata freely).
Full example
# 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 = ["ANTHROPIC_API_KEY", "OPENAI_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
kortix_version = 1kortix_version is the schema version. Manifests without it are treated as v1 for backward compatibility (null, 1, and "1" all decode to v1). A manifest declaring a version higher than the platform knows about is rejected outright — the platform won't silently misread future fields. When the platform writes the manifest back after a dashboard edit, it ensures kortix_version is the first key.
What's parsed where
| Surface | What it reads |
|---|---|
| Trigger sweep | [[triggers]] |
| Sandbox builder | [[sandbox.templates]] |
| Sandbox runtime | [opencode] (where to launch opencode with its config) |
| Session bootstrap | [env] (advisory — surfaced to dashboard, not enforced) |
| Connector sync | [[connectors]] |
| Apps deploy sweep | [[apps]] (when KORTIX_APPS_EXPERIMENTAL=true) |
| Dashboard UI | All of the above + the raw manifest |
[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 [project] as a convention for people reading the repo.
[env]
Declares the env vars your sessions need. Values live in the Kortix Secrets Manager, never inline; the platform decrypts and injects them as plain env vars at session start.
[env]
required = ["DATABASE_URL"]
optional = ["ANTHROPIC_API_KEY", "OPENAI_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.
[[sandbox.templates]]
Array of tables. Each entry is one named, bootable sandbox image; 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 mirrors the runtime provider (Daytona): an image comes either from a Dockerfile in your repo or a public Docker image reference, and the resource spec maps onto the provider's cpu / memory / disk.
# From a repo Dockerfile
[[sandbox.templates]]
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
[[sandbox.templates]]
slug = "python"
name = "Python 3.12"
image = "python:3.12-slim" # must be tag- or digest-pinned
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. |
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 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 the [sandbox] table 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.
[[sandbox.templates]]
slug = "dev"
dockerfile = ".kortix/Dockerfile"
[sandbox]
default = "dev" # or "default" for the platform image (the implicit default)default must name a template defined in this manifest (or the reserved "default"). It's the only key allowed on the [sandbox] table itself — image/build keys there 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]
Where OpenCode's config dir lives. Optional, with a default. The agent daemon launches opencode with OPENCODE_CONFIG_DIR pointed here.
[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. Kortix doesn't read those files; opencode does. See Kortix vs OpenCode config and opencode.ai/docs.
[[triggers]]
Array of tables. Each entry spawns a fresh session that runs prompt as its initial message. Sorted alphabetically by slug in the parsed output, so UI ordering is stable. Full reference: Triggers.
[[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. Alias: prompt_template. |
name | no | string | slug | Human label. |
agent | no | string | "default" | OpenCode agent name. Alias: agent_name. |
enabled | no | bool | true | Accepts strings: "true"/"false"/"yes"/"no"/"on"/"off"/"1"/"0". |
Cron-only: cron (6-field croner second minute hour day month weekday, alias schedule) and timezone (IANA name, default "UTC"). Webhook-only: secret_env (name of a project_secrets entry holding the HMAC secret, alias secretEnv).
The parser accepts both alias forms (prompt / prompt_template, agent / agent_name, cron / schedule, secret_env / secretEnv). The platform serializes back with the canonical names, so editing-then-saving from the UI normalizes aliases away. Slug uniqueness is per-section — a trigger and an app may share a slug; two triggers may not.
[[connectors]]
Array of tables. 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.
[[connectors]]
slug = "slack"
provider = "pipedream" # pipedream | mcp | openapi | graphql | http
app = "slack" # pipedream app slug
credential = "per_user" # per_user | shared
[[connectors.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. |
name | no | string | Display name. Defaults to slug. |
enabled | no | bool | Defaults to true. |
credential | no | string | shared or per_user. Default: per_user for pipedream, shared otherwise. |
Provider-specific fields:
- pipedream —
app(required),account(optional, defaults to slug). - mcp —
url(required),transport=http(default) orsse. - openapi —
spec(required: URL or repo-relative path). - graphql —
endpoint(required),spec(optional SDL). - http —
base_url(required, aliasbaseUrl),spec(optional).
[connectors.auth]
Optional. type = bearer | basic | custom | none (default none); in =
header (default) or query; name (required when type = "custom"); prefix
(optional); secret (optional secret name, ^[A-Z_][A-Z0-9_]{0,63}$). Not
allowed with provider = "pipedream".
[[connectors.policies]]
Optional per-tool gates. match (glob over tool names, required) + action =
always_run | require_approval | block (required).
[[apps]] (experimental)
Gated behind KORTIX_APPS_EXPERIMENTAL=true. With the flag off, /apps routes return 404 (JSON error explaining the flag) and the deploy sweep skips every project — entries are parsed but never acted on. [[apps]] declares deployable surfaces alongside the agent, fly.toml-style. The platform dispatches through a provider adapter (Freestyle today; pluggable) and records each deploy in the deployments table.
[[apps]]
slug = "marketing-site"
name = "Marketing site"
enabled = true
framework = "next"
domains = ["marketing.example.com"]
[apps.source]
type = "git" # or "tar"
repo = "https://github.com/me/site"
branch = "main"
root_path = "apps/site"
[apps.build]
command = "pnpm build"
out_dir = "dist"
[apps.env]
NEXT_PUBLIC_API_URL = "https://api.example.com"Entries sort alphabetically by slug; slug uniqueness is per-section.
| Field | Required | Type | Notes |
|---|---|---|---|
slug | yes | string | URL-safe, unique among apps. |
name | no | string | Display name. Defaults to slug. |
enabled | no | bool | Defaults to true. Disabled apps are skipped. |
domains | no | string[] | Optional. Omit it and the platform auto-issues a free *.style.dev URL at deploy time. When present, every entry must be a non-empty string. |
framework | no | string | Hint for the provider adapter (e.g. "next"). |
[apps.source]
| Field | Required for git | Required for tar | Notes |
|---|---|---|---|
type | yes | yes | "git" or "tar". |
repo | no | — | Git clone URL. Falls back to the project's own repo URL if omitted. |
branch | no | — | Defaults to the project's default branch. |
root_path | no | — | Path inside the source to deploy from. Defaults to ".". |
url | — | yes | HTTPS URL of the tarball. |
[apps.build]
| Field | Required | Notes |
|---|---|---|
command | no | Build command. Empty → no build step. |
out_dir | no | Output directory served by the provider. |
If both are empty, the parsed entry collapses to null — the provider treats it as "no build phase."
[apps.env]
Key/value map. Keys must match ^[A-Za-z_][A-Za-z0-9_]*$ (mixed case allowed, unlike [env] secrets which are uppercase-only). Values must be strings — numbers and booleans are rejected.
Hash-based redeploy
Apps redeploy when the manifest-derived hash of their config changes. The hash excludes slug and name, so renaming an app doesn't trigger a redeploy. Edits to source, build, env, domains, framework, or enabled do.
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_versionas the first key. - Inside a
[[triggers]]entry, write fields in this order:slug,name,type,agent,enabled, then type-specific fields, thenpromptlast. - If you add a webhook trigger before its secret is set, declare the secret name in
[env].optionalso it shows up in the Secrets Manager, and leave the triggerenabled = falseuntil the value is in.