# Harn > Harn is a pipeline-oriented language and runtime for orchestrating AI agents. Harn is pre-1.0. Prefer this file and the per-page `.md` URLs over scraping HTML. If the intended version is unclear, clarify before using these pages. Website: https://harnlang.com/ --- # Harn > Harn is a programming language and runtime for building AI agents. Model calls, tools, retries, concurrency, transcripts, and workflows are language and standard-library... Website: https://harnlang.com/introduction.html This page documents Harn, which is pre-1.0. Language, standard library, and CLI APIs may change. If the intended version is unclear, clarify before using this page. --- # Harn Harn is a programming language and runtime for building AI agents. Model calls, tools, retries, concurrency, transcripts, and workflows are language and standard-library features, so programs need less orchestration glue. ```harn,check title="example.harn" fn main(harness: Harness) { const response = harness.llm.call( "Explain quicksort in two sentences.", "You are a computer science tutor.", { provider: "mock" } ) harness.stdio.println(response.text) } ``` That runs with no API key: the `mock` provider is deterministic and offline. [Getting started](./getting-started.md) installs Harn and runs it. ## When Harn helps Harn helps when you want agent behavior to read like a program: which model runs, when a tool fires, how a failure is handled, and what the run records. You do not need experience with an agent framework. Familiarity with one programming language or with large language models (LLMs) will make some terms feel familiar, but it is not required. For one model call, an existing SDK may be enough. Harn becomes more useful as your program gains tools, retries, multiple providers, concurrency, replay, or long-running work. Harn also includes a [portal](./portal.md) for inspecting persisted runs. ## Building blocks Harn supports one model request, an agent loop, or a multi-stage workflow. Start with the smallest building block that fits the task: | Need | Start with | |---|---| | One request and one response | [`harness.llm.call`](./llm/llm_call.md) | | A model that can use tools across turns | [`agent_loop`](./llm/agent_loop.md) | | Named stages with joins and retries | [`workflow_execute`](./workflow-runtime.md) | Put any of these in [`fn main`](./language-basics.md) or a named [pipeline](./language-basics.md#pipelines). Add a larger abstraction only when the program needs it. [The expressiveness spectrum](./concepts/expressiveness-spectrum.md) shows the same task as a model call, an agent loop, and a workflow. ## Where to go next - [Why Harn?](./why-harn.md) explains the design with the same program in Python and Harn. - [Coming from elsewhere](./concepts/sota-comparison.md) maps Harn terms to other agent tools and protocols. - The [feature matrix](./how-harn-compares.md) compares runtime guarantees across Harn, Inngest, Temporal, LangGraph, and Cursor Automations. - The [mental model](./concepts/mental-model.md) shows how Harn's parts fit. - [Common tasks](./common-tasks.md) starts from a goal you want to complete. Harn owns the reusable agent behavior: orchestration, model and tool calls, transcripts, replay and evaluation, worker lineage, and capability policy. Your application keeps its own interface, approval flow, file changes, and product data. In practice, you write the steps the agent should take. The Harn virtual machine (VM) handles provider adapters, retries, transcripts, and runtime policy. [The host boundary](./host-boundary.md) explains the full split. ## Harn at a glance
.harn for programs.
.harn.prompt for prompt templates.
`.
Two related rules fire once the handles are in place. `HARN-LNT-069` reports a
function holding more authority than it uses, and points at the narrower handle
to take instead. `HARN-LNT-070` reports a public function taking four or more
positional parameters of the same type, where a caller can swap two and still
typecheck.
### Staging a large upgrade
An unmigrated call is a hard error, so a large codebase cannot land the upgrade
one package at a time without help. Set `HARN_LEGACY_AMBIENT_CAPABILITIES=1` to
let the removed globals resolve while you convert:
```bash
HARN_LEGACY_AMBIENT_CAPABILITIES=1 harn check ./src
```
Strict enforcement is the default and stays on unless the variable is set to
`1`, `true`, `yes`, or `on` — an unset, empty, or `0` value keeps failing
closed. The bridge only recognizes names that the typed registry already owns,
so a misspelled global is still undefined with the bridge on.
This is a transition aid for staging a migration across many packages, not a
supported long-term mode. Drop the variable as soon as `harn check` passes
without it; that passing run is what proves the migration is done.
### `host_call` has no single replacement
`host_call` is the one removal on this page that a table cannot cover, because
it was a generic dispatcher rather than one operation. It is declared
`privileged_wire`: the runtime and the host bridge still call it, but Harn
source cannot name it. `HARN-LNT-072` reports each remaining call site.
In practice most calls do have a destination, because the namespace in the
`host_call` string is usually a capability name:
```harn
host_call("ast.outline", {path: p}) // before
harness.ast.outline(p) // after
```
You do not have to look most of them up. When the operation name is a string
literal, `HARN-LNT-072` resolves it against the declared contracts and names the
destination in the diagnostic, spelling differences included — a
`prmonitor.run_commands` target reports `harness.pr_monitor.run_commands`. A
computed operation name cannot be resolved, so those calls get the generic route
and you look them up with `harn contracts builtins`, which lists every declared
`harness..` with its handle type.
Arguments do not carry over unchanged, and the diagnostic does not guess them:
`host_call` packed one dict keyed by the host's parameter names, and a typed
method takes the parameters its own signature declares.
An operation with no declared contract has no capability method to move to.
Register it with `register_callable_host_operation`, install the callable root
with `apply_callable(root)`, and reach it as `.` with an
argument shape you choose.
Scope the work with `harn lint`, not `harn check`. The checker reports an
undefined name; the linter reports which surface replaced it, and
`harn fix --apply --safety surface-changing` rewrites the calls that have a
mechanical repair.
Run `harn check` on every file `harn fix` touched before committing the result.
Since [#6161] the apply path groups every edit for a file by canonical path and
parse-checks the candidate before writing, so it no longer places an inserted
argument at a stale byte offset ([#6148]) and no longer reports success over
source the parser rejects. One defect is still open: [#6149] over-attenuates
connector runtime exports, and the result looks plausible in a diff. Applying
per file, or in small batches, keeps the blast radius readable.
Read the output on stderr, not stdout. `harn lint` and `harn check` write every
human-readable line there — findings and the clean-file line alike — so
`2>/dev/null` silences a sweep rather than trimming it.
[#6148]: https://github.com/burin-labs/harn/issues/6148
[#6149]: https://github.com/burin-labs/harn/issues/6149
[#6161]: https://github.com/burin-labs/harn/pull/6161
## LLM call options
The `harness.llm.call` family, streams, and `agent_loop` dispatch now share one option
registry. Update option dicts before upgrading the runtime; stale computed dicts
fail at the call boundary instead of losing fields during projection.
### Update a call
1. Replace each removed top-level key using the table below.
2. Move provider-specific knobs below `provider_options.`.
3. Run `harn check ` to find removed names in literals.
4. Exercise any computed option builders. Runtime validation covers shapes the
checker cannot know statically.
```harn
// Before
const old = harness.llm.call(prompt, nil, {
output_schema: schema,
output_validation: "error",
reasoning_effort: "high",
system_prefix: "Be precise.",
timeout: 120,
ollama: {num_ctx: 32768},
})
// After
import {system_before} from "std/llm/prompts"
const current = harness.llm.call(prompt, nil, {
output: {schema: schema, validation: "error"},
effort: "high",
system: [system_before("Be precise.")],
timeout_ms: 120000,
provider_options: {ollama: {num_ctx: 32768}},
})
```
### Replacement table
| Removed | Replacement |
|---|---|
| `schema`, `json_schema`, `output_schema` | `output: schema` or `output: {schema, ...}` |
| `response_format`, `output_format` | `output: "json"` or `output: {schema, ...}` |
| `output_validation` | `output: {schema, validation: ...}` |
| `schema_stream_abort` | `output: {schema, stream_abort: ...}` |
| `llm_repair` | `repair` |
| `system_preamble`, `system_prefix`, `system_context` | `system: [{content, position: "before"}]` |
| `system_appendix`, `system_suffix` | `system: [{content, position: "after"}]` |
| `system_prompt_parts` | `system` |
| `caps`, `project_context_profile` | `capabilities`, `context_profile` |
| `api`, `role` | `api_mode`, `model_role` |
| `prefer`, `fallback_strategy`, `strategy` | `route_policy` |
| `model_ladder` | `models` for inline steps; `ladder` for a catalog name |
| `budget_usd` | `budget: {max_cost_usd: ...}` |
| `reasoning_effort` | `effort` |
| `thinking_policy`, `problem_scale`, `task_kind`, `task` | `reasoning_policy`, `reasoning_scale`, `reasoning_task` |
| `hosted_tools` | `provider_tools` |
| `response_store`, `responses_store` | `store` |
| `timeout`, `idle_timeout` | `timeout_ms`, `idle_timeout_ms` (milliseconds) |
| `fast` | `speed: "fast"` |
| top-level provider names such as `openai` or `ollama` | `provider_options: {openai: {...}}` |
| `transcript` | open/resume a session and pass `session_id` |
The runtime no longer creates internal `response_format` or `json_schema`
mirrors after parsing `output`. Provider dialects lower the typed `output`
value directly. Code that constructs computed option dicts must therefore use
the replacement above; no later adapter projection restores a removed key.
The complete live surface is in the [`harness.llm.call` reference](../llm/llm_call.md#options-dict).
### Check portable options against the selected route
`top_logprobs` was removed. Put the alternative count in the value that
enables log probabilities:
```harn
// Before
{logprobs: true, top_logprobs: 3}
// Now
{logprobs: {top: 3}}
```
Harn no longer treats an unsupported portable generation or prompt-cache
option as a hint that an adapter may discard. Caller-selected `temperature`,
`top_p`, `top_k`, `logprobs`, `logit_bias`, `min_p`, `repetition_penalty`,
`prediction`, `verbosity`, `mirostat`, `seed`, `frequency_penalty`,
`presence_penalty`, `stop`, `parallel_tool_calls`, `cache: true`, and
`prompt_cache_ttl` are admitted before provider transport. An explicit
incompatibility throws a terminal `invalid_request` error.
Run `harn provider catalog matrix` to inspect supported routes. Remove the
portable option, choose a compatible route, or move a genuinely provider-native
control below `provider_options.`. Provider-native spellings of a
first-class field are rejected there. Catalog defaults do not count as caller
intent. Unknown custom routes remain open-world for the original scalar
controls, but advanced generation controls require an authored
`advanced_generation_options` lowering. Custom prompt-cache routes must declare
`prompt_caching` and any selectable `prompt_cache_ttls` in `harn.toml` because
their wire lowering is provider-specific.
## `llm_retries` / `llm_backoff_ms` (removed)
The per-call transient-retry options on `harness.llm.call`-family surfaces and
`agent_loop` are gone. `harness.llm.call` and `agent_loop` are now fail-fast on
transient provider errors (429 / 5xx / connection); retry policy is
composed on the caller seam with `with_retry` from `std/llm/handlers`.
**Mind the off-by-one**: `llm_retries: K` counted retries *after* the
first attempt, while `with_retry`'s `max_attempts` counts *total*
attempts, so `llm_retries: K` → `with_retry(..., {max_attempts: K + 1})`.
### Before (0.9)
```harn,ignore
const r = harness.llm.call(
"prompt", nil, {llm_retries: 2, llm_backoff_ms: 500},
)
```
### After (0.10)
```harn,ignore
import {default_llm_caller} from "std/llm/caller"
import {with_retry} from "std/llm/handlers"
const caller = with_retry(
default_llm_caller(), {max_attempts: 3, base_ms: 500},
)
const envelope = caller({prompt: "prompt", system: nil, opts: {}})
```
For `agent_loop`, wire the composed caller into the `llm_caller:` seam:
```harn,ignore
const result = agent_loop(harness, task, system, {
loop_until_done: true,
llm_caller: with_retry(default_llm_caller(), {max_attempts: 3}),
})
```
Behavior changes that ride along:
- **`agent_loop` profiles no longer default to two transient retries.**
The pre-0.10 profiles injected `llm_retries: 2`; loops that relied on
that implicit resilience must opt in via `llm_caller:` + `with_retry` —
or build their options with `agent_preset(kind, ...)`, which bakes the
equivalent bounded transport retry (`with_retry`, `max_attempts: 3`)
onto the effective `llm_caller:` by default (`retry: false` opts out).
- **The zero-token empty-completion retry is now a fixed built-in**: one
silent retry per call for provider-shaped routes (none for the `mock`
provider, whose scripted turns must stay deterministic). It is no
longer widened by `llm_retries`.
- The proactive per-route rate limiter, shared 429/overload cooldowns,
the network circuit breaker, and the one-shot tool-channel /
stream-transport degrades are unchanged — they never depended on the
removed options.
## Model policy now comes from catalog and capability data
`std/llm/defaults`, built-in `agent_preset` rows, and `std/agent/sitrep` no
longer branch on model IDs, providers, or model lineages. The runtime
capability matrix owns reasoning lowering, while named `[model_ladders.*]`
catalog rows own ordered provider/model routes.
Calls to `pack_for` keep the same public signature. If you depended on its old
family-specific temperature or output-token calibration, set those portable
options explicitly or author them as model defaults in the catalog. The
canonical reasoning controls remain `reasoning_policy`, `reasoning_scale`, and
`reasoning_task`; explicit `thinking` or `effort` still bypasses policy
lowering.
Built-in agent presets now return `ladder: "agent_frontier"` or
`ladder: "agent_cheap"` instead of copying `provider` plus `models` rows into
the options dict. Treat route selection as an ownership group rather than
inspecting that old projection:
```harn,ignore
import {agent_preset} from "std/agent/presets"
const opts = agent_preset(harness, "audit")
const routes = harness.llm.model_ladder(opts.ladder).steps
```
`agent_sitrep_preferred_routes` now takes `harness.llm` because it projects the
catalog-owned `sitrep` ladder:
```harn,ignore
const routes = agent_sitrep_preferred_routes(harness.llm)
```
## `std/agent/stack` (removed)
The `std/agent/stack` module and its composition bundle are gone:
`agent_stack`, `agent_llm_caller`, `agent_tool_stack`,
`agent_stack_audit_line`, and `agent_stack_model_policy` no longer
exist. Compose the pieces directly:
- Model/route resolution: `agent_model_options` and
`agent_sanitize_model_options` survive and now live in
`std/agent/options` (same signatures; the `audit.kind` label changed
from `agent_stack.model_options` to `agent_model_options`). They are
ordinary module exports now, not ambient builtins — add
`import {agent_model_options} from "std/agent/options"`.
- LLM middleware: `compose` / `with_retry` / `with_logging` /
`with_cache` / `with_budget` from `std/llm/handlers` on the
`llm_caller:` seam.
- Tool middleware: `with_required_reason`, `compose_tool_callers`, and
`tools_use_middleware` from `std/llm/tool_middleware` on the
`tool_caller:` seam.
- `agent_stack_model_policy`'s projection is a `pick_keys(options,
[...], {drop_nil: true})` call with the stable model-policy keys.
### Before (0.9)
```harn,ignore
import {agent_stack} from "std/agent/stack"
const stack = agent_stack({
role: "planner", retry: {max_attempts: 3}, required_reason: true,
})
const result = agent_loop(
harness, task, system, stack.options + {loop_until_done: true},
)
```
### After (0.10)
```harn,ignore
import {agent_model_options} from "std/agent/options"
import {default_llm_caller} from "std/llm/caller"
import {with_retry} from "std/llm/handlers"
import {
compose_tool_callers, with_required_reason,
} from "std/llm/tool_middleware"
const route = agent_model_options({role: "planner"})
const mw = with_required_reason({})
const result = agent_loop(harness, task, system, route.options + {
loop_until_done: true,
llm_caller: with_retry(default_llm_caller(), {max_attempts: 3}),
tool_caller: compose_tool_callers([mw.caller]),
})
```
## `*_agent` preset wrappers and `agent_budget` (removed)
The eight one-line wrappers in `std/agent/presets` — `audit_agent`,
`repair_agent`, `summary_agent`, `verify_agent`, `merge_captain_agent`,
`review_captain_agent`, `oncall_captain_agent`,
`release_captain_agent` — are gone. `agent_preset(kind, options?)` is
the single preset surface; spread its result into `agent_loop`:
### Before (0.9)
```harn,ignore
import {audit_agent} from "std/agent/presets"
const result = audit_agent("Audit the release", {tools: tools})
```
### After (0.10)
```harn,ignore
import {agent_preset} from "std/agent/presets"
const opts = agent_preset("audit", {tools: tools})
const result = agent_loop(
harness, "Audit the release", opts?.system, opts,
)
```
`agent_budget(kind_or_options, overrides?)` is also gone. Preset kinds
already install their default `iteration_budget`; to override, pass an
`iteration_budget` dict (or the `"adaptive"` string sugar) directly:
```harn,ignore
const opts = agent_preset("repair", {
iteration_budget: {mode: "adaptive", initial: 4, max: 24, extend_by: 2},
})
```
## `transcript_policy` (verified gone)
The pre-0.7 `transcript_policy` dict on workflow nodes and the
`workflow_set_transcript_policy` builtin were already removed in 0.7 —
[sessions](../sessions.md) are the sole surface (see
[Migrating from 0.6.x to 0.7.0](./v0.7.md)). 0.10 re-verified that no
acceptance remains. Note that the worker-resume transcript policy
(`continue_transcript` on worker resume/replay) and the task-plan IR's
forward-declared `transcript_policy` metadata field are different,
still-supported mechanisms.
## `harn_vm::mcp_protocol` moves to stable MCP (Rust embedders)
This section is only for Rust code that embeds `harn-vm` and drives MCP
itself. `.harn` scripts, the CLI, and `harn serve` are unaffected — they
never named these symbols.
0.10.53 moved the MCP client and server onto stable `rmcp` 3.1 and made
MCP `2026-07-28` the sole Harn-owned server contract. The release-candidate
surface went with it: there is no negotiation left, so the types that carried
a negotiated result no longer carry anything.
### `PROTOCOL_VERSION` kept its name and changed its meaning
Read this one before the rename table. There used to be four version
constants, and `PROTOCOL_VERSION` was the *newest stable* one:
| Constant | Before | After |
| --- | --- | --- |
| `PROTOCOL_VERSION` | `"2025-11-25"` | `"2026-07-28"` |
| `DRAFT_PROTOCOL_VERSION` | `"DRAFT-2026-v1"` | removed |
| `LEGACY_2025_06_18_PROTOCOL_VERSION` | `"2025-06-18"` | removed |
| `LEGACY_2024_11_05_PROTOCOL_VERSION` | `"2024-11-05"` | removed |
So `PROTOCOL_VERSION` still compiles and now means something else. Code that
branched on "draft versus stable" and reached for `PROTOCOL_VERSION` on the
stable arm silently becomes correct-by-collapse; code that used it as "the
older version we still accept" silently becomes wrong. Grep for it rather than
trusting the compiler.
### Renamed
The `RC_META_KEY_*` values are unchanged — those are pure constant renames. The
`RC_HEADER_*` values are **not**: see the casing trap below.
| Removed | Replacement |
| --- | --- |
| `DRAFT_PROTOCOL_VERSION` | `PROTOCOL_VERSION` (different value — see above) |
| `rc_name_header_value` | `standard_name_header_value` |
| `negotiate_rc_http_request` | `negotiate_http_request` |
| `apply_rc_result_envelope(result, mode, cache)` | `apply_result_envelope(result, cache)` |
| `RC_HEADER_PROTOCOL_VERSION` | `MCP_HEADER_PROTOCOL_VERSION` |
| `RC_HEADER_METHOD` | `MCP_HEADER_METHOD` |
| `RC_HEADER_NAME` | `MCP_HEADER_NAME` |
| `RC_META_KEY_PROTOCOL_VERSION` | `MCP_META_KEY_PROTOCOL_VERSION` |
| `RC_META_KEY_CLIENT_INFO` | `MCP_META_KEY_CLIENT_INFO` |
| `RC_META_KEY_CLIENT_CAPABILITIES` | `MCP_META_KEY_CLIENT_CAPABILITIES` |
### Removed outright
- `McpProtocolMode`, with `is_modern()` and `default_protocol_version()`.
There is one protocol, so there is no mode to branch on. Any struct of yours
that stored a mode alongside a request and response version can usually be
deleted rather than ported: `negotiate_http_request` rejects any header that
is not `PROTOCOL_VERSION`, and `enforce_request_protocol_version` rejects any
`_meta` version that is not the same constant, so all three fields were
provably that one constant by the time you could read them. Keep the check;
drop what it returned.
- `MCP_SESSION_HEADER_LEGACY`. Sticky session ids are gone from both request
and response, so assertions that a response *lacks* the header now have
nothing to assert.
- The `initialize` method. `server/discover` is the sole lifecycle handshake.
### Changed signatures and behavior
- `enforce_request_protocol_version` returns `Result<(), JsonValue>` rather
than `Result