The kora command
cargo install --path crates/kora-cliCommands
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.
| Flag | Effect |
|---|---|
--record | call models, then save every call to a cassette |
--replay | serve model calls from the cassette; never reach a provider |
--report | print token usage and call count afterwards |
--trace | record OpenTelemetry spans |
--durable | journal 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, deterministickora 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 resolutionChecking 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.kokora 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 sitekora 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.koSources 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.0Edits 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 0The 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.koDistinct 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.koexamples/13_packages.ko
greet 0.1.0
fixtures 2.0.0 (dev — reached only through test blocks)
unused — declared by this program, never importedA 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:stripekora 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 crashJournals 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:
| Interrupted | On resume |
|---|---|
a plain analyze() | sent again — repeating it costs tokens and nothing else |
a streamed analyze() that had written nothing | sent again |
a streamed analyze() that had written part of its answer | returns Failed; the pieces already on screen are not repeated |
an analyze() given tools | the run stops, naming the call — whether its tools ran is not knowable |
fs.write / fs.append / sql.execute | the 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.koreview 812ms
declassify 0ms
analyze Assessment 806mskora lsp
Run the language server over stdio. Editors start this; you do not.
| Diagnostics | parse errors and checker findings, pushed on open and on every change |
| Hover | signatures and docstrings for names under the cursor |
| Go to definition | jumps to where a name is declared |
| Outline | document symbols — functions, agents, and top-level names |
| Completion | triggered 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:
| Breakpoints | set per file; a breakpoint on a blank line or a comment moves to the next statement, and the editor is told where it landed |
| Stepping | step over, step into, step out, continue, and pause |
| Call stack | one frame per function call and per file’s top level, each naming its own file |
| Variables | locals for the selected frame, plus that file’s top-level names; lists, dicts, and objects expand |
| Watch and hover | names and field paths — total, employee.salary, rows.0 |
| Output | print 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 exporterA model written out in full takes four keys:
| key | meaning |
|---|---|
name | copied from the provider’s own documentation. Never parsed — slashes, colons, and tags survive |
endpoint | where the request goes. Omitted, the wire format’s default is used |
api_key_env | which 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 |
api | which 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-langRestart 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
.kofiles and F5 offers to find a Kora extension in the Marketplace. - Check the binary the editor will use has a
dapsubcommand. Runkora | grep "kora dap". If it prints nothing, thekorafirst on yourPATHpredates the debugger; setkora.serverPathto an absolute path rather than reorderingPATH.
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.
| Color | Verb / tag |
|---|---|
| green | fetched, added, vendored, removed, recorded, pass, ✓ |
| yellow | unchanged, repointed, updated, skipped, warning: |
| red | FAIL, unfetched, tampered, error: |
| cyan | everything 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 problemsExit codes
| Code | Meaning |
|---|---|
0 | success |
1 | the program failed, or a test failed |
2 | the command line was wrong |
3 | a 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.