Manifest reference

The project manifest — every table, field, default, and validation rule, for both kortix.yaml (v2) and kortix.toml (v1, legacy).

The one file the platform treats as authoritative for a project. For the plain-language version, see 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 and apps go into an errors list alongside the good ones, and unknown top-level keys are ignored (park your own metadata freely).

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:

Point a kortix.yaml's first line at it for editor validation + autocomplete:

# 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-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: below. There's no [[channels]] in v2 (see Channels).

Legacy example (v1, kortix.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

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.

runtime: opencode

What's parsed where

Surfacev2 (kortix.yaml)v1 (kortix.toml, legacy)
Trigger sweeptriggers:[[triggers]]
Sandbox buildersandbox.templates[[sandbox.templates]]
Sandbox runtimeopencode: (where to launch opencode with its config)[opencode]
Session bootstrapenv: (advisory — surfaced to dashboard, not enforced)[env]
Connector syncconnectors:[[connectors]]
Agent governanceagents: (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)
Apps deploy sweepapps: (gated per-project — see below)[[apps]] (same gate)
Dashboard UIAll of the above + the raw manifestSame
(removed)channels: does not exist in the v2 schema — see below[[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 — envsecrets inside each agents: block — see agents: below.)

env:
  required: [DATABASE_URL]
  optional: [STRIPE_API_KEY]
FieldTypeNotes
requiredstring[]Advisory list — surfaced in the dashboard. Not enforced at session start today.
optionalstring[]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 / [[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 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.

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)

[[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
FieldTypeDefaultNotes
slugstringRequired. Unique per project. default is reserved.
namestringslugDisplay label shown in the dashboard picker.
dockerfilestringRepo-relative path. Mutually exclusive with image.
imagestringPublic Docker image, tag- or digest-pinned (no bare latest). Mutually exclusive with dockerfile.
entrypointstringOverrides the container entrypoint the snapshot boots with. Optional — when omitted, the Kortix runtime layer's own entrypoint is used.
cpuintprovider defaultvCPU cores.
memoryintprovider defaultRAM in GiB.
diskintprovider defaultDisk 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 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.

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.

opencode:
  config_dir: .kortix/opencode
FieldTypeDefaultNotes
config_dirstring.kortix/opencodeRepo-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 and 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, or the trigger's prior session when session_mode: reuse (see below). 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

FieldRequiredTypeDefaultNotes
slugyesstring[a-z0-9][a-z0-9_-]{0,127}, unique among triggers.
typeyesstring"cron" or "webhook".
promptyesstringMustache-style template.
namenostringslugHuman label.
agentnostring"default"Agent name. Legacy projects resolve this through OpenCode discovery; declarative projects should use a registered [[agents]] name.
enablednobooltrueWhen false, the scheduler / receiver skip the entry.
modelnostringWire 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_modenostring"fresh""fresh" or "reuse" (camelCase sessionMode also accepted). "reuse" continues the trigger's existing session on each fire instead of spawning a new one.

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

v2 removes channels: from the schema outright. Channel↔agent routing (Slack today; Teams/Discord/etc. later) is live operational state — the dashboard's Channels page or Slack's own 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 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 inside Slack (its slash commands) or the dashboard's Channels page, not from this file.

[[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 }}
"""
FieldRequiredTypeDefaultNotes
platformyesstringe.g. "slack". One entry per platform per project — a duplicate platform is a validation error.
enablednobooltrueValidated as a boolean. Not read by any runtime path.
agentnostringParsed, not validated, not consumed.
eventsnostring[]Parsed as an array of strings. Not consumed.
prompt_prefixnostringParsed. Not consumed.

Declared but inert

Connecting Slack from the dashboard writes the install (bot token, signing secret) into the project's secrets and creates the live 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 Slack 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.

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
FieldRequiredTypeNotes
slugyesstring[a-z0-9][a-z0-9_-]{0,127}, unique among connectors; the tool namespace.
provideryesstringpipedream | mcp | openapi | graphql | http | channel.
namenostringDisplay name. Defaults to slug.
enablednoboolDefaults to true.
credentialnostringshared 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:

  • pipedreamapp (required), account (optional, defaults to slug).
  • mcpurl (required), transport = http (default) or sse.
  • openapispec (required: URL or repo-relative path).
  • graphqlendpoint (required), spec (optional SDL).
  • httpbase_url (required), spec (optional).
  • channelplatform (required) = slack | email | meet. Chat platforms. You rarely declare these by hand — connecting one (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) 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_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)

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.

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
FieldRequiredTypeDefault (v2)Notes
(map key)yesstring[a-z0-9][a-z0-9_-]{0,127}, unique per project. Matches the OpenCode .md filename it governs.
enablednobooltruefalse treats the name as undeclared — default-deny.
connectorsnostring[] | "all" | "none"noneWhich connector slugs this agent may call. "all" = every connector profile not scoped away from it.
secretsnostring[] | "all" | "none"noneProject secret names this agent receives as sandbox env vars and may read via the secrets API. Renamed from v1's env.
kortix_clinostring[] | "all" | "none"noneWhich Kortix CLI/API actions (open/merge CRs, deploy, manage triggers, spawn sessions, …) 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.
skillsnostring[] | "all" | "none"noneWhich skills this agent may load. Folds onto the compiled OpenCode permission.skill.
workspacenoReserved 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.

[[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
FieldRequiredTypeDefaultNotes
nameyesstring[a-z0-9][a-z0-9_-]{0,127}, unique per project. Matches the OpenCode .md filename it governs.
enablednobooltruefalse treats the name as undeclared — default-deny once the project has adopted [[agents]].
connectorsnostring[] | "all" | "none"none ([])Which [[connectors]] slugs this agent may call. "all" = every connector profile not scoped away from it.
kortix_clinostring[] | "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.
envnostring[] | "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.
modelnostringWire 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).
filenostringconventional .md by nameOverride 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).

apps: / [[apps]] (experimental)

Gated per project, not by a single global switch. Apps is one entry in the platform's experimental-feature registry: each project has an explicit choice — on or off — toggled from Customize → Settings, stored in projects.metadata (never in the manifest). KORTIX_APPS_EXPERIMENTAL is only the operator-wide default applied when a project hasn't made an explicit choice; a project can enable Apps even when that env var is false, and can opt out even when it's true. With the effective gate off for a project, its /apps routes return 404 (JSON error pointing at Customize → Settings) and the deploy sweep skips it — entries are parsed but never acted on; the sweep evaluates every active project individually rather than skipping the whole fleet when the operator default is off. Each entry declares a deployable surface 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]   # optional — omit for a free *.style.dev URL
    source:
      type: git            # or "tar"
      repo: https://github.com/me/site
      branch: main
      root_path: apps/site
    build:
      command: pnpm build
      out_dir: dist
    env:
      NEXT_PUBLIC_API_URL: https://api.example.com

Entries sort alphabetically by slug; slug uniqueness is per-section.

FieldRequiredTypeNotes
slugyesstringURL-safe, unique among apps.
namenostringDisplay name. Defaults to slug.
enablednoboolDefaults to true. Disabled apps are skipped.
domainsnostring[]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.
frameworknostringHint for the provider adapter (e.g. "next").

[apps.source]

FieldRequired for gitRequired for tarNotes
typeyesyes"git" or "tar".
reponoGit clone URL. Falls back to the project's own repo URL if omitted.
branchnoDefaults to the project's default branch.
root_pathnoPath inside the source to deploy from. Defaults to ".".
urlyesHTTPS URL of the tarball.

[apps.build]

FieldRequiredNotes
commandnoBuild command. Empty → no build step.
out_dirnoOutput 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_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.
Manifest reference – Kortix Docs