Typed model calls
analyze(data, "prompt") is Kora’s model primitive. The type on the left tells the model what shape to return.
type Expense:
merchant: str
amount: float
category: str
policy_violation: bool
def main():
row = "Steakhouse dinner, $680, client entertainment"
result: Expense = analyze(
row,
"extract the expense; flag meals over $200"
)
match result:
case Ok(expense):
if expense.policy_violation:
print(f"FLAG: {expense.merchant} ${expense.amount}")
case Uncertain(reason):
print(f"Skip it: {reason}")No raw JSON parsing. No confidence float to interpret. A model can give you a useful answer or say it is not sure.
Streaming prose
Annotate an answer as str and add stream to show it as it arrives, while
still receiving the same outcome to handle afterward:
answer: str = analyze(question, "answer in two sentences") stream
match answer:
case Ok(text):
print(text)
case Failed(why):
print(f"stream ended early: {why}")stream is for the normal terminal-output case. Use on token(piece): only
when each piece needs custom handling, and write inside it — print without
the newline — so one answer does not become as many lines as the model sent
pieces:
answer: str = analyze(question, "answer in two sentences") on token(piece):
write(piece)
print("")Both forms require str; typed results arrive as JSON and cannot be usefully
streamed as prose. stream and on token cannot be combined, and neither can
be combined with tools or with parallel for — those are refused rather than
silently degraded.
A stream that breaks after writing is Failed, and the characters already
written stay on screen — a program can print output and then take the failure
arm. It is never retried either: once a piece has reached the program, a
second attempt would write the answer twice over output that has already been
acted on.
Under --durable, a killed run follows the same rule. A stream interrupted
after some of its answer was written is not sent to the provider again: the
resume returns Failed, and the pieces on screen are not repeated. One
interrupted before anything was written showed nobody anything, so the resume
sends it again like any other unfinished work.
Images
The data argument can be a picture. fs.image loads one, and the same typed result comes back:
use fs
match fs.image("dataset/0.png"):
case Ok(picture):
expense: Expense = analyze(picture, "read this receipt", model="vision")
case Err(why):
print(why)PNG, JPEG, GIF, and WebP. No base64 and no hand-built request body — an image is a value, so it also crosses a parallel for boundary like anything else.
When nothing answers
A model that declines is Uncertain. Something that never answers at all is Failed — a refused connection, a timeout, a rate limit that outlasted the retries, or an MCP tool server that went quiet mid-call.
match analyze(issue, "triage this", tools=gh.tools):
case Ok(t):
print(t.summary)
case Uncertain(why):
print(f"the model declined: {why}")
case Failed(why):
print(f"nobody answered: {why}")The two are kept apart because they are fixed in different places: one is a prompt problem, the other is an outage. Neither raises.
Every MCP request carries a deadline, [mcp] timeout_secs, default 60 and settable per server for one that reaches a slow API. A tool call is never retried — generating twice costs tokens, but calling a tool twice may open two issues or charge a card twice, and a timeout is exactly the case where whether it ran is unknown.
Context policies
Use with context(max_input_tokens = N, reserve_output_tokens = N): around an
analyze call to bound its estimated input independently of spend budgets.
Kora retains only newest complete tool exchanges that fit, and returns
Failed(reason) rather than clipping prompt or data. Tool output is explicitly
untrusted reference data, not a source of higher-priority instructions.
Try it with a cassette
For a live run, configure an OpenAI key or a local Ollama model in kora.toml.
kora run --record examples/01_expense_check.ko
kora run --replay examples/01_expense_check.koThe second command uses the saved response. It is fast, free, and deterministic, which makes it a nice default for tests and CI.
Where providers fit
Kora currently supports OpenAI and Ollama. Pick the default in kora.toml:
[models]
default = "local:llama3.2:latest"
smart = "openai:gpt-4o"
vision = "local:moondream"
timeout_secs = 600A call reaches for one by name — analyze(..., model="vision") — so which model does the work is a config change, not a code change. A local vision model is slow, hence timeout_secs.
Keep prompts short and let the result type do more of the work.