Skip to Content
Language reference

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.5

kora 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 -= 1

The 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]) # 3

range(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, 2

str(value), int(value), float(value), bool(value)

Convert ordinary values.

int("42") # 42 float("4.5") # 4.5 str(42) # "42" bool([]) # False

abs(number)

Returns the positive magnitude.

abs(-12) # 12

min(values) and max(values)

Accept a list or two or more values.

min([4, 2, 9]) # 2 max(4, 2, 9) # 9

sum(list)

Adds the values in a list.

sum([10, 20, 30]) # 60

sorted(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 0

Branches 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 data

Your 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 gh

These 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.

Last updated on