KORA Language Reference
This is the quick reference. Each entry shows the shape first, then a tiny example.
Kora is still evolving. Features marked planned are part of the design, but may not be available in the current CLI yet.
Curious how this actually runs? See Internals for the compiler pipeline and the runtime mechanisms (journal, labels, budgets, cassettes) behind it.
Program shape
Kora uses indentation, .ko files, and main() as the entry point.
def main():
print("hello from Kora")Comments start with #:
# This is a comment.Values and literals
count = 3
price = 4.50
name = "Ada"
ready = True
nothing = None
scores = [8, 9, 10]
user = {"name": "Ada", "active": True}Strings can use single or double quotes. F-strings put expressions inside {}:
print(f"{name} has {len(scores)} scores")Types
Kora’s built-in types are int, float, str, bool, list, dict, and None.
Named records use a type block:
type User:
name: str
visits: int
ada = User("Ada", 3)
print(ada.name)Type annotations go on the left side of an assignment:
score: float = 9.5kora check verifies local declared assignments and returns, call and
constructor arity, field access, duplicate definitions, loop-control
placement, and direct classified flow into analyze. Dynamic integration
values remain runtime-checked. kora run, kora test, and the debugger stop
on these diagnostics before starting effects.
Functions
def greet(name: str) -> str:
return f"Hello, {name}!"
def main():
print(greet("Ada"))Use return, pass, break, and continue inside the usual blocks.
Conditions and loops
if score >= 9:
print("great")
elif score >= 5:
print("okay")
else:
print("try again")for score in scores:
print(score)
while count > 0:
count -= 1The operators are familiar: +, -, *, /, //, %, **, comparisons, and, or, not, and in.
match and outcomes
match checks a value against case arms:
match result:
case Ok(value):
print(value)
case Uncertain(reason):
print(reason)
case _:
print("something else")Model calls and standard-library operations use result values such as Ok, Err, Uncertain, and Exhausted. Treat the failure path as part of normal program flow.
Alternatives keep a shared branch compact when they bind the same names:
match result:
case Uncertain(reason) | Failed(reason):
print(f"try later: {reason}")Built-in functions
These functions are available without an import.
print(...values)
Prints values separated by spaces. It returns None.
print("total", 42)Classified leaves are never written to terminal output. They display as
__CLASSIFIED__ by default, configurable with [output] classified_placeholder
in kora.toml.
len(value)
Returns the length of a list, string, or dictionary.
len("Kora") # 4
len([1, 2, 3]) # 3range(stop) and range(start, stop)
Creates a list of integers. The stop value is not included.
for i in range(3):
print(i) # 0, 1, 2str(value), int(value), float(value), bool(value)
Convert ordinary values.
int("42") # 42
float("4.5") # 4.5
str(42) # "42"
bool([]) # Falseabs(number)
Returns the positive magnitude.
abs(-12) # 12min(values) and max(values)
Accept a list or two or more values.
min([4, 2, 9]) # 2
max(4, 2, 9) # 9sum(list)
Adds the values in a list.
sum([10, 20, 30]) # 60sorted(list)
Returns a new sorted list and leaves the original list alone.
ordered = sorted([3, 1, 2])append(list, value)
Adds one value to a list. Kora currently writes this as a function, not a method call.
names = ["Ada"]
append(names, "Grace")keys(dict) and values(dict)
Return the dictionary’s keys or values as lists.
user = {"name": "Ada", "team": "compilers"}
keys(user)
values(user)analyze(data, prompt)
Asks the configured model for a declared type. Add tools=[...] when the call needs tools, and model="name" to use a model other than the default.
type Label:
name: str
label: Label = analyze("a green apple", "give this a short label")model= takes a name declared under [models] in kora.toml, not a provider spec. The program names a role — vision, smart, cheap — and the config decides which model fills it.
receipt: Receipt = analyze(picture, "read this receipt", model="vision")The data argument may contain images. They are sent alongside the JSON, in the order they appear.
See Model calls for provider setup and replay.
A call with tools=[...] may watch each call before it runs, with on tool_call(name, args)::
type Ticket:
priority: int
tool priority_for(severity: str) -> int:
"Map a severity label to the on-call priority number."
if severity == "high":
return 1
return 3
t: Ticket = analyze(raw, "classify this ticket", tools=[priority_for]) on tool_call(name, args):
print(f"calling {name}({args})")args is a mutable dict; editing it in place rewrites what actually reaches the tool. returning a string from the block skips the tool and hands that string back to the model as the call’s result instead.
with context(...)
A lexical request-admission policy for analyze, separate from budget.
with context(max_input_tokens = 12_000, reserve_output_tokens = 2_000):
result: Report = analyze(case_file, "review the evidence", tools=[lookup])It retains newest complete tool exchanges that fit and returns Failed(reason)
when base prompt and data exceed the available context. Kora never clips them.
redact(value)
Masks classified fields before data leaves a safe scope.
safe = redact(employee)tokens_spent(), tokens_remaining(), calls_spent()
Inspect the current budget meter.
if tokens_remaining() < 1000:
print("wrapping up")These are most useful inside an agent or a with budget(...) block.
ask_human(question, context)
Pauses a durable workflow and waits for a human decision.
decision = ask_human("Approve this refund?", refund.reason)Agent constructs
agent
Declares a model-using, suspendable unit of work.
agent triage(raw: str) -> str:
budget: max_tokens = 20_000, max_steps = 5
result: str = analyze(raw, "summarize this ticket")
match result:
case Ok(summary):
return summary
case Uncertain(why):
return f"human review: {why}"
case Exhausted(meter):
return f"budget exhausted: {meter}"
case Failed(why):
return f"provider failed: {why}"tool
Declares a callable a model can use. Its signature becomes the tool schema.
tool lookup_customer(email: str) -> Customer:
"Fetch a customer record."
return db.query("select * from customers where email = ?", email)An agent may be passed as a tool too, for a supervisor delegating to specialists — each keeps its own budget: line:
agent billing(question: str) -> str:
budget: max_tokens = 4000
result: str = analyze(question, "answer this billing question")
match result:
case Ok(answer):
return answer
case Uncertain(why):
return f"human review: {why}"
case Exhausted(meter):
return f"budget exhausted: {meter}"
case Failed(why):
return f"provider failed: {why}"
a: Answer = analyze(question, "route this and answer it", tools=[billing])parallel for
Fans work out and collects the results.
results = parallel for ticket in tickets:
triage(ticket)Add first after the iterable to select the earliest input that returns a value. The loop yields that value rather than a list. Once a candidate answers, no work at or after that input index is started; lower indexes already in flight finish because one of them may become the deterministic winner.
answer = parallel for mirror in mirrors first:
return fetch(mirror)The winner is the earliest in input order, not the earliest to finish: an answer that depended on which core was free could not be replayed. Under --durable the winner is journaled for the same reason max_seconds journals its refusal. A race nobody wins is None. first is contextual, so a program that already uses it as a name keeps working.
break covers the other shape — a fan-out that is still collecting and has seen enough. The loop still yields a list; break <value> also contributes the stopping branch’s own find.
found = parallel for candidate in candidates:
if matches(candidate):
break candidate
return 0Branches that never started keep no slot, and neither do branches let go of part way through. break with a value is only legal here; anywhere else it is a check-time error. Prefer first when the loop is asking a question: a break loop’s result length depends on scheduling and is not replay-stable.
With break, a branch already running is let go at its next statement, not mid-statement. With first, work already in flight finishes so input-order selection stays deterministic. A request already sent runs to its own deadline, which budget(max_seconds = N) bounds.
with budget(...)
Places a nested spending fence around a block.
with budget(max_tokens = 5_000):
result = analyze(text, "summarize this")Four meters: max_tokens, max_calls, max_steps, and max_seconds.
Budgets nest and a child may only tighten; a parallel for shares one pot,
so its branches stop collectively.
max_seconds starts its clock when the scope is entered, and the deadline is
one instant shared by every branch rather than a duration each starts for
itself. It reaches work already sent: every model call, HTTP request, MCP tool
call, and package-helper call inside the scope gets the smaller of its own
timeout and what the scope has left. It does not bound computation — a budget
bounds agent work, not CPU.
with budget(max_seconds = 30):
answer: str = analyze(question, "answer briefly")
match answer:
case Ok(text):
print(text)
case Exhausted(meter):
print(f"gave up: out of {meter}")Data safety
classified and declassify
Sensitive values stay labeled. A scoped declassify gives the released value a name and names the only sink allowed to receive it.
declassify employee.salary as pay for local_model:
review: Review = analyze(pay, "assess pay")The block ends the permission. There is no permanent “safe now” copy.
Direct unsafe flow into analyze is reported statically. The runtime remains
the authoritative boundary check because it can see deep labels and the
configured sink selected by a live call.
Modules
Modules are brought in with use:
use json
use csv as dataYour own files
A quoted path imports another Kora file. A bare word is a stdlib module, so the two can never be confused.
use "./lib/tax.ko" as tax
def main():
print(tax.with_tax(100.0))Paths resolve relative to the file that writes them, not the working
directory, and always need as <name>. Every top-level name in the imported
file — functions, agents, tools, types, and variables — is reachable through
the alias.
Each file keeps its own top-level names, so importing a file cannot change what the code inside it means. A file’s top level runs once per run however many files import it, and an import cycle is an error rather than a half-built module.
json
json.parse(text), json.stringify(value), and json.get(value, path) return Ok(...) or Err(...).
use json
match json.parse("{\"name\": \"Ada\"}"):
case Ok(value):
print(json.get(value, "name"))
case Err(reason):
print(reason)csv
csv.parse(text, RowType) turns rows into declared records. csv.rows(text) returns dictionaries, and csv.write(rows) creates CSV text.
use csv
type Person:
name: str
age: int
people = csv.parse("name,age\nAda,36", Person)fs
fs.read(path), fs.lines(path), fs.write(path, text), fs.append(path, text), and fs.exists(path) cover basic file work. Reads and writes use result values.
fs.image(path) loads a PNG, JPEG, GIF, or WebP as a value you can hand to analyze. The type is read from the file’s magic bytes, not its extension.
fs.glob(pattern) and fs.list(dir) return sorted lists of full paths. * and ? match inside one path component, ** matches any run of directories, and dotfiles are skipped unless the pattern asks for one.
use fs
match fs.glob("dataset/*.png"):
case Ok(paths):
rows = parallel for p in paths:
return classify(p)
case Err(why):
print(why)http
http.get(url) and http.post(url, body) include timeouts and return Ok(response) or Err(reason). Responses are treated as outside, unverified data.
sql
sql.query(db_path, query, params) and sql.execute(db_path, query, params) use bound parameters.
use sql
rows = sql.query("app.db", "select * from users where id = ?", [user_id])time
time.now() returns a UTC Unix timestamp, time.format(seconds, "iso") formats it, and time.elapsed(since) returns whole seconds.
re
re.matches, re.find, re.find_all, re.replace, and re.split are linear-time pattern operations and return results rather than throwing.
env
env.get(name) reads an environment variable and env.has(name) checks whether it exists. Secret-looking names are classified automatically.
Package helpers
A package may declare one helper: a separate program it carries heavy work out to, for things that cannot be written in Kora — rendering a PDF page, decoding a video. use helper binds the declaring package’s own helper, the importer decides whether it may run through a helper grant, and a fetched helper is pinned by sha256 and recorded in kora.sums. Kora never loads native code into itself: a helper is a process, so a crash in it is an Err, a hang is killed, and it only ever sees the bytes it was handed.
A helper is also confined, not merely separated. needs in [package.helper] is empty by default, and an empty one means the operating system withholds the network and the ability to write files — a seccomp filter on Linux, a sandbox profile on macOS. needs = ["net"] is refused unless the importing program holds net itself. It is a denylist rather than a jail, and Windows has no mechanism to apply, which the runtime says on stderr rather than implying a sandbox that is not there.
use helper
def pages(document: bytes, dpi: int) -> list:
match helper.render(dpi, 20, document):
case Ok(images):
return images
case Err(why):
return []pdf
pdf.text(path) returns every page joined, pdf.pages(path) keeps the pages separate, and pdf.info(path) reports page_count, encrypted, and has_text_layer. A document whose pages are pictures is an Err, not an empty string, so a scan is never mistaken for an empty document. Text is unverified. Rendering a page to an image is a package (pdf_render), not a stdlib call.
yaml
yaml.parse(text) reads one document, yaml.parse(text, Type) checks it against a declared type the way json.parse does, and yaml.documents(text[, Type]) reads a ----separated file. yaml.get is the same path walk as json.get; yaml.stringify(value) writes YAML and refuses classified data.
Three behaviours differ from every mainstream YAML library, deliberately. A duplicate key is an Err naming the key and its line, rather than the last one silently winning. Only true and false are boolean, so NO, yes, and off stay strings (the Norway problem). And alias expansion is metered against a node budget, so a “billion laughs” file is a value to match on rather than an out-of-memory kill. Merge keys (<<: *defaults) work, with an explicit key overriding a merged one in either order. A multi-document file handed to yaml.parse is an Err naming the count, because applying half a bundle silently is worse than stopping. Parsed values are unverified.
xml
xml.parse(text) returns the root element; xml.find(element, "channel.item") and xml.find_all(...) walk a path of local tag names; xml.text(element) is all of its character data; xml.get is the json.get path walk. An element is an ordinary value: {"tag", "ns", "attrs", "text", "children"}.
Four things differ from every mainstream XML reader, deliberately. A DOCTYPE is refused outright, so an entity cannot name a local file or an internal URL (XXE) and the “billion laughs” bomb has nowhere to start, since it needs a DTD too. A namespace is a field rather than a {uri} prefix glued to the tag, and lookups match the local name, so a document that gains a default namespace does not break every query. text is all of an element’s character data in document order, with no .tail for the rest to hide in. And children is always a list, at any length, so the shape does not change with the size of the input. A dead end partway through a path is an Err naming the segment; no matches at the end is an empty list. There is no xml.parse(text, Type): in XML a value can live in an attribute or a child element, and guessing which would be the same mistake. Parsed text is unverified.
Packages
A package is named, not pathed. Where it comes from and what it may do live in
kora.toml.
use pkg receipts as r[dependencies.receipts]
git = "github.com/org/receipts"
tag = "v0.3.1"
grants = { net = true, sinks = ["stripe"] }Names resolve against the manifest that wrote them, never a global table, so
a package may depend on something its consumer has never heard of and two
packages may bind the same bare name to different sources. Types belong to
their package, so two dependencies may each declare Config.
Grants are net, fs, sql, env, python, and helper, plus sinks and mcp as
lists of names and declassify as a flag. Absent means nothing. A package
states what it needs with [package.requires], and a shortfall is reported
before the program runs.
Only what the source imports is fetched. kora.lock pins the commit;
kora.sums records what that commit contained.
Python and MCP bridges
use python statistics as stats
use mcp github as ghThese bridges keep outside values separate from live Kora objects and keep credentials in kora.toml.
A server is a separate process, so it gets a deadline. One that does not answer ends the analyze call as Failed(reason) naming the server and the tool, rather than ending the run. Tool calls are not retried: a timeout is precisely when whether the effect ran is unknown.
Configuration
The project configuration file is kora.toml:
[models]
default = "local:llama3.2:latest"
[budget]
program_max_tokens = 2_000_000
[output]
classified_placeholder = "[private]"
[install]
jobs = 16
[dependencies.receipts]
git = "github.com/org/receipts"
tag = "v0.3.1"
grants = { net = true }
[mcp]
timeout_secs = 60
max_retries = 2
[mcp.github]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
timeout_secs = 120
[telemetry]
exporter = "file"For the complete setup path, read Installation.