Skip to Content
CLI reference

The kora command

cargo install --path crates/kora-cli

Commands

kora run <file.ko>

Run a program. kora <file.ko> is the same thing.

Before any effect starts, run performs the same static analysis as kora check. Static errors and an invalid kora.toml stop the command instead of running with ambiguous code or policy.

FlagEffect
--recordcall models, then save every call to a cassette
--replayserve model calls from the cassette; never reach a provider
--reportprint token usage and call count afterwards
--tracerecord OpenTelemetry spans
--durablejournal every effect; survives being killed, and can suspend on ask_human
--resume <run-id>continue a durable run that was interrupted
kora run examples/00_basics.ko kora run --record --report examples/01_expense_check.ko # costs tokens kora run --replay --report examples/01_expense_check.ko # free, deterministic

kora run needs something to run: a file with no main() and no top-level statements is a library or a test file, and running it is refused rather than exiting silently. Use kora test for a file of test blocks.

Cassettes live in cassettes/ beside the program and are meant to be committed: they make a suite reproducible and free. A cassette is keyed on call site, model, prompt, and input, so changing the configured model invalidates them — re-record with --record.

The call site is structural: which function the call is in, and which call it is within that function. Comments, blank lines, and reformatting do not move it, so an edit that changes no code cannot invalidate a recorded answer. Adding, removing, or reordering a call does move the ones after it, which is correct — that is a different program.

A cassette recorded before Kora 0.3 is keyed on the line number instead. Those keep replaying unmodified, and are looked up under the old scheme automatically, but they stay line-sensitive until re-recorded: if one starts missing after an edit that changed no code, --record once and it will not happen again.

kora check <file.ko>...

Parse and check files without running them — the same analysis the editor shows. Useful in CI, and the only way to check a file needing resources this machine does not have.

kora check examples/*.ko kora check --syntax draft.ko # parse only, skip name resolution

Checking follows use "./lib.ko" imports, so a name that only exists in an imported file resolves here, and a name no imported file defines is reported. Problems inside an imported file belong to that file: check it directly.

The checker covers local declared assignments and returns, function and constructor arity, declared fields, duplicate definitions, loop-control placement, and direct classified flow into analyze. Dynamic sidecars, MCP, helpers, and untyped values remain runtime boundaries.

Exits non-zero if anything fails to parse or check.

kora test <file.ko>

Run the test blocks in a file. Model calls replay from the cassette, so a suite costs nothing and gives the same answer every time. Exits non-zero if anything fails.

kora test examples/07_tests.ko

kora audit <file.ko>

List every place classified data is released, and to which sink. The list is complete rather than best-effort, because every release goes through a declassify block the parser can see. Imported files are part of the program, so the audit covers every file the program imports and every package it uses. A declassify inside a dependency releases the importing program’s data; an audit that could not see it would make adding a dependency the way to hide one.

kora audit examples/03_salary_review.ko kora audit --deps program.ko # grouped by the package responsible

--deps answers a different question: not “where does this program release data” but “whose code does the releasing”. A dependency that declassifies is doing it with the importing program’s data.

examples/03_salary_review.ko:28 declassify pay for local_model 1 declassification site

kora install <file.ko>

Fetch the git dependencies the program actually uses. A dependency declared and never imported is not downloaded, so a typo’d name never reaches the disk at all.

kora install program.ko kora install --jobs 4 program.ko

Sources land in .kora/deps/<repository>@<commit>/, kora.lock records what was resolved, and kora.sums records what each commit contained the first time it was seen. Both files are committed; .kora/ is not. Fetching is IO-bound, so the default width is not the core count; [install] jobs sets it.

Cold resolution is wave-shaped — what a package depends on is unknowable until it is on disk — but once the lockfile exists the whole graph is known, so a warm install is one flat fan-out. Deep chains cost only on the first resolve, never in CI.

kora add / kora remove / kora update

kora add program.ko receipts github.com/org/receipts --tag v0.3.1 kora add program.ko local ../local-package kora remove program.ko receipts kora update program.ko receipts --tag v0.4.0

Edits are format-preserving: comments and layout in kora.toml survive. Adding does not fetch — a dependency nothing imports is not downloaded just because it was named — and grants written by hand survive a re-add, so kora add cannot quietly widen what a dependency may do.

kora update is the one command that deliberately moves past the lockfile, so it is where a new version’s authority is examined. It refuses when the new version asks for capabilities the old one did not, or declassifies in more places, until --accept-new-authority says a person has looked:

greet: v2.0.0 -> v3.0.0 this version of `greet` does more than the one it replaces: it now requires net it now requires sink `telemetry` it declassifies in 1 place, up from 0

The warning is advisory; the program still cannot run until the new authority is granted. Two independent gates — the update warns, the runtime enforces.

kora vendor <file.ko>

Copy the packages a shipped program needs into vendor/, so the project builds with no network at all.

kora vendor program.ko kora vendor --include-tests program.ko

Distinct from .kora/deps, which is a cache: vendor/ is deliberate and committed. Test-only packages are excluded by default, since they are not part of what ships. .git and .kora are never copied.

kora tree <file.ko>

The packages the program actually uses. Kora derives the graph from the source, so this is what would be fetched and shipped, not what kora.toml declares.

kora tree examples/13_packages.ko
examples/13_packages.ko greet 0.1.0 fixtures 2.0.0 (dev — reached only through test blocks) unused — declared by this program, never imported

A package reached only through test blocks is dev-only and stays out of a shipped program. Nothing declares that — there is no [dev-dependencies] table — and the line says which rule produced the classification.

Each package’s line is followed by the authority it holds:

receipts 0.3.1 grants: net, sink:stripe

kora check reports the same unused entries as warnings, and errors on a use pkg naming something no manifest declares, on a package requiring authority nobody granted it, and on one package granted two different ways by two importers.

Durable runs

kora run --durable program.ko # runs until it needs a person kora runs program.ko # what is waiting, and for what kora answer program.ko <run-id> yes # resume with an answer kora run --durable --resume <run-id> program.ko # resume after a crash

Journals live in .kora/runs/<run-id>.jsonl beside the program and are git-ignored. One line per effect, appended as the run goes, so the cost of --durable stays flat as a run gets long. A run written by an older Kora, as one .json object, is converted the first time it is resumed or answered.

A run is locked while a process holds it: a second --resume of the same id is refused rather than queued, since two processes appending to one journal would each replay to a different point. The lock is released by the operating system when the process ends, so a killed run is immediately resumable.

--resume with an id that names no run fails rather than starting a new one under that name — a typo must not turn “continue where it stopped” into “do the whole thing again”. kora runs <file.ko> lists the ids there are. An unreadable, corrupt, retired, or newer-format journal also stops. Kora never turns journal uncertainty into a fresh run because that could repeat effects.

Effects that cost money or change the world — model calls, tool calls, writes, a person’s answer — are fsynced before the program continues, so a power cut cannot make a resume repeat them. Output lines are not: a lost one repeats a print, and a synced write per line would cost more than the work it protects.

An effect that is not free to repeat also leaves a mark before it is sent, so a resume can tell “never started” from “started, outcome unknown”. What happens next depends on what was at stake:

InterruptedOn resume
a plain analyze()sent again — repeating it costs tokens and nothing else
a streamed analyze() that had written nothingsent again
a streamed analyze() that had written part of its answerreturns Failed; the pieces already on screen are not repeated
an analyze() given toolsthe run stops, naming the call — whether its tools ran is not knowable
fs.write / fs.append / sql.executethe run stops, naming the call

The two that stop are the cases where continuing would mean guessing whether something outside the program already happened. Check, then start a new run rather than resuming that one.

kora trace <file.ko>

Show the spans from the most recent traced run.

kora run --trace program.ko kora trace program.ko
review 812ms declassify 0ms analyze Assessment 806ms

kora lsp

Run the language server over stdio. Editors start this; you do not.

Diagnosticsparse errors and checker findings, pushed on open and on every change
Hoversignatures and docstrings for names under the cursor
Go to definitionjumps to where a name is declared
Outlinedocument symbols — functions, agents, and top-level names
Completiontriggered on ., plus general name completion

No rename, find-references, code actions, or formatting yet. Run/test buttons in the VS Code extension shell out to kora run/kora test directly; they are not LSP requests.

kora dap

Run the debug adapter over stdio. Editors start this; you do not.

It speaks the Debug Adapter Protocol, so any editor that implements DAP can drive it. What it supports:

Breakpointsset per file; a breakpoint on a blank line or a comment moves to the next statement, and the editor is told where it landed
Steppingstep over, step into, step out, continue, and pause
Call stackone frame per function call and per file’s top level, each naming its own file
Variableslocals for the selected frame, plus that file’s top-level names; lists, dicts, and objects expand
Watch and hovernames and field paths — total, employee.salary, rows.0
Outputprint reaches the debug console as it happens

The launch configuration takes program (required), stopOnEntry, and replay or record, which do what the same flags do on kora run. Debugging with replay costs nothing and gives the same answer every time, which is usually what you want when stepping through code that calls a model.

Two limits worth knowing. A parallel for body runs on worker threads that have no debugger attached, so breakpoints inside one do not fire — its output arrives when the run ends. And a watch expression is a lookup, not an evaluation: a debugger that could call a model or write a file while you hover over a variable is not inspecting the program, it is changing it.


Configuration

kora.toml, found by walking up from the program file. Every section is optional.

If a file exists but cannot be read or parsed, commands that execute or install work fail closed. A malformed declared model or unknown model API is an error, not an omitted entry.

[models] # a role names a model, never a vendor default = { name = "qwen3:8b", endpoint = "http://localhost:11434", api = "ollama" } smart = { name = "gpt-4o", api_key_env = "OPENAI_API_KEY" } cheap = { name = "openrouter/free", endpoint = "https://openrouter.ai/api/v1", api_key_env = "OPENROUTER_API_KEY" } vision = "local:moondream" # the shorthand still works # roles are named by `analyze(..., model="vision")` timeout_secs = 600 # one model call; 0 is clamped, not honoured max_retries = 2 # three attempts; 0 turns retrying off [models.openai] # defaults for the `openai:` shorthand only max_output_tokens = 4096 # bounds worst-case budget reservation endpoint = "https://api.openai.com/v1" api_key_env = "OPENAI_API_KEY" [models.local] # defaults for the `local:` shorthand only endpoint = "http://localhost:11434" [sinks] # which labels may reach which sink local_model = { allow = ["classified"] } openai = { allow = ["internal"], deny = ["classified"] } [package] # only when this project *is* a package name = "receipts" version = "0.1.0" entry = "src/lib.ko" # the default [install] jobs = 16 # parallel fetches; IO-bound, so not the # core count. Default max(8, cores * 2) [dependencies.receipts] # where a package comes from. Whether it path = "./receipts" # is used is decided by the source, so # declaring one costs nothing until # something writes `use pkg receipts` grants = { net = true, sinks = ["stripe"] } # and what it may do. Absent # means nothing: a dependency never # given the network cannot reach it [http] allow_private = false # loopback and private ranges refused timeout_secs = 30 # 0 is clamped, not honoured [python] command = "python3" # or a virtualenv's interpreter [mcp] # defaults for every server below timeout_secs = 60 # one request; 0 is clamped, not honoured max_retries = 2 # starting a server only, never a tool call [mcp.github] # how to launch a server command = "npx" args = ["-y", "@modelcontextprotocol/server-github"] env = { GITHUB_PERSONAL_ACCESS_TOKEN = "$GITHUB_TOKEN" } timeout_secs = 120 # this one reaches a slow API [telemetry] exporter = "file" # "file" | "otlp" | omit for none path = ".kora/last.trace.json" # endpoint = "http://localhost:4318" # for exporter = "otlp" level = "calls" # off | agents | calls | full redact = true # labeled values never reach the exporter

A model written out in full takes four keys:

keymeaning
namecopied from the provider’s own documentation. Never parsed — slashes, colons, and tags survive
endpointwhere the request goes. Omitted, the wire format’s default is used
api_key_envwhich variable holds the key. Omitted with an endpoint of your own, no Authorization header is sent at all — which is what a local vLLM or llama.cpp server wants
apiwhich request to build: "openai" (default) or "ollama". A wire format, not a company

max_output_tokens, timeout_secs, and max_retries may also be set per entry, overriding the [models] defaults.

The endpoint belongs to the entry, so two services coexist in one project:

[models] smart = { name = "gpt-4o", api_key_env = "OPENAI_API_KEY" } cheap = { name = "openrouter/free", endpoint = "https://openrouter.ai/api/v1", api_key_env = "OPENROUTER_API_KEY" }

The key itself is never written in kora.toml, which is a checked-in file — only the name of the variable holding it. A missing key is reported under that name.

The older shorthand still resolves: provider:model, where local: and ollama: mean the Ollama wire format and openai: the OpenAI one, with the endpoint and key variable coming from [models.openai] / [models.local]. Everything after the first colon is the model name, so local:llama3.1:8b survives intact.


Editor support

cargo install --path crates/kora-cli ln -s "$PWD/editors/vscode" ~/.vscode/extensions/kora-lang

Restart VS Code. You get diagnostics as you type, hover signatures and docstrings, go-to-definition, an outline, completion, and run/test buttons. Set kora.serverPath if the binary is not on PATH.

Press F5 on a .ko file to debug it — breakpoints in the gutter, the call stack, and a variables pane.

Two things catch people out, both of them VS Code’s rules rather than Kora’s:

  • Quit and reopen VS Code after installing or updating the extension. Contributions are read once, at startup. Until that happens the gutter refuses breakpoints on .ko files and F5 offers to find a Kora extension in the Marketplace.
  • Check the binary the editor will use has a dap subcommand. Run kora | grep "kora dap". If it prints nothing, the kora first on your PATH predates the debugger; set kora.serverPath to an absolute path rather than reordering PATH.

No launch.json is needed for the common case; write one to pin the options:

{ "version": "0.2.0", "configurations": [ { "type": "kora", "request": "launch", "name": "Debug current Kora file", "program": "${file}", "stopOnEntry": false, "replay": true } ] }

Terminal output

kora colors its output — an error:/warning: tag in bold red or yellow, a = hint: line dimmed under it, and status verbs (fetched, added, vendored, pass/FAIL, updated, …) right-aligned and colored by what they mean, the same shape cargo uses. A run that ends cleanly gets a green ✓ summary line. kora --version and bare kora print a small banner first.

Color comes from raw ANSI escapes in crates/kora-cli/src/ui.rs — no crate dependency added. It turns itself off when NO_COLOR is set or stdout/stderr are not a real terminal, so piped output and CI logs stay plain text.

ColorVerb / tag
greenfetched, added, vendored, removed, recorded, pass, ✓
yellowunchanged, repointed, updated, skipped, warning:
redFAIL, unfetched, tampered, error:
cyaneverything else (a package tree’s entry path)
warning: `greet` is declared by this program but never imported = hint: remove it from kora.toml, or write `use pkg greet` ✓ checked 1 file: no problems

Exit codes

CodeMeaning
0success
1the program failed, or a test failed
2the command line was wrong
3a durable run suspended, waiting on a person

Read Installation for the routes into getting kora on your machine, or the KORA Language Reference for the language itself.

Last updated on