Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

The expressiveness spectrum

One task. Five levels of control. The point of this page is a promise: the easy case stays a few lines, and when you need more control you reach for it without throwing away what you already wrote. Every level is the same primitives composed one step further. You never pay for machinery you don't use.

The task, held fixed the whole way down: take a bug report and produce a fix. At the top that means "guess the likely cause in a sentence." At the bottom it means "edit the code, run the test, and don't stop until an independent check says the test is green, and let the host (which can see the test runner) tell the agent when it's stalling." Same job, more control.

Read top to bottom once to see the shape. Then use it as a menu: pick the highest line on the page that still says "yes, that's all I need."

Level 1: one call

You have a bug report and you want a fast read on it. Nothing to edit yet, no tools, no loop. One request:

const hint = llm_call(
  "Bug: add(2,2) returned 5. One sentence: what's the likely cause?",
  "You are a careful Rust engineer.",
  {provider: "mock"},
)
log(hint.text)

Three lines. llm_call sends one prompt and hands back a result dict. This is the floor, and most classification, summarization, and extraction never needs to leave it. If your whole job is "read this, tell me that," stop here.

Level 2: make the answer a shape you can trust

hint.text is prose. The moment you want to branch on the answer in code (route by severity, file under a component), free text is a liability. Ask for a schema instead and get back typed data, validated, with a retry on the model's first malformed attempt:

const triage = llm_call_structured(
  "Triage this bug: add(2,2) returned 5 in src/math.rs.",
  {
    type: "object",
    required: ["severity", "component"],
    properties: {
      severity: {type: "string"},
      component: {type: "string"},
    },
  },
  {provider: "mock"},
)
log(triage.severity)
log(triage.component)

Same call underneath. llm_call_structured just pre-applies the schema-validated-JSON defaults and re-asks once if the model returns something off-shape, so triage.severity is a string you can switch on instead of a paragraph you have to parse. You climbed one rung and wrote two extra lines.

Level 3: let it actually do the work

Reading and classifying is one model call. Fixing the bug is not. The agent has to read the file, edit it, run the test, look at what the test said, and decide what to do next. That is a loop, and you should not hand-write it.

agent_loop is the loop. But you don't have to configure it by hand. agent_preset(kind) hands you a tuned options dict for a common shape. Here that's "repair": a tool-using agent with a sane iteration budget, stall detection, and bounded transport retry already set.

import { agent_preset } from "std/agent/presets"

const opts = agent_preset("repair", {provider: "mock"})
const run = agent_loop(
  "Fix the failing test test_add in src/math.rs, then run it to confirm.",
  opts?.system,
  opts,
)
log(run.status)
log(run.visible_text)

agent_preset is not a new tier. It returns a plain agent_loop options dict and your explicit keys always win, so it's a starting point you customize, never a wall. The built-in kinds are audit, repair, summary, verify, and the four captains (merge_captain, review_captain, oncall_captain, release_captain). Register your own with agent_preset_register when a shape recurs in your codebase.

Most agents want exactly this: a preset, your tools, go. If the defaults fit, you're done at Level 3.

Level 4: tune the loop by composing building blocks

Sometimes the defaults don't fit and you need to shape how the loop behaves: cap its spend, hide tools it shouldn't touch, add a real gate on "done," nudge its prompt. The mistake here is to write that logic inline in a loop body you hand-rolled. Don't. Each concern has one home module, and each returns a value you fold into the same agent_loop options dict. You compose them; the loop stays the loop.

import { agent_preset } from "std/agent/presets"
import { with_governance } from "std/agent/governors"
import { agent_completion_gate } from "std/agent/judge"
import { lane_policy } from "std/agent/lanes"
import { with_overlay } from "std/agent/overlays"

// Start from the preset, then layer control onto it.
let opts = agent_preset("repair", {provider: "ollama", model: "qwen3-coder", tools: repair_tools})

// Bound cost and cadence; add a stall detector that fires when the agent
// keeps editing but nothing verifies as progress.
opts = with_governance(opts, {
  governor: {budget: 40.0, signal: "iterations"},
  detectors: {no_progress: {messages: 3}, stuck: {same_diagnostic: 3}},
})

// An independent "are we actually done?" gate, not the model's own say-so.
opts = {...opts, ...agent_completion_gate({
  require: { result -> contains(to_string(result?.text), "test result: ok") },
})}

// Hide tools this task can't need, and fill in a prompt nudge — both are
// fill-nil, so they never override anything you set explicitly.
opts = lane_policy(lane_rows, task, opts)
opts = with_overlay(opts, overlay_rows, "repair")

const run = agent_loop(task, opts?.system, opts)

Every one of those lines is opt-in. Governors bound spend (std/agent/governors). Detectors catch loops and stalls. The completion gate (std/agent/judge) is a deterministic veto plus an optional bounded judge, so "done" is a fact you control, not a mood the model is in. Lanes narrow the tool surface; overlays add data-driven prompt nudges (std/agent/lanes and std/agent/overlays). Leave any of them out and the loop still runs. You add guardrails one at a time, each from its own module, none of them a new hook on the loop. The placement contract is the full map of which concern lives where.

Level 5: full control, and let the host feed in what it can see

The ceiling. You want more than one attempt, an independent verify stage between attempts, and (the part only a workflow reaches) the ability to run each attempt as your own code and to let the host tell the loop things it cannot see on its own. This is the level Burin builds on.

Two seams open up here.

The executor closure. A stage's executor runs the attempt as a plain Harn closure instead of delegating to a spawned worker. It receives the attempt's context and returns a result; a throw counts as a failed attempt and feeds the retry. You own what happens inside a try:

{
  id: "act",
  kind: "stage",
  retry_policy: {max_attempts: 3, feedback: true},
  executor: { ctx ->
    // ctx = {task, attempt, prior_findings, prior_verification, prior_text, artifacts}
    const patched = my_patch_step(ctx.task, ctx.prior_findings)
    return {text: patched.summary, artifacts: patched.artifacts}
  },
}

Host-fed facts. The stall detector owns the mechanism: when to warn, when to cut a run for spinning in place. But only the host can see whether the test runner actually moved. So the host feeds that fact in through a callback. The loop supplies the payload; you return the one number that matters:

// "Writes are not progress." Return the turn's best verify-state — say, the
// count of passing tests. nil means "no verification evidence this turn."
const opts = {...base, stall_diagnostics: {
  progress_signal: { payload -> host_passing_test_count() },
}}

Now "the agent edited a file" and "the build got closer to green" are two different facts, and the loop stops mistaking motion for progress. Those callbacks (progress, delivered-fix-not-landing, pace decisions) are covered in Host-supplied facts. Wrap the whole thing in workflow_stages for the verify stage, retry threading, replay, and audit, and you have the full machine.

import { workflow_stages } from "std/workflow/patterns"

const graph = workflow_stages({
  name: "fix-the-test",
  stages: [
    {id: "act", kind: "stage", mode: "agent",
      retry_policy: {max_attempts: 3, feedback: true},
      executor: my_executor},
    {id: "check", kind: "verify", mode: "command",
      verify: {command: "cargo test test_add --quiet", expect_status: 0}},
  ],
})
const run = workflow_execute("Fix the failing test.", graph)

The through-line

Five levels, one task, and each level is the level above it with one more thing composed on:

LevelReach forYou getYou write
1llm_callone answer3 lines
2llm_call_structuredtyped, validated output+2 lines
3agent_preset(kind) + agent_loopa tuned tool-using agent+1 import
4agent_loop + governors / judge / lanes / overlaysbounded, gated, narrowed controlone fold per concern
5workflow_stages + executor + host-fed factsattempts, verify gates, replay, your code in the loopa closure and a callback

You are never forced up the table, and moving up never means a rewrite. The call you wrote at Level 1 is still the call running underneath at Level 5. That is the whole design: the easy case is easy, and the hard case is the easy case with more composed onto it.

See also