# 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
Paradigm
Pipeline-oriented, imperative, with structured concurrency
Typing
Gradual and structural. Annotations are optional everywhere.
Implemented in
Rust, as a lexer, parser, type checker, and tree-walking VM
Runs on
macOS and Linux on Intel and ARM, Windows on x86-64. Platform support has the detail.
File extensions
.harn for programs. .harn.prompt for prompt templates.
Speaks
MCP, ACP, and A2A, natively
License
MIT or Apache-2.0, at your option
Maturity
Pre-1.0. Surface-level breaking changes are possible between minor and patch releases. See the changelog.
## Links - [Language specification](./language-spec.md) - [GitHub repository](https://github.com/burin-labs/harn) --- ## Read next - [Start here](https://harnlang.com/concepts/index.md) --- # Concepts > These pages explain how Harn fits together. They do not replace the syntax reference or a task guide. Website: https://harnlang.com/concepts/index.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. --- These pages explain how Harn fits together. They do not replace the syntax reference or a task guide. ## Read first | If you want to... | Read... | |---|---| | See how the main pieces fit together | [Mental model](./mental-model.md) | | Choose between a call, loop, workflow, or worker | [Choosing an abstraction](./abstraction-ladder.md) | | Find the exact meaning of a Harn term | [Glossary](./glossary.md) | | Understand the Harn/host boundary | [Host boundary](../host-boundary.md) | | Compare Harn terms with other agent tools | [Coming from elsewhere](./sota-comparison.md) | ## If you need to ship code - [Getting started](../getting-started.md) — install Harn and run a first program. - [Language basics](../language-basics.md) — learn the syntax. - [LLM calls and agents](../llm-and-agents.md) — choose a model-backed API. - [Common tasks](../common-tasks.md) — follow a focused implementation path. --- ## Read next - [Introduction](https://harnlang.com/introduction.md) - [Mental model](https://harnlang.com/concepts/mental-model.md) --- # Mental model > Think of a Harn program as a task moving through a model, tools, and a recorded run. Add a loop or a workflow only when the task needs it. Website: https://harnlang.com/concepts/mental-model.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. --- Think of a Harn program as a task moving through a model, tools, and a recorded run. Add a loop or a workflow only when the task needs it. ```mermaid flowchart TD accTitle: How work moves through a Harn program accDescr: A Harn program optionally enters a workflow, then an agent loop. The loop makes a model call; if a tool is needed the model runs a Harn capability and the loop repeats, otherwise the loop produces a result. Every model call also records a transcript and usage. P[Harn program] --> W[Optional workflow] W --> L[Agent loop] L --> C[Model call] C --> T{Tool needed?} T -->|yes| H[Harn capability] H --> L T -->|no| R[Result] C -.-> X[Transcript and usage] ``` ## The building blocks | Building block | What it does | |---|---| | **Model call** | Sends one request and returns one response. Use `harness.llm.call`. | | **Tool call** | Runs a capability that the model selected or that your program called. | | **Agent loop** | Repeats model and tool turns until the task reaches a terminal state. Use `agent_loop`. | | **Workflow** | Names stages and their dependencies. Use it when several steps must be inspected, joined, retried, or resumed. | | **Pipeline** | The top-level Harn program that owns the run. | | **Transcript** | The structured record of model messages, tool calls, events, and results. | | **Worker** | A child execution context that can run its own loop and transcript. | These pieces are composable. A workflow can contain agent loops. An agent loop can call tools. A pipeline can run several workers in parallel. ## Sessions and transcripts A session gives related calls a durable conversation boundary. A transcript is the record inside that boundary. Keep a session when later calls need earlier messages; use a fresh session when the tasks should not share context. Transcripts also support rendering, replay, evaluation, and audit. The host can choose how to display them without taking ownership of their lifecycle. ## Workers Use a worker when a parent program must delegate independent or long-running work. A worker has its own loop and transcript. The parent can wait for it, send input, suspend it, resume it, or close it. Workers are an orchestration boundary, not a replacement for your host's UI or approval system. See [delegated workers](../llm/agent_loop.md#delegated-workers) and [the host boundary](../host-boundary.md). ## Choosing a level Start with [`harness.llm.call`](../llm/llm_call.md). Move up the stack only when you need the behavior in the next row: 1. One response: model call. 2. Several model/tool turns: agent loop. 3. Named dependent stages: workflow. 4. Independent or long-running child work: worker. The [abstraction ladder](./abstraction-ladder.md) gives more examples. --- ## Read next - [Start here](https://harnlang.com/concepts/index.md) - [Glossary](https://harnlang.com/concepts/glossary.md) --- # Glossary > One-line definitions for every term Harn uses to describe a conversation, its parts, and its containers. Where two terms in Harn mean the same thing, the preferred one is... Website: https://harnlang.com/concepts/glossary.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. --- One-line definitions for every term Harn uses to describe a conversation, its parts, and its containers. Where two terms in Harn mean the same thing, the preferred one is marked; the others are alias-only. For SOTA cross-references (what LangGraph or OpenAI or ACP calls the same idea), see [Coming from elsewhere](./sota-comparison.md). ## Conversation units **LLM call.** One request to a language model. Smallest unit. Produced by `harness.llm.call`. **Token reference.** One token ID paired with its exact tokenizer vocabulary. Harn's `TokenRef` prevents a bare integer from crossing into a model that gives that integer another meaning. See [Exact token references](../llm/tokenizer.md). **Tool call.** The model's request to invoke a named tool, with arguments. Lives inside an iteration; an iteration can contain several. Executed by `agent_dispatch_tool_call` or by `agent_loop` automatically. **Iteration.** One model round-trip inside an agent loop: prompt-out, response-in, optional tool dispatch. Counted in `result.llm.iterations`. *Preferred name.* **Round-trip.** Alias for **iteration**. Used in prose; prefer "iteration" in field names and code. **Turn.** Overloaded. Prefer **iteration** for one model round-trip and **prompt turn** for the outer user-message cycle. See [Coming from elsewhere](./sota-comparison.md) for ACP terminology. **Prompt turn.** The outer cycle: one user message → final agent response, terminated by a `stop_reason`. Maps directly to ACP's `prompt_turn` and to one invocation of `agent_loop`. **Agent loop.** A function that runs iterations until completion. The `agent_loop` stdlib entrypoint owns this lifecycle. Status outcomes include `done`, `stuck`, `suspended`, `budget_exhausted`, `provider_error`, `idle`, `watchdog`, `failed`. **Daemon loop.** An agent loop that idles waiting for wake sources (triggers, timers) instead of returning when no work is pending. Same primitive, different terminal conditions. ## Containers and graphs **VM (virtual machine).** The Harn interpreter state for one running script or child task. Most user-facing docs say "interpreter instance" or "child task"; runtime and ADR pages often use VM because they describe implementation boundaries. **Portable kernel.** The authority-free Harn compiler and deterministic execution state machine shared by native and browser hosts. It accepts a versioned program artifact and returns completed, suspended, or failed. **Program artifact.** An immutable, versioned encoding of checked Harn bytecode plus the metadata needed by the portable kernel. It is data, not a serialized Rust object or a grant of host authority. **Linked program.** The closed native execution artifact inside a schema-v3 `.harnpack`. It contains the entry bytecode and only the reachable module symbols needed by that exact source graph. It is separate from the authority-free portable-kernel program artifact. **Capability request.** A typed request emitted when portable execution needs host-owned authority. The host may deny it or resume the authenticated snapshot with a matching typed result. **Run authority posture.** The typed combination of run interactivity, approval availability, and workspace trust used to construct one run approval policy before execution. **Host-materialized workspace.** An isolated workspace created by the host for one run. Its trust is a run fact and is not written to a durable per-path trust store. **Child VM.** The isolated interpreter instance created for a `spawn` or `parallel` child task. Captured values are copied into it. Explicit shared handles such as channels, shared cells/maps, mailboxes, and sync permits are the way child tasks coordinate with siblings or the parent. **Stage.** One node in a workflow graph. Kinds: `stage`, `verify`, `join`, `condition`, `fork`, `map`, `reduce`, `subagent`, `escalation`. **Workflow.** A typed, inspectable, replayable graph of stages with edges and per-node policies. Executed by `workflow_execute`. Lives above `agent_loop` when orchestration structure matters. See the [workflow runtime](../workflow-runtime.md). **Pipeline.** The `pipeline` language keyword: a named, callable, function-like composition serving as the top-level entrypoint of a `.harn` program, with lifecycle callbacks. The container in which agents, workers, and workflows run. Not itself agentic — and not the stage-graph runtime, which is a **workflow** (above). See the [pipeline lifecycle](../pipeline-lifecycle.md). **Workflow session.** The durable execution record of one `workflow_execute` run. Holds artifacts, per-stage results, and the replay trace. ## Durable state **Execution.** One top-level Harn program invocation and every child VM it creates. Its durable `hxe-...` identity is shared by local spans, run records, flight recordings, OpenTelemetry, and host projections. **Execution evidence.** The facts and artifacts Harn records about an execution. A run record is the durable index. Spans and an optional flight recording are projections or linked artifacts, not separate execution owners. **Run record.** The durable JSON index for one execution or workflow session. It carries lifecycle state, evidence identity, spans, artifacts, transcript pointers, and replay inputs that the run produced. **Flight recording.** An opt-in, bounded record of the exact VM instructions and source locations an execution reached. It omits runtime values, arguments, results, and stack contents. See [Debugging agent runs](../debugging.md#record-the-exact-code-path). **Model job.** One finite, asynchronous model request with a closed lifecycle: `queued`, `running`, `succeeded`, `failed`, or `canceled`. Harn owns its events, receipt, output storage, and replay; a backend owns provider translation. See [Why Harn has model jobs](./model-jobs.md). **Media asset.** Model output bytes stored under their SHA-256 digest. A media asset has a portable `asset://sha256/...` identity, a verified MIME type, and a current local path. See the [model-job reference](../stdlib/model-jobs.md). **Session.** The first-class VM resource that owns a transcript, subscribers, parent/child lineage, a pinned system prompt, and a pinned model. Created by `agent_session_open`. Outlives any single agent loop. Its `session_id` identifies the transcript owner; a `run_id` identifies one exact invocation within that history. **Transcript.** The structured `{messages, events, assets}` document that hangs off a session. `messages` are durable conversational turns; `events` are an audit trail; `assets` are large or non-text payloads. **Transcript event.** One entry in the `events` log. Includes `iteration_start`, `iteration_end`, tool dispatch events, reminder events, and lifecycle events. **Run report.** The versioned JSON view produced by `harn runs report` or the `harn.run.report` MCP tool. It correlates a root run with delegated child runs, timelines, trace spans, and verified transcript pointers, then reports structural checks without changing the source data. **Run review.** The versioned model assessment produced by `harn runs review` or `harn.run.review` from one validated run report. Harn can build that report in memory from a root run record. The review binds its verdict and evidence-addressed findings to the report, rubric, and resolved model route. It does not replace the run report's deterministic checks or read source artifacts itself. **Snapshot.** A frozen, serializable copy of a session or worker state, used for resume-after-suspend and for replay. **System reminder.** A typed, turn-boundary injection into the transcript. Carries a `mode` (`interrupt_immediate`, `finish_step`, `audit_only`), a `role_hint`, optional `dedupe_key`, and optional TTL. See [System reminders](../system-reminders.md). ## Hypotheses and evidence **Hypothesis.** A versioned, testable claim with an owner, provenance, and explicit evidence lane. It is not a fact or a mutable confidence label. **Evidence policy.** The typed contract that selects the registered inference mode, practical threshold, evidence ladder, claim ceiling, and explicit gate promotion. The experiment registration owns assignment and statistical decisions; the design budget owns execution ceilings. **Experiment plan.** The deterministic compilation of a hypothesis and evidence policy into an existing experiment registration. It is data, not model-authored Harn source, an executable workflow, or a grant of host authority. A registered host adapter must separately enforce approval, capability, and resource ceilings. **Hypothesis-event authority.** A non-serializable proof minted by a registered native adapter and bound to one event fingerprint, plan fingerprint, aggregate, run, and authority kind. It authorizes one specialized append to the reserved hypothesis topic. Serialized event payloads and audit headers are provenance, not authority. **Observation.** One immutable, assignment-bound measurement admitted by an evidence policy. A revised value is a new corrective event, never an in-place edit of accumulated evidence. **Decision.** A typed statistical result derived from registered evidence and a frozen policy. It does not itself mutate a product default. **Hypothesis workflow.** A read-first state machine over the canonical hypothesis ledger. It inspects current state; controls start, pause, resume, and stand-down transitions; and advances one Harn-randomized case/trial block at a time through a registered native adapter. Harn owns assignment, admission, stopping, and decision. Without the adapter, a mutating request returns `adapter_unavailable` and records no lifecycle event. **Promotion proposal.** A decision-bound request for a host-owned product change. Approval and application are separate events with separate receipts. ## Delegation **Worker.** An agent running in its own execution context with its own transcript and loop. Spawned by `spawn_agent`; can be suspended, snapshotted, and resumed. The unit of parallelism and of multi-agent orchestration. **Subagent.** A worker in a workflow context. The `subagent` node kind delegates a stage to a child agent. **Persona.** A typed multi-stage agent identity with handoff policies, profile bulletins, and per-stage tool scoping. Built on top of agent loops and sessions. **Skill.** A bundle of metadata, system-prompt fragment, scoped tools, and lifecycle hooks. Passed to `agent_loop` via the `skills:` option to match, activate, scope, and deactivate across iterations. ## Steering and lifecycle **Suspend.** Cooperatively pause a worker at the next iteration boundary. Persists a resumable snapshot. Not a sleep — the runtime honors the boundary and emits a lifecycle event. **Resume.** Wake a suspended worker, optionally with new input. **Self-park.** A worker pausing itself from inside the loop via `agent_await_resumption(reason, conditions, resume_by)`. The model decides to wait for something. **Steering.** Any out-of-band influence on a running agent: injecting a system reminder, queuing a user message, revoking a pending injection, cancelling an in-flight tool call. See [Steering seams](./steering-seams.md). **Inject mode.** The bridge-injection delivery variant for queued user messages and system reminders. Three runtime values: `interrupt_immediate` (drain at the next safe seam, including `pre_tool_dispatch` — the model's pending tool batch is skipped when one arrives there), `finish_step` (drain at the next iteration boundary), `audit_only` (drain at loop exit and append to the transcript; the model never sees these — use `finish_step` if the model must react before the agent terminates). The full seam catalog lives in [Steering seams](./steering-seams.md). **Checkpoint.** A safe point in the loop body where the runtime checks for pending steering injections. Every drain in the agent loop and the daemon idle path routes through the typed `agent_stage` seam; observers subscribe via `harness.agent.register_checkpoint_hook(kinds, handler)`. See [Steering seams](./steering-seams.md) for the canonical catalog. ## Things Harn doesn't use as nouns **Thread.** Not a Harn term. The role thread plays in Mastra and LangGraph is filled by **session** here. If you arrive from those systems, read `thread` as `session`. **Step.** Used informally in prose; the formal noun for the same concept is **stage** in workflows and **iteration** in agent loops. The Inngest-style `step.run` memoization barrier exists as a stdlib namespace for durable replay of completed handler results. **Run.** Used colloquially for "one invocation of a pipeline or workflow." Not a first-class noun in the language. Persisted runtime records still use a `run_id` as the stable identity of one exact invocation; do not substitute a session ID for it. **Phase.** Appears around pipeline lifecycle callbacks but is not a conversational-unit noun. ## Where each concept's authoritative reference lives | Concept | Reference page | |---|---| | `harness.llm.call`, `harness.llm.call_structured`, `harness.llm.completion` | [LLM calls](../llm/llm_call.md) | | `TokenRef`, `tokenize`, `detokenize`, `logit_bias` | [Exact token references](../llm/tokenizer.md) | | `agent_loop`, `AgentSpec`, profiles, `turn_end_condition` | [Agent loops](../llm/agent_loop.md) | | Tools, Tool Vault, MCP server tools | [LLM tools](../llm/tools.md) | | Sessions, fork, reset, compact, snapshot | [Sessions](../sessions.md) | | Transcripts, events, assets | [Transcript architecture](../transcript-architecture.md) | | Workers, suspend, resume, self-park | [Agent lifecycle](../agent-lifecycle.md) | | Workflows, graphs, stages | [Workflow runtime](../workflow-runtime.md) | | Pipelines, harness, lifecycle callbacks | [Pipeline lifecycle](../pipeline-lifecycle.md) | | Portable artifacts, capability requests, execute/resume | [Portable kernel contract](../portable-kernel-reference.md) | | System reminders, inject modes | [System reminders](../system-reminders.md) | | Skills | [Skills](../skills.md) | | Model jobs, receipts, media assets | [Model-job reference](../stdlib/model-jobs.md) | | Personas | [Personas](../personas.md) | | Daemon loops | [Daemon stdlib](../stdlib/daemon.md) | | Hypotheses, evidence policy, experiment plans, decisions | [ADR 0007](../adr/0007-hypothesis-compiler-ownership.md) | --- ## Read next - [Mental model](https://harnlang.com/concepts/mental-model.md) - [Execution vocabulary](https://harnlang.com/concepts/execution-vocabulary.md) --- # Execution vocabulary > The words Harn uses for an execution, its evidence, its conversation history, its external effects, and its run authority. Each entry names the preferred term and the... Website: https://harnlang.com/concepts/execution-vocabulary.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. --- The words Harn uses for an execution, its evidence, its conversation history, its external effects, and its run authority. Each entry names the preferred term and the near-synonyms to avoid, so code, receipts, events, and prose all say the same thing. For conversation-unit terms and cross-references to other frameworks, see the [Glossary](./glossary.md). ## Execution evidence **Execution**: One top-level invocation of a compiled Harn program, including all child tasks, agent turns, effects, and its terminal outcome. _Avoid_: Workflow run, session, trace **Execution fact**: An ordered, typed, redacted statement about something that occurred during one execution and can be durably replayed. _Avoid_: Telemetry event, log line, span **Execution evidence**: The complete durable fact stream for one execution plus the identity and integrity metadata needed to verify its order and terminal state. _Avoid_: Observability data, trace, event dump **Projection**: A replaceable view derived from execution evidence, such as a run record, OpenTelemetry trace, CLI event stream, Replay Lab view, or host presentation. _Avoid_: Source of truth, duplicate record **Run record**: The materialized Harn product view of one execution, derived from its execution evidence for inspection, replay, evaluation, and export. _Avoid_: Event log, workflow-only record **Flight recording**: An opt-in, bounded sequence of source locations and control-flow outcomes that shows the exact code path taken by an execution without recording values by default. _Avoid_: Opcode trace, debug log, always-on span ## Conversation history **Conversation message**: A provider-neutral durable turn. Native assistant calls use `tool_calls` with `id`, `name`, and `arguments`; native results use `tool_result` with the matching `tool_call_id`. Provider adapters project these facts onto their wire formats. _Avoid_: Provider message, Anthropic block, OpenAI message **Provider continuation**: Opaque provider-bound state required to continue a prior model turn. It is kept apart from conversation content and returned only to the provider that created it. _Avoid_: Reasoning text, message block, transcript content ## External actions **External action**: A consequential effect in a provider or account outside the current Harn workspace. _Avoid_: Tool call, transaction, side effect **Action intent**: An immutable, normalized proposal whose fingerprint covers the exact actor, provider, capability, environment, payload, and external spend. _Avoid_: Request, plan, tool arguments **Action grant**: A time-bounded authorization tied to exactly one action-intent fingerprint and its external-spend ceiling. _Avoid_: Approval, permission **Action receipt**: A durable provider-neutral record of whether an external action was confirmed, denied, not dispatched, or left indeterminate. _Avoid_: Result, response, log **Reconciliation**: A read-only provider query that resolves an indeterminate action receipt without dispatching the action again. _Avoid_: Retry, recovery ## Run authority **Prepared run**: A run whose declared requirements have been reconciled with host facts, policy, provenance, budgets, and approval availability before execution can begin. _Avoid_: Preflight, launch config **Authority requirement**: A value-free declaration of one filesystem, process, network, secret-consumer, environment, host, MCP, budget, provenance, or startup need. _Avoid_: Permission string, raw secret **Authority lease**: A time-bounded, fingerprinted authorization to execute exactly one prepared run within its granted requirements. _Avoid_: Session grant, approval receipt **Authority delta**: A typed request bound to a parent authority lease and intersected with its ceiling, without mutating or broadening the parent lease. _Avoid_: Escalation flag, policy exception **Toolchain probe**: An exact post-readiness inquiry that discovers toolchain read roots within a previously reviewed authority ceiling. _Avoid_: Preflight command, ambient path scan **Platform identity broker**: A host-side authority that exchanges a value-free identity reference for a short-lived handle bound to one provider, audience, tenant, and consumer. _Avoid_: Credential fallback, profile chain **Opaque identity handle**: A non-transferable, process-local capability to use one brokered platform identity through its declared consumer. _Avoid_: Token, secret value, bearer credential **Authority receipt**: A durable, non-authorizing record of requested, granted, used, denied, and unused run authority and its decider. _Avoid_: Authority lease, log line --- ## Read next - [Glossary](https://harnlang.com/concepts/glossary.md) - [Portable execution](https://harnlang.com/concepts/portable-execution.md) --- # Portable execution > Harn has one language implementation and more than one place to run it. The portable kernel makes WebAssembly an adapter for Harn, not a second Harn language. Website: https://harnlang.com/concepts/portable-execution.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 has one language implementation and more than one place to run it. The portable kernel makes WebAssembly an adapter for Harn, not a second Harn language. ```mermaid flowchart TD accTitle: From Harn source to a host-owned capability accDescr: Harn source and imports go through the canonical front end, which emits a versioned program artifact. The portable execution kernel runs that artifact on a native host, a browser Web Worker, or a future adapter, and every one of them reaches the same host-owned typed capabilities. source[Harn source and imports] --> frontend[Canonical lexer, parser, type checker, compiler] frontend --> artifact[Versioned program artifact] artifact --> kernel[Portable execution kernel] kernel --> native[Native Harn host] kernel --> browser[Browser Web Worker] kernel --> future[Future Component Model and edge adapters] native & browser & future --> capabilities[Host-owned typed capabilities] ``` The artifact contains checked bytecode and metadata. It contains no filesystem, network, process, clock, randomness, or model authority. Native Rust and browser WebAssembly decode the same bytes and call the same execution kernel. For a program with imports, the compiler first resolves a closed package graph. The graph is compiled in a stable order and its module identities, imports, and export projections are stored in the artifact. A host never has to re-resolve a source import after compilation: ```mermaid flowchart LR accTitle: One artifact byte stream for every adapter accDescr: The root module and its imports are resolved into a closed, sorted module closure, which becomes one artifact byte stream. The native, browser worker, and Burin host adapters all consume that same byte stream. root[Root Harn module] --> resolve[Canonical module graph resolver] dep[Imported Harn module] --> resolve resolve --> closure[Closed, sorted module closure] closure --> bytes[One artifact byte stream] bytes --> native[Native adapter] bytes --> worker[Browser worker adapter] bytes --> burin[Burin host adapter] ``` `compile(source, ...)` remains the smallest API for a self-contained module. `compilePackage(manifest, ...)` is the load-bearing API when imports are present. The browser demo's JSON manifest is generated from its checked-in `.harn` sources by `harn portable package` through `make gen-portable-demo-package`; it is a delivery projection, not a second source language. ## How the surfaces stay synchronized The ecosystem does not treat a failing drift check as the primary integration mechanism. Each relationship uses the strongest available form: | Relationship | Mechanism | |---|---| | Native VM and browser Wasm semantics | Both depend directly on `harn-kernel`; neither owns a second compiler, opcode enum, value contract, or builtin vocabulary. | | Capability methods | Macro declarations generate one immutable manifest used by parser, type checker, native VM, artifact fingerprint, and portable grants. | | Language keywords and highlighting | One lexer declaration generates tokenization plus keyword/literal projections; docs, website, playground, REPL, LSP, and tree-sitter consume generated or direct projections. | | Benchmark receipts and limits | One kernel type owns serialization and validation; the CLI and browser use it directly, and a generator writes the public JSON Schema. | | WIT tooling input | WIT remains the standards-facing source; pinned `wasm-tools` generates its committed JSON projection. | | Independent target behavior | Differential corpus tests compare exact native and browser results and diagnostics. This is a proof boundary, not synchronization between duplicate implementations. | Generated-artifact checks catch broken wiring or stale committed delivery files. They do not compensate for parallel semantic owners. ## A cooperative authority boundary Execution advances until one of three transitions occurs: ```mermaid stateDiagram-v2 accTitle: Kernel run states accDescr: A run starts in Running. It reaches Completed when pure computation finishes, Failed on a diagnostic or denied authority, or Suspended when a granted host capability is required. A suspended run returns to Running when it is resumed with a typed result. [*] --> Running: start(program, input, grants) Running --> Completed: pure computation finishes Running --> Failed: diagnostic or denied authority Running --> Suspended: granted host capability is required Suspended --> Running: resume(snapshot, typed result, grants) Completed --> [*] Failed --> [*] ``` A suspension is explicit. The kernel does not block a browser thread, poll a clock, or depend on JavaScript Promise Integration. The host receives a typed request and an authenticated snapshot. It decides whether and how to perform the operation, then supplies a result carrying the same request identifier. This design makes least authority visible. A browser can grant a small local capability while a server host grants a different implementation of the same contract. Code cannot gain ambient authority merely because it moved between hosts. ## Placement and concurrency The artifact and its decoded program image are immutable. A native host may share that image across operating-system threads, with a separate execution state, fuel budget, grant set, and suspension snapshot for every invocation. This supports parallel reducer dispatch without introducing scheduler behavior into Harn semantics. A browser host uses the same rule at a different boundary: run each execution lane in a dedicated Web Worker and send typed values and artifact bytes through worker messages. The portable kernel does not depend on Wasm threads, `SharedArrayBuffer`, atomics, or JavaScript Promise Integration. That keeps the adapter usable on browser and edge deployments that do not expose the same threading features. It also means one CPU-heavy execution is cooperative only at a terminal or capability-suspension boundary; the host should isolate it from the browser main thread and enforce fuel limits. ## Why the artifact targets a kernel rather than Wasm instructions Compiling every Harn construct directly to WebAssembly would introduce another semantic owner. Language changes would then require coordinated changes to two compilers, two lowering paths, and target-specific builtins. Instead, Harn compiles its canonical execution kernel to core Wasm. Direct native or Wasm code generation can be added later as an accelerator behind the artifact contract, after measurement, without owning language behavior. The immediate browser adapter uses `wasm-bindgen --target web`, which produces browser-ready ES modules and supports current Chrome, Firefox, Safari, and Edge. The WIT world is checked as the portable interface contract, but browsers do not yet execute components directly. A future Component Model adapter can use `jco` to transpile a component to JavaScript and core Wasm when that reduces deployment work. WASI 0.3 now standardizes native async functions, streams, and futures, so it is a credible future server adapter. It is not the semantic owner: runtime support remains deployment-specific, and some edge platforms still expose only partial or experimental WASI support. ## Tooling and dependency decision The kernel reuses dependencies already present in the Harn workspace. The browser adapter adds no new third-party production dependency; it links the same regex and hashing implementations as the native VM instead of recreating them for Wasm. The relevant packages and pinned tools are: | Package | Scope and reason | License | Maintenance evidence checked 2026-08-01 | Browser size cost | |---|---|---|---|---:| | [`wasm-bindgen`](https://github.com/wasm-bindgen/wasm-bindgen) 0.2 | Existing generated core-Wasm/JavaScript boundary | MIT or Apache-2.0 | Workspace lockfile resolves 0.2.126; active upstream repository | Included in the measured module; not newly introduced | | [`regex`](https://github.com/rust-lang/regex) 1.13.0 | Shared native/Wasm regular-expression semantics, including Unicode behavior | MIT or Apache-2.0 | Existing workspace dependency; active Rust project | Included in the measured cutover delta | | [`sha2`](https://github.com/RustCrypto/hashes) 0.11.0 | Shared native/Wasm SHA-256 implementation | MIT or Apache-2.0 | Existing workspace dependency; active RustCrypto project | Included in the measured cutover delta | | `harn-secret-catalog` | Internal canonical secret-pattern catalog shared by both runtimes | Harn workspace license | Maintained in this repository | No third-party dependency | | `wasm-bindgen-test` 0.3 | Development-only real-browser worker tests | MIT or Apache-2.0 | Maintained in the same active upstream repository | 0 bytes in release artifacts | | [`wasm-pack`](https://github.com/wasm-bindgen/wasm-pack) 0.15.0 | Pinned development/CI build and browser-test driver | MIT or Apache-2.0 | 0.15.0 released in May 2026 | 0 bytes; installed tool only | | [`wasm-tools`](https://github.com/bytecodealliance/wasm-tools) 1.255.0 | Pinned development/CI WIT parser and JSON projection | MIT, Apache-2.0, or Apache-2.0 with LLVM exception | Active Bytecode Alliance repository with versioned releases | 0 bytes; installed tool only | `jco` was evaluated but is not installed. It would add a transpilation and packaging layer without removing the core-Wasm browser adapter while browsers cannot load components directly. Revisit it when a Component Model artifact reduces deployment work rather than duplicating it. ## Current boundary The portable kernel supports a proved pure subset plus typed host capability suspension. The full native VM still owns hostful orchestration, concurrency, and other unavailable operations. A closed artifact may contain those paths so one real application package does not need a portable-only source fork; the kernel returns an exact unsupported diagnostic if execution reaches one. Callers must not interpret “portable” as “all Harn programs.” See the [contract reference](../portable-kernel-reference.md) for the exact boundary. The unchanged logo-studio package is the reference boundary test. Its complete source closure compiles into one 927,272-byte artifact. With no grants, its first privileged operation fails at `fs.source_dir`. With typed grants, the same artifact suspends and resumes through `fs.source_dir`, `env.get_or`, and `fs.exists`, then reaches `tool_registry`. That final operation stores Harn closures as host callbacks, which cannot cross the portable data-value ABI. It remains an exact `unsupported_builtin` boundary until Harn has one canonical callback-registration contract; the portable kernel does not grow a second tool registry to hide that seam. ## Primary references - [`wasm-bindgen` browser support](https://rustwasm.github.io/docs/wasm-bindgen/reference/browser-support.html) - [Testing `wasm-bindgen` in browser workers](https://rustwasm.github.io/docs/wasm-bindgen/wasm-bindgen-test/browsers.html) - [The WebAssembly Component Model and `jco`](https://component-model.bytecodealliance.org/running-components/jco.html) - [WASI releases](https://wasi.dev/releases) - [Cloudflare Workers WebAssembly constraints](https://developers.cloudflare.com/workers/runtime-apis/webassembly/) --- ## Read next - [Execution vocabulary](https://harnlang.com/concepts/execution-vocabulary.md) - [Choosing an agent abstraction](https://harnlang.com/concepts/abstraction-ladder.md) --- # Choosing an agent abstraction > Harn ships several agent primitives at different heights on the stack. Use the lowest one that covers what you need. Climbing higher costs you control and code transparency;... Website: https://harnlang.com/concepts/abstraction-ladder.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 ships several agent primitives at different heights on the stack. Use the lowest one that covers what you need. Climbing higher costs you control and code transparency; climbing too low costs you machinery you'd otherwise get for free. ## The three rungs, in one line The whole ladder collapses to three primary rungs, chosen by *how many goals* the work has: > [`harness.llm.call`](../llm/llm_call.md) = **one request** < > [`agent_loop`](../llm/agent_loop.md) = **one goal** (one transcript, run to > completion) < [`workflow`](../workflow-runtime.md) = **more than one goal, > attempt, or model**. Two rules keep you on the right rung: - **Never hand-write a `while` around `harness.llm.call`.** If a single goal needs several model round-trips — call a tool, read the result, decide what's next — that *is* `agent_loop`. Rolling your own loop re-implements completion detection, budgets, transcript management, and status semantics that the loop already owns. (The rare exceptions are in [When to write your own loop](#when-to-write-your-own-loop).) - **Lift to a `workflow` only when the *shape* matters** — a second goal, a retry-with-feedback attempt loop, a different model per stage, a verify/join/fork, or replay and audit. One agent doing one job is never a workflow. ### `agent_preset` is not a rung [`agent_preset(kind, options?)`](../llm/agent_loop.md#presets-how-you-build-agent_loop-options) is an **options-builder for `agent_loop`**, not a tier above or below it. It resolves per-kind fill-nil defaults (provider, budget, model ladder, completion gate, lanes/overlays…) and returns a plain `agent_loop` options dict — you still call `agent_loop` with the result. Reach for it to *configure* the `agent_loop` rung consistently, never as a substitute for choosing a rung. ### Model ladders are not a rung either `models:` / `ladder:` on `harness.llm.call` (and `agent_loop`) is a cheap-first, escalate-on-*transport*-failure fallback *within a single request* — see [Model ladders](../docs/llm/harn-quickref.md#model-ladders-models--ladder). It changes which model answers, not which rung you are on. A ladder never advances on a schema-validation failure (that re-asks the same rung), and it is mutually exclusive with an explicit `model:`/`routing:`. ## The ladder | Reach for | When | What you get | What you give up | |---|---|---|---| | [`harness.llm.call`](../llm/llm_call.md) | One question, one answer. Classification, summarization, extraction, completions. | Direct control over tokens, cache, schema. Cheapest. | No looping, no automatic tool dispatch, no completion detection. | | [`harness.llm.call_structured`](../llm/llm_call.md#llm_call_structured) | Same as above, but the answer must match a schema. | Validated JSON, safe and result-envelope variants. | One extra schema-validation pass. | | [`agent_loop`](../llm/agent_loop.md) | The model needs several iterations — calling tools, reading results, deciding what to do next. | Tool dispatch, completion sentinels, budgets, status outcomes, transcript management, profiles, skills, daemon mode. | More machinery; opinionated about what "done" means. | | [`spawn_agent`](../agent-lifecycle.md) / [`sub_agent_run`](../agent-lifecycle.md) | A *separate* agent should run, possibly in parallel or background, with its own transcript and possibly a different model. | Independent execution context, suspend/resume, snapshots, joins. | Coordination overhead — handles, resume conditions, wait points. | | [`workflow_execute`](../workflow-runtime.md) | The orchestration shape itself matters — multiple stages, conditional branches, joins, replay, audit, typed contracts. | Typed graph, validated topology, per-stage results, replay, structured artifacts. | Up-front graph definition. Overkill for "one agent does one job." | | [`tree_of_thoughts`](../llm/ensemble.md) | Deliberate branching search where you score and prune candidates. | Deterministic BFS/DFS/beam with caller-defined `expand`/`evaluate`/`is_terminal`. | You write the search semantics. | | Handler middleware ([`std/llm/handlers`](../stdlib/llm-handlers.md)) | Cross-cutting concerns under every LLM call: retry, cache, rate limit, circuit-breaker. | A composable middleware chain at the call boundary. | One more layer to read when debugging. | ## The four-step decision 1. **One shot?** → `harness.llm.call`. If you need JSON, `harness.llm.call_structured`. 2. **Loop until done, optionally with `turn_end_condition`?** → `agent_loop`. 3. **Need a parallel or backgrounded helper agent?** → `spawn_agent` or `sub_agent_run`. 4. **Need typed, inspectable, replayable orchestration over many stages?** → `workflow_execute`. If your answer to all four is "yes, sort of," start with `agent_loop` and lift to a workflow when the orchestration shape genuinely starts to matter — usually around the third or fourth stage. ## Two common anti-patterns **Building a workflow when an agent loop would do.** A workflow with three stages where every stage is `kind: "stage", mode: "agent"` and the only edges are linear is just an agent loop with extra YAML. Use a workflow when stages differ in kind (verify, join, fork, subagent) or when you need replay-aware artifact passing. **Building an agent loop when a `harness.llm.call` would do.** If your "agent" makes one model call, parses the result, and returns, it doesn't need a loop. The loop machinery adds latency, transcript management, and status semantics you're not using. ## The placement contract: where cross-cutting mechanisms live The rungs answer *how many goals*. A second question — *where does each cross-cutting concern live?* — has one canonical answer per concern, so you always import the same module rather than re-deriving the behavior inline. Every one of these is a plain stdlib module that composes onto `agent_loop` options (or, for a preset, is bundled by a [pack row](../llm/agent_loop.md#presets-how-you-build-agent_loop-options)): | Concern | Lives in | Reach for | |---|---|---| | **"Are we actually done?"** completion gate | [`std/agent/completion_gate`](../llm/completion-control.md#completion-gate-agent_completion_gate) | `agent_completion_gate(runtime, options)` checks host write and verification facts and can add a bounded LLM judge. | | **Pace / budget governors** | [`std/agent/governors`](../stdlib/governors.md) | `with_governance(...)`, `governor_decision(...)` — bound cost and cadence. | | **Progress / stall detectors** (unified) | [`std/agent/stall`](../stdlib/governors.md#unified-detectors) | `agent_stall_initial_state()` + `agent_stall_observe_tool_calls(...)` / `agent_stall_no_net_progress(...)` — ping-pong, no-net-progress, and repeated-verified-pass detection in one place. | | **Tool-surface narrowing** (lanes) | [`std/agent/lanes`](../stdlib/agent-lanes-overlays.md) | `lane_policy(rows, task, opts)` — classify the task, hide the tools it can't need. | | **Prompt overlays** (data-driven nudges) | [`std/agent/overlays`](../stdlib/agent-lanes-overlays.md#overlays-data-driven-prompt-nudges) | `with_overlay(opts, rows, mode)` — fill-nil prompt fragments, never overriding explicit input. | | **Auto-compaction** (*when* to compact) | [`std/agent/autocompact`](../llm/agent_loop.md#agent-loop-compaction) | `compaction_policy(...)`, `agent_autocompact_if_needed(session, opts)` — keep the transcript under the context ceiling. | | **Compaction pins** (*what* to preserve) | [`std/agent/pins`](../stdlib/agent-pins-goal.md) | `pin(kind, content)`, `with_pin_roots(opts, pins)`, `pin_compaction_policy(pins)` — a typed pin taxonomy that survives compaction by construction and doubles as reachability-GC roots. Pins *feed* auto-compaction's preservation; they don't decide when it runs. | | **Goal object** (structured objective + convergence) | [`std/agent/goal`](../stdlib/agent-pins-goal.md) | `goal(spec)`, `with_goal(opts, g)`, `goal_check(g, facts)`, `goal_reloop(g)` — machine-checkable success criteria, a turn-end-judge composed from `std/agent/judge`, and a bounded re-loop. The durable *what*; not a per-turn surface. | | **Running-notes recitation** (scratchpad) | `std/agent/scratchpad` | `agent_scratchpad_options(...)`, `agent_scratchpad_recitation_fragment(session, opts)` — re-surface the goal and running notes at the prompt tail each turn. The per-turn *recitation surface* for the goal object above. | | **Default mutation toolset** | [`std/agent/host_tools`](../llm/tools.md#default-mutation-tools) | `agent_edit_tools(registry?, opts?)` — the canonical `write_file` / `edit_file` / `create_directory` / `delete_path` set; customize through the existing middleware seams. | | **Retry with feedback** (attempt loop) | [`std/workflow` stage `retry_policy`](../workflow-runtime.md#retry-with-feedback) | `retry_policy: {max_attempts, feedback}` or a `repair_prompt_builder` closure — thread findings into the next attempt. This is a *workflow* concern (more than one attempt at a goal). | | **Bundling several of the above** | [`std/agent/presets`](../llm/agent_loop.md#presets-how-you-build-agent_loop-options) | `agent_preset(kind, options?)` — one fill-nil pack ships a budget, provider, model ladder, completion gate, lanes, and overlays together. | The rule of thumb: **if you're about to write governor / detector / gate / lane / overlay / compaction logic inline in a loop body, import the module for it instead.** These modules are the home; the loop is the caller. ## When to write your own loop Rarely. The cases that justify hand-rolling on top of `agent_dispatch_tool_call` and `agent_parse_tool_calls`: - You need a custom completion detector that doesn't fit `done_sentinel` or `turn_end_condition`. - You're implementing a research pattern (tree search, voting, debate) where the loop body isn't "ask, dispatch, append". - You're building a different *kind* of agent — one that doesn't talk back, only emits actions. If you find yourself wanting `agent_loop` minus *one* feature, file an issue. The loop is meant to be the right answer for ~95% of multi-iteration agents, and missing features usually mean the loop hasn't grown an option it should have. ## What about the chat surface? The host owns its input loop, commands, and presentation. Pass caller-managed history through `AgentContextSpec.history`, or reuse a `session_id`, and invoke `agent_loop` once for each prompt turn. This keeps agent lifecycle semantics in Harn without moving editor or terminal UI policy into the stdlib. ## See also - [Build your first workflow](../tutorials/build-your-first-workflow.md) — the hands-on path up these rungs: one `harness.llm.call`, then an `agent_loop`, then a `workflow_stages` with a verify stage and retry-with-feedback, runnable end to end on the mock provider. - [The expressiveness spectrum](./expressiveness-spectrum.md) — the same task solved at five escalating levels of control, and why you never pay for machinery you don't use. - [Glossary](./glossary.md) — one-line definitions for every rung and container (LLM call, iteration, agent loop, stage, workflow, session…). - [Coming from elsewhere](./sota-comparison.md) — what LangGraph / OpenAI / ACP call the same ideas. - [Migrating to 0.10](../migrations/v0.10.md) — the removed resilience options (`llm_retries`, `llm_backoff_ms`, `transcript_policy`) and their replacements. --- ## Read next - [Portable execution](https://harnlang.com/concepts/portable-execution.md) - [The expressiveness spectrum](https://harnlang.com/concepts/expressiveness-spectrum.md) --- # 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... Website: https://harnlang.com/concepts/expressiveness-spectrum.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. --- 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: ```harn const hint = harness.llm.call( "Bug: add(2,2) returned 5. One sentence: what's the likely cause?", "You are a careful Rust engineer.", {provider: "mock"}, ) harness.stdio.log(hint.text) ``` Three lines. [`harness.llm.call`](../llm/llm_call.md) 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: ```harn const triage = harness.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"}, ) harness.stdio.log(triage.severity) harness.stdio.log(triage.component) ``` Same call underneath. [`harness.llm.call_structured`](../llm/llm_call.md#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`](../llm/agent_loop.md) is the loop. But you don't have to configure it by hand. [`agent_preset(kind)`](../llm/agent_loop.md#presets-how-you-build-agent_loop-options) 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. ```harn import { agent_preset } from "std/agent/presets" const opts = agent_preset("repair", {provider: "mock"}) const run = agent_loop(harness, "Fix the failing test test_add in src/math.rs, then run it to confirm.", opts?.system, opts, ) harness.stdio.log(run.status) harness.stdio.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. ```harn,ignore import { agent_preset } from "std/agent/presets" import { with_governance } from "std/agent/governors" import { agent_completion_gate } from "std/agent/completion_gate" 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(harness.runtime, { facts: fn(ctx) { return host_completion_facts(ctx.session_id) }, verify_command: fn() { return host_run_verify() }, })} // 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(harness, task, opts?.system, opts) ``` Every one of those lines is opt-in. Governors bound spend ([`std/agent/governors`](../stdlib/governors.md)). Detectors catch loops and stalls. The completion gate ([`std/agent/completion_gate`](../stdlib/agent-judge.md)) 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`](../stdlib/agent-lanes-overlays.md)). 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](./abstraction-ladder.md#the-placement-contract-where-cross-cutting-mechanisms-live) 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: ```harn,ignore { 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: ```harn,ignore // "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, and pace cut rules) are covered in [Host-supplied facts](../stdlib/fact-intake-seams.md). Wrap the whole thing in [`workflow_stages`](../workflow-runtime.md#building-linear-stage-graphs) for the verify stage, retry threading, replay, and audit, and you have the full machine. ```harn,ignore 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: | Level | Reach for | You get | You write | |---|---|---|---| | 1 | `harness.llm.call` | one answer | 3 lines | | 2 | `harness.llm.call_structured` | typed, validated output | +2 lines | | 3 | `agent_preset(kind)` + `agent_loop` | a tuned tool-using agent | +1 import | | 4 | `agent_loop` + governors / judge / lanes / overlays | bounded, gated, narrowed control | one fold per concern | | 5 | `workflow_stages` + `executor` + host-fed facts | attempts, verify gates, replay, your code in the loop | a 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 - [Build your first workflow](../tutorials/build-your-first-workflow.md) — the guided walkthrough of levels 1, 3, and 5, runnable end to end. - [Choosing an agent abstraction](./abstraction-ladder.md) — the ladder and the placement contract in reference form. - [Workflow runtime](../workflow-runtime.md) — the executor closure and retry policy in full. - [Host-supplied facts](../stdlib/fact-intake-seams.md) — the callbacks that let your host feed the loop what it can see. --- ## Read next - [Choosing an agent abstraction](https://harnlang.com/concepts/abstraction-ladder.md) - [Steering seams](https://harnlang.com/concepts/steering-seams.md) --- # Steering seams > A steering seam is a point during a running agent loop where the runtime checks for pending out-of-band influence: a queued user message, a system reminder, an inbox feedback... Website: https://harnlang.com/concepts/steering-seams.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. --- A *steering seam* is a point during a running agent loop where the runtime checks for pending out-of-band influence: a queued user message, a system reminder, an inbox feedback note, or a revocation. Every drain in the agent loop routes through `agent_stage(agent, session_id, stage, input?)`. Its `AgentStage` argument is a closed vocabulary, so the seam catalog is checked by the type system rather than recovered by grepping the loop body. ## What you can inject Three orthogonal channels feed into the loop: | Channel | Producer | Drained from | Renders as | |---|---|---|---| | **Bridge injections** | `session/inject` and `session/remind` over ACP; `agent_session_push_bridge_injection` for Harn-driven hosts | `agent_stage` at every bridge seam (see below) | New user message or system reminder in the transcript | | **Inbox feedback** | In-pipeline `agent_session_inject_feedback`, `agent_session_post_event`, command policy, MCP server hooks, stall diagnostics | `agent_stage` at `pre_compact` / `post_compact` | User-role messages | | **Direct transcript inject** | `transcript.inject_reminder`, internal `agent_session_inject` | Appended directly when called | Whatever shape the caller built | Bridge injections carry a **mode** — `interrupt_immediate`, `finish_step`, `audit_only` — that decides *which* seams drain it. > **Note on `audit_only`.** This mode was previously called > `wait_for_completion`. The rename (harn#2212) is truth-in-advertising: > reminders queued with this mode land in the transcript at `loop_exit` > but are **never rendered into a model prompt**. Hosts that need the > model to react to a reminder before the agent terminates must use > `finish_step`, which drains at every iteration boundary. ## The seam catalog `agent_stage` receives exactly these `AgentStage` values, in this order, per iteration: | Kind | Where | Bridge modes drained | Inbox? | |---|---|---|---| | `iteration_start` | Top of each iteration, after `iteration_start` event | `interrupt_immediate`, `finish_step` | no | | `pre_compact` | Just before `agent_autocompact_if_needed` | — | yes | | `post_compact` | Just after `agent_autocompact_if_needed` | — | yes | | `pre_tool_dispatch` | After `__invoke_llm` returns, before `__dispatch_tool_calls` | `interrupt_immediate` only | no | | `iteration_end` | Stalled-turn-end-judge "done" path, before the loop falls through to terminal | `interrupt_immediate`, `finish_step` | no | | `post_tool_dispatch` | After every successful turn dispatch | `interrupt_immediate`, `finish_step` | no | | `daemon_idle_pre` | Daemon idle wait, before sleep | `interrupt_immediate` only | no | | `daemon_idle_post` | Daemon idle wait, after sleep | `interrupt_immediate` only | no | | `loop_exit` | After the loop body exits, before finalize | `audit_only` only (transcript audit, never rendered) | no | Every stage pass emits a `LoopCheckpoint` event carrying `iteration`, `kind`, `delivered` (bridge injections drained at this seam), `inbox_delivered` (feedback notes drained), and `dispatch_skipped`. ## `pre_tool_dispatch` is the new "stop" seam The seam that didn't exist before #2211: between the LLM returning a tool call and the dispatcher actually firing it. When a host pushes an `interrupt_immediate`-mode injection (via ACP `session/remind` or `agent_session_push_bridge_injection`), the `pre_tool_dispatch` checkpoint drains it and returns `dispatch_skipped: true`. The loop: 1. Skips `__dispatch_tool_calls` entirely — the tool batch does not run. 2. Records usage for the iteration so cost/token accounting stays honest. 3. Emits `iteration_end` with `dispatch_skipped: true` and `skip_reason: "interrupt_immediate"` in the turn info. 4. Continues to the next iteration, where the injected reminder is already in the transcript and visible to the model on its next prompt build. In other words, `interrupt_immediate` finally means *"stop before the next tool fires"*, not *"land at the next iteration boundary anyway."* The same `interrupt_immediate` injection arriving at `iteration_start` or `post_tool_dispatch` is still drained, but those seams sit between iterations where no tool is pending — there's nothing to skip, the injection just lands in the transcript and the next prompt sees it. ## Observe stage events Plugin authors observe seams through one canonical builtin: ```harn,ignore harness.agent.register_checkpoint_hook( ["pre_tool_dispatch", "iteration_end"], { event -> harness.stdio.log( "seam fired:", event.kind, "delivered:", event.delivered, ) }, ) ``` `kinds` accepts a single seam name, a list of seam names, or `nil` / `"*"` for every seam. The handler receives the `LoopCheckpoint` payload directly. Under the hood this registers a `loop_checkpoint` session hook with a pattern derived from `kinds` — `harness.agent.register_session_hook("loop_checkpoint", ...)` works too, with explicit pattern syntax (`kind=="pre_tool_dispatch"`, `kind=~"^(iteration_start|loop_exit)$"`). ## Migration from the old drain sites Pre-#2211 code called `harness.agent.drain_bridge_injections(session_id, checkpoint)` directly at several sites. Those low-level calls bypass the `AgentStage` contract, `LoopCheckpoint` event, and hook fan-out. Runtime loop code should cross `agent_stage`; hosts and plugins should observe the projected event through `harness.agent.register_checkpoint_hook`. For one-off hooks on a single seam the `harness.agent.register_session_hook("loop_checkpoint", pattern, ...)` plumbing is still available, but `harness.agent.register_checkpoint_hook` covers every seam in one call and exposes the `dispatch_skipped` signal that per-event hooks never see. ## Mid-tool preemption Steering at iteration boundaries handles "stop before the next tool fires." For the case where a tool is *already in flight* and the host wants to abort *that* call — e.g. one click cancels a runaway `git push --force` without losing the rest of the session — Harn ships `harness.agent.cancel_in_flight_tool_call(session_id, call_id, opts?)` and the matching ACP method `session/cancel_tool_call`. Both share a per-call cancellation registry keyed by `(session_id, call_id)`: ```harn cancel_in_flight_tool_call( "sess_abc", "call_42", {reason: "user clicked stop", inject_reminder: true, timeout_ms: 5000}, ) // → {status: "cancelled" | "already_cancelled" | "not_found" | "timeout", // call_id: "call_42", tool: "git_push", reason: "user clicked stop"} ``` The cancelled call returns to the loop shaped as `status: "cancelled"` — distinct from `status: "error"` — so the model can distinguish "the host stopped me" from "the tool failed." Tools written against tokio's drop semantics unwind immediately; tools that hold non-droppable resources (a `spawn_blocking` thread, an external process without `kill_on_drop`) can additionally observe the cancellation handle via the registry and shut down cooperatively. ## What's still out of scope These were called out as separate issues in #2211 and are not yet shipped: - **Bridge-level dedupe-key collapsing.** Multiple `interrupt_immediate` injections with the same `dedupe_key` are drained as separate transcript events instead of collapsing. > `audit_only` reminders that drain at `loop_exit` and never render to the > model are now expected behavior, not a bug — see > [harn#2212](https://github.com/burin-labs/harn/issues/2212). Use > `finish_step` if you want the model to see the reminder before the loop > terminates. ## Host-supplied facts: steering the detectors, not the transcript Everything above steers the loop by putting *content* into it: a message, a reminder, a cancellation. There's a second, quieter way the host influences a run, and it's worth understanding as its own thing: feeding the loop's detectors the facts they can't observe on their own. A stall detector can count turns and spot a repeated error. It cannot see your test runner. So when the question is "is this agent actually making progress?", the honest answer needs a fact only the host holds: did the build get closer to green this turn? Harn's design splits that cleanly. Harn owns the *mechanism*: the rule for when repetition means "stuck," the budget math for when to extend or cut. The host owns the *facts*: whether verification advanced, whether a delivered fix landed, how much longer this job deserves. You supply those facts through callbacks the loop calls each turn, not through injected transcript content. The difference matters. An injected reminder is a message the model reads and reacts to. A supplied fact is an input to a detector's decision; the model never sees it. One steers by persuasion, the other by measurement. This split is why "writes are not progress" is enforceable. The loop cannot tell a productive edit from a flailing one, but your host can. It knows whether the verifier moved. Hand the loop that one number and its no-progress detector stops mistaking motion for progress. The same shape applies to a fix that was delivered but didn't land, and to a smart timeout that extends a progressing run and cuts a stalled one. The callbacks, their signatures, and the exact decisions they drive live in [Host-supplied facts](../stdlib/fact-intake-seams.md). The mechanism they feed (governors and detectors) is [Agent governors and detectors](../stdlib/governors.md). ## Cross-references - [System reminders](../system-reminders.md) — the user-facing API for queuing reminders. - [ACP `session/inject_reminder` RFC](../protocol-contributions/acp-session-inject-reminder.md) — the protocol-side proposal that depends on the `interrupt_immediate` semantics this page describes. - [Agent lifecycle](../agent-lifecycle.md) — suspend, resume, and self-park interact with steering but are not themselves steering seams. --- ## Read next - [The expressiveness spectrum](https://harnlang.com/concepts/expressiveness-spectrum.md) - [Stop, steer, and queue as control events](https://harnlang.com/concepts/control-events.md) --- # Stop, steer, and queue as control events > A control event is a person telling a running session to stop or to change course. Harn treats it as an event the session must answer, not as a request the session may finish... Website: https://harnlang.com/concepts/control-events.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. --- A control event is a person telling a running session to stop or to change course. Harn treats it as an event the session must answer, not as a request the session may finish its current plan first and then consider. This page explains why the boundary sits where it does. For where the loop drains an injection, see [Steering seams](./steering-seams.md). For the per-tool-call variant, see `session/cancel_tool_call` on that same page. ## A stop is three guarantees, not one Stopping is usually described as one thing and implemented as one thing, which is why it usually half-works. It is three: 1. **The loop stops.** No further iteration, model call, or tool dispatch. 2. **What the session started dies.** A cancelled agent that leaves a backgrounded command running has relocated the work, not stopped it. 3. **The record says a person stopped it.** The prompt's terminal names the control. The first is the one everybody implements and the only one a demo shows. The other two fail quietly. A leaked child keeps burning a machine nobody is watching. A terminal that does not name the stop makes a stopped run and a run that merely produced nothing the same row in every downstream report — and the difference between those two matters exactly when someone is trying to work out why a fleet of runs went nowhere. ## Why an accepted cancel is the hard case The intuition is that a cancel makes the turn *fail to finish*, so tagging the outcome when a turn fails to settle should catch it. It does not, because an accepted cancel **is answered**. The runtime unwinds the agent loop and replies to the prompt with `stopReason: "cancelled"`. The drain settles. The turn looks, to every structural check, exactly like a turn that ended normally and happened to produce no assistant message. So the observed control is authoritative over the settled/unsettled distinction, not subordinate to it. If a stop was observed, the record says so, regardless of how tidily the turn wrapped up. ## Absence must not read as success The failure mode that survives review is a control that names something which is not there. A `session/cancel` naming a session the server has not registered used to be consumed silently. Nothing was cancelled, and the frame was gone. From the outside, "nothing was cancelled" and "everything was cancelled" produced the same observable: no error, no event, no further output about it. A miss is now loud. The server warns and emits a `ControlOutcome` with `status: "rejected"`, `outcome: "unknown_session"`, and the session id it could not find. Every control decision — accepted or rejected — produces one of these records, carrying method, outcome, status, actor, target, and reason. Read the structured record rather than grepping prose for a verdict. This also shapes how the behavior is tested. A positive test that a cancel stops the loop is passed just as well by an implementation that cancels *everything*, so it needs a sibling negative control: a cancel naming an unknown session must leave the live loop running. And a kill of a session's background handles needs the id it keys on asserted equal to the session id the control names, because a kill on a mismatched id is a no-op that reads as success. ## Three things a mid-turn message can mean Someone types while the agent is working. They mean one of three things: | Intent | Mechanism | Owner | |---|---|---| | "Do this instead, at a sensible point" | `session/inject` mode `steer` | Harn | | "Do this now, before the next tool" | `session/inject` mode `interrupt_immediate` | Harn | | "Do this after you finish" | a new turn, started later | the host | A runtime that assumes one of the three is wrong two-thirds of the time, so this is a setting rather than a behavior. The third is deliberately not Harn's. "After this turn" means a *new* turn, which is a client-side decision about what to submit and when; Harn has no say in it and should not pretend to. A host that queues must also discard the queue when a stop lands — a stop that then runs whatever the person had lined up behind it has deferred, not stopped. Where a host exposes the choice as configuration, it should be a typed enum with exactly one wire spelling per behavior, and an unrecognized value must be reported rather than silently defaulted. A control policy that quietly reverts to the default is one the operator believes is in force while it is not, which is worse than having no setting at all. ## Signals are controls A headless or supervised run has an operator too. SIGINT is that operator saying stop now; SIGTERM is a supervisor asking the run to wind down. Both are answered the same way an interactive stop is: one `session/cancel` on the running session, because that is the path the runtime already terminates cleanly on. Building a second stop path for signals would mean maintaining two answers to the same question. Two details carry weight. The signal races the **frame-receive point** rather than the whole drain, so cancelling does not discard the frames the turn already accumulated — the partial turn survives and still reports what it observed. And the post-cancel wait is bounded, so a wedged engine cannot hold the process open after the operator already said stop; a second signal skips the wait entirely. The two signals stay distinguishable in the recorded reason. A supervisor winding a run down and a person hitting Ctrl-C are different facts about what happened, and collapsing them costs you that later. --- ## Read next - [Steering seams](https://harnlang.com/concepts/steering-seams.md) - [Pattern knowledge](https://harnlang.com/concepts/cross-session-pattern-knowledge.md) --- # Cross-session pattern knowledge > Harn owns cross-session pattern recall as a typed harness.knowledge layer over std/memory . The concrete stdlib entrypoint is std/agent/pattern_knowledge , which records... Website: https://harnlang.com/concepts/cross-session-pattern-knowledge.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 owns cross-session pattern recall as a typed `harness.knowledge` layer over `std/memory`. The concrete stdlib entrypoint is `std/agent/pattern_knowledge`, which records completed-run observations, drafts reviewable skill proposals, and loads accepted learned skills into future context. This is not an A.5 session-store cross-session API. Session storage is useful for transcript and run-event audit trails; pattern recall is long-lived agent knowledge with its own retention, recall, and promotion policy. ## Decision criteria | Criterion | Decision | |---|---| | Query and write rate | Write once per completed run observation, then query during context assembly or explicit review. The default backend stays deterministic and cheap because `std/memory` uses local BM25 unless a host selects vector or hybrid recall. | | Retention | Keep the newest bounded observation set, soft-delete superseded pending proposals, and persist accepted skills as project artifacts. Imports write a migration marker so legacy host stores are consumed once. | | Multi-tenant shape | Namespace and memory root isolate local projects today. Cloud agents can map the same namespace to tenant, organization, and project scopes without adding a Burin-specific host API. | | Embedding versus keyword | The default clustering is deterministic lexical matching so tests and replay are stable. Hosts that need semantic recall can open the same memory namespace in vector or hybrid mode through `std/memory`. | ## Primitive shape `std/agent/pattern_knowledge` stores `harn.pattern_learning.v1` records under the `project/pattern-learning` namespace: - `observation` records capture a redacted prompt, session id, tool sequence, and observation time. - `pending` records hold reviewable proposals, usually learned skills. - `state` records store enablement and temporary suppression after rejection. Accepted proposals become `SKILL.md` files under the project skill root. Future context assembly reads those skills through the same Harn module, so local IDE agents and cloud agents share the same read/write path. ## Migration Burin previously wrote Swift-owned JSONL stores under `.harn/session-store/burin.agent_context.pattern_learning.*.jsonl`. `pattern_learning_ensure_migrated` imports those observation, pending, and state records into `.harn/memory/project/pattern-learning/events.jsonl`, then writes `.burin-session-store-migrated-v1.json` inside that namespace. Mutating pattern-learning operations call that migration automatically. Read operations never migrate or write, so hosts that need legacy records during inspection should call the explicit migration once during a writable launch phase. The migration probes the marker with `harness.fs.status` before reading legacy records. A `missing` status starts migration. Any denied or errored status returns `{migrated: false, unavailable: true, marker_status: "..."}` without writing pattern state. Those per-stream files are migration inputs, not the current session-store backend. `std/session-store` now writes all streams through the canonical `harn-session-store` SQLite database at `.harn/session-store.sqlite`. The migration remains lazy on writable paths: projects that never used the old store pay no startup cost, and projects that did keep auditability because the imported records become ordinary append-only memory events. --- ## Read next - [Memory](https://harnlang.com/memory.md) - [Transcript architecture](https://harnlang.com/transcript-architecture.md) --- # Why Harn has model jobs > An image generator and a speech service look different at their HTTP edges. Inside an application, they have the same awkward shape: submit work, wait, show progress, collect... Website: https://harnlang.com/concepts/model-jobs.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. --- An image generator and a speech service look different at their HTTP edges. Inside an application, they have the same awkward shape: submit work, wait, show progress, collect bytes, and handle failure or cancellation. Harn calls that shape a [model job](./glossary.md#durable-state). The runtime owns its lifecycle. A backend translates one service into Harn's five states. The application sees the same events and receipt whether the service runs on a local GPU or behind a hosted API. This boundary keeps model engines out of the language. PyTorch, ComfyUI, and hosted inference services already own tensors, kernels, model files, and GPU memory. Rebuilding those systems in Harn would add a second, weaker inference stack. Harn instead owns the application facts that must survive a provider change: - the request and selected backend; - legal state changes and progress events; - the exact output bytes and their origin; - cancellation, timeout, failure, and offline replay. ## Jobs are not agent turns An agent turn asks a language model to reason, call tools, and continue a conversation. A model job asks a service to produce a fixed result. It may run for minutes, but it does not own a conversation. An agent can submit a model job. A GUI can submit the same job directly. Both use [`std/model_job`](../stdlib/model-jobs.md), so progress, receipts, and replay do not depend on the host that drew them. ## Outputs become media assets A backend can return bytes, a local path, or a download URL. Harn reads that source once, checks the file signature against the declared MIME type, and stores the bytes under their SHA-256 digest. The resulting [media asset](./glossary.md#durable-state) has a portable `asset://sha256/...` identity and a local materialization path. Content addressing gives replay a hard failure mode. If the bytes have changed, the digest check fails. Harn does not quietly contact the original provider and produce a different result. For the exact types and state rules, read the [model-job reference](../stdlib/model-jobs.md). To run a local image model, use the [ComfyUI how-to guide](../cookbooks/run-comfyui-model-job.md). To run a hosted model, use the [OpenAI image how-to guide](../cookbooks/run-openai-image-job.md). --- ## Read next - [Pattern knowledge](https://harnlang.com/concepts/cross-session-pattern-knowledge.md) - [Why Harn apps separate behavior from pixels](https://harnlang.com/concepts/interactive-apps.md) --- # Why Harn apps separate behavior from pixels > A Harn app is a Harn program with an interactive view. The program owns the decisions: state, validation, model calls, restoring work, replay, and recovery. A shared renderer... Website: https://harnlang.com/concepts/interactive-apps.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. --- A Harn app is a Harn program with an interactive view. The program owns the decisions: state, validation, model calls, restoring work, replay, and recovery. A shared renderer owns browser work such as updating controls, painting the canvas, following the pointer, and downloading files. This split keeps application code portable. The same Harn event handler can run in the standalone browser host, an MCP Apps client, a test, or a future native host. It does not import React, browser globals, or a Rust product module. ## The round trip 1. Harn returns a typed `UiDocument`. 2. The renderer draws its elements. 3. The renderer turns user input into a typed `UiEvent`. 4. A Harn tool validates the event, changes state, and returns a `UiUpdate`. 5. Optional effects ask the renderer to schedule another event, capture a canvas, download bytes, or perform another host-owned presentation task. Canvas pointer movement is local while the pointer is down. The renderer sends one `canvas.stroke` event with coordinates from `0.0` to `1.0` when the stroke ends. Harn therefore owns the stroke and undo history without paying for a tool call per pixel. For high-frequency state changes, `ui.portable_app_resource` runs the same Harn reducer in a host-owned browser worker. Each update includes the next plain state. If browser execution is unavailable, the renderer sends that latest state to the standard Harn event tool, which runs the same artifact. The view still has no direct worker, file, network, or model authority. ## What belongs where | Layer | Owns | |---|---| | Harn app | Product state, event handling, prompts, jobs, file rules, replay | | `std/ui` | Typed documents, events, effects, validation, shared renderer | | `std/media` | Durable assets, parent lineage, exact-text design documents, SVG/PNG export | | Harn host | Files, network, process isolation, MCP transport, browser startup and shutdown | | Browser | Pointer capture, canvas rasterization, DOM, accessibility primitives | App-specific Rust is a design failure: add the missing reusable host capability instead. App-specific JavaScript needs the same evidence. This rule lets later image editors, audio tools, eval explorers, and review consoles use one tested foundation. Continue with [Build an interactive Harn app](../cookbooks/build-interactive-app.md) or [Run Harn app logic in the browser](../cookbooks/run-app-logic-in-browser.md). Use the [`std/ui` reference](../stdlib/ui.md) for exact types and limits. --- ## Read next - [Why Harn has model jobs](https://harnlang.com/concepts/model-jobs.md) - [Coming from elsewhere](https://harnlang.com/concepts/sota-comparison.md) --- # Coming from elsewhere > Harn's vocabulary doesn't always match what you'd read in OpenAI's, Anthropic's, Flue's, LangGraph's, Inngest's, Mastra's, Cloudflare's, AWS Strands', BAML's, or the... Website: https://harnlang.com/concepts/sota-comparison.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's vocabulary doesn't always match what you'd read in OpenAI's, Anthropic's, Flue's, LangGraph's, Inngest's, Mastra's, Cloudflare's, AWS Strands', BAML's, or the ACP/A2A/MCP specs. This page is the cross-reference. If a term in your home system collides with a Harn term, find your row in the table for that system. ## OpenAI Agents SDK | OpenAI term | Harn equivalent | Notes | |---|---|---| | `Agent` (class) | `agent_loop(harness, ...)` invocation, or a `persona` | OpenAI's `Agent` bundles instructions, tools, and output type; the closest Harn shape is a configured `agent_loop` call site. | | `Runner.run(...)` | one `agent_loop(harness, ...)` invocation | One OpenAI "turn" wraps many model round-trips. One Harn agent loop invocation does the same. | | **"turn"** | Harn **prompt turn** / `agent_loop` | OpenAI's "turn" is the outer cycle (one user request → final answer), not Harn's per-iteration counter. | | "model roundtrip" (unnamed) | Harn **`iteration`** | The inner unit. | | `max_turns` | `max_iterations` | Both bound a budget, but the nouns are off-by-one — OpenAI counts outer SDK invocations, Harn counts inner LLM calls. | | `Session` (`SQLiteSession("id")`) | `session_id` + `harness.agent.open(id)` | Direct match. | | `handoff` | persona handoff or `spawn_agent` | Direct match in shape. | | `input_guardrails` / `output_guardrails` | `agent_input_guardrail` + tool middleware + completion gates | Harn exposes input guardrails as a named pre-loop bookend; output checks use completion gates, judges, validators, and tool middleware. | ## Anthropic Claude Agent SDK | Anthropic term | Harn equivalent | Notes | |---|---|---| | **"agent loop"** | `agent_loop` | Direct vocabulary match. | | `query()` / `ClaudeSDKClient` | `agent_loop(harness, ...)` | Pass `history` or reuse `session_id` for stateful prompt turns. | | `AssistantMessage`, `TextBlock` (typed stream) | transcript events | Anthropic streams typed messages; Harn streams typed transcript events. | | Session resumption | `harness.agent.open(id)` + transcript continuity | Direct match. | | **"hook"** | `register_tool_hook`, `register_session_hook`, `register_reminder_provider` | Harn's hook registry is the richer version. | Anthropic and Harn align closely on agent-loop vocabulary; this is the easiest mapping in the table. ## LangGraph | LangGraph term | Harn equivalent | Notes | |---|---|---| | `Node` | `stage` (workflow) | Both encode a unit of computation. | | `Edge` | workflow transition | Same shape; Harn doesn't expose it as a separate noun. | | **`State` (typed dict)** | Workflow artifacts + optional state-channel proposal | LangGraph's strict typed-dict-with-reducers is not Harn's default state model. The v0 design is tracked in [Workflow state channels](../spec/workflow-channels/v0.md). | | **`Channel`** | proposed workflow state channel | LangGraph channels are typed slots with merge reducers. Harn's `agent_channels` are something else entirely (pub/sub for agent-to-agent communication). | | `Thread` (`thread_id`) | `session_id` | Direct match. | | **`super-step`** | `iteration` | LangGraph's super-step is one parallel barrier; semantically Harn's per-iteration. | | `checkpoint` / `checkpointer` | session bundle, snapshot | Direct match. | | `interrupt` / `Command(resume=...)` | `agent_await_resumption` | Direct match. | LangGraph's biggest *Harn-doesn't-have-this-by-default* is **typed-state channels with reducers**. Harn's v0 design keeps artifacts and transcripts as the common path, then adds explicit workflow state channels for structured fan-out/reduce cases. ## Flue Flue and Harn both put agents inside a harness and separate continuing agents from finite workflows. Flue is a TypeScript framework. Harn is a language and runtime with host adapters. | Flue term | Harn equivalent | Notes | |---|---|---| | `defineAgent()` | configured `agent_loop` or persona | Both bind a model, instructions, tools, skills, and an execution environment. | | Agent instance | session | Both preserve one continuing conversation identity. | | `defineWorkflow()` | workflow or pipeline | Both describe finite work; Harn uses a typed stage graph when the graph must be inspectable. | | Durable event stream | transcript plus EventLog | Both retain replayable runtime events. Harn also uses the EventLog for deterministic effect replay. | | `@flue/react` hooks | Harn Apps host plus MCP Apps UI resource | Flue projects durable state into React hooks. Harn serves host-neutral app resources and lets each host own native presentation. | | Virtual, local, or remote sandbox | Harn sandbox and host capabilities | Both keep conversation persistence separate from workspace lifetime and access policy. | | Target | host or deployment adapter | Flue targets Node.js and Cloudflare. A Harn program runs through CLI, IDE, protocol, self-hosted, or cloud adapters. | Start with Flue's [agent guide](https://flueframework.com/docs/guide/building-agents/), [workflow guide](https://flueframework.com/docs/guide/workflows/), [event reference](https://flueframework.com/docs/api/events-reference/), and [React guide](https://flueframework.com/docs/guide/react/) when comparing a specific developer path. ## Inngest | Inngest term | Harn equivalent | Notes | |---|---|---| | `Function` | pipeline | Both are the durable unit. | | `Run` | session, run_id | Direct match. | | **`step.run(key, ...)`** | `step.run(key, input?, handler, options?)` | Harn memoizes completed step results in the EventLog and replays matching steps without re-invoking the handler. | | `step.sleep` / `step.waitForEvent` | `agent_await_resumption` + `resume_when` | Conceptually equivalent. | | `Event` | trigger event, agent event | Direct match. | | Step replay | `step.run` + session resume + worker snapshot | Harn supports both replay-from-top memoized steps and checkpoint/snapshot resume. | If you arrive from Inngest expecting `step.run`-style memoized replay, start with [Durable step stdlib](../stdlib/step.md). Durable timers and event waits remain separate primitives: use `agent_await_resumption` and `resume_when` for long waits. ## Mastra | Mastra term | Harn equivalent | Notes | |---|---|---| | `Agent` | `agent_loop` invocation | Direct match. | | `Workflow` | workflow | Direct match. | | **`Thread` (per-conversation)** | `session_id` | Mastra's `thread` is Harn's `session`. | | **`Resource` (per-user/entity)** | partial — `tenant_id` covers some of it | Mastra splits per-user vs per-conversation; Harn collapses to session + tenant. | | `working memory` / `semantic recall` | memory builtins | Conceptually similar, less typed. | ## Cloudflare Agents SDK Cloudflare's model is the most different one on this page: an agent is a Durable Object, so identity, state, and compute are the same thing. | Cloudflare term | Harn equivalent | Notes | |---|---|---| | `Agent` (a Durable Object class) | session plus its transcript | Cloudflare fuses the agent's identity, its storage, and the compute that serves it into one addressable object. Harn keeps the session as data and lets any host run it. | | `this.setState()` / `this.sql` | session state, transcript, artifacts | Each Durable Object carries its own embedded SQLite. Harn's equivalents are host-provided, so the same program can run against a local file or a database. | | `@callable` method | tool, exported function | Both expose a typed entry point to a caller. | | `this.schedule(...)` | `agent_await_resumption` + `resume_when` | Both let work sleep and wake later without a process staying alive. | | WebSocket hibernation | session suspend and resume | Same intent: stop paying for an idle conversation without losing it. | Cloudflare's durability comes from where the code runs. Harn's comes from the EventLog, so you get replay on a laptop and in CI, not only in production. The tradeoff is real in the other direction too: Cloudflare hands you global addressing and hibernation for free, and Harn asks the host to provide them. ## AWS Strands Agents | Strands term | Harn equivalent | Notes | |---|---|---| | **"agent loop"** | `agent_loop` | Direct vocabulary match, and the same inner unit. | | `Agent(model=..., tools=...)` | configured `agent_loop` call site | Direct match in shape. | | `@tool` decorator | tool registration | Strands infers the schema from Python type hints; Harn takes it from the declared shape. | | `Swarm` | `spawn_agent` plus agent channels | Both fan work out to several agents that can hand off. | | `Graph` | workflow | Both are a deterministic node graph over agent steps. | | `Agents as Tools` | `spawn_agent` from inside a tool | Direct match. | | Session persistence | session, snapshot | Direct match. | Strands is explicitly model-driven: the loop decides what to do next, and the framework's argument is that you should let it. Harn's argument is the inverse one, that the program should decide when a step is worth a model call. Both are reasonable, and they optimize for different failure modes. Strands recovers from situations you did not anticipate; Harn keeps cost and behavior predictable in the ones you did. ## BAML BAML is the closest thing to a peer language rather than a framework, so it is worth being precise. Both projects are pre-1.0, both are implemented in Rust, and both argue that agent orchestration deserves language-level support rather than another library. | BAML term | Harn equivalent | Notes | |---|---|---| | `function` with a typed return | `harness.llm.call` into a declared shape | Both make "the model returns this type" a language-level promise instead of a parsing chore. | | Schema-aligned parsing | shape coercion and diagnostics | Both repair almost-valid model output rather than failing on a stray comma. BAML's is exposed as a reusable stdlib call and published against a function-calling benchmark. | | `spawn` / `await`, `Future` | `spawn_agent`, structured concurrency | Both give you real concurrency. The semantics differ; see below. | | `test` and `testset` blocks | `harn test`, replay, evals | Both treat testing a prompt as a first-class activity. | | Client registry, retry policies | provider config, retry and fallback policy | Direct match, including retry, fallback, and round-robin wrappers. | | Generated SDKs for Python, TypeScript, Go, Java, C#, Rust, and more | `harn serve`, [embedding in Rust](../embedding-rust.md) | The clearest difference in distribution model. BAML's primary path is generating a typed client so an existing codebase calls into it. Harn's is running the program, or embedding the runtime. | The center of gravity differs. BAML invests in the boundary of a single model call and in making that boundary hard for a model to get wrong, which is why it reads well when an agent writes the code. Harn invests in the program around the calls, which is why transcripts, replay, capability policy, and protocol adapters are in the language. Two differences are worth stating precisely rather than as a scoreboard. **Concurrency.** BAML has green threads with `spawn` and `await`, and it deliberately rejected structured concurrency: a future outlives the scope that created it, and there is no automatic cancellation on scope exit. Harn's concurrency is scoped. Neither is strictly better. BAML's model has no syntactic cost for functions that ignore cancellation; Harn's makes lifetimes obvious and leaks harder. Pick the one whose default failure mode you prefer. **Durability.** This is a real scope difference rather than a maturity gap. BAML has no durable execution, checkpointing, run replay, or human-in-the-loop, and its journal is in-memory and per-run. Harn's replay, session bundles, and approval flows are the parts BAML has not entered. On protocols, BAML has an MCP client and deliberately keeps protocol knowledge out of its core; Harn speaks MCP, ACP, and A2A directly. If your problem is "this one call must return a reliable object" and you want to keep your existing codebase, BAML solves that directly and its generated clients are the shortest path. If your problem is "the orchestration between the calls became the hard part, and I need to replay what happened," that is what Harn is for. ## ACP — Agent Client Protocol ACP is the most important map for anyone using Harn's `serve` adapter, because we speak ACP natively. | ACP term | Harn equivalent | Notes | |---|---|---| | `session/new`, `session/load`, `session/resume` | `agent_session_open`, session fork, snapshot resume | Direct match. | | `session/prompt` | one user message → `agent_loop` invocation | Direct match. | | **`prompt_turn`** | one **`agent_loop` invocation** | The outer user-message → final-response cycle, terminated by a typed `terminal` outcome and lossless `stop_reason`. One invocation contains many iterations. | | `stop_reason` | `stop_reason` | Same names. | | `available_commands` | skills, tool registry | Partial match; ACP advertises slash-style commands. | | `Plan` (agent plan updates) | `task_ledger`, progress tool | Direct match. | | `tool_call` | tool call | Same names. | | `session/cancel` | `close_agent`, cancellation token | Direct match. | | `session/request_permission` | `approval_policy`, permissions | Direct match. | **Read this carefully if you're writing ACP integrations:** ACP's `prompt_turn` is the *outer* concept (one user request → final response with stop reason). Harn's loop counts *iterations*, which are model round-trips inside the prompt turn — the transcript events fire as `iteration_start` / `iteration_end`, and the steering seams use the same names. One `agent_loop(...)` invocation maps to one ACP `prompt_turn` and contains many `iteration_*` events. ## A2A — Agent2Agent Protocol | A2A term | Harn equivalent | Notes | |---|---|---| | `Task` | worker, agent_loop invocation | A2A's `Task` is one unit of work with lifecycle state and history. | | `Message` | transcript event, message | Direct match. | | `TaskState` (submitted, working, input-required, completed, ...) | `final_status`, suspended states | A2A's state machine is more explicit; Harn maps closely. | | `Part` (TextPart, FilePart, DataPart) | block | Direct match. | | `Artifact` | artifact | Same name. | ## MCP — Model Context Protocol MCP deliberately avoids conversation-shape vocabulary. It defines `Tool`, `Resource`, `Prompt`, `Sampling`, `Elicitation` — primitives on which conversations run, not the conversations themselves. | MCP term | Harn equivalent | Notes | |---|---|---| | `sampling/createMessage` | `harness.llm.call` | One model call. | | `Tool` | tool | Same shape. | | `Resource` | hostlib resource, transcript asset | Same shape. | | `Prompt` | prompt template, prompt library | Same shape. | | `Elicitation` | HITL `hitl.ask` | Server-initiated pause-and-ask pattern. | MCP has no `turn` / `session` / `agent_loop` — those are above MCP's layer. Use MCP as your tool surface, not as your orchestration model. ## AG-UI AG-UI is event-based UI streaming, not loop topology. Its vocabulary (`Lifecycle Events`, `Text Message Events`, `Tool Call Events`, `State Management Events`) maps onto Harn's transcript event categories with different names but the same shapes. Harn's `serve` adapter emits AG-UI-compatible events. ## Reference: SOTA links - [OpenAI Agents SDK — Running agents](https://openai.github.io/openai-agents-python/running_agents/) - [Anthropic Claude Agent SDK overview](https://docs.claude.com/en/agent-sdk/overview) - [LangGraph Graph API overview](https://docs.langchain.com/oss/python/langgraph/graph-api) - [Flue documentation](https://flueframework.com/docs/) - [Inngest — step.run reference](https://www.inngest.com/docs/reference/functions/step-run) - [Mastra — Memory threads and resources](https://mastra.ai/docs/memory/threads-and-resources) - [Cloudflare Agents SDK](https://developers.cloudflare.com/agents/) - [AWS Strands Agents — Agent loop](https://strandsagents.com/docs/user-guide/concepts/agents/agent-loop/) - [BAML documentation](https://docs.boundaryml.com/home) - [Agent Client Protocol](https://agentclientprotocol.com/get-started/introduction) - [A2A Protocol Specification](https://a2a-protocol.org/latest/specification/) - [Model Context Protocol](https://modelcontextprotocol.io/docs/getting-started/intro) --- ## Read next - [Why Harn apps separate behavior from pixels](https://harnlang.com/concepts/interactive-apps.md) - [Why Harn?](https://harnlang.com/why-harn.md) --- # Why Harn? > Building AI agents usually means coordinating models, tools, retries, concurrency, state, and sub-agents. In most languages, that turns into a stack of libraries: Website: https://harnlang.com/why-harn.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. --- # Why Harn? ## The problem Building AI agents usually means coordinating models, tools, retries, concurrency, state, and sub-agents. In most languages, that turns into a stack of libraries: - An LLM SDK (LangChain, OpenAI SDK, Anthropic SDK) - An async runtime (asyncio, Tokio, goroutines) - Retry and timeout logic (tenacity, custom decorators) - Tool registration and dispatch (custom JSON Schema plumbing) - Structured logging and tracing (separate packages) - A test framework (pytest, Jest) Each layer adds configuration, boilerplate, and failure modes. The orchestration logic gets buried under infrastructure code. ## What Harn does differently Harn puts agent orchestration primitives in the language instead of leaving them to framework glue. For a capability-by-capability comparison with Inngest, Temporal, LangGraph, and Cursor Automations, see the [capability comparison](how-harn-compares.md). In practice, Harn is the orchestration boundary between product code and provider/runtime code. Product integrations declare workflows, policies, capabilities, and UI hooks; Harn handles transcripts, tool queues, replay fixtures, and provider response normalization. ### Native LLM calls `harness.llm.call` and `agent_loop` are language primitives. No SDK imports, no client initialization, no response parsing. Set an environment variable and call a model: ```harn const answer = harness.llm.call( "Summarize this code", "You are a code reviewer.", ) ``` Harn ships with built-in configs for 44 providers, including Anthropic, OpenAI, Google Gemini, OpenRouter, Groq, DeepSeek, Ollama, and local OpenAI-compatible servers. The [provider capability matrix](provider-matrix.md) has the full list. Switching providers is a one-field change in the options dict. ### Pipeline composition Pipelines are the unit of composition. They can extend each other, override steps, and be imported across files, which keeps multi-stage agent workflows readable: ```harn pipeline analyze(harness: Harness, task) { const context = harness.fs.read_text("README.md") const plan = harness.llm.call( "${task}\n\nContext:\n${context}", "Break this into steps.", ) const steps = json_parse(plan.text) const results = parallel each steps { step -> agent_loop( harness, step, "You are a coding assistant.", {loop_until_done: true}, ) } harness.fs.write_text("results.json", json_stringify(results)) } ``` Files can also contain top-level code without a pipeline block (implicit pipeline), which keeps scripts and quick experiments short. ### MCP and ACP integration Harn has built-in support for the [Model Context Protocol](https://modelcontextprotocol.io). Connect to any MCP server, or expose your Harn pipeline as one. ACP integration lets editors use Harn as an agent backend. The CLI handles standalone OAuth for remote HTTP MCP servers, so cloud MCP integrations can be ordinary runtime dependencies instead of host-specific glue. ```harn const client = harness.tools.mcp_connect( "npx", ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], ) const tools = harness.tools.mcp_list_tools(client) const content = harness.tools.mcp_call( client, "read_file", {path: "/tmp/data.txt"}, ) harness.tools.mcp_disconnect(client) ``` ### Concurrency without async/await Agent work is mostly waiting: for files, HTTP calls, tool calls, model responses, or other workers. Harn makes that waiting explicit in the program without asking you to write an event loop. ```harn const results = parallel each files { file -> harness.llm.call( harness.fs.read_text(file), "Review this file for security issues", ) } ```
How to read this snippet For each path in `files`, Harn starts a child task, reads the file inside that task, calls the model, and returns the results in input order. If there are `N` files and the slowest branch takes `T` seconds, wall-clock time is roughly `T` plus scheduling overhead. Memory grows with in-flight tasks and their inputs/results, so use `with { max_concurrent: K }` for large file sets or provider queues. Because `parallel each` is part of the language runtime, cancellation, replay, trace spans, and host-capability checks stay attached to the whole fan-out.
### Retry and error recovery `retry` and `try`/`catch` are control flow constructs. Wrapping an unreliable LLM call in retries is a one-liner: ```harn retry 3 { const result = harness.llm.call(prompt, system) json_parse(result.text) } ```
How to read this snippet `retry` is deliberately not clever. It doesn't know what an LLM is and it doesn't classify errors: any error thrown inside the block starts another attempt, up to three in total. That's what makes the two lines above work together, because the failure worth retrying here is usually the second one. A model that returns prose where you asked for JSON fails at `json_parse`, not at the network, and a construct that only retried transport errors would give up on exactly the case you wrote this for. The cost of that generality is that a genuine bug retries too. A missing key or a wrong type in the block will fail three times before the error escapes. If all three attempts fail, the last error propagates rather than returning `nil`, so a `retry` block is not a way to make failure quiet. Wrap it in `try`/`catch` if you want to handle exhaustion. When you do want the error-aware version, that lives in the standard library rather than the language: [`harness.llm.with_rate_limit`](builtins.md#llm) retries with exponential backoff only on `rate_limit`, `overloaded`, `transient_network`, and `timeout`. See [Error handling](error-handling.md#retry) for the full contract.
### Gradual typing Type annotations are optional. Add them where they help, leave them off where they don't. Structural shape types let you describe expected dict fields: ```harn type Review = { path: string, risk: "low" | "medium" | "high", summary?: string, } fn render_review(review: Review) -> string { return "${review.path}: ${review.risk}" } render_review({path: "src/auth.rs", risk: "high", owner: "security"}) ``` `path` and `risk` are required keys. `summary` is optional. Extra keys such as `owner` are allowed, so typed boundaries can describe the fields a function actually needs without forcing every caller to erase useful metadata. ### Embeddable Harn compiles to a WASM target for browser embedding and ships with LSP and DAP servers for IDE integration. Agent pipelines can run inside editors, CI systems, or web applications. ## Who Harn is for - **Developers building AI agents** who want orchestration logic to be readable and concise, not buried under framework boilerplate. - **IDE authors** who want a scriptable, embeddable language for agent pipelines with built-in LSP support. - **Researchers** prototyping agent architectures who need fast iteration without setting up infrastructure. ## Comparison Here is what a "fetch three URLs in parallel, summarize each with an LLM, and retry failures" pattern looks like across approaches: **Python (LangChain + asyncio)**: ```python import asyncio from langchain_anthropic import ChatAnthropic from tenacity import retry, stop_after_attempt import aiohttp llm = ChatAnthropic(model="claude-sonnet-5") @retry(stop=stop_after_attempt(3)) async def summarize(url): async with aiohttp.ClientSession() as session: async with session.get(url) as resp: text = await resp.text() result = await llm.ainvoke(f"Summarize:\n{text}") return result.content async def main(): urls = ["https://a.com", "https://b.com", "https://c.com"] results = await asyncio.gather(*[summarize(u) for u in urls]) for r in results: print(r) asyncio.run(main()) ``` **Harn**: ```harn pipeline default(harness: Harness) { const urls = ["https://a.com", "https://b.com", "https://c.com"] const results = parallel each urls { url -> retry 3 { const page = harness.net.get(url) harness.llm.call("Summarize:\n${page}", "Be concise.") } } for r in results { harness.stdio.log(r) } } ``` The Harn version has no imports, decorators, client initialization, async annotations, or runtime setup. ## Getting started See the [Getting started](getting-started.md) guide to install Harn and run your first program, or jump to the [cookbook](cookbook.md) for practical patterns. --- ## Read next - [Coming from elsewhere](https://harnlang.com/concepts/sota-comparison.md) - [How Harn compares](https://harnlang.com/how-harn-compares.md) --- # How Harn compares > This page is for a developer who runs agents on another system and is deciding whether to move them to Harn. Each row asks where a primitive lives and what the runtime... Website: https://harnlang.com/how-harn-compares.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. --- # How Harn compares This page is for a developer who runs agents on another system and is deciding whether to move them to Harn. Each row asks where a primitive lives and what the runtime guarantees by default, so you can find out quickly whether Harn is the wrong tool for your problem. The comparison covers platform shape, not product quality. A `No` in a column often means the system chose a different scope, not that it fell short. Every non-Harn claim was checked against that system's public documentation in August 2026. These systems ship quickly. Where a linked source disagrees with a row here, the source is right; open an issue and the row gets fixed. ## Capabilities Pick the systems you're comparing against. Select a capability to read what its rating covers, or hover a cell for the one-line reason behind it. The rows at the end are ones Harn doesn't win. | Capability | Harn | Inngest / AgentKit | Temporal | LangGraph | BAML | Cursor Automations | Flue | |---|---|---|---|---|---|---|---| | [Own orchestration language](#orchestration-language) | Yes | No | No | No | Yes | No | No | | [Runtime replay contract](#runtime-replay-contract) | Yes | Partial | Yes | Partial | No | No | Partial | | [Model-aware trigger predicates](#model-aware-trigger-predicates) | Yes | Partial | No | Partial | No | No | — | | [Open source and self-hostable](#open-source-and-self-hostable) | Yes | Partial | Yes | Partial | Yes | Partial | — | | [One program across environments](#one-program-across-environments) | Yes | Partial | Partial | Partial | Partial | No | Partial | | [Cost limits in program code](#cost-limits) | Yes | Partial | Partial | Partial | — | Partial | — | | [Human review and trust records](#human-review-and-trust) | Yes | Partial | Partial | Partial | No | Partial | — | | [Model and infrastructure choice](#model-and-infrastructure-choice) | Yes | Partial | Partial | Partial | Yes | Partial | — | | [Sandboxed by default](#sandboxed-by-default) | Yes | No | No | No | No | Partial | — | | [Signed, versioned packages](#signed-versioned-packages) | Yes | Partial | Partial | Partial | Partial | No | — | | [Callable from an existing codebase](#callable-from-an-existing-codebase) | Partial | Yes | Yes | Yes | Yes | No | Yes | | [Reuse your language's libraries](#reuse-your-languages-libraries) | Partial | Yes | Yes | Yes | Partial | No | Yes | | [Managed hosting from the vendor](#managed-hosting-from-the-vendor) | Partial | Yes | Yes | Yes | Partial | Yes | — | | [Proven at production scale](#proven-at-production-scale) | No | Yes | Yes | Yes | Partial | Partial | — | | [Third-party integration catalog](#third-party-integration-catalog) | No | Partial | Partial | Yes | No | Partial | — | | [Futures that outlive their scope](#futures-that-outlive-their-scope) | No | Yes | Yes | Yes | Yes | — | Yes | | [Small install](#small-install) | No | Yes | Partial | Yes | Yes | Partial | Yes | **Tracked work.** These rows have work behind them, landed or in flight. Each links its issue, so you can check the state yourself rather than taking this page's word for it. - [Small install](#small-install) — The Windows archive carried three identical copies of one executable, tripling its size. Fixed on main: the archive now ships one executable and the installer creates the aliases. The measured figures below predate that, and the next release should land near the other platforms. ([#7175](https://github.com/burin-labs/harn/issues/7175)) **Already using one of these?** These systems have a term-by-term map onto Harn's vocabulary, which is the shortest way to read your own workflow in Harn's terms. - [Coming from Inngest / AgentKit](./concepts/sota-comparison.md#inngest) - [Coming from LangGraph](./concepts/sota-comparison.md#langgraph) - [Coming from BAML](./concepts/sota-comparison.md#baml) - [Coming from Flue](./concepts/sota-comparison.md#flue) ## How to read the table | Rating | Meaning | |---|---| | Yes | The core platform provides an explicit contract or runtime primitive. | | Partial | The outcome needs application code, a paid plan, or deployment setup outside the core programming model. | | No | The system doesn't publish the capability as a platform primitive. | | — | Not checked against that system's documentation yet. | ## What each row covers ### Orchestration language Harn uses a purpose-built language for agent orchestration. That keeps trigger policy, model calls, concurrency, retries, budgets, human review, and trust metadata in one program. SDK-based systems express the same work through host language callbacks, queue handlers, and configuration. See [Language basics](./language-basics.md), [Workflow runtime](./workflow-runtime.md), and [Flow predicate language](./flow-predicates.md). ### Runtime replay contract LLM systems need replay for debugging, evaluation, and incident review. Harn's runtime owns the transcript and event-log boundary so replay can reason about the same model request, tool result, trigger event, approval, and dispatch history. Temporal replays workflow state when workflow code follows its deterministic constraints and side effects run in Activities. Inngest memoizes completed steps, so a model call inside a durable step isn't sent again on resume. LangGraph replay re-executes nodes after the selected checkpoint, including model calls, which may return different results. See [Durable step stdlib](./stdlib/step.md), [Transcript architecture](./transcript-architecture.md), [Testing](./testing.md), and [Trigger event schema](./triggers/event-schema.md). ### Model-aware trigger predicates Harn treats predicates over events as runtime objects, including model-backed classifiers and budget policy. The same trigger machinery makes those decisions inspectable, typed, budgeted, and replayable. Other systems can call a model before dispatch, but the application owns that classifier and its policy. See [Triggers](./triggers.md), [Flow predicate language](./flow-predicates.md), and [Trigger budgets](./triggers/budgets.md). ### Open source and self-hostable Harn's open-source boundary includes the runtime substrate: language, VM, orchestrator, EventLog contracts, connectors, protocols, and self-hostable deployment path. A cloud platform can add managed tenancy and operations, but the core orchestration model isn't reserved for a hosted service. See [Orchestrator](./orchestrator.md), [Deploy to Render](./deploy/render.md), [Deploy to Fly.io](./deploy/fly.md), and [Deploy to Railway](./deploy/railway.md). ### One program across environments A `.harn` program should remain the unit of review whether it runs as a local script, CI job, self-hosted orchestrator workflow, MCP server, ACP backend, or managed cloud workflow. That portability is the practical payoff of keeping the workflow in one language and putting host-specific details at the boundary. See [Harn portal](./portal.md), [Outbound workflow server](./harn-serve.md), [MCP, ACP, and A2A integration](./mcp-and-acp.md), and [Host boundary](./host-boundary.md). ### Cost limits Agent systems fail operationally when model calls, retries, and background triggers become invisible. Harn exposes trigger budgets and runtime context so teams can place limits next to the workflow. Provider billing pages still show account-wide spend. See [Trigger budgets](./triggers/budgets.md), [Runtime context](./runtime-context.md), and [LLM providers](./llm/providers.md). ### Human review and trust Human-in-the-loop (HITL) work pauses an agent for review, approval, or input. Harn records that step with agent session lineage and trust graph data, so the review remains part of the orchestration and audit trail. See [Human in the loop](./hitl.md), [Trust graph](./trust-graph.md), [Sessions](./sessions.md), and [Agent state](./agent-state.md). ### Model and infrastructure choice Harn is model-neutral by design. Workflows can target hosted providers, OpenAI-compatible endpoints, local model servers, Ollama, or a provider chosen by a team. The team can keep models and the runtime inside its own infrastructure, including networks without public internet access, when its providers support that setup. See [LLM providers](./llm/providers.md), [Provider capability matrix](./provider-matrix.md), and [Orchestrator secrets](./orchestrator/secrets.md). ### Sandboxed by default Agents run code and spawn commands, so the interesting question isn't whether a sandbox is available. It's whether one is on before anyone remembers to ask. `harn run` confines a script to its own project directory before the VM starts, and the operating system confines any subprocess the script spawns, using Landlock on Linux, `sandbox-exec` on macOS, and AppContainer on Windows. The default side-effect ceiling stops below `network`, so a script can touch its own files but can't open a socket until a run grants it. Widening it is per-path rather than all-or-nothing: `--write-root` and `--read-only-root` add one root for Harn and its children, `--sandbox-write-root` and `--sandbox-read-root` add one for children only. A run that widens anything prints the root it widened. `--no-sandbox` turns confinement off for a single run and warns when you use it. It also rejects the four root flags, so a run can't half-escape. Environment policy stays in force either way, so opting out of the filesystem sandbox doesn't hand a script your secrets. The systems in the other columns run your workflow in your own process with your own permissions, which is the ordinary library contract and not a shortcoming. Isolation there is the deployment's job. See [Process sandboxing](./sandboxing.md) and [Host boundary](./host-boundary.md). ### Signed, versioned packages A Harn package is a versioned unit with a lockfile, stable exports, and a `harn package verify` contract. Filesystem-backed skills can go further: a project can require an Ed25519 signature chain before `harness.agent.load_skill(...)` promotes a skill's body into an agent session, so a model doesn't silently load prompt instructions off disk. Read the other columns here carefully, because `Partial` is doing real work. A framework living inside npm or PyPI inherits a much larger packaging ecosystem than Harn's, and those ecosystems have their own signing stories. The difference is what gets packaged: there, the workflow is ordinary source inside a package, while here the workflow and the instructions an agent loads are the unit that carries a version and a signature. See [Package authoring](./package-authoring.md) and [Skill provenance](./skill-provenance.md). ## Where Harn is the wrong choice The rows above are ones Harn was built to win. These are the ones it doesn't, and they are the fastest way to find out that another system fits your problem better. ### Callable from an existing codebase Harn runs a program. The [Python](https://github.com/burin-labs/harn-sdk-python) and [TypeScript](https://github.com/burin-labs/harn-sdk-typescript) SDKs are REST clients for the Harn Agents API, so they need a running server rather than linking Harn into your process. In-process means embedding the runtime in Rust, and the other way in is over MCP, ACP, or A2A. All of those are heavier boundaries than a library call. BAML sits at the other end of this: its primary path is generating a typed client for Python, TypeScript, Go, Java, C#, or Rust so an existing codebase calls into it. If your problem is one call that must return a reliable object and you want to keep the codebase you have, that's the shorter path. See [MCP, ACP, and A2A integration](./mcp-and-acp.md) and [embedding in Rust](./embedding-rust.md). ### Reuse your language's libraries A workflow written in Harn can't reach for an arbitrary package from PyPI or npm. The stdlib and host capabilities cover the orchestration surface, and the host boundary covers the rest, but a framework written in your language lets you import anything you already depend on. If your workflow leans on a specific library, count that cost before moving. See [Host boundary](./host-boundary.md) and the [scripting cheatsheet](./scripting-cheatsheet.md). ### Managed hosting from the vendor Self-hosting is the first-class path. Temporal Cloud, Inngest Cloud, and LangGraph Platform are mature managed products; Harn's managed path is early. If you want someone else to run the control plane today, they are ahead. See [Orchestrator](./orchestrator.md), [Deploy to Render](./deploy/render.md), [Deploy to Fly.io](./deploy/fly.md), and [Deploy to Railway](./deploy/railway.md). ### Proven at production scale Harn is pre-1.0, and surface-level breaking changes are possible between minor and patch releases. Temporal has years of at-scale production operation behind it. If you're putting revenue-critical work on an orchestrator this quarter, that difference is the whole decision. For a side project, an internal tool, or an experimental alpha, that same difference costs you very little. If that's the work you have, Harn is worth a try, and the rough edges you hit are the most useful thing you can send back: [open an issue](https://github.com/burin-labs/harn/issues/new). See the [changelog](https://github.com/burin-labs/harn/blob/main/CHANGELOG.md). ### Third-party integration catalog Harn ships a small connector set on purpose, and the LangChain ecosystem around LangGraph is the largest in this table by a wide margin. If your work is mostly gluing together many third-party services, you will write more of that glue yourself here. See the [connector catalog](./connectors/catalog.md). ### Futures that outlive their scope Harn's concurrency is scoped: a spawned task doesn't outlive the scope that created it, and leaving a scope cancels what it started. That makes lifetimes obvious and leaks harder, and it means you can't fire off work that keeps running after its caller returns. BAML rejected structured concurrency deliberately, so a future there outlives its creating scope with no automatic cancellation. Neither default is better. BAML's has no syntactic cost for functions that ignore cancellation; Harn's makes lifetimes explicit. Pick the one whose failure mode you would rather debug. See [Concurrency](./concurrency.md) and [Coming from elsewhere](./concepts/sota-comparison.md). ### Small install Harn requires you to download a runtime, and you probably already have a package manager you're happy with. So the question is whether Harn is the thing you're deploying, or an addition to something you already deploy. If it's the thing you're deploying, this is a one-time cost you'd pay for any runtime. If you're adding one typed model call to an existing service, it's tens of megabytes and a new artifact in a pipeline that already knew how to install packages. A framework in your own language avoids both. As of `v0.10.114`, released 2026-08-24: | Platform | Download | |---|---| | macOS arm64 | 71.1 MB | | Linux arm64 | 74.3 MB | | Linux x86_64 | 76.6 MB | | macOS x86_64 | 78.6 MB | | Windows x86_64 | 232.3 MB | The Windows number is not the runtime's real weight, and shouldn't be read as one. Harn ships a single multi-call binary; `harn-lsp` and `harn-dap` are the same executable reached through `argv[0]`. On Unix the archive stores them as symlinks, so it carries one binary. Windows has no dependable unprivileged symlink, so that archive carried three identical copies and `.zip` compressed each in full. Divide by three and Windows lands at 77.4 MB, within a megabyte of Linux x86_64. That's fixed on `main`: the archive now ships one executable and the installer creates the two aliases. `v0.10.114` predates the fix, so the figure above is what you would download today, and the next release should put Windows beside the other platforms. The other 71 to 79 MB is the runtime itself, and no work is currently tracked to reduce it. ## Public references - Inngest documents SDK-defined AI workflows, AgentKit, durable steps, flow control, and self-hosting in its public docs and repository: and . - Temporal describes open-source durable workflows, event histories, and deterministic workflow constraints in its docs: and . - LangGraph documents durable execution, checkpointing, interrupts, and human-in-the-loop patterns: and . Its time-travel documentation states directly that replay re-executes nodes rather than reading from a cache: . - Inngest AgentKit documents tool-approval human-in-the-loop: . - Temporal documents its LLM SDK integrations, and announced the experimental Temporal Agent Harness in August 2026: and . - Cursor documents self-hosted agent pools and Automations: and . - Cursor announced Automations and self-hosted cloud agents in its public changelog: and . - Cursor documents per-agent Firecracker microVM isolation and a separate AWS account for cloud agent execution: . - Temporal documents that its Python workflow sandbox isolates global state and restricts non-deterministic calls, and states directly that it is not completely isolated: . --- ## Read next - [Why Harn?](https://harnlang.com/why-harn.md) - [Getting started](https://harnlang.com/getting-started.md) --- # Getting started > Install Harn, create a project, and run your first program. Harn can run a local model if your machine has room for one, mock a model while you write the program around it, or... Website: https://harnlang.com/getting-started.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. --- Install Harn, create a project, and run your first program. Harn can run a local model if your machine has room for one, mock a model while you write the program around it, or run ordinary code with no model at all, so you can get started before signing up for a provider. ## Install Harn ### macOS and Linux ```bash curl -fsSL https://harnlang.com/install.sh | sh ``` The installer downloads the release for your operating system and CPU. To install a particular release, set `HARN_VERSION` to its release tag. ### Windows Run this command in PowerShell: ```powershell irm https://harnlang.com/install.ps1 | iex ``` ### From source ```bash git clone https://github.com/burin-labs/harn.git cd harn make setup ``` Check the installation: ```bash harn --version ``` ## Create a project The project generator creates a `harn.toml`, a program, a library directory, and a test directory. ```bash harn init hello-harn cd hello-harn ``` For a small first program, replace `main.harn` with: ```harn,check fn main(harness: Harness) { const response = harness.llm.call( "Say hello in one short sentence.", nil, { provider: "mock" } ) harness.stdio.println(response.text) } ``` Run it: ```bash harn run main.harn ``` The mock provider is deterministic and needs no network access or API key. Use it while you learn the language and write tests. ## Check your program Run these commands before you commit: ```bash harn fmt main.harn harn check main.harn harn lint main.harn ``` `fmt` applies the formatter. `check` validates syntax and types. `lint` finds common problems and style issues. ## Pick a model for your machine Before you choose a provider, let Harn choose one for you. `harn models recommend` measures free memory, GPU, and disk, checks which provider credentials it can find, and names one model to start with: ```bash harn models recommend ``` ```text vertex/claude-sonnet-4-6 17 GB free, MPS available, cloud creds available -> vertex/claude-sonnet-4-6 (local installable route available: devstral-small-2) ``` The first line is the model. The second is the reasoning: free memory, GPU, whether a cloud credential was found, and — in parentheses — the other route you could take. Your output will differ, because the answer depends on your hardware and on which credentials are already in your environment. With no cloud credentials at all, every recommendation is a local model, so this works as a first command even before you have signed up for anything. ## Run a local model If the recommendation is a local model, or you want the local route it offered as an alternative, install it: ```bash harn models install devstral-small-2 ``` For an Ollama model that pulls the weights. For llama.cpp, MLX, or vLLM it prints the exact download and launch steps for your platform instead of downloading anything. `harn local list` then shows every local runtime Harn knows about and which models each is serving, and `harn local switch ` makes one of them the active local model. ## Call a hosted provider To use a cloud provider, set its API key in your shell and test the route: ```bash export ANTHROPIC_API_KEY=your-key harn models test claude-sonnet-5 --provider anthropic ``` `harn models test` sends one small prompt and reports timing, tokens, and cost. It works for a local model too — pass the alias you installed above. Either way it checks the provider path without requiring a Harn program. To use the same provider in code, change the options in the example to: ```harn { provider: "anthropic", model: "claude-sonnet-5" } ``` Do not copy API keys into Harn source or commit them. See [Configure a provider](./provider-setup.md) for discovery, readiness checks, local models, and provider-specific details. ## See bundled examples Harn includes offline demos that do not need an API key: ```bash harn demo --list harn demo ``` When you know the kind of program you want to build, use [Common tasks](./common-tasks.md). When you need syntax details, use [Language basics](./language-basics.md). --- ## Read next - [How Harn compares](https://harnlang.com/how-harn-compares.md) - [Build your first workflow](https://harnlang.com/tutorials/build-your-first-workflow.md) --- # Build your first workflow > By the end of this page you will have built an agent that fixes a failing test and checks its own work. You will start with a single model call, grow it into an agent that can... Website: https://harnlang.com/tutorials/build-your-first-workflow.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. --- By the end of this page you will have built an agent that fixes a failing test and checks its own work. You will start with a single model call, grow it into an agent that can use tools, and finish with a two-stage workflow that verifies the result and retries with feedback when the check fails. No prior agent-orchestration background is assumed. Every block on this page is a complete program you can save and run. We use the built-in `mock` provider so everything runs offline with no API key. The `mock` provider returns canned text, so it won't actually repair code, but it lets you watch the whole machine wire up and run. When you want a model that does real work, change one word (`provider`) and read [LLM providers](../llm/providers.md). ## What you need A working `harn` binary. Check it: ```bash harn --version ``` That's the only prerequisite. The `mock` provider ships in the binary. ## Step 1: one model call The smallest useful thing is a single request. Save this as `fix.harn`: ```harn const answer = harness.llm.call( "The test test_add expects add(2, 2) == 4 but got 5. What's the" + " likely bug?", "You are a careful Rust engineer.", {provider: "mock"}, ) harness.stdio.log(answer.text) ``` Run it: ```bash harn run fix.harn ``` `harness.llm.call` sends one prompt and returns [a result dict](../llm/llm_call.md#return-value). The text is in `answer.text`; token counts, the model name, and the full transcript are on the same dict when you need them. One call, one answer. There is no loop and no way for the model to look at a file or run the test. That is the right tool when the whole job is "read this, tell me that." Ours needs more: to fix the bug, the agent has to read the code, edit it, run the test, and react to what the test says. That is a loop. ## Step 2: an agent that uses tools `agent_loop` runs a model in a loop: it calls the model, dispatches any tools the model asked for, feeds the results back, and repeats until the model reports it's done. You give it a task, a system prompt, and a set of tools. It owns the loop, the budget, and deciding when "done" means done. ```harn const result = agent_loop(harness, "Fix the failing test test_add in src/math.rs, then run the test" + " to confirm.", "You are a senior engineer. Make the smallest change that turns the" + " test green.", {provider: "mock", loop_until_done: true}, ) // "done", "stuck", "budget_exhausted", ... harness.stdio.log(result.status) harness.stdio.log(result.text) // the agent's final output // how many model round-trips it took harness.stdio.log(result.llm.iterations) ``` `loop_until_done: true` tells the loop to keep going until the model signals completion rather than stopping after the first turn. The [`status`](../llm/agent_loop.md) tells you how it ended: `done` when the model finished cleanly, `stuck` or `budget_exhausted` when it ran out of road. On the `mock` provider this returns almost immediately with canned text. On a real provider you would also pass `tools:` so the agent can read and edit files and run the test. See [Agent tools](../llm/tools.md#default-mutation-tools) for the ready-made `write_file` / `edit_file` / `run` set; the shape is: ```harn,ignore import { agent_edit_tools } from "std/agent/host_tools" const result = agent_loop(harness, task, system, { provider: "ollama", model: "qwen3-coder", tools: agent_edit_tools(), loop_until_done: true, }) ``` One `agent_loop` is one goal, run to completion. It's the right rung for "make this one thing happen." But notice what it doesn't do: nothing outside the model checks whether the test actually passes at the end. The agent decides it's done; we take its word. For real work you want an independent gate, and a way to hand the agent another attempt when the gate says no. That is a workflow. ## Step 3: verify the result A workflow runs stages in order. Each stage is a step: an agent doing work, or a plain command that checks the work. Here we wire two stages: an `act` stage that tries the fix, and a `verify` stage that runs the test and reads the exit code. ```harn import { workflow_stages } from "std/workflow/patterns" const graph = workflow_stages({ name: "fix-the-test", stages: [ { id: "act", kind: "stage", mode: "agent", model_policy: {provider: "mock"}, }, {id: "check", kind: "verify", mode: "command", verify: {command: "cargo test test_add --quiet", expect_status: 0}}, ], }) const run = workflow_execute("Fix the failing test test_add.", graph) harness.stdio.log(run.status) harness.stdio.log(run.path) ``` The `act` stage is an agent, like Step 2. The `check` stage runs a real command and passes only when it exits `0`. The verifier is separate from the agent, so "the agent thinks it's done" and "the test is actually green" are two different facts, decided by two different things. That separation is the entire point of lifting to a workflow. `workflow_execute` runs the graph and writes a run record to `run.path`. Inspect it with: ```bash harn runs view --json ``` Right now, if `check` fails, the workflow stops. The agent gets one shot. The last piece is giving it another shot, with the failure in hand. ## Step 4: retry with feedback Add a `retry_policy` to the acting stage. Two keys turn a single attempt into a repair loop: `max_attempts` caps how many tries the stage gets, and `feedback: true` threads the previous failure's findings into the next attempt's prompt. The agent's second try starts with the first try's error, not a blank slate. ```harn import { workflow_stages } from "std/workflow/patterns" const graph = workflow_stages({ name: "fix-the-test", stages: [ {id: "act", kind: "stage", mode: "agent", model_policy: {provider: "mock"}, retry_policy: {max_attempts: 3, feedback: true}}, {id: "check", kind: "verify", mode: "command", verify: {command: "cargo test test_add --quiet", expect_status: 0}}, ], }) const run = workflow_execute("Fix the failing test test_add.", graph) harness.stdio.log(run.status) ``` Now the stage tries up to three times. After a failed attempt, the next prompt carries `Previous attempt N failed: `, where the findings are the verifier's output. The agent reads what went wrong and adjusts. It stops the moment an attempt passes, and every attempt is recorded in the run so you can replay exactly what it tried. That is the self-verifying agent from the top of the page: it does the work, an independent check grades it, and a failed grade becomes the input to the next attempt. On the `mock` provider the canned reply never actually fixes the code, so you'll watch all three attempts run and the stage exhaust its retries, the machine working exactly as designed. Point `model_policy` at a real model and give the `act` stage edit and run tools, and the same graph fixes the test for real. ## What you built, and where to go next You climbed the three rungs of the [abstraction ladder](../concepts/abstraction-ladder.md): - `harness.llm.call` — one request, one answer. - `agent_loop` — one goal, run to completion with tools. - `workflow_stages` — more than one attempt, with an independent verify gate and retry-with-feedback. The rule that keeps you on the right rung: use the lowest one that covers the job, and lift only when the *shape* of the work changes: a second goal, a verify stage, a retry loop, a different model per stage. From here: - [Choosing an agent abstraction](../concepts/abstraction-ladder.md) — the full ladder and when each rung earns its cost. - [The expressiveness spectrum](../concepts/expressiveness-spectrum.md) — the same task solved at five escalating levels of control, from a three-line call up to a hand-tuned workflow. - [Workflow runtime](../workflow-runtime.md) — the reference for stages, verify nodes, retry policy, and the executor closure. - [LLM providers](../llm/providers.md) — swap `mock` for Ollama, Anthropic, or OpenAI. --- ## Read next - [Getting started](https://harnlang.com/getting-started.md) - [Tutorial: code review agent](https://harnlang.com/tutorial-code-review-agent.md) --- # Tutorial: build a code review agent > This tutorial shows a small but realistic review pipeline. The goal is not to rebuild a full IDE integration. Instead, we want a deterministic Harn program that can review a... Website: https://harnlang.com/tutorial-code-review-agent.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. --- This tutorial shows a small but realistic review pipeline. The goal is not to rebuild a full IDE integration. Instead, we want a deterministic Harn program that can review a patch, inspect context, and return a concise report. Use the companion example as a starting point: ```bash harn run examples/code-reviewer.harn -- "$(git diff)" ``` ## 1. Start with a tight review prompt The simplest useful reviewer is just an LLM call with a strong system prompt. Keep the instructions short, specific, and opinionated: ```harn pipeline default(harness: Harness, task) { const system = """ You are a senior code reviewer. Review the patch for correctness, security, maintainability, and tests. Return: - must-fix issues - suggestions - missing tests End with a short verdict. """ const review = harness.llm.call(task, system, { temperature: 0.2, max_tokens: 1200, }) harness.stdio.log(review.text) } ``` This is enough when the user pastes a diff directly into `task`. The `task` parameter is supplied by whatever drives the pipeline — an editor host over ACP, `harn serve`, or a caller that runs the pipeline for you. `harn run` does not bind it, so a pipeline you want to run straight from the CLI reads its input from `argv` instead, the way the companion example above does. Pick the shape that matches how the reviewer will be invoked. ## 2. Add file context when you need it Real review agents usually need a bit of surrounding code. The simplest route is to read a small, explicit list of files and combine them with the patch. Keep the list short so the prompt stays focused. ```harn pipeline default(harness: Harness, task) { const files = ["src/main.rs", "src/lib.rs"] let context = "" for file in files { context = context + "\n\n=== " + file + " ===\n" + harness.fs.read_text(file) } const review = harness.llm.call( "Patch:\n" + task + "\n\nContext:\n" + context, """ You are a strict code reviewer. Flag correctness bugs first, then test gaps, then maintainability issues. Do not invent missing context. If the context is insufficient, say so. """, {temperature: 0.2, max_tokens: 1400} ) harness.stdio.log(review.text) } ``` If you want to review a directory tree instead, use `harness.fs.list_dir()` and `parallel each` to gather files concurrently, then trim the result to the most relevant ones before calling the model. ## 3. Make the review measurable Good review agents should record something observable, even if it is only a small heuristic. Use `eval_metric()` to track whether the agent found issues and how often it asked for more context. ```harn pipeline default(harness: Harness, task) { const review = harness.llm.call( task, "You are a code reviewer. Return a concise bullet list.", {temperature: 0.2} ) const has_issue = review.text.contains("issue") || review.text.contains("bug") eval_metric("review_has_issue", has_issue) eval_metric("review_chars", review.text.count) harness.stdio.log(review.text) } ``` Recorded metrics are printed by the run and, for a workflow run, saved into the run record under `.harn-runs/` that `harn eval` reads. See [the eval pipeline tutorial](./tutorial-eval-pipeline.md) for that path. ## 4. When to stop Use the agent loop when the review needs to gather context, but stop once the review itself is stable. For code review, that usually means: - inspect a small, explicit file set - keep the system prompt short - request concrete fixes, not a long essay - record metrics so you can compare review quality over time If you need a richer workflow, combine this with the eval tutorial and the [debugging tools](./debugging.md). --- ## Read next - [Build your first workflow](https://harnlang.com/tutorials/build-your-first-workflow.md) - [Tutorial: MCP server](https://harnlang.com/tutorial-mcp-server.md) --- # Tutorial: build an MCP server > This tutorial builds a small MCP server in Harn. The same program can expose tools, static resources, resource templates, and prompts over stdio or Streamable HTTP. Website: https://harnlang.com/tutorial-mcp-server.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. --- This tutorial builds a small MCP server in Harn. The same program can expose tools, static resources, resource templates, and prompts over stdio or Streamable HTTP. Use the companion example as a baseline: ```bash harn serve mcp examples/mcp_server.harn ``` ## 1. Register tools Start by creating a tool registry and attaching a few tools with explicit schemas: ```harn,check pipeline main(harness: Harness) { let tools = tool_registry() tools = tool_define(tools, "greet", "Greet someone by name", { parameters: { name: "string" }, handler: { args -> "Hello, " + args.name + "!" }, annotations: { title: "Greeting Tool", readOnlyHint: true, destructiveHint: false, } }) tools = tool_define(tools, "add", "Add two numbers", { parameters: { a: "number", b: "number" }, handler: { args -> to_string(args.a + args.b) } }) harness.tools.mcp_tools(tools) } ``` Keep tool names short and descriptive. The description should be written for a model, not for a human reading source code. ## 2. Add resources and templates Resources are good for static content, while resource templates are better for parameterized data. ```harn,check pipeline main(harness: Harness) { harness.tools.mcp_resource({ uri: "docs://readme", name: "README", mime_type: "text/markdown", text: "# Harn MCP Demo\n\nThis server is implemented in Harn." }) harness.tools.mcp_resource_template({ uri_template: "config://{key}", name: "Configuration values", mime_type: "text/plain", completions: { key: ["name", "version"] }, handler: { args -> if args.key == "version" { "0.6.0" } else if args.key == "name" { "harn-demo" } else { "unknown key: " + args.key } } }) } ``` That pattern is useful for docs, policy data, generated summaries, and other state you want to expose without writing a dedicated tool for each lookup. ## 3. Add prompts Prompts let the client ask the server for structured guidance: ```harn,check pipeline main(harness: Harness) { harness.tools.mcp_prompt({ name: "code_review", description: "Review code for correctness and maintainability", arguments: [ { name: "code", description: "The code to review", required: true }, { name: "language", description: "Programming language", suggestions: ["rust", "typescript", "python"] } ], handler: { args -> const lang = args.language ?? "unknown" "Please review this " + lang + " code for correctness, bugs, and" + " tests:\n\n" + args.code } }) } ``` Prompts are a good way to standardize a client workflow while still letting the client supply the final payload. ## 4. Run it Once the pipeline calls `harness.tools.mcp_tools()`, `harness.tools.mcp_resource()`, or `harness.tools.mcp_prompt()`, launch the server with: ```bash harn serve mcp examples/mcp_server.harn ``` `harn serve mcp` automatically detects whether the script defines its surface through `pub fn` exports (the recommended path) or through the `harness.tools.mcp_tools(...)` / `harness.tools.mcp_resource(...)` / `harness.tools.mcp_prompt(...)` registration builtins shown above and serves the appropriate one over the requested transport. Use `--transport http` to expose the same MCP surface over Streamable HTTP. All user-visible output goes to stderr; the MCP transport stays on stdout. That keeps the server compatible with Claude Desktop, Cursor, and other MCP clients. ## 5. Keep the surface small A good MCP server has a narrow surface area: - expose only the operations the client truly needs - keep tool names and schemas stable - prefer explicit resources over ad hoc text blobs - use resource templates when one static resource is not enough If you want the server to be consumable from a desktop client, add a short launch snippet in the client config and test the tool list before expanding the surface. --- ## Read next - [Tutorial: code review agent](https://harnlang.com/tutorial-code-review-agent.md) - [Tutorial: eval pipeline](https://harnlang.com/tutorial-eval-pipeline.md) --- # Tutorial: build an eval pipeline > This tutorial builds a small evaluation loop that runs a set of examples, records metrics, and produces an auditable summary. The goal is to make quality visible, not to build... Website: https://harnlang.com/tutorial-eval-pipeline.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. --- This tutorial builds a small evaluation loop that runs a set of examples, records metrics, and produces an auditable summary. The goal is to make quality visible, not to build an elaborate benchmark harness. Use the companion example as a baseline: ```bash harn run examples/eval-workflow.harn ``` ## 1. Define the dataset inline Start with a tiny set of representative inputs. Keep the examples small enough that you can inspect failures by eye: ```harn pipeline main(harness: Harness) { const cases = [ {id: "case-1", input: "What is 2 + 2?", expected: "4"}, {id: "case-2", input: "Capital of France?", expected: "Paris"}, {id: "case-3", input: "Color of grass?", expected: "green"}, ] harness.stdio.log("Loaded ${cases.count} eval cases") } ``` ## 2. Run the cases in parallel If each case is independent, use `parallel each` so the slow parts overlap. ```harn pipeline main(harness: Harness) { const cases = [ {id: "case-1", input: "What is 2 + 2?", expected: "4"}, {id: "case-2", input: "Capital of France?", expected: "Paris"}, {id: "case-3", input: "Color of grass?", expected: "green"}, ] const results = parallel each cases { tc -> const answer = harness.llm.call( tc.input, "Answer in one word or short phrase.", { temperature: 0.0, max_tokens: 64, }) { id: tc.id, expected: tc.expected, actual: answer.text, correct: answer.text.contains(tc.expected), } } harness.stdio.log(json_stringify(results)) } ``` For a real eval suite, replace the inline `cases` list with a manifest or a dataset file that your pipeline reads with `harness.fs.read_text()`. ## 3. Record metrics The important part of an eval pipeline is the metric trail. Use `eval_metric()` to record per-case and aggregate results. ```harn pipeline main(harness: Harness) { const cases = [ {id: "case-1", input: "What is 2 + 2?", expected: "4"}, {id: "case-2", input: "Capital of France?", expected: "Paris"}, ] let passed = 0 for tc in cases { const answer = harness.llm.call( tc.input, "Answer in one word.", {temperature: 0.0}, ) const correct = answer.text.contains(tc.expected) if correct { passed = passed + 1 } eval_metric("case_correct", correct, {case_id: tc.id}) } // `/` on two ints truncates: 1 of 2 passing would report 0, not 0.5. const accuracy = to_float(passed) / cases.count eval_metric("accuracy", accuracy, {passed: passed, total: cases.count}) eval_metric("run_id", harness.random.uuid()) eval_metric("generated_at", harness.clock.timestamp()) } ``` ## 4. Export a report Once the metrics are recorded, write a compact report so a later run can diff the results. ```harn pipeline main(harness: Harness) { const summary = { run_id: harness.random.uuid(), generated_at: harness.clock.timestamp(), accuracy: 0.83, notes: "Replace the fixed accuracy with real case scoring", } harness.fs.write_text("eval-summary.json", json_stringify(summary)) harness.stdio.log(json_stringify(summary)) } ``` ## 5. How to use it Run the pipeline and read the metric trail it prints: ```bash harn run examples/eval-workflow.harn ``` ```text [harn] === Recorded Metrics === [harn] case_correct = true [harn] case_correct = true [harn] case_correct = true [harn] accuracy = 1.0 [harn] test_suite_size = 3 ``` To compare runs over time you need a saved run record. `harn run` on a plain pipeline does not write one — `.harn-runs/` is populated by `workflow_execute`, and that is what `harn eval` and `harn replay` consume: ```bash harn eval .harn-runs/.json harn eval .harn-runs/ # every record in the directory ``` See [the workflow runtime](./workflow-runtime.md) for the run-record shape and how to produce one. A good eval pipeline answers three questions: - did the model improve? - did latency or token usage regress? - which cases failed, and why? ## Skill and guidance gates Use `harn eval skill-gate` when the artifact under review is a skill or guidance edit. The manifest records contamination-safe held-out tasks, with-vs-without observations, the frontier score, context-cost inputs, and immutable grader checksums: ```bash harn eval skill-gate examples/evals/skill-gate/smoke/manifest.json \ --output .harn-runs/skill-gate/smoke ``` The command writes `summary.json`, `per_case.jsonl`, `summary.md`, and a machine-readable `receipt.json` using `harn.skill_gate.receipt.v1`. The gate excludes static public/pre-cutoff tasks, reports gap recovery per cluster, rejects regressions and context bloat, and fails closed when a protected grader file or directory hash changes. --- ## Read next - [Tutorial: MCP server](https://harnlang.com/tutorial-mcp-server.md) - [Tutorial: durable daemon agent](https://harnlang.com/tutorial-daemon-agent.md) --- # Tutorial: from one-shot agent to durable daemon > This tutorial walks an agent through every rung of the lifecycle ladder: from a single agent_loop call that returns once, to a parked worker that survives process restart, to a... Website: https://harnlang.com/tutorial-daemon-agent.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. --- This tutorial walks an agent through every rung of the lifecycle ladder: from a single `agent_loop` call that returns once, to a parked worker that survives process restart, to a bounded pool of agents that wake on channel events. Each step builds on the previous one and runs unmodified on a fresh `harn` install — no API keys, no extra setup, only the `mock` provider so the output is deterministic. If you want the long-form reference for any primitive used below, see [Agent lifecycle](./agent-lifecycle.md), [Pipeline lifecycle](./pipeline-lifecycle.md), [Pool stdlib](./stdlib/lifecycle-pool.md), and [Agent channels](./agent-channels.md). The [lifecycle cookbook](./cookbooks/lifecycle.md) collects production-shaped recipes that compose the same primitives. > Each step prints a small diagnostic line ("status=...", "snapshot=...") so > you can compare your run against the expected output. `harness.stdio.log` > prefixes every line with a `[harn]` tag, which is why the expected output > below carries that prefix. From step 3 on, the runtime also writes > `worker snapshot ... dropping non-serializable option` warnings to stderr > when it persists a parked worker; those are expected and elided below. ## 1. One-shot agent Start with the smallest useful loop: one prompt, one mocked response, one result. The pipeline returns as soon as the loop completes. ```harn,check import { agent_loop } from "std/agent/loop" pipeline main(harness: Harness) { harness.llm.mock_enqueue({ text: "Triaged ticket #42 as duplicate of #38." }) const result = agent_loop(harness, "Triage ticket #42.", "You are a careful triage assistant.", {provider: "mock"}, ) harness.stdio.log("status=" + result.status) harness.stdio.log("text=" + result.visible_text) } ``` ```text $ harn run step1.harn [harn] status=done [harn] text=Triaged ticket #42 as duplicate of #38. ``` This is the baseline. The loop ran one turn and returned `status: "done"`. Nothing else is happening — no channels, no checkpoints, no resume. ## 2. Capture unsettled work at finish Real pipelines spawn subagents, queue pool tasks, and emit channel events that may outlive the body. The runtime exposes that work through `harness.unsettled_state()`; `pipeline_on_finish` registers a callback that fires after the pipeline returns and decides what to do with whatever is still in flight. The `on_finish_drain` preset walks the unsettled buckets and applies a default disposition (cancel, acknowledge, defer, drain) per item. ```harn,check import { agent_loop } from "std/agent/loop" import { on_finish_drain } from "std/lifecycle" pipeline main(harness: Harness) { harness.agent.pipeline_on_finish(on_finish_drain) harness.llm.mock_enqueue({ text: "Triaged ticket #42 as duplicate of #38." }) const result = agent_loop(harness, "Triage ticket #42.", "You are a careful triage assistant.", {provider: "mock"}, ) harness.stdio.log("status=" + result.status) harness.stdio.log("text=" + result.visible_text) } ``` The visible output matches step 1 — the loop returns naturally, so there is nothing unsettled to act on. The registration becomes load-bearing in step 3, where a parked worker stays in `suspended_subagents` until the drain callback walks the bucket. See [Pipeline lifecycle presets](./stdlib/lifecycle.md) for the rest of the preset family (`on_finish_abandon`, `on_finish_handoff_to`, `on_finish_block_until_settled`) and [Pipeline lifecycle](./pipeline-lifecycle.md) for the full callback contract. ## 3. Self-park mid-loop `agent_loop` exposes `agent_await_resumption` to the model as a callable tool. When the model wants to wait on an external signal (a human review, an upstream merge, anything off-VM), it calls that tool and the loop yields between turns. The pipeline gets back `status: "suspended"` with a snapshot path. ```harn,check import { agent_loop } from "std/agent/loop" import { on_finish_drain } from "std/lifecycle" pipeline main(harness: Harness) { harness.agent.pipeline_on_finish(on_finish_drain) harness.llm.mock_enqueue({ tool_calls: [{ id: "park_1", name: "agent_await_resumption", arguments: {reason: "waiting on maintainer review"}, }], }) const result = agent_loop(harness, "Triage ticket #42, escalate to a human if you need review.", "If you need a human review before proceeding, call" + " agent_await_resumption.", {provider: "mock", tool_format: "native", max_iterations: 2}, ) harness.stdio.log("status=" + result.status) harness.stdio.log("reason=" + result.reason) harness.stdio.log("snapshot=" + result.handle.snapshot_path) } ``` ```text $ harn run step3.harn [harn] status=suspended [harn] reason=waiting on maintainer review [harn] snapshot=.harn/workers/worker_01a0087a-….json ``` The snapshot is a JSON document on disk. The runtime persists the full transcript, the parsed conditions, the resume responsibility, and enough session metadata to rehydrate in a different process. Suspend is *cooperative* — the loop honors the request at the next turn boundary, not mid-tool-call. See [Agent lifecycle § When to suspend](./agent-lifecycle.md#when-to-suspend) for the rest of the ways an agent can yield and which one to reach for. ## 4. Resume from the snapshot The snapshot from step 3 is enough to drive the worker forward in any process. The CLI does this with `harn run --resume `: it rehydrates the worker, replays a single-shot `resume_continuity` system reminder onto the next turn, and finishes the loop. The script below runs both halves in one process so the tutorial stays self-contained; the prose after the snippet shows the cross-process command. ```harn,check import { agent_loop } from "std/agent/loop" import { on_finish_drain } from "std/lifecycle" import { resume_agent, wait_agent } from "std/agent/workers" pipeline main(harness: Harness) { harness.agent.pipeline_on_finish(on_finish_drain) harness.llm.mock_enqueue({ tool_calls: [{ id: "park_1", name: "agent_await_resumption", arguments: {reason: "waiting on maintainer review"}, }], }) harness.llm.mock_enqueue({ text: "Approved. Triaged ticket #42 as duplicate of #38." }) const first = agent_loop(harness, "Triage ticket #42, escalate to a human if you need review.", "If you need a human review before proceeding, call" + " agent_await_resumption.", {provider: "mock", tool_format: "native", max_iterations: 3}, ) harness.stdio.log("first.status=" + first.status) harness.stdio.log("snapshot=" + first.handle.snapshot_path) resume_agent(harness.agent, first.handle) const done = wait_agent(harness.agent, first.handle) harness.stdio.log("after_resume=" + done.status) harness.stdio.log("text=" + done.result.summary) } ``` ```text $ harn run step4.harn [harn] first.status=suspended [harn] snapshot=.harn/workers/worker_01a0087b-….json [harn] after_resume=completed [harn] text=Approved. Triaged ticket #42 as duplicate of #38. ``` To do the same thing across processes, run step 3, copy the printed snapshot path, and run `harn run --resume --json`. The `--json` flag streams newline-delimited JSON envelopes — transcript and hook events as the loop runs, then a final line whose `data.event_type` is `result` and whose `data.value` is the loop's return value — so the run can be piped into another tool. Snapshots live under `.harn/workers/` by default; the path is script-relative, so resume the script from the same working directory. ## 5. Wake on a channel event Step 3 left the worker parked open — only an operator can resume it. Most real waits have a concrete signal to listen for: a PR merging, a release cutting, a calendar event firing. Attach `conditions.trigger` to the `agent_await_resumption` call and the runtime registers the trigger with the dispatcher; firing the trigger drives the worker to completion with `initiator: "triggered"` and no explicit `resume_agent` call from the pipeline. ```harn,check import { on_finish_drain } from "std/lifecycle" import { sub_agent_run, wait_agent } from "std/agent/workers" pipeline main(harness: Harness) { harness.agent.pipeline_on_finish(on_finish_drain) harness.llm.mock_enqueue({ tool_calls: [{ id: "park_for_release", name: "agent_await_resumption", arguments: { reason: "waiting on release.cut", conditions: { trigger: { kind: "channel.emit", provider: "channel", match: {events: ["channel:release.cut"]}, }, }, }, }], }) harness.llm.mock_enqueue({ text: "Release cut; tagged v0.9.0 and posted to the changelog.", }) const worker = sub_agent_run( harness, "Tag the next release once the maintainer signals.", { provider: "mock", background: true, tool_format: "native", max_iterations: 3, }, ) const parked = wait_agent(harness.agent, worker) harness.stdio.log("parked_status=" + parked.status) const waiting = parked.suspension.conditions.trigger.match.events[0] harness.stdio.log("waiting_on=" + waiting) harness.channels.append("release.cut", {tag: "v0.9.0"}) const done = wait_agent(harness.agent, worker) harness.stdio.log("final_status=" + done.status) harness.stdio.log("final_text=" + done.result.summary) } ``` ```text $ harn run step5.harn [harn] parked_status=suspended [harn] waiting_on=channel:release.cut [harn] final_status=completed [harn] final_text=Release cut; tagged v0.9.0 and posted to the changelog. ``` `wait_agent` blocks until the worker reaches a terminal *or* parked state, which gives the pipeline a deterministic point to fire the event. Any trigger kind that `trigger_register` accepts works as a resume condition — GitHub webhooks, file watchers, calendar events, custom providers. See [Agent channels](./agent-channels.md) for the channel surface and [Agent lifecycle § Conditioned resume](./agent-lifecycle.md#conditioned-resume) for the rest of the `ResumeConditions` shape (timeouts, `on_event`, `resume_by`). ## 6. Fan out under a bounded pool A real product runs many agents at once but only so many in parallel. The `std/lifecycle/pool` registry shares a single concurrency budget across submissions; `max_concurrent` caps the active slot count, the rest queue. Pool tasks compose with the same waiter as agent handles, so `pool_wait` (or `wait_agent`) collects results uniformly. ```harn,check import { agent_loop } from "std/agent/loop" import { on_finish_drain } from "std/lifecycle" import { pool_create, pool_wait } from "std/lifecycle/pool" pipeline main(harness: Harness) { harness.agent.pipeline_on_finish(on_finish_drain) const tickets = ["t-101", "t-102", "t-103"] const pool = pool_create( harness.agent, {name: "ticket-triage", max_concurrent: 2}, ) let handles = [] for ticket in tickets { const t = ticket handles = handles + [pool.submit({ -> return agent_loop(harness, "Triage ticket " + t + ".", "You are a careful triage assistant.", {provider: "mock"}, ) })] } const outcomes = pool_wait(harness.agent, handles) for outcome in outcomes { harness.stdio.log(outcome.status + " " + outcome.result.visible_text) } } ``` ```text $ harn run step6.harn [harn] completed Mock response to 3-word prompt: Triage ticket t-101. [harn] completed Mock response to 3-word prompt: Triage ticket t-102. [harn] completed Mock response to 3-word prompt: Triage ticket t-103. ``` The mock provider's default echo keeps the output deterministic here. `harness.llm.mock_enqueue` cannot be used to pin per-ticket replies inside a pool task today — enqueued entries registered on the pipeline thread are not visible inside `pool.submit` closures ([#6726](https://github.com/burin-labs/harn/issues/6726)). Pin responses in the pipeline body, or drive the pool from a test that stubs the tool layer instead. The pool ran two agents concurrently and queued the third. Combine the pool with the parking pattern from step 5 to fan a queue of long-running human-in-the-loop reviews across a bounded worker set — each pool task spawns an agent that parks waiting for its own signal, the orchestrator emits the signals as approvals land, and the resume-continuity reminder (auto-injected on every resume; visible in the persisted transcript) tells the model what changed during the pause so it picks up without re-reading the whole conversation. See [Pool stdlib](./stdlib/lifecycle-pool.md) for queue strategies, backpressure, and fairness keys; the [Pool cookbook](./cookbooks/pools.md) shows the production-shaped patterns. ## Where to go next - [Agent lifecycle](./agent-lifecycle.md) — the long-form reference for every suspend/resume primitive used above, plus `ResumeBy.*` for naming who owns the resume. - [Pipeline lifecycle cookbook](./cookbooks/lifecycle.md) — multi-pipeline hand-off, custom drain policies, supervised suspend denial, replay- deterministic test harnesses. - [Daemon stdlib](./stdlib/daemon.md) — first-class `daemon_spawn` / `daemon_trigger` / `daemon_resume` wrappers when a worker needs to outlive a single pipeline run. - [`harn run --resume` in the CLI reference](./cli-reference.md) — the out-of-process resume path used in step 4. --- ## Read next - [Tutorial: eval pipeline](https://harnlang.com/tutorial-eval-pipeline.md) - [Common tasks](https://harnlang.com/common-tasks.md) --- # Common tasks > Find the task that matches your next step. Follow one path until you have a working first version, then return to the reference pages for detail. Website: https://harnlang.com/common-tasks.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. --- Find the task that matches your next step. Follow one path until you have a working first version, then return to the reference pages for detail. ## Build an agent | Task | Start here | |---|---| | Learn the language | [Language basics](./language-basics.md) | | Call a model | [LLM calls and agents](./llm-and-agents.md) | | Give an agent tools | [LLM tools](./llm/tools.md) | | Add several dependent stages | [Workflow runtime](./workflow-runtime.md) | | Run independent child agents | [Delegated workers](./llm/agent_loop.md#delegated-workers) | | Test without an API key | [Mock LLM responses](./llm/llm_call.md#testing-with-mock-llm-responses) | ## Connect a system | Task | Start here | |---|---| | Call an MCP server | [MCP, ACP, and A2A integration](./mcp-and-acp.md) | | Expose a Harn program | [Outbound workflow server](./harn-serve.md) | | Receive events | [Trigger manifests](./triggers/manifest.md) | | Author a connector package | [Connector authoring](./connectors/authoring.md) | | Add human approval | [Human in the loop](./hitl.md) | ## Operate a run | Task | Start here | |---|---| | Inspect a run | [Harn portal](./portal.md) | | Debug a failed agent | [Debugging agent runs](./debugging.md) | | Replay or evaluate behavior | [Testing](./testing.md) | | Deploy an orchestrator | [Orchestrator](./orchestrator.md) | | Manage secrets and OAuth | [Orchestrator secrets](./orchestrator/secrets.md) | ## Starting from zero 1. Follow [Getting started](./getting-started.md). 2. Read [Language basics](./language-basics.md). 3. Build one small model-backed program with [LLM calls and agents](./llm-and-agents.md). 4. Add tools, workflows, or workers only when the task needs them. 5. Read [Best practices](./best-practices.md) before you run unattended or write to an external system. --- ## Read next - [Tutorial: durable daemon agent](https://harnlang.com/tutorial-daemon-agent.md) - [Configure a provider](https://harnlang.com/provider-setup.md) --- # Configure a model provider > This guide helps you connect Harn to a model provider. It covers the checks you can run before you put a provider in a program. Website: https://harnlang.com/provider-setup.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. --- This guide helps you connect Harn to a model provider. It covers the checks you can run before you put a provider in a program. ## 1. Check the local installation Run the doctor command first: ```bash harn doctor harn doctor --check-providers ``` The first command reports the local Harn setup. The second also checks the configured provider paths. It does not print secret values. ## 2. Find a provider and model List the models that Harn knows about: ```bash harn models list harn models list --provider anthropic ``` Inspect one model before you use it: ```bash harn models info claude-sonnet-5 harn provider dispatch-explain anthropic claude-sonnet-5 ``` The catalog is the source of truth for current aliases and capabilities. Do not copy an old model name from an example when the catalog gives you a newer one. ## 3. Set the provider credential Each provider reads its key from an environment variable. Set the one for the provider you picked: ```bash export ANTHROPIC_API_KEY="sk-ant-..." ``` Harn names these providers first, because most people already have an account with one of them: | Provider | Environment variable | |---|---| | Anthropic | `ANTHROPIC_API_KEY` | | OpenAI | `OPENAI_API_KEY` | | Google Gemini | `GEMINI_API_KEY` | | OpenRouter | `OPENROUTER_API_KEY` | | Groq | `GROQ_API_KEY` | | DeepSeek | `DEEPSEEK_API_KEY` | | Ollama | none — runs locally without a key | Harn supports dozens more, and some accept more than one variable. For every provider and the variables it reads, see [credential variables](./provider-support.md#credential-variables); for the endpoint and header details behind them, see the [provider reference](./llm/providers.md#provider-api-details). To see which variables are already set on this machine, run `harn doctor`. ### Keep the key out of your shell A variable can hold a secret reference instead of the key itself: ```bash export ANTHROPIC_API_KEY="harn-secret://work/anthropic" ``` Harn resolves the reference when it makes the call, so the key never lands in your shell history or a config file. See [secrets](./orchestrator/secrets.md) for how to store one. After you set the variable, confirm the provider resolves: ```bash harn doctor --check-providers ``` That command reports which providers have a working credential path. It never prints a secret value. ## 4. Test the connection Use the model test command for a small smoke test: ```bash harn models test claude-sonnet-5 --provider anthropic ``` The command sends a test request. It can use provider credits. Use a model that is available to your account. ## 5. Use the provider in a program Keep the provider and model in the call options: ```harn fn main(harness: Harness) { const response = harness.llm.call( "Reply with one short greeting.", nil, { provider: "anthropic", model: "claude-sonnet-5", max_tokens: 64 } ) harness.stdio.println(response.text) } ``` For a single project, put stable defaults in `harn.toml` when the provider reference says the setting is supported. Keep credentials out of that file. ## Local providers Start your local server first, then use the provider's name and model from the catalog: ```bash harn models list --provider ollama harn provider ready ollama --model ``` For an OpenAI-compatible server, check the endpoint and model settings in the [provider reference](./llm/providers.md#local-openai-compatible-server). ## Use the mock provider in tests The `mock` provider needs no credentials and returns deterministic responses. Use it for syntax checks, unit tests, and examples. A mock run proves that the program reaches Harn's model-call boundary; it does not prove that a cloud provider is configured or that a model produces useful answers. See [mock LLM responses](./llm/llm_call.md#testing-with-mock-llm-responses) for queued responses and error cases. --- ## Read next - [Common tasks](https://harnlang.com/common-tasks.md) - [Run a workflow bundle from the CLI](https://harnlang.com/workflow-authoring-quickstart.md) --- # How to run a workflow bundle from the CLI > This guide takes a fresh harn install from "I have the binary" to "I can author, validate, preview, run, and supervise a portable workflow bundle." Every command and fixture... Website: https://harnlang.com/workflow-authoring-quickstart.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. --- This guide takes a fresh `harn` install from "I have the binary" to "I can author, validate, preview, run, and supervise a portable workflow bundle." Every command and fixture below is checked in CI by `scripts/check_docs_workflow_quickstart.harn`, so the snippets below match what you should see locally. > **Which "workflow" is this?** Harn has two surfaces that both use the > word. This page is the **bundle CLI**: `harn workflow validate` / > `preview` / `run` operate on a portable bundle JSON file that a host > (an IDE or a cloud orchestrator) later executes. It is not the > in-script `workflow_stages` API you call from `.harn` code to run an > agent with retry and verify stages. If you want to write a workflow in > Harn and run it in-process, start with > [Build your first workflow](./tutorials/build-your-first-workflow.md) > and the [workflow runtime reference](./workflow-runtime.md) instead. No paid API credentials are required. The deterministic local runner walks the bundle's reachable graph and emits a receipt without calling any LLM or hitting any provider. ## Prerequisites You need the `harn` CLI on `$PATH`: ```bash harn --version ``` The fixtures referenced below live in this repo at: - `docs/fixtures/workflow-bundles/quickstart-minimal.bundle.json` - `docs/fixtures/workflow-bundles/quickstart-agentic.bundle.json` - `docs/fixtures/connect-demo/harn.toml` Copy them to your workspace and edit in place. ## 1. Validate a minimal deterministic bundle A workflow bundle is JSON while authoring. The smallest viable schema v2 bundle has canonical package metadata, identity fields, one trigger, and one node. ```bash harn workflow validate \ --bundle docs/fixtures/workflow-bundles/quickstart-minimal.bundle.json \ --json ``` You should see: ```json { "valid": true, "bundle_id": "quickstart-minimal", "workflow_id": "quickstart_minimal_workflow", "graph_digest": "sha256:4eccd4902bd587b1bc0f9a6d03b699ba0c957917b3e4069a009d769f0f059782", "errors": [], "warnings": [] } ``` The `graph_digest` is a SHA-256 over the canonical workflow graph. The same bundle always produces the same digest, which is how IDE hosts and cloud orchestrators later compare two runs against the same workflow identity. ## 2. Preview the normalized graph `harn workflow preview --json` emits the contract that hosts use to render and edit the workflow. It expands the bundle into nodes for triggers, connectors, catchup branches, DLQ paths, and terminal states that the bundle file does not enumerate. ```bash harn workflow preview \ --bundle docs/fixtures/workflow-bundles/quickstart-minimal.bundle.json \ --json ``` For quick visual inspection, the same command supports `--mermaid`: ```bash harn workflow preview \ --bundle docs/fixtures/workflow-bundles/quickstart-minimal.bundle.json \ --mermaid ``` ```text flowchart TD n_389fbfc4_node_notify["notification: Notify completion"] n_7507e151_node_summarize["action: Summarize input"] n_8725ce85_terminal_completed["terminal: Completed"] n_df3386d3_terminal_failed["terminal: Failed"] n_f5062c77_trigger_manual_start["trigger: manual-start"] n_389fbfc4_node_notify -->|completed| n_8725ce85_terminal_completed n_7507e151_node_summarize --> n_389fbfc4_node_notify n_f5062c77_trigger_manual_start -->|dispatch| n_7507e151_node_summarize ``` Use `--json` for editing and validation; the Mermaid view is for debugging and quickref docs only. See [Portable workflow bundles](./workflow-bundles.md) for the full set of fields the JSON view exposes (`graph.nodes`, `graph.edges`, `graph.editable_fields`, `graph.diagnostics`). ## 3. Run a deterministic local receipt `harn workflow run` materializes a deterministic local receipt. It walks the reachable graph from the entry node, records each node's status, and copies the bundle's policy/connectors/environment. It does not invoke LLMs, mutate files, or call provider APIs — those are the host's job. ```bash harn workflow run \ --bundle docs/fixtures/workflow-bundles/quickstart-minimal.bundle.json \ --json ``` The receipt is reproducible: pinning `receipts.run_id` in the bundle (or passing `--trigger-id` and `--event-id` for replay) gives you byte-identical output runs across machines. ## 4. Add prompt capsules and connectors Real workflows mix deterministic actions with agentic steps and provider connector calls. The agentic fixture below adds a `review` node of kind `agent`, attaches a prompt capsule keyed to it, and declares a GitHub connector requirement: ```bash harn workflow validate \ --bundle docs/fixtures/workflow-bundles/quickstart-agentic.bundle.json \ --json harn workflow run \ --bundle docs/fixtures/workflow-bundles/quickstart-agentic.bundle.json \ --json ``` The local run still needs no API credentials — prompt capsules are self-contained continuation prompts that hosts execute when they dispatch the agent node. The bundle stays portable and the receipt stays deterministic; only the live host run involves the LLM. To replay the agentic bundle as if a specific GitHub event fired it: ```bash harn workflow run \ --bundle docs/fixtures/workflow-bundles/quickstart-agentic.bundle.json \ --trigger-id github-pr-opened \ --event-id github:event:pr-1 \ --json ``` ## 5. Inspect connector requirements Bundles declare provider connectors the host must satisfy before autonomous dispatch. `harn connect status --json` reports installed connectors and what they need; `harn connect setup-plan --json` emits the host setup steps for one connector. The fixture under `docs/fixtures/connect-demo/` is a self-contained manifest you can run those commands against without OAuth or real credentials: ```bash cd docs/fixtures/connect-demo harn connect status --json harn connect setup-plan --connector demo --json ``` You will see the demo connector reported as `installed: true` but `usable: false` with `status: "missing_auth"` — exactly the surface a host UI consumes when it offers the user a "Connect" button. The `recovery` block carries the human-readable remediation copy. For real providers, see: - [Connector authoring](./connectors/authoring.md) — package layout and setup metadata schema. - [Connector OAuth](./orchestrator/oauth.md) — provider-specific flows for GitHub, Linear, Slack, and Notion. - [Connector parity matrix](./connectors/parity-matrix.md) — which providers are wired today. ## 6. Supervise locally with `harn supervisor` For trigger-driven workflows that need to react to live events (GitHub webhooks, cron ticks, MCP wakeups), the supervisor keeps a durable orchestrator process running and exposes pause/resume/fire/ replay controls. The supervisor reads the same `harn.toml` manifest your trigger packages live under — it does not consume bundle JSON directly: ```bash harn supervisor start --config harn.toml --state-dir .harn/orchestrator --json harn supervisor list --config harn.toml --state-dir .harn/orchestrator --json harn supervisor fire --payload-json '{}' --json harn supervisor stop --config harn.toml --state-dir .harn/orchestrator --json ``` See [Local workflow supervisor](./workflow-supervisor.md) for the complete host contract, DLQ commands, and recovery flows. ## 7. Failure modes and remediation | Symptom | Likely cause | Fix | | --- | --- | --- | | `validate` reports `unknown trigger kind` | typo in `triggers[].kind` | Use one of `github`, `cron`, `delay`, `webhook`, `mcp`, `manual`. See [Portable workflow bundles](./workflow-bundles.md#contract). | | `validate` reports `node id mismatch` | the map key in `workflow.nodes` does not match its inner `id` | Make the inner `id` equal the key. | | `run` exits without executing every node | unreachable nodes from the bundle's `entry` | Add `edges` connecting them, or change `entry`. | | `connect status --json` returns `manifest: null` | no `harn.toml` found in cwd or parents | `cd` into a directory whose `harn.toml` declares the providers you want to inspect. | | `connect status` shows `missing_auth` for a demo provider | expected — the demo fixture deliberately has no stored credentials | For real providers, run the `setup_command` from `connect setup-plan --json`. | | Supervisor `start` errors with "address already in use" | an existing supervisor is bound to `127.0.0.1:8080` | Run `harn supervisor stop` first, or pass `--bind 127.0.0.1:0`. | ## Next steps - Edit one of the quickstart bundles and re-run `harn workflow validate` to watch the diagnostics evolve. - Read [Portable workflow bundles](./workflow-bundles.md) for the full schema and host contract. - For an in-process host that owns approval UX, file mutations, and notifications on top of these bundles, embed Harn in an IDE host or a cloud orchestrator. Both consume the same bundle JSON without changes. --- ## Read next - [Configure a provider](https://harnlang.com/provider-setup.md) - [Cookbook](https://harnlang.com/cookbook.md) --- # Cookbook > Task-oriented recipes for building agents and pipelines in Harn. Each recipe is self-contained — copy a block into a .harn file, set the provider credentials it expects, and run. Website: https://harnlang.com/cookbook.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. --- Task-oriented recipes for building agents and pipelines in Harn. Each recipe is self-contained — copy a block into a `.harn` file, set the provider credentials it expects, and run. For deeper topics that need their own pages, see: - [Channel cookbook](./cookbooks/channels.md) — agent channel patterns - [Pool cookbook](./cookbooks/pools.md) — agent pool patterns - [Pipeline lifecycle cookbook](./cookbooks/lifecycle.md) — `on_finish`, handoffs, suspend/resume - [Tool hooks cookbook](./cookbooks/tool-hooks.md) — `preset_run_command` recipes per stack - [OAuth client + provider cookbook](./oauth.md) — connector OAuth - [Structured refactorings cookbook](./cookbooks/structured-refactorings.md) — extract function, change signature across callers, and friends - [Destructure with defaults cookbook](./cookbooks/destructure-with-defaults.md) — collapse `input?.x ?? default` blocks into a single destructuring bind - [Extending the CLI in `.harn`](./cli-extending-in-harn.md) — add or port a `harn` subcommand without writing Rust ## LLM calls ### How to make a basic LLM call Single-shot prompt with a system message. Set `ANTHROPIC_API_KEY` (or the appropriate key for your provider) before running. ```harn pipeline default(harness: Harness) { const response = harness.llm.call( "Explain the builder pattern in three sentences.", "You are a software engineering tutor. Be concise." ) harness.stdio.log(response) } ``` To switch provider or model, pass an options dict: ```harn pipeline default(harness: Harness) { const response = harness.llm.call( "Explain the builder pattern in three sentences.", "You are a software engineering tutor. Be concise.", {provider: "openai", model: "gpt-5.4-mini", max_tokens: 512} ) harness.stdio.log(response) } ``` ### How to ask for structured JSON output Ask the model for JSON, parse it with `json_parse`, and validate the shape before using it. Wrap the parse in `retry` so a malformed first attempt does not end the run. ```harn pipeline default(harness: Harness) { const system = """ You are a task planner. Given a task description, break it into steps. Respond with ONLY a JSON array of objects, each with "step" (string) and "priority" (int 1-5). No other text. """ fn get_plan(task_desc) { retry 3 { const raw = harness.llm.call(task_desc, system) const parsed = json_parse(raw.text) guard type_of(parsed) == "list" else { throw "Expected a JSON array, got: ${type_of(parsed)}" } for item in parsed { guard item.has("step") && item.has("priority") else { throw "Missing required fields in: ${json_stringify(item)}" } } return parsed } } const plan = get_plan("Build a REST API for a todo app") const sorted = plan.filter({ s -> s.priority <= 3 }) for step in sorted { harness.stdio.log("[P${step.priority}] ${step.step}") } } ``` For schema-validated JSON without the manual guards, use [`harness.llm.call_structured`](./llm/llm_call.md#llm_call_structured). ### How to evaluate prompts in parallel `parallel each` fans out a list of prompts. Results preserve the input order. ```harn const prompts = [ "Explain quicksort in two sentences.", "Explain mergesort in two sentences.", "Explain heapsort in two sentences." ] const responses = parallel each prompts { p -> harness.llm.call(p, "Be concise.") } for r in responses { harness.stdio.log(r.text) } ``` ## Tools and agent loops ### How to give an agent tools Register tools with JSON Schema-compatible parameters, generate a system prompt that describes them, then let the LLM call tools in a loop. For typed tools and Tool Vault, see [LLM tools](./llm/tools.md). ```harn pipeline default(harness: Harness) { const task = "Inspect this project and summarize the main risks." let tools = tool_registry() tools = tool_define(tools, "read", "Read a file from disk", { parameters: {path: {type: "string", description: "Path to read"}}, returns: {type: "string"}, handler: { path -> return harness.fs.read_text(path) } }) tools = tool_define(tools, "search", "Search code for a pattern", { parameters: {query: {type: "string", description: "Query to search"}}, returns: {type: "string"}, handler: { query -> const result = harness.process.exec("grep", "-r", query, "src/") return result.stdout } }) const system = tool_prompt(tools) let messages = task let done = false let iterations = 0 while !done && iterations < 10 { const response = harness.llm.call(messages, system) const calls = tool_parse_call(response.text) if calls.count() == 0 { harness.stdio.log(response) done = true } else { let tool_output = "" for call in calls { const t = tool_find(tools, call.name) const handler = t.handler const result = handler(call.arguments[call.arguments.keys()[0]]) tool_output = tool_output + tool_format_result(call.name, result) } messages = tool_output } iterations = iterations + 1 } } ``` For loop-until-done agents that own completion detection and budget enforcement, reach for [`agent_loop`](./llm/agent_loop.md) instead of writing the loop yourself. ### How to delegate to multiple worker agents Spawn workers for different roles and collect their results. ```harn pipeline default(harness: Harness) { const task = "Review this project." const roles = ["research", "analyze", "summarize"] const results = parallel each roles { role -> const agent = spawn_agent({ name: role, task: "Handle ${role}: ${task}", node: { kind: "subagent", mode: "llm", model_policy: {provider: "mock"}, output_contract: {output_kinds: ["summary"]}, }, }) wait_agent(agent) } for r in results { harness.stdio.log(r) } } ``` ## Precise edits with AST tools `std/edit` ships structural and hash-guarded text primitives for agent-authored source mutations. Pick the simplest mechanism that safely fits the **shape and reach** of the change: | Shape | Good fit | |---|---| | Replace an existing node when a stable query is available | [`edit_apply_node`](#how-to-rewrite-a-function-body-via-a-tree-sitter-query) | | Add a sibling or child relative to a structural anchor | [`edit_insert_at_anchor`](#how-to-add-a-new-test-to-a-rust-mod) | | Rename an identifier and update semantic neighbors across the workspace | [`edit_rename_symbol`](#how-to-rename-a-symbol-across-the-workspace) | | Preview a risky or multi-step plan before committing | [`edit_dry_run`](#how-to-preview-a-multi-step-edit-plan-and-approve) | | Apply an exact localized text change with collision protection | [`edit_safe_text_patch`](#how-to-apply-a-multi-hunk-text-patch-atomically) | Grammar support is a capability, not an obligation. A small exact replacement does not become safer merely because the agent can write a Tree-Sitter query; structural tools earn their overhead when their addressing, validation, or semantic reach prevents a real failure mode. A `system_reminder` that lifts this guidance into the agent's next-turn prompt — see [How to guide an agent's edit choice](#how-to-guide-an-agents-edit-choice) — can help specialized coding agents make that tradeoff consistently. ### How to rewrite a function body via a tree-sitter query `edit_apply_node` from [`std/edit`](./stdlib/edit.md) replaces AST nodes matched by a Tree-Sitter query with a fresh fragment, leaving the surrounding indentation and trailing trivia untouched. This is the right reach when an agent needs to "change this function body" or "replace this call" — freeform text patching breaks on whitespace drift; AST-aware splice does not. The query must declare at least one capture; the capture named by `target_capture` (default `target`) is the replaced span. Single-capture queries accept any capture name. ```harn,ignore import "std/edit" pipeline default(harness: Harness) { const result = edit_apply_node(harness.ast, { path: "src/lib.rs", query: "(function_item name: (identifier) @name (#eq? @name" + " \"greet\") body: (block) @target)", replacement: "{ format!(\"hi {name}!\") }", }) if !result.applied { harness.stdio.log("edit failed: ${result.result} — ${result.details}") return } harness.stdio.log( "rewrote ${len(result.edits)} match(es) in ${result.path}" ) } ``` The default selector is `"unique"`: more than one match returns `result == "ambiguous"`. Use `"first"`, `"all"`, or `"nth"` (with `nth: N`, 1-based) to disambiguate. ```harn,ignore import "std/edit" pipeline default(harness: Harness) { // Rewrite every `fn foo() { … }` body in a file. const result = edit_apply_node(harness.ast, { path: "src/lib.rs", query: "(function_item body: (block) @target)", replacement: "{ unimplemented!() }", select: "all", }) harness.stdio.log("rewrote ${result.match_count} bodies") } ``` Validation is on by default: the post-edit source is re-parsed and any tree-sitter `ERROR` / `MISSING` node aborts with `result == "syntax_error"`. The original file is left untouched on rejection. ```harn,ignore import "std/edit" pipeline default(harness: Harness) { const result = edit_apply_node(harness.ast, { path: "src/lib.rs", query: "(function_item body: (block) @target)", replacement: "{ (", // intentional syntax error }) // result.applied == false, result.result == "syntax_error", // file on disk is unchanged. harness.stdio.log(result.details) } ``` Pass `dry_run: true` to inspect the splice without writing. When a hostlib `session_id` is supplied, both the read and the write route through the staged filesystem (see issue #1722), so the edit is atomic alongside any sibling staged writes. ```harn,ignore import "std/edit" pipeline default(harness: Harness) { const preview = edit_apply_node(harness.ast, { path: "src/lib.rs", query: "(function_item body: (block) @target)", replacement: "{ 42 }", select: "first", dry_run: true, }) // rewritten source; file on disk is untouched harness.stdio.log(preview.preview) } ``` Supported languages on the first batch: Rust, TypeScript / TSX, JavaScript / JSX, Python, Go, Swift, Java, C / C++, C#, Ruby, Kotlin, PHP, Scala, Bash, Zig, Elixir, Lua, Haskell, R. Languages outside the table return `result == "unsupported_language"`; callers can fall back to `edit_apply_old_new_patch` (text-mode) in that branch. ### How to add a new test to a Rust mod `edit_insert_at_anchor` (also from [`std/edit`](./stdlib/edit.md)) is the companion primitive for adding a sibling or child node next to an AST anchor — perfect for "append a new test case to this `mod tests`" or "add a new variant before the trailing `}`". Where `apply_node` *replaces* a span, `insert_at_anchor` *adds* one. ```harn,ignore import "std/edit" pipeline default(harness: Harness) { // src/lib.rs contains: // // #[cfg(test)] // mod tests { // #[test] // fn one() {} // } // const result = edit_insert_at_anchor(harness.ast, { path: "src/lib.rs", query: "(mod_item name: (identifier) @name (#eq? @name \"tests\") " + "body: (declaration_list) @anchor)", position: "last_child", content: "#[test]\nfn two() {}", }) harness.stdio.log(result.result) // "applied" harness.stdio.log(result.position) // "last_child" } ``` `position` is one of `"before"`, `"after"`, `"first_child"`, or `"last_child"`. The first two place a sibling at the anchor's indent depth; the last two place a child at the anchor's body depth (taken from existing children if present, else `anchor_indent + indent_unit`). The anchor query must match exactly one node. Multi-match returns `result == "ambiguous"` and lists every competing span. Tighten with `(#eq? @name "…")` or an extra structural predicate to pin a single target. ### How to add a new import to a TypeScript file ```harn,ignore import "std/edit" pipeline default(harness: Harness) { // src/index.ts contains: // // import { a } from "./a"; // import { b } from "./b"; // // const x = 1; // const result = edit_insert_at_anchor(harness.ast, { path: "src/index.ts", // Anchor on the last existing import so the new line lands right // below it (and above any code). query: "(import_statement source: (string (string_fragment) @src) " + "(#eq? @src \"./b\")) @anchor", position: "after", content: "import { c } from \"./c\";", }) harness.stdio.log(result.applied) // true } ``` Validation is on by default for both primitives: the post-edit source is re-parsed and any tree-sitter `ERROR` / `MISSING` node aborts with `result == "syntax_error"`, leaving the file on disk untouched. Pair either primitive with a `session_id` to route the read + write through the staged filesystem (#1722) so the edit is atomic alongside other staged writes. ### How to apply a multi-hunk text patch atomically `edit_safe_text_patch` is the right reach for text edits that touch the filesystem — it composes hunks against the staged-fs overlay, hash-checks the pre-image, and commits all-or-nothing. Use it when the change spans multiple regions of one file, when sibling agents might be editing the same file in parallel, or when the language has no tree-sitter grammar and you still need collision safety. ```harn,ignore import { edit_safe_text_patch } from "std/edit" pipeline default(harness: Harness) { const snapshot = harness.fs.staged_read_text({path: "src/lib.rs"}) const result = edit_safe_text_patch(harness.fs, harness.random, { path: "src/lib.rs", expected_hash: snapshot.sha256, hunks: [ {old_text: "return 1", new_text: "return 11"}, {old_text: "return 3", new_text: "return 33"}, ], }) if result.result == "stale_base" { // Another writer landed first. Re-read and retry — never blind-write. harness.stdio.log( "retrying — current hash is now ${result.current_hash}" ) return } if result.result == "hunk_conflict" { harness.stdio.log( "hunk ${result.failed_hunk_index} rejected:" + " ${result.failed_hunk_error_code}", ) return } harness.stdio.log("applied ${result.hunks_count} hunks") } ``` The result carries a `telemetry` envelope (`applied`, `stale_base`, `hunk_conflict`, `no_op` counters plus `hunks`) so hosts can roll up collision rates without log scraping. ### How to preview a multi-step edit plan and approve When an agent needs to chain several edits — rewrite a body, rename the function, insert a new statement — running them one at a time risks landing the first edit before the second is validated. `edit_dry_run` measures twice: it runs the whole plan against a transient staged-fs overlay, renders one unified diff per touched file, then discards the overlay. Nothing reaches disk until the caller commits. The diff is standard unified diff with `@@ -a,b +c,d @@` hunks, so it round-trips through `git apply` and renders cleanly in any reviewer UI. ```harn,ignore import "std/edit" pipeline default(harness: Harness) { const bundle = edit_dry_run({ plan: [ { op: "apply_node", path: "src/lib.rs", query: "(function_item body: (block) @target)", replacement: "{ format!(\"hi {name}!\") }", select: "first", }, { op: "safe_text_patch", path: "src/lib.rs", old_text: "fn greet", new_text: "fn greeter", }, { op: "rename_symbol", symbol_ref: {name: "Widget", path: "src/lib.rs", kind: "Type"}, new_name: "Gadget", }, ], }) if bundle.result == "ok" { // Surface the diff to the operator for approval. The path of // least resistance: pipe the bundle to a HITL reviewer, then // re-run the same ops without dry_run to commit them. for file in bundle.per_file_unified_diff { harness.stdio.log( "---- ${file.path} (+${file.lines_added} /" + " -${file.lines_removed})", ) harness.stdio.log(file.diff) } } else { // `partial` or `no_ops_applied` — inspect bundle.ops[i].reason // to learn why each op was rejected (no_match, ambiguous, // invalid_query, syntax_error, …). for op in bundle.ops { if !op.applied { harness.stdio.log( "REJECTED ${op.op}: ${op.reason} — ${op.details}" ) } } } } ``` Plan ops share a transient staged-fs session, so the second op sees the first op's pending write. That means several ops touching the same file collapse to one cumulative diff, which is the form a reviewer (human or LLM) actually wants. ### How to rename a symbol across the workspace `edit_rename_symbol` resolves an identifier through the typed symbol graph from [`std/code_librarian`](./stdlib/code-librarian.md) and rewrites every identifier-context occurrence — never comments, never string literals, never partial-name matches — across every file in scope. It short-circuits with `result == "conflict"` if `new_name` already exists as an identifier in any file the rename would touch, so the workspace can't end up with two identically named definitions in the same scope. ```harn,ignore import { edit_rename_symbol } from "std/edit" pipeline default(harness: Harness) { const result = edit_rename_symbol(harness.code_index, { symbol_ref: {name: "Widget", path: "src/lib.rs", kind: "Type"}, new_name: "Gadget", scope: "workspace", dry_run: true, }) if result.result != "applied" { harness.stdio.log( "rename refused: ${result.result} — ${result.details ?? \"\"}" ) return } for file in result.touched_files { harness.stdio.log("${file.path}: ${len(file.edits)} edit(s)") } } ``` Drop `dry_run` to actually rewrite the files; pair the call with a `session_id` for full staged-fs atomicity across the rename plus any sibling writes. Supported languages on the first batch: Harn, Rust, TypeScript/TSX, JavaScript/JSX, Python, Swift, Go. For the full result shape (`touched_files[*].edits[*]` with byte and `(row, col)` spans, plus `conflicts[*]` shadow sites), see the deeper [Rename a symbol across the workspace](./cookbooks/rename-symbol.md) cookbook. ### When to use text patches The AST primitives all require a tree-sitter grammar for the file's language. They return `result == "unsupported_language"` instead of silently mangling bytes. The supported set is intentionally narrower than what `tree-sitter` can parse — the host vendors only grammars that have been smoke-tested against the edit primitives. Reach for `edit_safe_text_patch` (or the lower-level `edit_apply_old_new_patch`) when: - the file's language is not in the supported batch (printable list lives next to each primitive in [`std/edit`](./stdlib/edit.md)); - the change is an exact, localized replacement and a structural query would add ceremony without improving correctness; - the change is purely textual — `LICENSE` headers, `CHANGELOG.md` entries, embedded SQL inside a string literal — and writing a tree-sitter query would mean writing one that matches a comment or string node anyway; - the agent reasoned in terms of literal lines and the model already has the exact pre-image bytes in the prompt; - a sibling agent might be rewriting the same file concurrently and you need a deterministic `stale_base` outcome rather than a tree re-parse failure. `edit_safe_text_patch` is the safe text mechanism: it hash-checks the pre-image against `expected_hash`, composes all hunks against the same staged-fs overlay, and either commits the post-image atomically or returns a single rejection result that the caller can react to. ```harn,ignore import { edit_safe_text_patch } from "std/edit" pipeline default(harness: Harness) { const snapshot = harness.fs.staged_read_text({path: "CHANGELOG.md"}) const result = edit_safe_text_patch(harness.fs, harness.random, { path: "CHANGELOG.md", expected_hash: snapshot.sha256, hunks: [ { old_text: "## Unreleased\n", new_text: "## Unreleased\n\n- Fixed onboarding race.\n", }, ], }) harness.stdio.log(result.result) } ``` Choose from the intended reach of the change, not from grammar availability alone. If a structural operation is the right fit but returns `unsupported_language`, retry with `edit_safe_text_patch`; if an exact patch already expresses the whole change safely, use it directly. ### How to guide an agent's edit choice Specialized coding agents can benefit from a short reminder to consider structural reach without turning it into a blanket requirement. Reminders are typed, ephemeral transcript injections with TTL and dedupe — see [System reminders](./system-reminders.md) for the lifecycle — so the snippet survives the next turn but does not bloat the durable transcript. The canonical body and producer wiring: ```harn,ignore const edit_strategy_reminder = """ Choose the simplest safe edit mechanism for each change. - Use edit_apply_node or edit_insert_at_anchor when structural addressing and parse validation materially reduce risk. - Use edit_rename_symbol when semantic neighbors must change together. - Use edit_safe_text_patch for exact localized text changes, whether or not the language has a Tree-Sitter grammar. - Use edit_dry_run to preview risky or multi-operation plans. """ const injected = transcript.inject_reminder(transcript(), { body: edit_strategy_reminder, tags: ["edit_strategy"], dedupe_key: "edit_strategy:prefer_ast", ttl_turns: 4, preserve_on_compact: true, propagate: "all", role_hint: "developer", }) ``` `propagate: "all"` lets sub-agents inherit the same guidance, and `preserve_on_compact: true` keeps it visible across the next compaction boundary. The `dedupe_key` makes the reminder safe to re-inject on every iteration (e.g. from a session_start hook) — the lifecycle collapses duplicates automatically. Lift the same body into a host-side reminder by sending an ACP `session/remind` notification with the same payload; see [System reminders > From a host bridge](./system-reminders.md#from-a-host-bridge). ## MCP servers ### How to call an MCP server Connect to an MCP-compatible tool server, list available tools, and call them. This example uses the filesystem MCP server. ```harn pipeline default(harness: Harness) { const client = harness.tools.mcp_connect( "npx", ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], ) const info = harness.tools.mcp_server_info(client) harness.stdio.log("Connected to: ${info.name}") const tools = harness.tools.mcp_list_tools(client) for t in tools { harness.stdio.log("Tool: ${t.name} - ${t.description}") } harness.tools.mcp_call( client, "write_file", { path: "/tmp/hello.txt", content: "Hello from Harn!", }, ) const content = harness.tools.mcp_call( client, "read_file", {path: "/tmp/hello.txt"}, ) harness.stdio.log("File content: ${content}") const entries = harness.tools.mcp_call( client, "list_directory", {path: "/tmp"}, ) harness.stdio.log(entries) harness.tools.mcp_disconnect(client) } ``` You can also declare MCP servers in `harn.toml` for automatic connection. See [MCP, ACP, and A2A integration](./mcp-and-acp.md) for the config surface. For remote HTTP MCP servers, authorize once with the CLI and reuse the stored token: ```bash harn mcp login notion ``` ### How to give an agent MCP tools Connect to an MCP server and feed its tools to `agent_loop`. The LLM chooses which to call. ```harn pipeline default(harness: Harness) { const client = harness.tools.mcp_connect( "npx", ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], ) const mcp_tool_list = harness.tools.mcp_list_tools(client) let tools = tool_registry() for t in mcp_tool_list { tools = tool_define(tools, t.name, t.description, { parameters: t.inputSchema?.properties ?? {}, returns: {type: "string"}, handler: { args -> return harness.tools.mcp_call(client, t.name, args) } }) } const result = agent_loop(harness, "List all files in /tmp and read the first one.", "You are a helpful file assistant.", { tools: tools, loop_until_done: true, max_iterations: 10 } ) harness.stdio.log(result.text) harness.tools.mcp_disconnect(client) } ``` ## Concurrency ### How to run N indexed tasks in parallel Use `parallel` when the unit of work is "N independent operations" and you want them indexed by position. ```harn pipeline default(harness: Harness) { const prompts = [ "Write a haiku about Rust", "Write a haiku about concurrency", "Write a haiku about debugging" ] const results = parallel(prompts.count) { i -> harness.llm.call(prompts[i] ?? "", "You are a poet.") } for r in results { harness.stdio.log(r) } } ``` Use `parallel each` when you're mapping over a collection. See [Concurrency](./concurrency.md) for the full surface, including backpressure and streamed results. ### How to coordinate spawned tasks with channels Channels coordinate producers and consumers. Use them when one spawned task needs to hand work to another and you want backpressure rather than an unbounded queue. ```harn pipeline default(harness: Harness) { const ch = channel("work", 10) const results_ch = channel("results", 10) const producer = spawn { const items = ["item_a", "item_b", "item_c"] for item in items { send(ch, item) } send(ch, "DONE") } const consumer = spawn { let processed = 0 let running = true while running { const item = receive(ch) if item == "DONE" { running = false } else { send(results_ch, "processed: ${item}") processed = processed + 1 } } send(results_ch, "COMPLETE:${processed}") } await(producer) await(consumer) let collecting = true while collecting { const msg = receive(results_ch) if msg.starts_with("COMPLETE:") { harness.stdio.log(msg) collecting = false } else { harness.stdio.log(msg) } } } ``` For durable cross-agent channels (publish from one pipeline, subscribe from another), see the [Channel cookbook](./cookbooks/channels.md). ### How to recurse without blowing the stack Tail-recursive functions are optimized by the VM, so deep recursion does not overflow even across thousands of iterations. ```harn pipeline default(harness: Harness) { const items = [ "Refactor auth module", "Add input validation", "Write unit tests", ] fn process(remaining, results) { if remaining.count == 0 { return results } const item = remaining.first const rest = remaining.slice(1) const result = retry 3 { harness.llm.call( "Plan how to: ${item}", "You are a senior engineer. Output a numbered list of steps." ) } return process(rest, results + [{task: item, plan: result}]) } const plans = process(items, []) for p in plans { harness.stdio.log("=== ${p.task} ===") harness.stdio.log(p.plan) } } ``` For non-LLM workloads, tail-call optimization handles deep recursion without issue: ```harn pipeline default(harness: Harness) { fn sum_to(n, acc) { if n <= 0 { return acc } return sum_to(n - 1, acc + n) } harness.stdio.log(sum_to(10000, 0)) } ``` ## Error handling ### How to retry and structure errors Wrap LLM calls in `try`/`catch` with `retry` to handle transient failures. Use a typed catch when you want different handling per error kind. ```harn pipeline default(harness: Harness) { enum AgentError { LlmFailure(message) ParseFailure(raw) Timeout(seconds) } fn safe_llm_call(prompt, system) { retry 3 { try { const raw = harness.llm.call(prompt, system) return json_parse(raw.text) } catch (e) { harness.stdio.log("LLM call failed: ${e}") throw AgentError.LlmFailure(to_string(e)) } } } try { const result = safe_llm_call( "Return a JSON object with keys 'summary' and 'score'.", "You are an evaluator. Always respond with valid JSON only." ) harness.stdio.log("Summary: ${result.summary}") harness.stdio.log("Score: ${result.score}") } catch (e) { if type_of(e) == "enum" { match e.variant { "LlmFailure" -> { harness.stdio.log("LLM failed after retries: ${e.fields[0]}") } "ParseFailure" -> { harness.stdio.log("Could not parse LLM output: ${e.fields[0]}") } "Timeout" -> { harness.stdio.log("Timed out after ${e.fields[0]}s") } } } else { harness.stdio.log("Unexpected error: ${e}") } } } ``` For deeper coverage of the error model, see [Error handling](./error-handling.md). ## Patterns ### How to build context from multiple sources Gather context in parallel, merge it into a single dict, and feed it to the model. ```harn pipeline default(harness: Harness) { const task = "Improve error handling in this project." fn read_or_empty(path) { try { return harness.fs.read_text(path) } catch (e) { return "" } } const sources = ["README.md", "CHANGELOG.md", "docs/architecture.md"] const contents = parallel each sources { path -> {path: path, content: read_or_empty(path)} } // Accumulate into an explicitly-typed // `dict`. An untyped // `{}` is the opaque top object type, // so annotating the dict keeps `.merge` // and the key/value iteration below well-typed. let files: dict = {} for item in contents { if item.content != "" { files = files.merging({[item.path]: item.content}) } } let prompt = "Task: ${task}\n\n" for entry in files { prompt += "=== ${entry.key} ===\n${entry.value}\n\n" } const result = harness.llm.call( prompt, "You are a helpful assistant. Use the provided files as context.", ) harness.stdio.log(result) } ``` ### How to compose pipelines across files Split logic into reusable pipelines using `import` and `extends`. See [Modules and imports](./modules.md) for the resolution rules. **lib/context.harn** — shared context gathering: ```harn fn gather_context(task) { const readme = harness.fs.read_text("README.md") return { task: task, readme: readme, timestamp: harness.clock.timestamp() } } ``` **lib/review.harn** — a reusable review pipeline: ```harn,ignore import "lib/context" pipeline review(harness: Harness) { const task = "Review this project." const ctx = gather_context(task) const prompt = "Review this project.\n\nREADME:\n${ctx.readme}\n\nTask:" + " ${ctx.task}" const result = harness.llm.call(prompt, "You are a code reviewer.") harness.stdio.log(result) } ``` **main.harn** — extend and customize: ```harn,ignore import "lib/review" pipeline default(harness: Harness) extends review { override setup() { harness.stdio.log("Starting custom review pipeline") } } ``` ### How to filter with `in` and `not in` `in` works on lists, strings (substring test), dicts (key membership), and sets. ```harn pipeline default(harness: Harness) { const allowed_extensions = [".rs", ".harn", ".toml"] const files = harness.fs.list_dir("src") const relevant = files.filter({ f -> const ext = extname(f) ext in allowed_extensions }) harness.stdio.log("Relevant files: ${relevant}") const config = { host: "localhost", port: 8080, debug: true, secret: "abc", } const sensitive = ["secret", "password"] for entry in config { if entry.key not in sensitive { harness.stdio.log("${entry.key}: ${entry.value}") } } } ``` ### How to deduplicate with sets Sets give O(1)-style membership testing and are immutable — `set_add` returns a new set rather than mutating in place. ```harn pipeline default(harness: Harness) { const urls = [ "https://example.com/a", "https://example.com/b", "https://example.com/a", "https://example.com/c", "https://example.com/b" ] const unique_urls = to_list(set(urls)) harness.stdio.log( "${len(unique_urls)} unique URLs out of ${len(urls)} total" ) let visited = set() for url in unique_urls { if !set_contains(visited, url) { harness.stdio.log("Processing: ${url}") visited = set_add(visited, url) } } const batch_a = set("task-1", "task-2", "task-3") const batch_b = set("task-2", "task-3", "task-4") const already_done = set_intersect(batch_a, batch_b) const new_work = set_difference(batch_b, batch_a) harness.stdio.log( "Overlap: ${len(already_done)}, New: ${len(new_work)}" ) } ``` ### How to enforce types at call boundaries Annotate function parameters. The VM throws a `TypeError` before the body runs if a caller passes the wrong type; `harn check` rejects most of these statically. ```harn,ignore pipeline default(harness: Harness) { fn summarize(text: string, max_words: int) -> string { const words = text.split(" ") if words.count <= max_words { return text } const truncated = words.slice(0, max_words) return "${join(truncated, " ")}..." } harness.stdio.log( summarize("The quick brown fox jumps over the lazy dog", 5) ) try { summarize(42, "not a number") } catch (e) { harness.stdio.log("Caught: ${e}") // -> TypeError: parameter 'text' expected string, got int (42) } fn process_batch(items: list, verbose: bool) { for item in items { if verbose { harness.stdio.log("Processing: ${item}") } } harness.stdio.log("Done: ${len(items)} items") } process_batch(["a", "b", "c"], true) } ``` Annotations cover `string`, `int`, `float`, `bool`, `list`, `dict`, and `set`. For structural shape types, see [Language basics](./language-basics.md#types-and-values). ### How to track quality metrics `eval_metric` records named values to the run record for later analysis. Track accuracy, latency, token usage, and failure counts during agent execution. ```harn pipeline default(harness: Harness) { const task = "Explain this project in three sentences." const result = harness.llm.call(task, "Be concise.") const usage = harness.obs.llm_usage() eval_metric("cost_tokens", usage.input_tokens + usage.output_tokens) eval_metric("output_length", result.text.length, {model: result.model}) harness.stdio.log(result.text) } ``` See [Debugging agent runs](./debugging.md#evaluation) for the eval harness around these calls. --- ## Read next - [Run a workflow bundle from the CLI](https://harnlang.com/workflow-authoring-quickstart.md) - [Scripting cheatsheet](https://harnlang.com/scripting-cheatsheet.md) --- # Scripting cheatsheet > A compact reference for writing Harn scripts. For the one-page agent reference, see Harn quick reference . Website: https://harnlang.com/scripting-cheatsheet.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. --- A compact reference for writing Harn scripts. For the one-page agent reference, see [Harn quick reference](docs/llm/harn-quickref.md). ## Strings Use standard double-quoted strings with `\n` escapes for short literals, and triple-quoted `"""..."""` for multiline prose like system prompts: ```harn const greeting = "Hello, ${name}!" const prompt = """ You are a strict grader. Emit exactly one verdict. """ ``` Heredoc-style `< 2400 { content[0:400] + "..." + content[len(content) - 400:len(content)] } else { content } ``` ## Stream operators `stream.*` accepts lists, ranges, channels, generators, and lazy `iter(...)` values. Operators stay lazy until a sink such as `stream.collect`, `stream.fold`, or `stream.first` pulls from them. ```harn const first_three = stream.collect( stream.take(results_channel, 3), {max: 3}, ) const tool_events = stream.collect( stream.filter(agent_events, { ev -> ev?.topic == "tool_call" }), {max: 100} ) const winner = stream.first(stream.race(primary_stream, fallback_stream)) const total = stream.fold( stream.merge(worker_a, worker_b, worker_c), 0, { acc, item -> acc + item.cost } ) ``` Always pass a realistic `{max: N}` to `stream.collect` when the upstream can be unbounded. ## LLM resilience patterns `agent_loop` accepts an `llm_caller:` closure that owns each turn's `harness.llm.call(...)`. Wrap it with middleware from `std/llm/handlers` to compose retry / fallback / shadow / logging / budget behavior: ```harn,ignore import {default_llm_caller} from "std/llm/caller" import {with_retry, with_fallback, compose} from "std/llm/handlers" const caller = compose([ with_retry({max_attempts: 4, base_ms: 250, backoff: "exponential"}), ])(default_llm_caller()) const result = agent_loop(harness, task, system, { loop_until_done: true, llm_caller: caller, }) ``` Migrating from `llm_retries: K` (removed in 0.10): use `with_retry(default_llm_caller(), {max_attempts: K + 1})`. The off-by-one is deliberate — `llm_retries` historically counted retries after the first attempt; `max_attempts` counts total attempts. See [`stdlib/llm-handlers.md`](./stdlib/llm-handlers.md) for the full catalog (handlers, ensemble, refine, budget, defaults, safe, prompts, catalog). ## Module scope Top-level `const` / `let` and `fn` declarations are visible inside functions defined in the same file — no wrapping in a getter fn needed: ```harn const GRADER_SYSTEM = """ You are a strict grader... """ pub fn grade(path) { return harness.llm.call(harness.fs.read_text(path), GRADER_SYSTEM, { provider: "auto", model: "local-gemma4-e4b", }) } ``` (Module-level mutable `let` cross-function mutation is not fully supported yet. If you need shared mutable state across functions, use atomics: `harness.runtime.atomic(0)`, `harness.runtime.atomic_add(a, 1)`, `harness.runtime.atomic_get(a)`.) ## Results and error handling ```harn const r = try { harness.llm.call(prompt, nil, opts) } // Optional chaining short-circuits on Result.Err. const text = r?.text ?? "no response" // Explicit error inspection. if unwrap_err(r) != "" { harness.stdio.log("failed") } // `try/catch` also works as an expression — the whole form evaluates to // the try body's tail value on success or the catch handler's tail value // on a caught throw, so simple fallbacks don't need Result gymnastics. const answer = try { harness.llm.call(prompt, nil, opts).text } catch (e) { "fallback" } ``` ## Concurrency ```harn,ignore // Spawn a task, collect its result. const h = spawn { long_work() } const value = await(h) // parallel each: concurrent map over a list. const doubled = parallel each xs { x -> x * 2 } // parallel settle: concurrent map that collects per-item Ok/Err. const outcome = parallel settle paths { p -> grade(p) } harness.stdio.log(outcome.succeeded) // Cap in-flight workers so you don't overwhelm the backend. const results = parallel settle paths with { max_concurrent: 4 } { p -> harness.llm.call(p, nil, opts) } ``` `max_concurrent: 0` (or a missing `with` clause) means unlimited. See `concurrency.md` for the RPM rate limiter, channels, `select`, `deadline`, and `defer`. ## Stream generators Use `gen fn` plus `emit` for lazy script-level streams: ```harn gen fn numbers() -> Stream { emit 1 emit 2 } for n in numbers() { harness.stdio.log(n) } ``` `Stream` is distinct from the older `Generator` type. Existing `yield` behavior is unchanged; use `emit` inside `gen fn`. Streams are single-pass, support `.next()` returning `{value, done}`, and propagate throws to the consumer when the next item is pulled. ## CLI: `argv` ```bash harn run my_script.harn -- file1.md file2.md ``` Inside the script: ```harn fn grade_file(path) { harness.stdio.log(path) } for path in argv { grade_file(path) } ``` `argv` is always defined as `list`; empty when no positional args were given. ## Fixed-arity tuples ```harn const row = tuple("retries", 3) // tuple const key: string = row[0] // exact positional type const value: int = row[-1] // negative indexes work const typed: tuple = ["timeout", 30] ``` Bracket literals remain lists unless a `tuple<...>` annotation or function parameter supplies tuple context. Constant out-of-bounds indexes are `HARN-TYP-027`; dynamic indexes return the union of all positions plus `nil`. ## Reuse narrowing checks A `const` keeps the narrowing facts from its condition: ```harn fn normalize(value: string | int) -> string { const kind = type_of(value) const text = kind == "string" if text { return value.upper() } return to_string(value) } ``` Declare a predicate when several callers need the same check: ```harn fn is_text(value: unknown) -> value is string { return type_of(value) == "string" } ``` Use `implies value is T` if a false result can still be `T`. Invalid predicate contracts report `HARN-TYP-029`. ## Regex ```harn const matches = regex_match("[0-9]+", "abc 42 def 7") const swapped = regex_replace("(\\w+)\\s(\\w+)", "$2 $1", "hello world") const captures = regex_captures("(?P[A-Z][a-z]+)", "Mon Tue") ``` `regex_replace` replaces every match and supports `$1`, `$2`, and `${name}` backrefs from the `regex` crate. ## LLM calls ```harn const r = harness.llm.call(prompt, system, { provider: "auto", // infers from model prefix model: "local-gemma4-e4b", output: {schema: schema, validation: "error", stream_abort: true}, schema_retries: 2, // retry with corrective nudge on schema mismatch }) // the public answer (preferred for "the answer") harness.stdio.log(r.text) harness.stdio.log(r.data.verdict) // parsed structured output ``` Key options: | Option | Default | Notes | |---|---|---| | `provider` | `"auto"` | `"auto"` infers from model prefix (`local:` / `/` / `claude-*` / `gpt-*` / `:`). | | `output` | `"text"` | `"json"`, a schema, or `{schema, strict?, validation?, stream_abort?}`. | | `schema_retries` | `1` | Re-prompt after an `output` schema mismatch. | | `schema_retry_nudge` | auto | String (verbatim), `true` (auto), or `false` (bare retry). | | `effort` | provider default | Provider-neutral reasoning intent such as `low`, `medium`, or `high`. | | `timeout_ms` | provider default | Whole-call timeout in milliseconds. | See `docs/src/llm-and-agents.md` for the overview, or `docs/src/llm/agent_loop.md` for `agent_loop`, tool dispatch, and the full option surface. ## Rate limiting `max_concurrent` bounds simultaneous in-flight tasks on the caller side. Providers can also be rate-limited at the throughput layer via `rpm:` in `providers.toml` / `harn.toml` or `HARN_RATE_LIMIT_=N` env vars. The two compose: use `max_concurrent` to prevent bursts, and `rpm` to shape sustained throughput. ## More - LLM-friendly one-pager: `docs/llm/harn-quickref.md` (hosted at and loaded automatically by the `harn-scripting` Claude skill when present). - Full mdBook: `docs/src/` (`introduction.md`, `language-basics.md`, `concurrency.md`, `error-handling.md`, `llm-and-agents.md`). - Language spec: `spec/HARN_SPEC.md`. - Conformance examples: `conformance/tests/*.harn`. --- ## Read next - [Cookbook](https://harnlang.com/cookbook.md) - [Reuse narrowing checks](https://harnlang.com/narrowing-checks.md) --- # Reuse narrowing checks > Use a const when a condition needs a clear name. Use a type predicate when several functions need the same check. Website: https://harnlang.com/narrowing-checks.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. --- Use a `const` when a condition needs a clear name. Use a type predicate when several functions need the same check. ## Name a condition Bind the check with `const`, then branch on that name: ```harn fn label(value: string | int) -> string { const kind = type_of(value) const is_text = kind == "string" if is_text { return value.upper() } return to_string(value + 1) } ``` Use `const`, not `let`. A mutable value may change after the check. ## Share a two-sided check Name the parameter and its narrower type after the return arrow: ```harn fn is_text(value: unknown) -> value is string { return type_of(value) == "string" } ``` Call the helper in an `if`, `guard`, or another condition: ```harn fn normalize(value: string | int) -> string { if is_text(value) { return value.upper() } return to_string(value) } ``` A false result also rules out `string` when the input is a closed union. ## Share a one-sided check Add `implies` when false does not rule out the type: ```harn fn is_nonempty_text(value: unknown) -> implies value is string { return type_of(value) == "string" && len(value) > 0 } ``` Here, false may mean an empty string. Harn narrows only the true branch. Keep the helper body simple. It may contain `const` aliases followed by one return condition. Harn reports `HARN-TYP-029` when the condition does not prove the declared contract. See [Type annotations](./spec/language/19-type-annotations.md#type-predicates) for the full rules. --- ## Read next - [Scripting cheatsheet](https://harnlang.com/scripting-cheatsheet.md) - [LLM quick reference](https://harnlang.com/docs/llm/harn-quickref.md) --- # Harn quick reference (LLM-friendly) > Canonical URL: https://harnlang.com/docs/llm/harn-quickref.html Website: https://harnlang.com/docs/llm/harn-quickref.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. --- **Canonical URL:** This file is a one-pass reference optimized for LLM consumption and grep. It covers the syntax, stdlib highlights, concurrency, and the LLM / agent_loop surface an agent typically needs to write scripts. You can fetch the hosted quick reference in any agent context that supports HTTP fetches (Claude with `WebFetch`, Cursor's `@web`, Aider, etc.) using the canonical URL above. The human-facing companion lives at `docs/src/scripting-cheatsheet.md`. Keep the two in lockstep when syntax changes. For trigger manifests, connector contract v1, and the provider catalog, also load `docs/llm/harn-triggers-quickref.md`. ## `--json` cheatsheet (agent-driven Harn) Every machine-readable mode returns a versioned envelope: `{ "schemaVersion": N, "ok": bool, "data": ..., "error": ..., "warnings": [] }`. Stdout is one parseable JSON document (or one NDJSON event per line); logs and progress always go to stderr. - Discover supported commands and their current schema versions: `harn --json-schemas` (filter with `--command `). - Per-command shape reference: `docs/src/cli-json-contract.md`. - Decode `harn lint --json` through `std/cli/envelope` (`decode_lint_json`); `harn --json-schemas --command lint` publishes the complete `schemaJson`. Diagnostic spans are UTF-8 half-open byte offsets `[start, end)`. - Common pairs an agent will use: - `harn version --json` — build metadata (`name`, `version`, `description`, optional build-attested `source_revision`). - `harn upgrade --check --json` — resolve target release without downloading. - `harn lint --json ` — structured lint diagnostics + summary; pair with `harn lint --fix ` (no `--json`) to apply the recommended edits. - `harn replay --json ` — per-stage replay summary + fixture verdict. - `harn check --json ` / `harn fmt --json ` — type-check and format reports with the same `CheckDiagnostic` shape. - `harn run --json script.harn` — NDJSON event stream (one envelope per line). - `harn doctor --json` — capability matrix for host / targets / providers. - `harn models batch manifest --json` — grouped offline batch manifest with stable request ids. - `harn models batch prepare --json` — provider-native batch request files and prepare receipt. - `harn models batch submit --dry-run --json` — validate prepared jobs and write a submission receipt. - `harn models batch status --dry-run --json` — validate/poll submitted jobs and write a status receipt. - `harn models batch download --dry-run --json` — validate/download completed result files. - `harn models batch execute init|advance|inspect|cancel --json` — own a resumable, hash-bound batch lifecycle. - `harn models batch rejoin --json` — normalize provider rows and emit a typed consumable/quarantine receipt. ## Files and execution - File extension: `.harn`. - Entry points: - Script entrypoint: `fn main(harness: Harness) { ... }`. - Pipeline entrypoint: `pipeline default(harness: Harness) { ... }` (pipeline mode — `compile_top_level_declarations` runs first, then the pipeline body). - Bare script with top-level statements for tiny one-off files. - Run: `harn run script.harn`. - Complete function and pipeline programs also work inline, for example `harn run -e 'fn main(harness: Harness) { harness.stdio.println("hi") }'`. Their entrypoint executes as it does in a file, including thrown failures. - Inline body snippet: `harn run -e 'harness.stdio.log("hi")'`. The snippet is wrapped in `pipeline main(harness: Harness) { ... }`; leading `import "..."` / `import { x } from "..."` / `import * as ns from "..."` / `pub import { x } from "..."` lines are hoisted out of the wrapper. The temp file lives in the current directory so relative imports (`import "./lib"`) and `harn.toml` discovery resolve against your project, e.g. `harn run -e $'import "./lib"\nharness.stdio.log(answer())'`. Imports must come first — interleaved imports are not lifted. - Shebang: a `#!/usr/bin/env harn` line at byte offset 0 of a `.harn` file is skipped by the lexer, so executables on PATH can `chmod +x` scripts and run them directly. - CLI arguments: `harn run script.harn -- a b c` exposes `argv: list` as a global (`argv == ["a", "b", "c"]`). - Exit code: any of three paths sets the process exit code. - `harness.runtime.exit(code)` terminates immediately with that code. - `pipeline main(harness: Harness)` (or any pipeline used as the entry) — the value flowing out of the body sets the exit code: - `return n: int` → exits `n` (clamped 0..=255). - `return Err(msg)` → writes `msg` to stderr, exits 1. - `return Ok(_)` / no explicit return → exits 0. - Uncaught errors exit with 1 and a rendered diagnostic. ### Effect authority Effects flow from the harness capability object; imports are pure. Importing a module binds code and types but never grants authority. Pass the narrowest nominal sub-handle a helper needs: ```harn fn load_config(fs: HarnessFs, path: string) -> string { return fs.read_text(path) } fn main(harness: Harness) { harness.stdio.println(load_config(harness.fs, "harn.toml")) } ``` Pure builtins remain ordinary globals. Effectful runtime operations are methods on the closed `Harness*` types; their typed contracts are the shared source for checking, policy, receipts, and reference generation. Test fixtures are scoped to one harness through `harness.testing`, including `respond`/`calls`, HTTP and transport mocks, and the virtual clock. Globals that returned one host value now read a field off a snapshot: `platform()` and `arch()` are `harness.system.platform().os` and `.arch`; `username()`, `hostname()`, and `pid()` come from `harness.system.identity()`; path globals live on `harness.fs`. `harn fix` rewrites these calls, including calls inside `${...}` interpolation, and adds the parameter to callers that need to supply the handle. Keep root `Harness` at entrypoints and at boundaries that genuinely coordinate several capabilities. Elsewhere pass the narrowest handle the function needs. When a helper takes a record of capabilities, build it with `pick(harness, ["env", "fs", "tools"])`. `pick` is a global builtin. It keeps each field's type and keeps `nil` values. A list literal, or a `const` holding one, gives the result exact field types. A list only known at runtime makes every field optional. See [Pick fields from a record](../src/pick.md). `capability-attenuation` warns when a helper takes root but uses only one sub-handle, and suggests a named record when it uses exactly two. The surface-changing fixer updates the signatures and call sites it can prove are safe. Harness values are runtime authority: never serialize, store, or checkpoint them. For public APIs with four or more easy-to-swap parameters of the same type, `homogeneous-positional-api` recommends one named record. Optional host protocols use the same typed surface, for example `harness.workspace.search(request)`, `harness.lsp.diagnostics(request)`, and `harness.pr_monitor.gh_snapshot(request)`. A host manifest says which of these optional methods that host implements. It cannot add methods a script could call. ## Merge captain eval loop Use `harn merge-captain run` when iterating on the Merge Captain persona from a single command. It resolves a backend, streams canonical agent JSONL, persists a receipt, runs the Merge Captain oracle, and exits non-zero on unsafe action attempts or any oracle error. ```bash # Mock playground smoke path. Streams JSONL to stdout and writes a receipt under # .harn-runs/merge-captain//receipt.json. harn merge-captain run --backend mock examples/merge_captain/playground_3repos --once # Keep stdout for the machine-readable summary and put the transcript/receipt in # explicit files. harn merge-captain run --backend mock examples/merge_captain/playground_3repos \ --once \ --model-route value/gemma \ --timeout-tier smoke \ --transcript-out .harn-runs/mc/event_log.jsonl \ --receipt-out .harn-runs/mc/receipt.json # Replay a deterministic transcript fixture through the same receipt + oracle # path. harn merge-captain run --backend replay \ examples/personas/merge_captain/transcripts/green_pr.jsonl \ --once --no-stdout # Run the in-process fake GitHub/fake git golden-transition suite. ./scripts/cargo_with_worktree_build_dir.sh test -p harn-cli --test merge_captain_cli issue_1012 ``` Backends: | Backend | Argument | Use | |---|---|---| | `mock` | playground directory or scenario manifest | Local fake-backend scenario loop. | | `replay` | transcript JSONL file or event-log directory | Deterministic replay/audit without backend I/O. | | `live` | none | Production connector runtime selector; fails closed when the connector runtime is unavailable. | Flags: | Flag | Use | |---|---| | `--once` / `--watch` | One sweep or finite watch mode (`--max-sweeps`, `--watch-backoff-ms`). | | `--model-route ROUTE` | Pin the model/profile route in the receipt. | | `--timeout-tier TIER` | Pin the timeout/budget tier in the receipt. | | `--transcript-out PATH` | Write JSONL transcript to a file instead of stdout. | | `--receipt-out PATH` | Write receipt JSON to an explicit path. | | `--summary-out PATH` | Write run summary JSON to a file. | Use `harn merge-captain ladder ` to run the same backend fixture across a matrix of model routes and timeout tiers. The report records the first route/tier that completed correctly, every degraded or looping tier, and paths to each tier's JSONL transcript, receipt, and summary. ```bash harn merge-captain ladder personas/merge_captain/harn.eval.toml \ --report-out .harn-runs/merge-captain-ladder/report.json \ --format json ``` The same ladder manifests can live inside eval packs, so `harn eval personas/merge_captain/harn.eval.toml` and `harn test package --evals` use the same runner and JSON artifact contract as host TUI/CLI surfaces. Use `harn merge-captain iterate ` when an agent needs the brute-force outer loop: scenarios × variants, where variants include model route, timeout tier, Harn package revision, and prompt-asset revision metadata. The command copies replay fixtures or materializes mock playgrounds into one iteration directory, writes every run's JSONL transcript, receipt, and summary, then emits `summary.json` plus a Markdown ranking table sorted by transcript-drift score and cost. ```bash harn merge-captain iterate examples/personas/merge_captain/iterations/smoke.toml \ --report-out .harn-runs/merge-captain-iterations/latest.json \ --markdown-out .harn-runs/merge-captain-iterations/latest.md harn merge-captain iterate --diff \ examples/personas/merge_captain/iterations/diff/baseline-summary.json \ examples/personas/merge_captain/iterations/diff/candidate-summary.json ``` Iteration manifests are intentionally small: ```toml version = 1 id = "merge-captain-local-loop" base_dir = "." artifact-root = ".harn-runs/merge-captain-iterations/local-loop" [budget] max-runs = 12 max-wallclock-ms = 30000 max-cost-usd = 0.01 [[scenarios]] id = "single-green" [scenarios.backend] kind = "mock" path = "examples/merge_captain/scenarios/single_green.json" [[variants]] id = "value-route-balanced" model-route = "local/qwen-value" timeout-tier = "balanced" package-revision = "harn-package@workspace" prompt-asset-revision = "merge-captain/prompts@v2" max-tool-calls = 8 max-model-calls = 1 ``` ### Mock-repos playground (#1020) `harn merge-captain mock` materializes a real on-disk sandbox — temp git repos plus a fake GitHub HTTP server — so you can iterate on the captain against real `git` codepaths without touching live infrastructure. This is the recommended local iteration loop. ```bash # 1. Create a playground from a built-in scenario. Default scenario is # `three_repo_basic`. List built-ins with `mock scenarios`. harn merge-captain mock init ./pg --scenario three_repo_basic # 2. Sweep the captain against it. The driver detects the on-disk # playground and synthesizes a canonical JSONL transcript reflecting # the live state. harn merge-captain run --backend mock ./pg --once # 3. Advance the scenario between sweeps — flip a check, advance base, # force-push as the author, merge a PR, etc. Steps come from the # scenario manifest; `--action ` is the one-off escape hatch. harn merge-captain mock step ./pg --name gamma_force_push_fix harn merge-captain mock step ./pg --action \ '{"kind":"set_check","repo":"alpha","pr_number":101,"name":"ci","status":"completed","conclusion":"success"}' # 4. Boot the fake GitHub HTTP server pointing at the playground state. # Real HTTP clients (e.g. harn-github-connector) talk to this; the # captain still uses real `git` against bare remotes under # ./pg/remotes/.git. harn merge-captain mock serve ./pg --bind 127.0.0.1:0 --print-addr # 5. Snapshot or tear down. harn merge-captain mock status ./pg --json harn merge-captain mock cleanup ./pg ``` Subcommands: | Subcommand | Purpose | |---|---| | `mock init ` | Materialize bare+working git repos + `state.json` from a scenario. `--scenario` (built-in) or `--manifest ` (custom JSON/YAML). `--force` cleans up first. | | `mock step ` | Apply a manifest-defined `--name ` or one-off `--action `. Mutates `state.json` (and the bare remote when the action is `merge_pull_request`, `force_push_author`, or `advance_base`). | | `mock status ` | Print the current PR/check/history state. `--json` for machine output. | | `mock serve ` | Boot the fake GitHub HTTP server. Endpoints: `pulls`, `pulls/.../merge`, `pulls/.../files`, `commits/.../check-runs`, `actions/runs/.../logs`, `merge_queue/queues/...`, `issues`, `issues/.../comments`, `issues/.../labels`. | | `mock cleanup ` | Remove the playground. Idempotent and refuses to delete arbitrary directories without the playground marker. | | `mock scenarios` | List built-in scenarios. | Scenario manifests live at `examples/merge_captain/scenarios/*.json` and follow the `merge_captain_playground_scenario` schema documented in `crates/harn-vm/src/orchestration/playground/manifest.rs`. ## stdin / stdout / stderr / TTY - Stdio capability calls route through `Harness`: use `harness.stdio.print(s)` / `harness.stdio.println(s)` for stdout, `harness.stdio.eprint(s)` / `harness.stdio.eprintln(s)` for stderr, and `harness.stdio.read_line()` / `harness.stdio.prompt(msg?)` for interactive input. - Terminal capability calls also route through `Harness`: use `harness.term.width()` / `harness.term.height()` for dimensions and `harness.term.read_password(prompt?)` for no-echo password input. - `harness.stdio.read_stdin()` slurps the rest of stdin to a `string` and returns `nil` at EOF. - `harness.stdio.is_stdin_tty()`, `harness.stdio.is_stdout_tty()`, `harness.stdio.is_stderr_tty()` — `bool`, uses `std::io::IsTerminal`. Use these to decide between rich interactive UI and pipe-friendly output. - `std/io` exposes structured interactive helpers: `is_tty(fd?)`, `harness.stdio.read_line({prompt?, timeout_ms?, trim?, echo?, raw?})`, `read_password(prompt?, timeout_ms?)`, and `write_stderr(text)`. Structured reads return `{ok, value?, status?, error?}` with statuses `ok`, `eof`, `timeout`, `interrupt`, or `error`. - `harness.term.set_color_mode("auto"|"always"|"never")` controls whether `color`/`bold`/`dim` emit ANSI. Auto honors `NO_COLOR` and `FORCE_COLOR` env vars and only emits when stdout is a TTY. In tests: `harness.testing.stdin_set(text)` / `harness.testing.stdin_reset()`, `harness.testing.tty_set(stream, bool)` / `harness.testing.tty_reset()`, `harness.testing.capture_stderr_start()` / `harness.testing.capture_stderr_take()`. For long terminal artifacts, import `std/tui`: ```harn import { page, rule, terminal_width, clear } from "std/tui" const result = page({title: "Audit", body: markdown, format: "markdown"}) ``` `page(...)` uses `$PAGER` when stdout is a TTY, adds `-R -F -X` for `less`, falls back to full print output when stdout is not interactive or the pager is missing, and returns `{ok, paged, error?}`. For interactive pickers, the same module exports `select_from(items, opts?)` so harness scripts stop hand-rolling `fzf` / `gum choose` detection. It returns `{ok, value, status}`, auto-detects fzf then gum then falls back to a numbered `read_line` menu, and honors `mock_stdin` under `prefer_external: "none"`. ## Command helpers (`std/command`) Use `std/command` for script-side harness commands. It runs through the same host command substrate as model-facing tools, but returns deterministic Harn records for retries, artifacts, tails, classification, and recovery hints. Use `harness.process.run({program, args?, cwd?, env?, stdin?, timeout_ms?})` when you only need one synchronous subprocess capture record: `{exit_code, stdout, stderr, duration_ms, success, timed_out}`. ```harn,ignore import { command_cancel, command_json, command_json_step, command_run, command_try, command_wait_for_output, } from "std/command" const server = command_run( harness.tools, ["my-server", "--port", "8080"], {background: true}, ) defer { command_cancel(server, {wait_result_ms: 5000}) } const ready = command_wait_for_output( harness.tools, server, "listening on 8080", {timeout_ms: 10000}, ) if !ready.matched { throw "server readiness failed: " + ready.status } const repo = command_json( harness.agent, harness.tools, harness.clock, ["gh", "api", "repos/burin-labs/harn"], { capture: {max_inline_bytes: 65536}, }) const step = command_json_step( harness.agent, harness.tools, harness.clock, "repo metadata", ["gh", "api", "repos/burin-labs/harn"], { retry: {max_attempts: 2, delay_ms: 0}, }) const fallback = command_try(harness.agent, harness.tools, harness.clock, [ { source: "connector", run: fn() { return repos_get("burin-labs", "harn") }, }, {source: "cli", run: fn() { return command_json( harness.agent, harness.tools, harness.clock, ["gh", "api", "repos/burin-labs/harn"], ) }}, ], { normalize: { value, source -> return {source: source, name: value.name} } }, ) ``` `command_run` throws a structured process-spawn I/O error when the OS cannot start the program. Check `error.kind == "not_found"` for an optional executable; do not treat other kinds, policy errors, timeouts, or nonzero exit results as absence. - `sandbox_profile` in the command spec or options selects one command's profile. Use `workspace_paths` only for trusted toolchain or nested-Harn commands that need path enforcement without recursive OS confinement; untrusted children still require `worktree` or `os_hardened`. The override preserves the surrounding workspace roots and capability ceilings. - `shell_command_from_argv(argv)` renders argv as safe shell text and unwraps `["bash", "-lc", "cmd"]`-style shell wrappers to the script payload. - `shell_command_from_value(value)` accepts string, argv-list, or dict-shaped provider command values with `argv`, `command`, or `cmd` fields. - `command_json(harness.agent, harness.tools, harness.clock, spec, opts?)` parses stdout as JSON, returns `nil` for empty output only with `allow_empty: true`, and supports `result: "record"` for `{ok:false,error,step}` instead of throwing. Command results and structured errors carry the canonical effective `cwd`, including when it was inherited. - `command_json_step(harness.agent, harness.tools, harness.clock, name, spec, opts?)` preserves `command_step` retry, classify, recovery, artifact, and attempt fields, then adds `json` or `parse_error`. - `command_try(harness.agent, harness.tools, harness.clock, attempts, opts?)` is only for ordered equivalent probes. It adds `fallback_index`, `fallback_total`, and per-attempt summaries; it is not a retry system or provider framework. - `command_wait_for_output(harness.tools, handle, pattern, opts?)` parks on background output, exit, or timeout without polling. Use `source: "stdout" | "stderr" | "combined"`, `regex: true`, and `from_offset` when needed. A match reports byte offsets and leaves teardown to `command_cancel`. ## Time, sleep, monotonic clock - `harness.clock.now_ms()` — wall-clock millis since UNIX_EPOCH (`int`). - `harness.clock.monotonic_ms()` — monotonic millis since process start (`int`). - `harness.clock.sleep_ms(d)` / `harness.clock.sleep_ms(n)` — async sleep. **Mock-aware**: under `mock_time`, both advance the mocked clock instantly instead of blocking — so tests of retry/backoff/timeout logic stay deterministic and fast. The same mock is observed by `now_ms`, `monotonic_ms`, `timestamp`, `elapsed`, the trigger dispatcher, and the cron scheduler. - `harness.runtime.yield_now()` — cooperative scheduling primitive. Lets sibling `parallel each` / spawned tasks make progress without advancing time. Useful inside `harness.testing.clock_set(...)` blocks where you want one more poll cycle but no clock movement. - `harness.testing.clock_set(ms)` / `harness.testing.clock_advance(ms)` / `harness.testing.clock_reset()` — install, advance, and tear down the mock. The clock stack nests, so a Rust test harness can install an outer mock and a Harn pipeline can layer its own on top. ## Strings ```harn const plain = "hello\n" const interp = "Hello, ${name}!" const multi = """ This is a triple-quoted multiline string. It keeps line breaks verbatim and is the preferred way to declare long system prompts in source code. """ const raw = r"C:\path\does\no\escapes" ``` Heredoc-style `< 2400 { head_slice + "..." + tail_slice } else { content } const grade = if score >= 90 { "A" } else if score >= 80 { "B" } else { "C" } ``` ## Iteration Harn loops are `for x in `. Reach for destructuring and stdlib helpers instead of integer-indexed loops — they read better and avoid off-by-one bugs. ```harn for x in items { /* work */ } // enumerate: yields a list of {index, value} dicts. for {index, value} in items.enumerate() { harness.stdio.log("${index}: ${value}") } // zip: yields [a, b] pairs — destructure with list pattern. for [a, b] in xs.zip(ys) { /* work */ } // dict iteration: entries() yields [{key, value}, ...]. for {key, value} in my_dict.entries() { /* work */ } // Ranges: // [0, 1, 2, 3, 4] — half-open, Python-style const first_5 = range(5) const middle = range(3, 7) // [3, 4, 5, 6] const inc = 1 to 5 // [1, 2, 3, 4, 5] — inclusive default const exc = 1 to 5 exclusive // [1, 2, 3, 4] — half-open ``` Note: `for` heads accept three destructuring shapes, each matching what the iterable yields — mixing them fails loudly (no more silent `nil` bindings): - `for (a, b) in ...` — a **pair** pattern, for iterables that yield `Pair` values: `iter(x).enumerate()`, `iter(x).zip(...)`, `dict.iter()`. - `for [a, b] in ...` — a **list** pattern, for `list.zip(other)` (yields `[a, b]` lists). - `for {index, value} in ...` — a **dict** pattern, for `list.enumerate()` / `entries()` (yield `{index, value}` / `{key, value}` dicts). ## Streams - Declare stream producers with `gen fn name(...) -> Stream { ... }`. - Emit one value with `emit expr`; `emit` is valid only inside `gen fn`. - Consume with `for item in stream`, `.next()` (`{value, done}`), or `.iter()`. - `Stream` is distinct from `Generator`; existing `yield` behavior is unchanged. - Throws inside a stream propagate when the consumer pulls the next item. ```harn gen fn numbers() -> Stream { emit 1 emit 2 } for n in numbers() { harness.stdio.log(n) } ``` `stream.*` works with any iterable source: lists, ranges, channels, generators, and lazy `iter(...)` values. Operators are single-pass and lazy unless the name is a sink such as `collect`, `fold`, or `first`. ```harn // LLM token feed -> tap to log, then keep a bounded transcript. const chunks = stream.collect( stream.tap( harness.llm.stream_call("Summarize logs", nil, {provider: "mock"}), { chunk -> harness.stdio.log(chunk.visible_delta) }, ), {max: 200} ) // Parallel or channel results -> take the first three. const first_three = stream.collect( stream.take(results_channel, 3), {max: 3}, ) // Agent events -> filter by topic. const tool_events = stream.collect( stream.filter(agent_events, { ev -> ev?.topic == "tool_call" }), {max: 100} ) // Two streams -> race; the first source to emit wins. const winner = stream.first(stream.race(primary_stream, fallback_stream)) // Combine streams and fold to a result. const total = stream.fold( stream.merge(worker_a, worker_b, worker_c), 0, { acc, item -> acc + item.cost } ) ``` Common operators: | Operator | Use | |---|---| | `stream.map(s, f)` / `stream.filter(s, pred)` / `stream.tap(s, f)` | Per-item transform, selection, side effects. | | `stream.scan(s, seed, f)` / `stream.fold(s, seed, f)` | Running accumulator vs final accumulator. | | `stream.collect(s, {max: N})` | Materialize with an explicit cap; exceeding it throws loudly. | | `stream.take(s, n)` / `stream.take_until(s, pred)` / `stream.first(s)` | Bounded consumption and head lookup. | | `stream.merge(...)` / `stream.interleave(...)` / `stream.zip(a, b)` / `stream.race(...)` / `stream.broadcast(s, n)` | Combine or fan out streams. | | `stream.throttle(s, per_sec)` / `stream.debounce(s, window_ms)` | Basic emission pacing and burst coalescing. | `harness.llm.stream_call(prompt, system?, options?)` returns `Stream<{delta, visible_delta, partial, role, stop_reason}>` (typed as `LlmStreamChunk` from `std/llm/envelope`). It accepts the same options as `harness.llm.call`; the `stream` option is still only the provider transport toggle. Use `visible_delta` for UI rendering because it hides open internal `` blocks. Breaking out of consumption drops the stream and cancels the background request. For app-facing chat UIs with private model scratchpads, use `std/agent/stream` instead of hand-rolled pending-buffer logic: ```harn,ignore import {agent_stream_call} from "std/agent/stream" const result = agent_stream_call(prompt, system, { provider: "openai", model: "gpt-5-mini", private: {open_tag: "", close_tag: ""}, on_delta: { delta, _event, _state -> harness.stdio.print(delta) }, }) ``` `agent_private_stream_delta` holds back split private tags such as `""`, `agent_private_stream_finish` emits a terminal envelope, and `agent_stream_call` always returns either `status: "done"` or `status: "stream_interrupt"` so host loops do not hang without a completion event. ## Module scope Mark declarations `pub` to export them from a module: `pub fn`, `pub pipeline`, `pub tool`, `pub skill`, `pub struct`, `pub enum`, `pub type`, and `pub import` (re-export). A `pub type` alias can be imported alongside the functions that use it — `import { SmartTarget, pick } from "./targets"` — and used in annotations or as a `harness.llm.call_structured` schema type; non-`pub` type aliases stay module-private and error on import. Top-level `const` / `let` and `fn` declarations are visible inside functions defined in the same file: ```harn const GRADER_SYSTEM = """ You are a strict grader... """ pub fn grade_file(harness: Harness, path: string) { // GRADER_SYSTEM is in scope here. return harness.llm.call("mock", GRADER_SYSTEM, {temperature: 0.0}) } ``` Top-level mutable `let` bindings are shared across functions: a mutation in one function is visible to the others. For state mutated from `parallel`/`spawn` bodies, prefer atomics (`harness.runtime.atomic(0)`, `harness.runtime.atomic_add`, `harness.runtime.atomic_get`) or a channel — concurrent branches share one cell and a plain read-modify-write races (see HARN-LNT-064). ## Attributes (`@name(...)`) Declarative metadata on a top-level decl. Stack any number; each line attaches to the **next** declaration. Args are literals only (no expr evaluation). ```harn @deprecated(since: "0.8", use: "compute_v2") @test pub fn compute(x: int) -> int { return x + 1 } ``` | Attr | Effect | |---|---| | `@deprecated(since: "X", use: "Y")` | Type-check warning at every call site (both args optional). | | `@test` | Marks a `pipeline` as a test. `harn test` discovers it alongside the legacy `test_*` naming convention. | | `@serial(group: "name")` | Test-scheduler hint: tests sharing the group are run serially under `--parallel`. Bare `@serial` shares a default group. | | `@heavy(threads: N)` | Test-scheduler hint: the test reserves `N` worker permits under `--parallel` so it never oversubscribes the pool. | | `@job("name")` | Marks a `pub fn` as a trigger-dispatched job. `harn run --as-job file.harn --job name --request req.json` runs it once; `harn serve worker file.harn` runs schedules and queue consumers. | | `@annotations(readOnly: true, destructive: false, idempotent: true, openWorld: false)` | MCP behavior hints on a `pub fn`. Only declared hints are projected; omitted hints stay off the wire. | | `@schedule("cron", "UTC")` | Job modifier: activates the job from `harn serve worker` through the cron connector. | | `@queue("name")` | Job modifier: makes `harn serve worker` consume durable jobs from the named worker queue. | | `@retry(max: N, backoff: "svix" \| "linear" \| "exponential")` | Job modifier: maps to dispatcher retry/DLQ policy. `@job(..., retry: {...})` remains accepted for generated trigger-style metadata. | | `@complexity(allow)` | Suppresses the `cyclomatic-complexity` lint warning on this fn. | | `@host_entry` | Declares that an embedding host, not any Harn call site, supplies this fn's arguments. Takes no args. The `capability-attenuation` lint stops reporting it and `harn fix --capability-migrations-only` stops narrowing or renaming its capability parameter — the `{net: HarnessNet, ...}` record carrier is never proposed for it. Harn's own entrypoints (`main`, `@job`, trigger handlers, `handler:` callbacks, connector runtime exports) are recognized already and must not use it. Note the ambient-capability migration is a separate rewrite: a fn calling deprecated ambient builtins still has `harness: Harness` introduced. | | `@invariant("fs.writes", "src/**")` | Checked only by `harn check --invariants`. Current built-ins: `fs.writes`, `budget.remaining`, `approval.reachability`. `harn explain --invariant ` prints the violating CFG path. | | `@acp_tool(name: "X", kind: "edit", side_effect_level: "mutation", ...)` | Compiles to `tool_define(...)` with the fn as the handler and the named args (minus `name`) lifted into `annotations`. `name` defaults to the fn name. | | `@acp_skill(name: "X", when_to_use: "...", invocation: "explicit", ...)` | Compiles to `skill_define(...)` with the fn bound as the skill's `on_activate` hook. Named args (minus `name`) become skill-metadata fields. `name` defaults to the fn name. | Unknown attribute names produce a type-checker warning (typo guard) but don't break compilation. Attached to any non-decl statement is a parse error. ## Typing: `any` vs `unknown` vs no annotation Harn is gradually typed. Three levels of "I don't know the type yet": | Annotation | Accepts any value in | Flows out to concrete types | Use when | |---|---|---|---| | *(omitted)* | yes | yes | Internal, unstable code you haven't typed yet. | | `unknown` | yes | **no** — must narrow first | Untrusted boundaries: LLM responses, parsed JSON, dynamic dicts. | | `any` | yes | yes (escape hatch) | Last resort. Prefer `unknown` unless you have a specific reason to defeat checking. | Narrow `unknown` with `type_of(x) == "T"` or `schema_is(x, Shape)`: ```harn fn handle(v: unknown) -> string { // v: string here if type_of(v) == "string" { return "str:${v.upper()}" } // v: MyShape here if schema_is(v, MyShape) { return "shape:${v.name}" } return "other" } ``` Narrowing survives `const` aliases. A helper can publish the same fact with a type predicate: ```harn fn is_text(value: unknown) -> value is string { return type_of(value) == "string" } fn is_nonempty_text(value: unknown) -> implies value is string { return type_of(value) == "string" && len(value) > 0 } fn normalize(value: string | int) -> string { const text = is_text(value) if text { return value.upper() } return to_string(value) } ``` Use `value is T` when false rules out `T`. Use `implies value is T` when only true proves `T`. The checker rejects a false contract with `HARN-TYP-029`. `never` is the bottom type — expressions like `throw`, `return`, `unreachable()`, and blocks that always exit infer to `never`. It's a subtype of every type. ### Discriminated unions & distribution Three discriminated-union surface forms, all check identically once you've written them — pick whichever reads best at the call site. **Pure literal unions.** No discriminant, no shape: just enumerate the literal values. `match` covers them like an enum. ```harn,ignore type Verdict = "pass" | "fail" | "unclear" fn classify(v: Verdict) -> string { match v { "pass" -> { return "ok" } "fail" -> { return "no" } "unclear" -> { return "?" } } } ``` **Tagged shape unions.** Two or more dict shapes joined by `|`. The checker auto-detects the discriminant: a field that is non-optional in every variant, has a literal type, and takes a distinct literal value per variant. The field can be named anything — `kind`, `type`, `op`, whatever fits the domain — there is no privileged spelling. ```harn,ignore type Msg = {kind: "ping", ttl: int} | {kind: "pong", latency_ms: int} fn handle(m: Msg) -> string { match m.kind { // narrows m per arm "ping" -> { return "ttl=" + to_string(m.ttl) } "pong" -> { return to_string(m.latency_ms) + "ms" } } } // Same narrowing works on `if`: if m.kind == "ping" { /* m: {kind: "ping", ttl: int} */ } else { /* m: {kind: "pong", latency_ms: int} */ } ``` **Legacy `enum`.** Nominal variants with optional payload fields, matched on `.variant`. ```harn,ignore enum Action { Create, Edit, Delete } match a.variant { "Create" -> { … } "Edit" -> { … } "Delete" -> { … } } ``` **`match` must be exhaustive.** Missing a variant is a hard error. Add the missing arm or end with `_ -> { … }`. `if/elif/else` chains stay intentionally partial; opt into exhaustiveness by ending the chain with `unreachable("…")`. **Or-patterns (`pat1 | pat2 -> body`)** let a single arm body cover two or more alternatives, and each alternative counts toward exhaustiveness. Inside the arm, the matched variable is narrowed to the *union* of the alternatives' matches — on a tagged shape union this is a sub-union, not a single variant: ```harn,ignore match m.kind { "ping" | "pong" -> { /* m is {kind:"ping",…} | {kind:"pong",…} */ } "close" -> { /* m is the close variant */ } } ``` Or-pattern alternatives are restricted to literals (string, int, float, bool, nil) and the wildcard `_`. Guards (`… if cond ->`) work on or-pattern arms too. **Generic aliases distribute over closed unions.** When you write `Container`, the checker expands it to `Container | Container` so each instantiation fixes the type parameter independently. This is what makes the TypeScript pain around `(t: "create" | "edit") => void` not bite in Harn: ```harn,ignore type Action = "create" | "edit" type ActionContainer = {action: T, process_action: fn(T) -> nil} fn process_create(a: "create") { … } fn process_edit(a: "edit") { … } const containers: list> = [ {action: "create", process_action: process_create}, {action: "edit", process_action: process_edit}, ] ``` `ActionContainer` is `ActionContainer<"create"> | ActionContainer<"edit">`, so the literal-tagged elements fit one specific branch each — no contravariance grief. ### Intersection types (`A & B`) `A & B` requires the value to satisfy *every* component, not just one. The intersection of two shape types behaves like a dict that has every field from each component, so both fields are accessible: ```harn,ignore type BaseCtx = {request_id: string} type AuthCtx = {user_id: string} fn use_ctx(ctx: BaseCtx & AuthCtx) -> string { return ctx.request_id + "/" + ctx.user_id } ``` `&` binds tighter than `|`, so `A & B | C` parses as `(A & B) | C`. Inline shapes work too: `fn f(env: {region: string} & {tier: string})`. Lowering: at runtime an intersection annotation becomes a JSON-Schema `allOf` guard, so missing a field from any component triggers the parameter-runtime check just like a single-shape mismatch. ### Variance (`in T` / `out T`) User-declared generics default to **invariant**. Mark a type parameter `out T` for covariance (T appears only in output position) or `in T` for contravariance (T appears only in input position): ```harn,ignore type Reader = fn() -> T interface Sink { fn accept(v: T) -> int } fn map(value: A) -> B { ... } ``` Built-ins: `iter` and value-semantic `list` are covariant; `dict` is invariant in `K` and covariant in `V`; `tuple` is covariant at each fixed position; `Result` is covariant in both. Function types are **contravariant in parameters**, covariant in return — `fn(float)` stands in for `fn(int)`, never the reverse. The numeric widening `int <: float` is suppressed in invariant positions. ### Fixed-arity tuples `tuple(a, b)` infers `tuple`. A bracket literal remains a list by default, but a `tuple<...>` annotation or parameter contextually checks each position: ```harn const row = tuple("retries", 3) const name: string = row[0] const same: tuple = ["retries", 3] ``` Constant positive or negative indexes select an exact position; a constant out-of-bounds index is `HARN-TYP-027`. A dynamic index returns the positional union plus `nil`, while iteration returns the union without `nil`. Tuples widen to compatible lists; arity-changing operations also return lists. Lists never narrow to tuples. ## Results and errors `try { ... }` returns a `Result.Ok(value)` on success or `Result.Err(value)` on thrown error. Unwrap with: - `unwrap(r) -> T` — returns `T`, panics if `Err`. - `unwrap_err(r) -> string` — returns the error message, panics if `Ok`. - `r?.field` — optional chaining that returns `nil` on `Err`. - `match r { Ok(v) -> { … } Err(e) -> { … } }` — bare variant patterns; the `Result.` qualifier is optional when the variant name is unambiguous, and payloads bind with the instantiated types (`Result` binds `v: int`, `e: string`). ```harn const r = try { harness.llm.call("hi", nil, opts) } const text = r?.text ?? "no response" ``` `try { body } catch (e) { handler }` is also an expression: its value is the body tail on success or the handler tail on a caught throw. A typed catch that doesn't match the thrown type rethrows past the expression. A trailing `finally { ... }` runs once for effect only. ```harn const parsed = try { json_parse(raw) } catch (e) { default_config() } ``` Optional chaining works for properties, methods, and subscripts: `obj?.field`, `obj?.method(args)`, and `obj?.["content-type"]` all return `nil` when the receiver is `nil`; otherwise they perform the same access as `.`, method call, or `[]`. `??` binds tighter than comparisons/logical operators and looser than multiplication. Read `classified == maybe_flag ?? false` as `classified == (maybe_flag ?? false)`. `harn fmt` inserts those clarifying parentheses automatically. `try* EXPR` (prefix) evaluates `EXPR` and rethrows any throw so an enclosing `try { ... } catch (e) { ... }` sees it. Use it instead of the verbose `try { foo() } / guard is_ok else / unwrap` boilerplate: ```harn fn fetch(prompt) { // Without try*: try { harness.llm.call(prompt) } / guard is_ok / unwrap const response = try* harness.llm.call(prompt) return parse(response) } const outcome = try { fetch(user_prompt) } catch (e: ApiError) { fallback(e) } ``` `try*` requires an enclosing function (`fn`, `tool`, or `pipeline`) so the rethrow has somewhere to live; it's a compile error at the module top level. It's distinct from postfix `?`: `?` early-returns `Result.Err(...)` from a `Result`-returning function, while `try*` rethrows a thrown value into an enclosing catch. ## JSON querying Use `json_pointer(value, ptr)` for RFC 6901 paths such as `/users/0/email`; escaping is `~0` for `~` and `~1` for `/`. Missing paths return `nil`. `json_pointer_set(value, ptr, new)` and `json_pointer_delete(value, ptr)` return modified copies. Use `jq(value, expr)` for a jq-like stream query; it always returns a list. Use `jq_first(value, expr)` when you expect one value or `nil`. Supported v1 forms include `.`, `.foo.bar`, `.[2]`, `.[2:5]`, `.[]`, `.["quoted key"]`, pipes, commas, `length`, `keys`, `values`, `type`, `map(...)`, `select(...)`, boolean comparisons, object construction, and recursive descent `..`. ```harn const api = json_parse(response.body) const first_email = json_pointer(api, "/users/0/email") const active = jq(api, ".users[] | select(.active == true) | .email") const summary = jq_first( api, "{ count: .users | length, next: .meta.next }", ) ``` For JSONL files that may be large, import `std/jsonl` and use `read_jsonl_page_result` or `read_jsonl_contract_page_result`. Each page is bounded by physical records and bytes, returns an exact `{offset, line}` cursor, and never returns a partial line. Contract pages preserve `malformed`, `schema_invalid`, `rule_failed`, and `rule_error` per-record issues with the raw line and its location. Use `fold_jsonl_file` when the whole file need not reside in memory; `read_jsonl` remains the list-materializing compatibility helper. For a file that is actively growing, use `read_jsonl_append_page_result`. It parses only newline-committed records, leaves an incomplete final row behind the cursor, and returns `partial_tail_bytes`. The cursor includes opaque file identity, observed size, and an optional caller `generation`. Replacement, truncation, or a generation change restarts from byte zero with a typed `reset_reason`; persist the returned cursor as one dict without reconstructing filesystem state in the caller. For schema-first code, `schema_report(value, schema, apply_defaults?)` returns `{ok, message, errors, issues, value?}` without throwing. Import `std/schema` for builder/composition helpers such as `schema_object(...)`, `schema_closed_object(...)`, `schema_strict_object(...)`, `get_typed_report(...)`, and `get_typed_value(...)`. Prefer closed/strict objects for option bags, receipts, structured LLM outputs, and host contracts that should reject unknown keys. Import `std/slug` for human-readable non-secret identifiers: `random_slug({segments: 3})`, `slug_from(value, {salt: "ci"})`, and `slugify("Agent 007 / Fast Verify")`. ## Concurrency ```harn // Spawn a background task. const h = spawn { long_work() } const value = await(h) // parallel each: concurrent map. Fail-fast: the first branch error // cancels in-flight siblings and propagates. const results = parallel each paths { p -> process(p) } // parallel settle: like `each` but collects per-item Ok/Err and never // cancels — use it when every branch must run regardless of failures. const outcome = parallel settle paths { p -> grade(p) } harness.stdio.log(outcome.succeeded) // count harness.stdio.log(outcome.failed) for r in outcome.results { // r is Result.Ok(...) or Result.Err(...) } // parallel N: fan-out with an index. Fail-fast like `parallel each`. const indices = parallel 8 { i -> fetch(i) } // Cap in-flight work to avoid overwhelming downstream services. const results = parallel settle paths with { max_concurrent: 4 } { p -> harness.llm.call(p, nil, opts) } ``` `std/abort` adds the third form: run everything and collect every outcome like `parallel settle`, but let a branch that reaches a doomed verdict stop the siblings that have not started. Use it when branches poll or wait and a sibling's failure makes the rest pointless. It is COOPERATIVE — a branch blocked inside one long call is not interrupted; the abort lands at the next checkpoint the branch itself checks. ```harn,ignore import { abort_requested, decisive_error, request_abort, settle_with_abort, } from "std/abort" const outcome = settle_with_abort(lanes, { lane, token -> if abort_requested(token) { // your own safe checkpoint return Err({ code: "stopped_waiting", message: "sibling already failed", }) } if doomed(lane) { let _ = request_abort( token, {code: "lane_failed", message: "${lane} red"}, ) return Err({code: "terminal", message: "${lane} red"}) } return proof(lane) }, {max_concurrent: 3}) // outcome.results is one Result per item // in source order (std/settled applies). // A branch fails when it throws OR returns // Result.Err — plain `parallel settle` // would record a returned Err as Ok(Err(..)) and count it a success. harness.stdio.log( outcome.aborted, outcome.abandoned, outcome.reason?.code, ) // first non-abandoned failure: the real cause harness.stdio.log(decisive_error(outcome)) ``` `max_failures: N` trips the token automatically after N failures; `abort_on: fn(err, i) -> AbortReason?` chooses which failures are decisive (return `nil` to keep going). With neither, it behaves exactly like `parallel settle`. `max_concurrent: 0` (or no `with` clause) means unlimited. See also `retry { }` (count mandatory; returns nil when all attempts fail — no `catch` clause), channels, `select`, and `deadline` in `docs/src/concurrency.md`. For quotas shared across Harn processes, use `harness.runtime.durable_rate_limit_acquire(options)`. It writes a SQLite reservation log under `.harn/rate-limits.sqlite` by default, supports atomic multi-bucket admission, and returns `{ok, timed_out, waited_ms, retry_after_ms, buckets}`: ```harn const admitted = durable_rate_limit_acquire({ buckets: [ {key: "provider:cerebras:rpm", limit: 5, units: 1, window_ms: 60s}, { key: "model:cerebras:gpt-oss-120b:tpm", limit: 30000, units: 12000, window_ms: 60s, }, ], timeout_ms: 2m, }) guard admitted.ok else { throw "quota admission timed out" } ``` Channel waits are guarded: if every active task is blocked on sends/receives that cannot match another task, the runtime raises `HARN-ORC-012` instead of hanging. Use `deadline { ... }`, `select timeout`, or `harness.runtime.channel_select(..., timeout_ms)` when a channel wait is intentionally bounded by time. ## Iteration & lazy iterators Eager collection methods (`list.map`, `list.filter`, `list.flat_map`, `dict.map_values`, `dict.filter`, set/string equivalents, `.reduce`, `.find`, `.any`, `.all`, etc.) still return eager collections. Nothing about those has changed — use them when you just want a list/dict back. Lazy iteration is opt-in via `.iter()`: ```harn const xs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] const first_three_doubled_evens = xs .iter() .filter({ x -> x % 2 == 0 }) .map({ x -> x * 2 }) .take(3) .to_list() // [4, 8, 12] ``` `.iter()` lifts a list/dict/set/string/generator/channel into `Iter` — a lazy, single-pass, fused iterator. Combinators chain by returning a new `Iter`. Sinks drain the iter and return an eager value. ### Lazy combinators (`Iter -> Iter<...>`) `.map(f)`, `.filter(p)`, `.flat_map(f)`, `.take(n)`, `.skip(n)`, `.take_while(p)`, `.skip_while(p)`, `.zip(other)`, `.enumerate()`, `.chain(other)`, `.chunks(n)`, `.windows(n)`, `.iter()` (no-op on an iter). `iter(x)` is also available as a free builtin. ### Sinks (drain, return eager value) `.to_list()`, `.to_set()`, `.to_dict()` (requires `Pair` items), `.count()`, `.sum()`, `.min()`, `.max()`, `.reduce(init, f)`, `.first()`, `.last()`, `.any(p)`, `.all(p)`, `.find(p)`, `.for_each(f)`. ### Dict iteration and `Pair` `.iter()` on a dict yields `Pair(key, value)` values — **not** `{key, value}` dicts. Access with `.first` / `.second`, or destructure in a for-loop: ```harn for (k, v) in {a: 1, b: 2}.iter() { harness.stdio.log("${k}: ${v}") } ``` A direct `for entry in some_dict` still yields `{key, value}` dicts (back-compat). A `pair(a, b)` builtin exists for constructing pairs explicitly; `.zip` and `.enumerate` also emit pairs. ### Semantics - **Lazy**: nothing runs until a sink (or for-loop) pulls values. - **Single-pass, fused**: once exhausted, stays exhausted. Call `.iter()` again on the source to restart. - **Snapshot**: the iter `Rc`-clones the backing collection, so mutating the source after `.iter()` doesn't affect the iter. - **String iteration**: yields chars (Unicode scalar values), not graphemes. - **Printing**: `harness.stdio.log(it)` renders `` or `` without draining. ### Ranges and iters `Range` (from `a to b` / `range(n)`) is its own value type with O(1) `.len() / .first() / .last() / .contains(x)` and `r[k]` subscript — no materialization. Calling any lazy combinator on a Range (`.map / .filter / .flat_map / .take / .skip / .take_while / .skip_while / .zip / .enumerate / .chain / .chunks / .windows`) returns a lazy `iter`. Sinks (`.to_list / .sum / .reduce / ...`) drain through the iter. In short: Range handles integer ranges with O(1) ops; Iter handles arbitrary lazy sequences. Chaining `(1 to 10000000).map(...).take(5).to_list()` finishes instantly because only 5 elements flow through the pipeline. ## Regex ```harn // ["42", "7"] or nil const matches = regex_match("[0-9]+", "abc 42 def 7") const swapped = regex_replace("(\\w+)\\s(\\w+)", "$2 $1", "hello world") // -> "world hello" const captures = regex_captures("(?P[A-Z][a-z]+)", "Mon Tue") const words = regex_split("a, b, c", ",\\s*") const ci = regex_match("hello", "HeLLo", "i") const fixed_ci = regex_replace("hello", "hi", "HeLLo", "i") const body = regex_captures("(?is)]*>(.*?)", html) const body2 = regex_captures("]*>(.*?)", html, "is") ``` `regex_replace` replaces every match and supports `$1`, `$2`, `${name}` backrefs plus the same optional `i`/`m`/`s`/`x` flags as `regex_match`. Inline regex flags such as `(?is)` use the same semantics as the trailing flags argument. Each `regex_captures` result has `match`, positional `groups` excluding the full match, character offsets `start`/`end`, 1-based `line`, and any named capture groups as top-level keys. ## Encoding, bytes, and compression Use byte helpers when content may not be UTF-8: ```harn const bytes = bytes_from_string("hello") const text = bytes_to_string(bytes) const hex = bytes_to_hex(bytes) const same = bytes_from_hex(hex) ``` Compression is in-memory and returns `bytes`. Encoders accept `bytes` or `string`; decoders always return `bytes`. ```harn const gz = gzip_encode("hello", 6) // level 0..9, default 6 const zst = zstd_encode(bytes, 3) // zstd level, default 3 const br = brotli_encode("hello", 11) // quality 0..11, default 11 const hello = bytes_to_string(gzip_decode(gz)) const tar = tar_create([ {path: "README.md", content: "# Hi\n", mode: 420}, ]) const tar_entries = tar_extract(tar) // [{path, content: bytes, mode}] const zip = zip_create([{path: "a.txt", content: "alpha"}]) const zip_entries = zip_extract(zip) // [{path, content: bytes}] ``` ## Scripting helpers ```harn const rng = rng_seed(42) const roll = harness.random.range(rng, 1, 6) const shuffled = harness.random.shuffle(rng, [1, 2, 3, 4]) const grouped = group_by(["a", "bb", "c"], { s -> len(s) }) const parts = partition([1, 2, 3, 4], { x -> x % 2 == 0 }) const padded = str_pad("é", 3, ".", "both") const graphemes = unicode_graphemes("éx") const parsed = uuid_parse(harness.random.uuid_v7()) ``` ### Postgres query helpers For Harn data-access modules, prefer `std/postgres/query` when direct `pg_query` calls become hard to review. It is not an ORM: SQL stays visible and dynamic values still go through Postgres bind parameters. ```harn,ignore import "std/postgres" import { ident, many, named_sql, run, sql, uuid_text, nullable_timestamptz_json, } from "std/postgres/query" fn list_receipts_query(tenant_id: string, limit: int) { return named_sql( "list_receipts", "many", """ SELECT {id}, payload, {finished_at} FROM {table} WHERE tenant_id = {tenant_id}::uuid ORDER BY {created_at} DESC LIMIT {limit} """, { id: uuid_text("id"), finished_at: nullable_timestamptz_json("finished_at"), table: ident("receipts"), tenant_id: tenant_id, created_at: ident("created_at"), limit: limit, }, {read_only: true}, ) } const rows = run(db, list_receipts_query(tenant_id, 50)) const direct = many( db, sql( "SELECT id::text AS id FROM receipts LIMIT {limit}", {limit: 10}, ), ) ``` Helpers: `one(handle, query)`, `many(handle, query)`, `exec(handle, query)`, `run(handle, named_query)`, `sql(template, values?, options?)`, `named_sql(name, mode, template, values?, options?)`, `named(name, mode, sql, params?)`, `ident(name)`, `ident_path(parts)`, `unsafe_sql(fragment)`, `uuid_text(name)`, `timestamptz_json(name)`, `nullable_timestamptz_json(name)`, `columns(parts)`, and `select_clause(parts)`. The projection helpers (`uuid_text`, `timestamptz_json`, `nullable_timestamptz_json`, `columns`, `select_clause`) return trusted `PgSqlFragment`s, so they drop into `{name}` placeholders without `unsafe_sql(...)`. `uuid_text`/`timestamptz_json`/`nullable_timestamptz_json` accept table-qualified names (`timestamptz_json("vaults.created_at")`); the alias is the trailing segment. In `sql(...)`, ordinary `{name}` placeholders become `$n` params and repeated placeholders reuse the first parameter index. Use `{{` and `}}` for literal braces. SQL structure is never inferred from strings; use `ident(...)` / `ident_path(...)` for identifiers and reserve `unsafe_sql(...)` for source-controlled fragments no typed helper covers. ## LLM surface ```harn const response = harness.llm.call(prompt, system, options) // public answer, post-projection harness.stdio.log(response.text) // pre-projection source, tags intact harness.stdio.log(response.raw_text) // sanitized human-visible output harness.stdio.log(response.visible_text) // canonical replay form of a tagged response harness.stdio.log(response.canonical_text) harness.stdio.log(response.usage.input_tokens) harness.stdio.log(response.usage.output_tokens) // outcome.kind is "complete" | "tool_use" | "truncated" | "refused" // | "paused" | "empty" harness.stdio.log(response.outcome.kind) // canonical blocks; may contain private signed reasoning harness.stdio.log(response.blocks) // present when requested and returned harness.stdio.log(response.logprobs) ``` All call accounting lives under `usage` and the typed `outcome` classifies what the call produced — branch on `outcome`, never on the provider-native `stop_reason`. The full contract is `LlmResponse` from `std/llm/envelope`. `thinking` is the readable reasoning projection. `blocks` retains exact provider continuation material, including Anthropic signed `thinking` and opaque `redacted_thinking` blocks. Keep those blocks private and unmodified; the capability matrix decides whether they may be replayed to a route. ### `harness.llm.call` options Typed shape: `LlmCallOptions` from `std/llm/options`. Prefer an annotated binding or `llm_options({...})`. One runtime registry validates direct calls, streams, and agent-loop dispatch; unknown and removed keys are errors. | Concern | Canonical options | |---|---| | Route | `provider`, `model`, `model_role`, `model_tier`, `api_mode`, `route_policy`, `fallback_chain`, `routing`, `equivalent_failover`, `models`, `ladder` | | Conversation | `system`, `messages`, `session_id`, `call_role`, `mock_scope`, `context_profile`, `capabilities`, `prefill`, `previous_response_id` | | Generation | `max_tokens`, `temperature`, `top_p`, `top_k`, `logprobs`, `logit_bias`, `min_p`, `repetition_penalty`, `prediction`, `verbosity`, `mirostat`, `stop`, `stop_at_tool_call`, `seed`, `frequency_penalty`, `presence_penalty`, `parallel_tool_calls` | | Output | `output`, `schema_retries`, `schema_retry_nudge`, `retries`, `schema_recover`, `repair` | | Reasoning | `thinking`, `effort`, `reasoning_policy`, `reasoning_scale`, `reasoning_task`, `interleaved_thinking`, `anthropic_beta_features` | | Modalities | `vision`, `audio`, `pdf`, `video` | | Tools | `tools`, `provider_tools`, `tool_choice`, `tool_search`, `tool_format`, `tool_format_override_reason` | | Transport | `cache`, `prompt_cache_ttl`, `budget`, `timeout_ms`, `idle_timeout_ms`, `stream`, `speed` | | OpenAI Responses | `store`, `background`, `truncation`, `compact`, `include`, `max_tool_calls` | | Extension | `provider_options`, `metadata`, `reminders`, `structural_experiment` | The `output` forms are: ```harn {output: "text"} // default {output: "json"} // parse JSON // validate Schema {output: Verdict} { output: { schema: Verdict, strict: true, validation: "error", stream_abort: true, } } ``` `system` is a string, an ordered fragment list, or an exclusive replacement root. Fragments use `{content, title?, position?: "before"|"after", enabled?}`; build them with `system_before`, `system_after`, and `with_system_fragments` from `std/llm/prompts`. `{mode: "replace", content: string}` makes `content` the entire system channel: no positional system text, fragment, context profile, tool guidance, provider thinking directive, or conversation-level `system`/`developer` message is added. Tools and ordinary conversation history remain available. Provider-specific request fields live only below `provider_options: {: {...}}`. Use `effort` for reasoning intent, `speed: "fast"` for accelerated serving, and millisecond integers in `timeout_ms` / `idle_timeout_ms`. Caller-selected `temperature`, `top_p`, `top_k`, `seed`, `frequency_penalty`, `presence_penalty`, and `stop` are admitted against each resolved route before transport. A catalog denial throws terminal `invalid_request`; Harn never silently drops the option. Unknown custom generation routes remain open-world. Explicit `cache: true` and `prompt_cache_ttl` instead require authored support because their wire lowering is provider-specific, and a TTL must be listed in `prompt_cache_ttls`. See the [complete option reference](../src/llm/llm_call.md#options-dict) and the [0.10 migration table](../src/migrations/v0.10.md#llm-call-options). Provider auto-resolution precedence: 1. An explicit `provider` option other than `"auto"` constrains the route. It must agree with a provider-qualified `model`. A built-in catalog provider must also own any matching catalog row or known model family. Custom adapters may proxy an upstream model identity. 2. `model_role` fills missing provider/model/routing options from `[model_roles.]` or role env overrides. 3. `provider: "auto"` with a `model` infers from the model selector. 4. If `provider` is omitted, `HARN_LLM_PROVIDER` wins when set; otherwise a `model` infers the provider. 5. Unknown model IDs fall back to `HARN_DEFAULT_PROVIDER`, then the configured default provider (`anthropic` in the built-in catalog), and emit a warning. A near-miss of a known alias fails instead, naming the compiled catalog version and suggested names. Resolution produces one typed receipt before provider dispatch: `requested_model`, `alias_chain`, `resolved_provider`, `resolved_model`, and `model_catalog_version`. Provider-qualified selectors are hard constraints, including custom proxy selectors and routing-policy transforms. The runtime compares the receipt with the transport route before every provider request and fails without egress on disagreement. ### OpenAI Responses mode Use `api_mode: "responses"` with `provider: "openai"` when a call needs OpenAI-native hosted tools, remote MCP, previous-response chaining, background mode, or provider-side truncation/compaction: ```harn const r = harness.llm.call(prompt, sys, { provider: "openai", model: "gpt-5.4", api_mode: "responses", output: {schema: schema, strict: true, validation: "error"}, provider_tools: [ {type: "web_search"}, { type: "mcp", server_label: "docs", server_url: "https://mcp.example.com", require_approval: "always", }, ], truncation: "auto", }) ``` Use Harn `tools`/MCP when Harn must execute, approve, and audit each call. Use `provider_tools` only when OpenAI should execute the hosted tool. Those calls appear as `provider_tool_call` blocks with provider-native IDs and `executor: "provider_native"`; Harn records metadata but does not locally mediate each remote call. Set `compact: true` for a standalone compaction pass. Harn records returned opaque `compaction` items as private blocks rather than implicitly rewriting the Harn transcript. | Model selector | Provider | Model sent to provider | |---|---|---| | `local:` | `ollama` | `` | | `ollama:` | `ollama` | `` | | `ollama/` | `ollama` | `` | | `/` (one slash, except `ollama/`) | `openrouter` | unchanged | | `claude-*` | `anthropic` | unchanged | | `gpt-*`, `o1*`, `o3*`, `o4*` | `openai` | unchanged | | `gemini-*` | `gemini` | unchanged | | `:` | `ollama` | unchanged | | anything else | `HARN_DEFAULT_PROVIDER` / configured default | unchanged | Native Gemini routes use Google's `generateContent` wire format directly: tool schemas become `functionDeclarations`, model tool requests are `functionCall` parts, tool observations are `functionResponse` parts, and JSON schemas lower to Gemini's JSON response controls. Vertex AI also serves Gemini models through `generateContent`, but keeps Google Cloud project / location and OAuth/service-account authentication. OpenAI-compatible Gemini routes such as OpenRouter remain OpenAI-wire routes and use OpenAI-style `tools`, `tool_calls`, and structured-output parameters. Google serves the Gemini models over two synchronous endpoints, and the `live_endpoint_family` capability says which one a route dispatches to: `gemini_generate_content` (the default, described above) or `gemini_interactions` (`POST /v1beta/interactions`, GA June 2026). The Interactions family models a turn as typed steps (`user_input`, `thought`, `model_output`, `function_call`, `function_result`) and adds provider-side conversation state via `previous_response_id`, streaming, and `background`. Harn sends `store: false` unless you ask for state, `thinking` maps onto the `minimal`/`low`/`medium`/`high` ladder rather than a token budget, and `frequency_penalty` / `presence_penalty` have no Interactions field. Gemini Batch is unaffected: it stays `generateContent`-shaped whichever live family a route uses. Opt in per project: ```toml [[capabilities.provider.gemini]] model_match = "gemini-3.5*" extends = true # one-field overlay; without it the row REPLACES the shipped one live_endpoint_family = "gemini_interactions" ``` Not every model is served by both endpoints; confirm with `harness.llm.provider_capabilities(provider, model).live_endpoint_family`, which honors project overrides. See [Providers](../src/llm/providers.md#gemini-interactions-api) for the full contract. ### Mid-conversation system & developer messages A conversation `messages` array (or a transcript built with `add_user` / `add_assistant` / `add_system`, or `add_message(convo, "developer", ...)`) may carry a `system`- or `developer`-role message **anywhere**, not just at the front — an operator instruction delivered mid-conversation (a mode switch, a runtime-fetched constraint, injected state). Harn makes that portable: at the request boundary it rewrites the interleaved directive to the exact form the target route accepts, driven by the `system_message_placement` capability. You write the same script for every provider; you never hit a provider-specific placement 400 or a silently-repositioned directive. ```harn let convo = add_user(transcript_from_messages([]), "My name is Ada.") convo = add_assistant(convo, "Nice to meet you, Ada.") convo = add_system( convo, "For the rest of this conversation, reply only in French.", ) convo = add_user(convo, "What is my name?") // Same script on every route: const base = {messages: transcript_messages(convo)} harness.llm.call( "", nil, base + {provider: "anthropic", model: "claude-opus-4-8"}, ) harness.llm.call( "", nil, base + {provider: "anthropic", model: "claude-haiku-4-5"}, ) harness.llm.call("", nil, base + {provider: "openai", model: "gpt-5.4"}) ``` Per-route behavior (capability-driven, not hardcoded): | Placement | Routes | Interleaved directive becomes | |---|---|---| | `inline` | OpenAI Chat/Responses, Ollama | Carried verbatim at its position (these APIs accept `system`/`developer` anywhere). | | `native_directive` | Claude Opus 4.8 | A validly-placed message rides natively as `role: "system"`; consecutive directives merge into one message while retaining ordered content blocks and cache metadata. Anything with an invalid neighbor folds instead. `developer` collapses to `system`. | | `fold` | Gemini, Bedrock, older/other Claude | No positional system channel, so the directive folds into the adjacent user turn as a `` block — its position and operator intent survive instead of being hoisted into the global system prompt or 400ing. | A **leading** run of `system`/`developer` messages is always the system prompt and merges into the top-level `system` field on every route. Only *interleaved* directives are governed by `system_message_placement` — unset derives from the wire dialect (OpenAI/Ollama → `inline`, else `fold`), so the safe default never 400s. The normalization runs at the wire boundary only; the persisted transcript keeps the original roles. ### Reranking and self-certainty ```harn import { pairwise_rerank, self_certainty } from "std/llm/rerank" const ranked = pairwise_rerank(candidates, { task: "Pick the most relevant search result.", criteria: "Prefer primary sources with direct evidence.", provider: "mock", }) const confidence = self_certainty( "ignored", {logprobs: [{token: "answer", logprob: -0.1}]}, ) ``` `pairwise_rerank` returns `{ranked, scores, comparisons}` using `O(n log n)` pairwise judge calls, or a deterministic `compare(left, right, ctx)` callback when supplied. `self_certainty` scores supplied/result `logprobs`, or makes an extra repeat-exactly model call with `logprobs: true`; live support depends on the provider returning OpenAI-compatible or legacy completion logprob records. ### Tool executor declarations Every `tool_define(...)` registration declares **how the tool is dispatched**. The runtime uses this to decide where the call runs and to tag ACP `tool_call_update.executor` events so clients can render "via host bridge" / "via mcp:linear" badges. | `executor` value | Required companion field | Where it dispatches | |---|---|---| | `"harn"` *(or `"harn_builtin"` alias)* | `handler` (a closure) | In-VM via the registered handler. The VM stdlib short-circuits `read_file` / `list_directory` even without a handler. | | `"host_bridge"` | `host_capability: "cap.op"` | Through the host shell's `builtin_call` bridge (Swift IDE bridge, BurinApp, BurinCLI). `harn check` validates the binding against the host capability manifest when one is configured. | | `"mcp_server"` | `mcp_server: ""` | Through the configured MCP server. Tools sourced from `mcp_list_tools` carry the `_mcp_server` annotation and don't need the explicit declaration. | | `"provider_native"` | *(none)* | Provider-side (e.g. OpenAI Responses API server tools). The runtime never dispatches these locally — the model returns the already-executed result inline. | ```harn // Harn handler (default when `handler` is present and `executor` is // omitted — back-compat path). registry = tool_define(registry, "look", "Read files", { parameters: {path: "string"}, handler: { args -> harness.fs.read_text(args.path) }, }) // Host-bridge tool — handler-less by design. registry = tool_define(registry, "ask_user", "Ask the user", { parameters: {prompt: "string"}, executor: "host_bridge", host_capability: "interaction.ask", }) // MCP-served tool with explicit server binding. registry = tool_define(registry, "github_search", "Search issues", { parameters: {query: "string"}, executor: "mcp_server", mcp_server: "github", }) // Provider-native — runtime never dispatches. registry = tool_define(registry, "tool_search", "...", { parameters: {query: "string"}, executor: "provider_native", }) ``` Harn handlers normally return the text shown to the model. When a producer also needs to expose typed facts to middleware or lifecycle consumers, return `agent_tool_handler_result(text, data)` from `std/agent/tool_lifecycle`. The dispatcher preserves the full `{schema, text, data}` record on `result` and projects the complete `data` map onto the flat dispatch result, the terminal `tool_call_update`, and the stored session tool-result message. ACP exposes the same map at `_meta.harn.data`. `rendered_result` and the model-visible observation contain only `text`. Ordinary unmarked dict returns retain their existing display rendering and do not gain promoted `data`. `tool_define` rejects invalid combinations at definition time, and `agent_loop` refuses to start if the registry contains a tool with no executable backend. The historical `[builtin_call] unhandled: ` runtime failure is replaced by a clear error pointing at the offending tool. ### Tool loading & search Mark tools that the model rarely needs with `defer_loading: true` and opt the call into progressive disclosure with `tool_search: "bm25"`: ```harn let registry = tool_registry() registry = tool_define(registry, "look", "Read files", { parameters: {path: {type: "string"}}, handler: { args -> harness.fs.read_text(args.path) }, }) registry = tool_define(registry, "deploy", "Deploy to production", { parameters: {env: {type: "string"}}, defer_loading: true, // schema held back until searched handler: { args -> harness.process.shell("deploy " + args.env) }, }) const r = harness.llm.call(prompt, sys, { provider: "anthropic", model: "claude-opus-4-7", tools: registry, tool_search: "bm25", // or "regex" / "hybrid" }) ``` Provider support matrix for `tool_search`: | Provider | Native | Client fallback | |---|---|---| | Anthropic — Opus/Sonnet 4.0+, Haiku 4.5+ | ✓ (`bm25`, `regex`) | ✓ | | Anthropic — pre-4.0 / other Claude | ✗ | ✓ | | OpenAI — GPT 5.4+ (Responses API, hosted) | ✓ (`tool_search`) | ✓ | | OpenAI — pre-5.4 (`gpt-4o`, `gpt-4.1`, older) | ✗ | ✓ | | OpenRouter, Together, Groq, DeepSeek, Fireworks, HuggingFace, local vLLM | ✓ when model matches `gpt-5.4+` upstream | ✓ | | Gemini, Ollama, others | ✗ | ✓ | Semantics: - `defer_loading: true` on an individual tool keeps its schema out of the model's context until a tool-search call surfaces it. On capable Anthropic models the schema goes into the API prefix but not the model's context, so prompt caching stays warm. On OpenAI GPT 5.4+ the wrapper-level flag rides alongside the `{"type": "tool_search"}` meta-tool in the tools array. - `tool_search: "bm25"` prepends the server-side `tool_search_tool_bm25_20251119` meta-tool on capable Anthropic models, or `{"type": "tool_search", "mode": "hosted"}` on GPT 5.4+ via the Responses API. On any other provider, Harn falls back to a client-executed equivalent: a synthetic `__harn_tool_search` tool whose handler runs BM25/regex/hybrid or a custom Harn scorer, then promotes the matching deferred tools into subsequent turns' schema list. - `tool_search: "regex"` uses the Python-regex variant (`tool_search_tool_regex_20251119`) on Anthropic, or an in-VM case-insensitive Rust-regex search on everything else. - `tool_search: {mode: "native"}` refuses to silently downgrade — errors if the provider isn't natively capable. - `tool_search: {mode: "client"}` forces the client-executed path even on providers with native support (useful for debuggability on GPT 5.4+, where the hosted path hides search deltas in the usage accounting). - `tool_search: {strategy: "bm25" | "regex" | "hybrid" | scorer}` (client mode only) picks the implementation. A scorer can be a Harn closure or `{handler: closure, name?: string}` and may call embeddings, host-backed tools, MCP tools, or project-specific indexes. - `tool_search: {budget_tokens: N}` caps the total token footprint of client-mode promoted tool schemas; oldest-first eviction when exceeded. - `tool_search: {name: "find_tool"}` renames the synthetic search tool (default `__harn_tool_search`). - `tool_search: {include_stub_listing: true}` appends a short list of deferred tool names to the contract prompt. - `namespace: "ops"` on a `tool_define(...)` call groups deferred tools for OpenAI's `tool_search` meta-tool. The distinct set of namespaces is collected into the meta-tool's `namespaces` field; Anthropic ignores the label (harmless passthrough). - Escape hatch for proxied OpenAI-compat endpoints whose model ID Harn cannot parse: pass `{: {force_native_tool_search: true}}` on the call options. Asserts the endpoint forwards `tool_search` + `defer_loading` unchanged and opts into the hosted path regardless of model detection. - Pre-flight: at least one user tool must be non-deferred, matching Anthropic's 400 on all-deferred tool lists. - Transcript events: `tool_search_query` and `tool_search_result` blocks appear in the run record so replay / eval can see which tools got promoted and when. Client-mode events carry a `metadata.mode: "client"` tag so replayers can distinguish the two paths; otherwise the shapes are identical. OpenAI hosted mode emits the same block shapes from the wire `tool_search_call` and `tool_search_output` entries in the response. ### Provider capabilities (data-driven matrix) The per-provider / per-model capability surface lives in a shipped TOML table (`crates/harn-vm/src/llm/capabilities.toml`), overridable per-project via `[[capabilities.provider.]]` in `harn.toml`: ```toml # harn.toml [[capabilities.provider.my-proxy]] model_match = "*" native_tools = true preferred_tool_format = "native" tool_mode_parity = "unknown" tool_search = ["hosted"] thinking_modes = ["effort"] ``` Query the effective matrix at runtime: ```harn const caps = harness.llm.provider_capabilities( "anthropic", "claude-opus-4-7", ) // { // provider: "anthropic", model: "claude-opus-4-7", // native_tools: true, text_tool_wire_format_supported: true, // preferred_tool_format: "native", tool_mode_parity: "unknown", // tools: true, defer_loading: true, // tool_search: ["bm25", "regex"], max_tools: 10000, // prompt_caching: true, thinking: true, // thinking_modes: ["adaptive"], // requires_completion_tokens: false, // reasoning_effort_supported: false, // interleaved_thinking_supported: true, // message_wire_format: "anthropic", // native_tool_wire_format: "anthropic", // prefers_xml_scaffolding: true, // structured_output_mode: "xml_tagged", // supports_assistant_prefill: false, // prefers_xml_tools: true, // thinking_block_style: "thinking_blocks", // reasoning_round_trip: "echo_signed", // } // `caps.tools` matches Harn's own tool gate: true when the route can call // tools via either the native API wire shape or Harn's text wire format. // Inspect `native_tools` or // `text_tool_wire_format_supported` directly when // you need to distinguish. Presets use `preferred_tool_format` when it is // present, so known native/text divergences stay data-driven. // `agent_loop` also uses this field for // `tool_format: "auto"`; if a concrete // provider/model pair has no recommendation, // it falls back to text tools and // emits a `capability_gap` warning event. // An explicit `tool_format` that disagrees // with `preferred_tool_format` or // chooses the catalog-marked unreliable // side emits a `tool_format_override` // transcript event. A non-empty // `tool_format_override_reason` deliberately // forces the requested channel past both // prompt and runtime capability gates; // provider-call records expose the // effective format and native tool count. if "bm25" in caps.tool_search { // opt into progressive disclosure } ``` Additional helpers: - `harness.llm.provider_capabilities_install(toml_src)` — install overrides from a TOML string (same layout as the shipped table). Useful for scripts that detect a proxied endpoint at runtime without editing `harn.toml`. - `harness.llm.provider_capabilities_clear()` — revert to the shipped defaults. `message_wire_format` and `live_endpoint_family` resolve one wire contract for the full call. Harn uses it for request building, stream decoding, response parsing, and error classification; a response header cannot select a different parser. Custom proxy rows should declare the format their endpoint actually speaks. Rule schema (per `[[provider.]]` entry). Shared defaults can also be set under `[provider_defaults.]`: | Field | Type | Purpose | |---|---|---| | `model_match` | glob string | Required. Matched against lowercased model ID. | | `version_min` | `[major, minor]` | Optional lower bound; parsed via Claude / GPT version extractors. | | `native_tools` | bool | Native tool-call wire shape supported. | | `text_tool_wire_format_supported` | bool | Harn text-tool contract supported. | | `preferred_tool_format` | string | Default preset tool mode: `native` or `text`. | | `tool_mode_parity` | string | Native/text interchangeability status: `interchangeable`, `unknown`, `native_unreliable`, `text_unreliable`, `native_only`, `text_only`, or `unsupported`. | | `tool_mode_parity_notes` | string | Optional explanation for known non-interchangeable routes. | | `tool_format_justification` | table | Why this row chose native vs text tools. Required on self-hosted rows that set `native_tools` or `preferred_tool_format`. `{ measured = "..." }`, `{ assumed = "..." }`, or `{ mirrors = { provider, model_match } }`. | | `message_wire_format` | string | Shared request/response message format: `openai`, `anthropic`, `gemini`, or `ollama`. | | `live_endpoint_family` | string | Which synchronous endpoint a route dispatches to when its dialect serves more than one: `gemini_generate_content` (default) or `gemini_interactions`. Absent for dialects with a single live endpoint. Independent of `batch_wire_format` — Gemini Batch stays `generateContent`-shaped either way. | | `native_tool_wire_format` | string | Native tool definition shape for shared helpers: `openai` or `anthropic`. Gemini/Vertex adapters emit Google `functionDeclarations` from canonical tool definitions. | | `defer_loading` | bool | Provider honors `defer_loading: true` on tool defs. | | `tool_search` | `[string]` | Native variants (`["bm25", "regex"]` or `["hosted", "client"]`). Empty = no native support. | | `responses_api` | bool | Harn native OpenAI Responses path is available for this route. | | `hosted_tools` | `[string]` | Provider-hosted tool kinds Harn can pass through. | | `remote_mcp`, `conversation_state`, `compaction`, `background_mode` | bool | OpenAI Responses remote MCP, previous-response state, provider compaction, and background-mode controls. | | `tool_approval_policy` | string | Approval policy story for provider-executed tools, for example `provider_or_harn`. | | `max_tools` | int | Cap on tool count (used by `harn lint`). | | `prompt_caching` | bool | Provider-side prompt caching is available. | | `cache_breakpoint_style` | string | Request marker strategy when caching is explicit: `none`, `top_level`, or `last_block`. | | `prefers_xml_scaffolding` | bool | Prompt sections prefer XML tags such as `` / ``. | | `prefers_markdown_scaffolding` | bool | Prompt sections prefer Markdown headings such as `## Task`. | | `structured_output_mode` | string | Preferred logical output shape: `native_json`, `delimited`, `xml_tagged`, or `none`. | | `supports_assistant_prefill` | bool | Assistant-role prefill turns are accepted. | | `prefers_role_developer` | bool | Durable instructions should use OpenAI's `developer` role. | | `prefers_xml_tools` | bool | Text-rendered tool specs should use XML wrappers. | | `thinking_block_style` | string | Preferred thinking representation: `none`, `thinking_blocks`, `reasoning_summary`, or `inline`. | | `thinking_modes` | `[string]` | Supported script-facing modes: `enabled`, `adaptive`, `effort`. | | `reasoning_wire_format` | string | Non-standard OpenAI-compatible reasoning shape: `openrouter` or `enabled`. | | `reasoning_history_wire_field` | string | Typed OpenAI-compatible field used when a route requires same-key reasoning replay. | | `reasoning_round_trip` | string | Private reasoning replay policy: `strip` (default), `echo_signed`, or `echo_same_key`. | | `requires_completion_tokens` | bool | Use OpenAI `max_completion_tokens` instead of `max_tokens`. | | `reasoning_effort_supported` | bool | Provider/model accepts OpenAI `reasoning_effort`. | | `interleaved_thinking_supported` | bool | `thinking: true` can request Anthropic's interleaved-thinking beta header. | | `anthropic_beta_features` | `[string]` | Anthropic beta feature names always requested for this route. | | `image_url_input_supported` | bool | Image content may use remote URLs. Set false for base64-only routes. | | `file_upload_wire_format` | string | Upload API family used by `files.upload`: `anthropic` or `gemini`. | | `seed_supported`, `top_k_supported`, `frequency_penalty_supported`, `presence_penalty_supported` | bool | Generation option support flags. | | `thinking_disable_directive` | string | In-prompt directive (e.g. `"/no_think"` for Qwen3) auto-prepended to system when `thinking: false`. Idempotent. | First match wins within a provider's rule list. `[provider_family]` declares siblings that inherit a canonical family's rules (OpenRouter → `openai`, etc.). ### Skills (bundled tool + prompt + MCP metadata) Use `skill NAME { ... }` to declare a named skill: metadata, a tool registry reference, MCP server names, a system-prompt fragment, and optional lifecycle hooks that run on activate/deactivate. Each body entry is ` ` — unreserved identifiers, regular expressions as values. The decl lowers to `skill_define(skill_registry(), NAME, { ... })` and binds the result to `NAME`. ```harn pub skill deploy { description "Deploy the application to production" when_to_use "User says deploy/ship/release" invocation "explicit" // "auto" | "explicit" | "both" paths ["infra/**", "Dockerfile"] allowed_tools ["bash", "git"] model "claude-opus-4-7" effort "high" prompt "Follow the deployment runbook." on_activate fn() { harness.stdio.log("deploy activated") } on_deactivate fn() { harness.stdio.log("deploy deactivated") } } ``` Registry ops: `skill_registry()`, `skill_define(reg, name, config)`, `skill_list(reg)`, `skill_find(reg, name)`, `skill_count(reg)`, `skill_select(reg, names)`, `skill_remove(reg, name)`, `skill_describe(reg)`. `skill_list` strips closure hooks for serialization; `skill_find` returns the full entry. Known-key validation in `skill_define`: `description`, `when_to_use`, `prompt`, `invocation`, `model`, `effort` must be strings; `paths`, `allowed_tools`, `mcp` must be lists. Unknown keys pass through. ### Common patterns Structured output with automatic retry — prefer `harness.llm.call_structured(prompt, schema, options?)`, which returns the validated data directly (no `.data` unwrap) and forces the schema defaults (`output: {schema, strict: true, validation: "error"}` and `schema_retries: 3`). Throws on exhausted retries or transport failure: ```harn const schema = { type: "object", required: ["verdict"], properties: { verdict: {type: "string"}, improvement: {type: "string"}, }, } const verdict = harness.llm.call_structured(prompt, schema, { provider: "auto", model: "local-gemma4-e4b", system: "You are a strict grader.", }) harness.stdio.log(verdict.verdict) ``` Non-throwing variant `harness.llm.call_structured_safe(prompt, schema, options?)` returns `{ok, data, error}` (same envelope as `harness.llm.call_safe`, but with the validated `.data` pre-unwrapped): ```harn const r = harness.llm.call_structured_safe( prompt, schema, {provider: "auto"}, ) if !r.ok { harness.stdio.log( "structured call failed:", r.error.category, r.error.message, ) return nil } harness.stdio.log(r.data.verdict) ``` Diagnostic envelope `harness.llm.call_structured_result(prompt, schema, options?)` returns the full failure-mode breakdown production agent pipelines need — `{ok, data, raw_text, error, error_category, attempts, repaired, repair_tier, extracted_json, usage, model, provider}`. Never throws; dispatch on `ok` / `error_category`: ```harn const r = harness.llm.call_structured_result(prompt, schema, { provider: "auto", schema_retries: 2, // Optional repair pass — runs only when the main call's JSON is // malformed or schema-invalid. Skipped on transport failures. repair: { enabled: true, model: "cheapest_over_quality(low)", max_tokens: 600, }, }) if r.ok { harness.stdio.log(r.data.verdict) } else { // error_category ∈ "transport" | "missing_json" | "schema_validation" // | "repair_failed" — plus retryable transport categories // ("rate_limit", "timeout", ...) when the underlying call failed. harness.stdio.log("grade failed:", r.error_category, "raw:", r.raw_text) } ``` `r.attempts` counts model calls (1 = no retries used; ≥2 = one or more schema retries were spent). `r.repaired: true` means a repair tier succeeded. `r.repair_tier` is `"local"` for a mechanical fix (no extra provider call), `"llm"` for a reissue, and `nil` otherwise. `r.extracted_json: true` flags responses where JSON had to be lifted from prose / markdown fences. Options: everything `harness.llm.call` accepts flows through, plus `retries` as an alias for `schema_retries`. Provider options, `system`, `provider`, `model`, `max_tokens`, etc. are all passed through unchanged. The `repair` block is recognized only by `harness.llm.call_structured_result`. After-the-fact recovery—`harness.llm.recover_schema(text, schema, opts?)` turns malformed output that's already in your hand into a validated payload. Three deterministic stages followed by an optional one-shot LLM repair, returning the same `{ok, data, raw_text, error, error_category, attempts, stage, repaired}` envelope shape: | Stage | When | Notes | |---|---|---| | `parsed` | Raw text is valid JSON that schema-validates. | Cheapest path; always tried first. | | `extracted` | JSON is wrapped in markdown fences or surrounded by prose. | Uses the same balanced-brace lifter as `json_extract`. | | `regex` | Model produced YAML-ish / unquoted `key: value` lines. | Only top-level scalar fields (string/int/number/boolean) are recovered — nested objects fall through. | | `llm_repair` | Earlier stages failed and `repair` is enabled (default). | Single shot, `schema_retries: 0`. Set `{repair: false}` for fully deterministic recovery. `llm_repair` is the reported stage name, not an option key. | ```harn const raw = harness.llm.call(prompt, sys, {provider: "auto"}).text const r = harness.llm.recover_schema(raw, schema) if r.ok { process(r.data) // narrowed-shape dict } else { harness.stdio.log( "recovery failed:", r.stage, r.error_category, r.error, ) } ``` Use it as a drop-in replacement for hand-rolled `normalize_*()` chains downstream of `harness.llm.call(...)` / Ollama prose responses, or when you want a deterministic local recovery pass before paying for a structured re-call. The `repair` block accepts the same overrides as `harness.llm.call_structured_result`'s `repair`: ```harn const r = harness.llm.recover_schema(raw, schema, { apply_defaults: true, // schema defaults during validation repair: { enabled: true, model: "cheapest_over_quality(low)", max_tokens: 600, }, }) ``` Stages report via `r.stage` ∈ `"parsed" | "extracted" | "regex" | "llm_repair" | "failed"`; `r.attempts` counts how many stages ran (1 = clean parse, 4 = ran every stage including the LLM repair). On failure, `r.error_category` is `"schema_validation"` (no stage recovered) or `"repair_failed"` / `"transport"` (LLM repair was attempted and failed). If you need the raw response (token counts, transcript, thinking trace) alongside the parsed data, call `harness.llm.call` directly: ```harn const r = harness.llm.call(prompt, sys, { provider: "auto", model: "local-gemma4-e4b", output: {schema: schema, strict: true, validation: "error"}, schema_retries: 2, }) harness.stdio.log(r.data.verdict) harness.stdio.log(r.usage.input_tokens) ``` Schema-as-type (a `type` alias drives both the schema and the narrowing guard — lowered to the canonical JSON-Schema dict at compile time; literal-string/int unions emit as `{type, enum}`). With `harness.llm.call_structured` the return narrows to `T` directly: ```harn type GraderOut = { verdict: "pass" | "fail" | "unclear", summary: string, } const out: GraderOut = harness.llm.call_structured(prompt, GraderOut, { provider: "auto", system: sys, }) harness.stdio.log(out.verdict) // narrowed to GraderOut ``` Reusable generic wrapper (narrows via the `Schema` generic param): ```harn fn grade(prompt: string, schema: Schema) -> T { return harness.llm.call_structured(prompt, schema, {provider: "auto"}) } const out: GraderOut = grade("Grade this", schema_of(GraderOut)) harness.stdio.log(out.verdict) ``` Use `SchemaContract` for cross-field invariants after structural validation. Each named `ValidationRule` returns `list`; an empty list passes. Capture typed context in the rule closure instead of passing an open dictionary. `schema_contract_check(value, contract)` never throws and returns `schema_invalid`, `rule_failed`, or `rule_error`. `std/fs` and `std/run_artifacts` preserve those failures in typed `Result` readers. Bind a stable artifact name and contract once with `artifact_descriptor`; reuse that descriptor for reads and writes. Descriptor writes validate the complete contract before conditional replacement. Batch grading at bounded concurrency: ```harn const outcome = parallel settle paths with { max_concurrent: 4 } { path -> harness.llm.call(harness.fs.read_text(path), GRADER_SYSTEM, { provider: "auto", model: "local-gemma4-e4b", output: {schema: grader_schema, strict: true, validation: "error"}, schema_retries: 2, }) } ``` ### `assemble_context` `assemble_context(options)` packs a list of artifacts into a token-budgeted slice of chunks for the next prompt. Complements `transcript_auto_compact` (which shrinks the ongoing conversation). ```harn const packed = assemble_context({ artifacts: [skill_a, skill_b, fetched_docs], budget_tokens: 8000, dedup: "chunked", // none | chunked | semantic strategy: "relevance", // recency | relevance | round_robin // scored by default keyword-overlap ranker query: user_prompt, microcompact_threshold: 2000, // artifacts over this get chunked }) // packed = {chunks, included, dropped, reasons, total_tokens, // budget_tokens, …} ``` Chunk ids are content-addressed (`{artifact_id}#{sha256(text)[..16]}`) so the same input produces the same ids across runs — safe to diff in replay. `reasons` names the strategy and inclusion verdict per chunk; `dropped` surfaces exclusions (`"duplicate"`, `"budget_exceeded"`, `"no_text"`). For a custom relevance ranker, pass `ranker_callback: { query, chunks -> chunks.map({ c -> score }) }`; the default ranker uses keyword overlap against `query`. Workflow nodes may set `context_assembler: {...}` to route the stage's selected artifacts through this builtin before the prompt is rendered. ### Compaction policies Compaction entrypoints accept a typed host/user instruction lane through `policy`, `compaction_policy`, `compaction_request`, or the direct fields `instructions`, `mode`, `scope`, `preserve`, `drop`, `extend_default_instructions`, and `author`. ```harn import {compact_preserving_test_failures} from "std/agent/autocompact" // Returns { messages, archived, summary, receipt // }. `receipt` is nil when archived is 0. // Use `archived` (the engine's true // archived-message count) to tell whether // compaction happened -- never infer // it from a length delta, since archiving one message and inserting one // summary leaves the length unchanged. const result = transcript_auto_compact(messages, { keep_last: 1, token_threshold: 1, policy: compact_preserving_test_failures({author: "host"}) }) const compacted = result.messages const applied = result.receipt?.engine_strategy ``` When compaction fires, `receipt` is the canonical engine result. It separates `requested_strategy` from `engine_strategy`, records `resolved_threshold_tokens`, `threshold_source`, and `hard_limit_tokens`, and carries the tri-state `source_measurement` (`nil` means unmeasured; a contained zero is a measured zero). Wrappers should project this receipt rather than reconstructing lifecycle facts from their input options. Omitting `extend_default_instructions` or setting it to `true` appends the instructions to Harn's default summary guidance; `false` replaces it. Host-only instructions are kept in `compaction` event metadata (`instruction_mode`, `instruction_source`, `compaction_policy`) and are not copied into the next model-visible summary unless `scope` is `"model_visible"`, `"summary"`, or `"transcript"`. Helper policies in `std/agent/autocompact`: `compaction_policy(...)`, `compact_for_bug_fix_resumption(...)`, `compact_preserving_test_failures(...)`, and `compact_retaining_current_plan(...)`. ### Transcript projection `transcript_project(transcript, opts?)` derives a model-visible prefix without mutating raw transcript history. `agent_loop(harness, ..., {transcript_projection: ...})` applies the same projection before each provider turn and records a `transcript.projection` event. Built-in policies: `raw`, `clean_tool_repair`, `squash_failed_calls`, `summary_prefix`, `reachability_gc`, and `custom`. `reachability_gc` reclaims stale tool-result bodies only in the projected prompt. It keeps tool-call metadata and emits `redacted_indices`, `reclaimed_tokens`, `roots_consulted`, and `redaction_pointers`; raw transcript/audit content stays available by pointer. Useful options: `root_window`, `min_chars`, `roots`, `active_plan`, `scratchpad`, `pending_tool_args`, `unresolved_findings`, `write_barrier_refs`, and `require_write_barrier`. In `agent_loop`, enabling both `scratchpad` and a reachability-GC projection automatically supplies the current scratchpad as a root plus a scratchpad-version write barrier for that turn. ## Governed Code Mode session state Code Mode snippets are isolated by default. Grant bounded JSON state through the normal binding manifest when snippets in one session and tool window must exchange values: ```harn const manifest = composition_binding_manifest(tools, { state: {max_value_bytes: 16384, max_total_bytes: 65536, max_keys: 64}, }) const saved = composition_execute( "state.put(\"draft\", {ready: true})\nreturn state.list()", manifest, {session_id: session_id}, ) const loaded = composition_execute( "return state.get(\"draft\")", manifest, {session_id: session_id}, ) ``` The injected API is `state.get`, `state.put`, `state.list`, and `state.delete`. The scope is `(session_id, manifest hash)`, using the current agent session when `session_id` is omitted, and is removed at session end. Values must be JSON; limits fail closed with typed `composition_state_error` records. Every operation is a composition child call, so reports, events, crystallization, and replay retain the ordered state transitions. ## Read-only stance (experimental) `agent_loop(harness, {read_only_stance: {...}})` arms a least-privilege tool window for tasks classified as read-only (research/investigation): only tools whose annotations declare them read-only (`kind` read/search/think/fetch, or `side_effect_level` none/read_only — unannotated tools count as mutating) plus an auto-registered escape hatch (default `request_write_access`) reach the model. The escape hatch verifies consent agentically: its `consent_check` (default: a structured `harness.llm.call` over the session's recent user messages) grants only when the user expressed or clearly implied consent to modify the workspace; a grant disarms the stance next turn, a denial tells the model to ask the user. Every transition emits a `stance_transition` event (`phase`: armed / write_access_granted / write_access_denied / disarmed). ```harn,ignore import { AgentSpec } from "std/agent/options" const stance_opts: AgentSpec = { tools: tools, read_only_stance: { enabled: true, armed: intent.should_use_read_only_agent, // host classifier decides // or infer here: // classifier: fn(message) -> {read_only, confidence}, // consent_check: fn(justification, session_id) -> {verdict, reason}, hard_keep: ["ask_user"], }, } agent_loop(harness, task, nil, stance_opts) ``` Ships default-OFF. This is the Harn mechanism for the tool-surface program's task-intent mount: the window derives from intent, and elevation is justified, consent-verified, and traced. ## Reminders System reminders are typed `system_reminder` transcript events for nudging a running agent without pretending the nudge is user input. They support `ttl_turns`, `dedupe_key`, `preserve_on_compact`, `propagate`, and an explicit `authority` tier: `contract`, `corrective`, or `advisory`. Full reference: `docs/src/system-reminders.md`. `transcript.inject_reminder(transcript, options)` appends a pending reminder and returns `{transcript, reminder_id, deduped_count}`. The input transcript is unchanged. ```harn const injected = transcript.inject_reminder(transcript(), { body: "Approaching context window cap.", tags: ["token_pressure"], dedupe_key: "token_pressure", ttl_turns: 3, preserve_on_compact: true, propagate: "session", authority: "advisory", }) const t = injected.transcript ``` `body` is required and must be non-empty. Optional `tags`, `dedupe_key`, `ttl_turns`, `preserve_on_compact`, `propagate`, `authority`, and legacy `role_hint` fields are validated; unknown option keys fail fast. Omitted `authority` defaults to `contract`. New and replayed reminders dedupe uniformly: an explicit `dedupe_key` wins, otherwise normalized body text is the key. When duplicates disagree, the highest authority survives (`contract` > `corrective` > `advisory`), then the newest reminder. Deduplication emits `transcript.reminder.deduped` on `transcript.reminder.lifecycle` when an EventLog is active. `transcript.clear_reminders(transcript, selector)` removes pending reminders and returns `{transcript, removed_count}`. Select by `id`, `tag`, or `dedupe_key`; when multiple selectors are present, all must match. ```harn const cleared = transcript.clear_reminders(t, {tag: "token_pressure"}) harness.stdio.log(cleared.removed_count) ``` `agent_loop(harness, ...)` enables canonical reminder providers by default; bare `harness.llm.call(...)` does not. Providers are: - `token_pressure` on `on_budget_threshold` at about 70/85/95% context use (`ttl_turns: 2`, critical threshold preserves across compaction). - `idle_nudge` on `session_idle` after `idle_seconds` (default 60). - `tool_output_truncated` on `post_tool_use` when tool output was compacted/truncated before the model saw it. - `post_compact_recap` on `post_compact` with the latest recap. - `resume_continuity` on `worker_resumed`, visible only to the first resumed turn. It names the suspend turn, reason, resume cause, and optional resume input; when `continue_transcript: false`, it also carries the pre-suspend digest. - `project_facts` on `session_start` and `on_budget_threshold` (`ttl_turns: 1`). Recalls typed `harn.fact.v1` records from the active project namespace, filters by `min_confidence` (default 0.5) and optional `kind_filter`, and renders the top `max_facts` (default 5) as a `` block so a fresh session boots with project context already in scope. - `workspace_anchor` on `session_start` and `on_budget_threshold` (`ttl_turns: 1`) when the session has an active workspace anchor. - `grounded_review` on `post_tool_use`, `post_step`, and `post_agent_turn` (`ttl_turns: 2`). It only injects advisory review context from concrete verifier/runtime evidence: explicit tool errors, non-accepted routing verifier signals, parse errors, undefined-name diagnostics, error-severity diagnostics, or failure lines from known verification commands. Warnings and style nits stay quiet unless `include_warnings: true`. - `idle_nudge`, `tool_output_truncated`, `resume_continuity`, and `grounded_review` use `propagate: "none"`; `post_compact_recap`, `project_facts`, and `workspace_anchor` use `propagate: "session"`. Opt out per loop: ```harn import { AgentSpec } from "std/agent/options" const reminder_opts: AgentSpec = { reminders: {providers: ["-token_pressure", "-idle_nudge"]}, } agent_loop(harness, task, system, reminder_opts) ``` Configure providers under `reminders.config`, e.g. `{reminders: {config: {token_pressure: {context_window: 128000}, idle_nudge: {idle_seconds: 120}}}}`. Register Harn-defined providers with `harness.agent.register_reminder_provider({id, subscribes_to, evaluate})`; the closure receives `{event, session, session_id, payload, options, config}` and returns a reminder effect, bare spec, effect list, or `nil`. Hooks can return `{reminder: {...}, then?: ...}`, a bare reminder spec, or a session-hook effect list. Hosts inject ambient context with the bridge `session/remind` notification; `session/inject` remains user-role input. The runtime commits each reminder envelope as one durable trailing user turn. Later requests retain those exact bytes at the same message index. This preserves provider prompt prefixes; compaction remains the deliberate prefix break. Rendering is provider-neutral. Every route receives one `` envelope in its own trailing user message. The speaker is `harness` when any directive in it came from harness machinery and `person` when every directive stands in for the person. Its directives are ordered by authority (`contract`, `corrective`, `advisory`) and then lifecycle order. Finite directives render `ttl_turns="N"`; they apply only to the N assistant turns immediately after the message where they first appear, and a later historical copy does not renew that lifetime. Internal tags, dedupe keys, and runtime signatures are not rendered. The system prompt remains byte-stable as reminders change. Persisted `role_hint` values remain accepted for replay compatibility and select the envelope speaker, but do not control placement or the wire role. Sub-agent handoffs carry a filtered `reminder_propagation` list. `propagate: "all"` reaches descendants, `"session"` reaches direct children only, and `"none"` stays local. Compaction decrements finite TTLs, drops expired reminders, dedupes by `dedupe_key`, preserves only `preserve_on_compact: true`, and passes surviving reminders to custom compactors. Gotcha: `preserve_on_compact: false` with no finite `ttl_turns` can live forever during normal turns but vanish on compaction; `HARN-RMD-004` flags that shape. ## External-agent delegation Import with `import { external_agent_delegate, external_agent_approve } from "std/external_agent"`. Use `external_agent_delegate(target, task, options?)` for open A2A external agents that advertise the `harn.external_agent.v1` capability contract. Options must include a hard `budget` cap such as `{max_usd: 0.25}` or `{max_tokens: 20000}`; the stdlib wrapper generates an idempotency key when one is not supplied. The first call normally returns `status: "checkpoint_required"` with a remote plan and expected scope. After host approval, pass that envelope to `external_agent_approve(envelope, options?)`; it preserves the idempotency key and dispatches at most once. Missing checkpoint support is refused unless `checkpoint.allow_local_fallback: true` supplies an explicit local plan, and over-budget results return a reviewable `status: "budget_exceeded"` envelope. ## Agent runtime ### `agent_loop` `agent_loop(harness, prompt, system?, options?)` runs a multi-turn loop with tool dispatch. Build options through `AgentSpec` from `std/agent/options` (`let opts: AgentSpec = {...}`) or an `agent_preset(...)` / `agent_options(...)` constructor. `AgentSpec` composes model, execution, capability, lifecycle, context, and observability records while remaining one flat runtime value. Native-tool loops complete naturally when the model returns final assistant text with no tool calls. Tagged text-tool stages use `##DONE##`, and no-tool sentinel loops use bare `##DONE##`. Set `done_sentinel` to a non-empty string to require a sentinel, or `nil` for no sentinel. Native-tool loop-until-done loops default to `nil`; text/no-tool loop-until-done loops default to `"##DONE##"`. Returns the typed `AgentResult`: top-level `status`, `terminal`, `text`, `visible_text`, `provider_call_count` (last iteration's prose with tool calls stripped), `task_ledger`, `transcript`, `daemon_state`, `daemon_snapshot_path`, `trace`, and `deferred_user_messages`; LLM execution metrics nested under `llm` (`iterations`, `duration_ms`, `input_tokens`, `output_tokens`); tool invocation data nested under `tools` (`calls`, `successful`, `rejected`, `mode`). Failed tool dispatches are fed back to the next model turn as error observations and appear under `tools.rejected`. The session-owned `provider_call_count` is always an integer, including measured zero, and counts physical provider dispatches even when transport, schema, or provider handling later fails. Cache/replay hits and routes rejected before dispatch do not increment it. Uncaught loop errors carry the same field on the typed `AgentLoopTerminalError` envelope. Its value is an integer when the session ledger was read, including measured zero, and `nil` only when that ledger itself was unavailable. Consumers therefore never reconstruct request counts from tokens, transcript absence, or error text. The resilience surface is the `llm_caller:` seam (see "Composable LLM callers"); the pre-0.10 `llm_retries` / `llm_backoff_ms` options were removed and the `removed-llm-options` lint hard-errors on them. Plus its own `profile`, `tool_retries`, `max_iterations`, `max_nudges`, and `native_tool_fallback` (`"allow"`, `"allow_once"`, or `"reject"` for native-tool stages that receive text-mode `` fallback output). `thinking`, `interleaved_thinking`, and `anthropic_beta_features` apply to every model turn; `reminders` controls canonical reminder providers (`false` disables all, `providers: ["-id"]` opts out by id, `config` carries provider-specific knobs). For Claude Opus 4.6/4.7, `thinking: true` is enough to enable the interleaved-thinking beta header for the whole loop. When using `agent_preset(kind, options)`, preset pack rows fill only absent keys. Model routing is grouped: any explicit route at top level or under `llm_options` (`provider`, `model`, `models`, `ladder`, `routing`, or related policy keys) suppresses the entire built-in route. Preset ladders use canonical catalog `[model_ladders.*]` rows; preset code contains only the stable ladder name. A caller's direct `provider` plus `model` remains a direct route and is not mixed with `models`. Profiles preload common loop budgets and retry counts. Explicit keys override the profile: | Profile | `max_iterations` | `max_nudges` | `tool_retries` | `schema_retries` | |---|---:|---:|---:|---:| | `tool_using` (default) | 50 | 8 | 0 | 0 | | `researcher` | 30 | 4 | 0 | 0 | | `verifier` | 5 | 0 | 0 | 3 | | `completer` | 1 | 0 | 0 | 0 | Use `iteration_budget: {mode, initial, max, extend_by}` when a loop should start with a small cap and extend only while making progress. `max_iterations` is equivalent to a fixed budget; if both are present, `iteration_budget.max` wins. Explicit `max_iterations`, `initial`, `max`, and adaptive `extend_by` values must be positive integers, and `initial <= max`. Workflow stage `model_policy` accepts the same `iteration_budget` shape and passes it through to the per-stage `agent_loop`. `step_judge: {...}` runs a structured per-turn critique after an assistant turn and before tool dispatch. It can veto with `on_veto: "replace"` to remove the assistant turn before regeneration, or `"retain"` to leave it in the transcript. `skip_when_iterations_remaining` defaults to `1`, so single-turn or final-turn loops skip the judge instead of spending their last turn on a veto that cannot be regenerated. Skip decisions emit `step_judge_decision` with `skipped: true` and a stable `reason` such as `"low_iteration_budget"`. Pass `stop_after_successful_tools: ["name", ...]` to terminate the loop the moment any of those tools is dispatched successfully. Same shape as Vercel AI SDK's `stopWhen: hasToolCall(name)` and OpenAI Agents SDK's `StopAtTools([name])`. Use this for "terminal" tools (e.g. `exit_plan_mode`, `submit_answer`, `ask_user`) that mark the end of an agent step: ```harn import { AgentSpec } from "std/agent/options" const stop_opts: AgentSpec = { tools: registry, stop_after_successful_tools: ["ask_question", "exit_plan_mode"], } agent_loop(harness, task, sys, stop_opts) ``` The check fires after each iteration's tool dispatch, so any other tool calls in the same iteration still run; only subsequent iterations are skipped. The loop exits with `status = "done"` and the tool name appears in `tools.successful`. ### Progress narration Use `agent_progress({message?, entries?, replace?, metadata?})` from inside an agent session when a meaningful sub-step completes or the visible plan changes. The payload must include a non-empty `message` or `entries`; `replace` defaults to `true`. ```harn agent_progress({ message: "Finished API inventory; checking auth paths next.", entries: [ { content: "Inventory public API routes", status: "completed", priority: "high", }, {content: "Trace auth middleware", status: "in_progress"}, ], }) ``` `entries` are task-list items with `content`, `status`, and optional `priority`. ACP clients receive entries as canonical `session/update` `plan` payloads. A2A clients receive non-terminal `TaskStatusUpdateEvent` updates with `status.state = "working"`. Message-only reports surface as Harn progress narration for clients that do not render plans. For model-facing loops, set `progress_tool: true` or pass a dict to customize the tool name, description, or system-prompt nudge. Call it after observable progress, not on a timer. `std/agent/progress` also exports schema-first helpers for harnesses and hosts that need to validate progress data at a boundary: use `agent_progress_payload_schema()`, `agent_progress_event_schema()`, and `agent_progress_tool_config_schema()` with `std/schema`, or the paired `*_report` / `*_value` helpers. `agent_progress_tool_config_normalize(config?)` validates config dictionaries and applies the default tool name and description. `agent_input_guardrail(classifier?, options?)` from `std/agent/guardrails` builds the input-side bookend to `agent_completion_gate`. Spread the returned bundle into `agent_loop` options to run a cheap classifier before the first main model turn. A tripwire emits `input_guardrail_verdict`, records a zero-token assistant explanation, and returns `status: "input_guardrail"` with `stop_reason: "input_guardrail_tripwire"`. ```harn import { agent_input_guardrail } from "std/agent/guardrails" agent_loop(harness, task, system, base_opts + agent_input_guardrail( { payload -> return cheap_policy_classifier(payload.user_message) }, {confidence_threshold: 0.8}, )) ``` Use `agent_input_guardrail_check(task, classifier?, options?)` when a script wants an explicit `{tripwire, reason, label, confidence}` preflight verdict instead of composing with `agent_loop`. Pass `turn_end_condition: true` or `turn_end_condition: {...}` to run a structured completion judge after a native-tool loop naturally completes or after the model emits `##DONE##` in a sentinel loop. The judge returns exactly `{verdict: "done" | "continue", detail}`. On `done`, `detail` names the strongest supporting evidence. On `continue`, it names the single most important gap and next action. The loop injects that detail and continues until the judge accepts, `turn_end_condition.max_invocations` is reached, or `max_verify_attempts` is exhausted. Each judge call emits a `JudgeDecision` agent event with optional `trigger`. Use `verify_completion_judge` instead when every natural stop should be judged. Set top-level `turn_end_condition.max_invocations` to a positive integer to cap repeated vetoes. Once reached, the loop stops with `status: "completion_unverified"` and `stop_reason: "turn_end_judge_cap_reached"`; the result carries structured `turn_end_condition` counters. Set it to `0` to disable the terminal cap. Use `turn_end_condition.cadence` when completion checks should be signal-gated instead of firing on every completion candidate: ```harn import { AgentSpec } from "std/agent/options" const cadence_opts: AgentSpec = { loop_until_done: true, turn_end_condition: { cadence: { every: 5, // judge turns 5, 10, 15, ... // or "stalled" / { state -> bool } when: "always", max_invocations: 3, min_iterations_before_first: 2, }, }, } agent_loop(harness, task, system, cadence_opts) ``` With `when: "stalled"`, stall diagnostics run the judge when `agent_loop_stall_warning` fires. `accept` stops the loop with `stalled_turn_end_judge`; `continue` keeps the normal stall feedback fallback. The judge event includes `trigger: "stalled"`. Omitting `cadence` preserves the default behavior: every completion candidate is judged. `when: "stalled"` is quiet during healthy turns and is reserved for stall diagnostics; pair it with stall-aware loop policy instead of fixed "are you done?" prompting. Fixed-cadence completion prompts are not recommended: Huang et al.'s [AutoGPT/agent benchmark study](https://arxiv.org/abs/2310.01798) found that periodic "are you done?" checks can distort behavior. Prefer explicit progress signals and `turn_end_condition.cadence.when: "stalled"` when the loop is actually showing stall symptoms. Pass `permissions` to scope one agent below the ambient `policy` ceiling: ```harn import { AgentSpec } from "std/agent/options" import { path_scope } from "std/tools" const scoped_opts: AgentSpec = { permissions: { allow: { read_note: path_scope(), write_note: path_scope({mount_modes: ["extend"]}), }, deny: ["dangerous_*"], on_escalation: { request -> {grant: "once", approver: "operator"} }, }, } agent_loop(harness, task, system, scoped_opts) ``` `allow` and `deny` accept tool-name globs, argument pattern lists, or VM predicates. `std/tools.path_scope(...)` checks path-like args (`path`, `destination`, `source`, `file` by default) against the active session `workspace_anchor`; use `mount_modes: ["extend"]` when a mutating tool should only accept writable mounted roots. Deny rules win. Escalation callbacks receive a `PermissionRequest` dict and return `false`, `true`, `{grant: "once"}`, or `{grant: "session"}`. Child agents still intersect with the parent capability policy; escalation cannot widen a parent ceiling. ### Agent lifecycle: pause, resume, stop, self-park `spawn_agent`, `wait_agent`, `resume_agent`, `suspend_agent`, `agent_stop`, and `list_agents` from `std/agent/workers` are the script-level lifecycle surface for delegated work. Layered on top, `agent_loop(harness, ...)` exposes a model-facing **lifecycle tool** so the agent can park itself between turns and so a parent loop can pause/resume/stop children. Full reference: `docs/src/agent-lifecycle.md`. Four model-facing tools: | Tool | Use | |---|---| | `agent_await_resumption(reason, conditions?)` | The current worker self-parks. Registered automatically by `agent_loop(harness, ...)`. | | `subagent_pause(handle, reason)` | Parent loop pauses a running child after its current turn settles. Opt-in via `subagents: true`. | | `subagent_resume(handle, input?, continue_transcript? = true)` | Parent loop resumes a suspended child. Opt-in via `subagents: true`. | | `subagent_stop(handle, graceful? = true, reason?)` | Parent loop stops a child. Graceful mode returns a recursive typed handoff summary; `graceful: false` hard-cancels. Opt-in via `subagents: true`. | When the model calls `agent_await_resumption(...)` inside an `agent_loop` running as a worker, the call is intercepted *before* normal tool dispatch: the loop validates `conditions` with `parse_resume_conditions(...)`, persists a snapshot, emits `WorkerSuspended`, and returns `{status: "suspended", handle, reason, initiator: "self", conditions, iterations_completed}` to the parent. Lifecycle calls emit `tool_call_audit` telemetry with `initiator` (one of `"self"`, `"parent"`, `"operator"`, `"triggered"`) and the supplied `reason`. Top-level loops use the same shape: a root `agent_loop(harness, ...)` that parks returns `status: "suspended"` with `handle.snapshot_path`, and the CLI cold-restores it with `harn run --resume `. The snapshot records the suspending provider turn first, so its transcript contains the exact messages, reasoning, `harness.llm.call` usage, and provider outcome needed by cross-compute resume. ```harn,ignore // Self-park mid-loop until a review approval lands or 30 minutes pass. import { agent_await_resumption } from "std/agent/workers" const result = agent_loop( harness, "Wait for the maintainer's review.", nil, { provider: "openai", model: "gpt-5", tool_format: "native", }) if result.status == "suspended" { harness.stdio.log(result.reason) // model-supplied // resumable snapshot on disk harness.stdio.log(result.handle.snapshot_path) } ``` ```harn,ignore // Parent-driven pause/resume of a background child. const handle = sub_agent_run("Draft the changelog.", { background: true, provider: "openai", }) suspend_agent(handle, "operator pulled context") // ... other work ... resume_agent(handle, "Pick up where you left off.") const final = wait_agent(handle) ``` ```harn,ignore // Conditioned self-park: trigger + timeout. agent_await_resumption("waiting on review", { trigger: { kind: "review.approved", provider: "github", match: {events: ["review.approved"]}, }, timeout: {duration_minutes: 30, on_timeout: "resume_with_summary"}, }) ``` Resume responsibility is named by an optional `resume_by` callback (third arg to `agent_await_resumption`). The four presets in `std/agent/resume_by` are `ResumeBy.parent_llm`, `ResumeBy.local_runtime`, `ResumeBy.cloud_harness`, and `ResumeBy.pipeline_drain`. They compose with `std/lifecycle/combinators::first_available`. `default_resume_by(...)` picks one based on whether `conditions` were supplied and whether a cloud session is bound: - `conditions == nil` → `ResumeBy.parent_llm` - `conditions != nil`, no cloud session → `ResumeBy.local_runtime` - `conditions != nil`, cloud session → `first_handled([cloud_harness, local_runtime])` Transcript continuity: `resume_agent(...)` defaults to `continue_transcript: true` — the resumed worker keeps its full transcript and the runtime injects a single-shot `system_reminder` with `dedupe_key: "resume_continuity"` summarizing the gap. Pass `continue_transcript: false` to restart from the prior summary plus new input only. Daemon idle is a degenerate case: `agent_loop(harness, ..., {daemon: true})` and the `daemon_*` stdlib wrappers (see `docs/src/llm/agent_loop.md#daemon-stdlib-wrappers`) internally call `agent_await_resumption(...)` when no wake source is queued. The snapshot carries daemon-specific fields (`pending_event_count`, `wake_interval_ms`, `watch_paths`) alongside the standard suspend metadata, so `harness.agent.daemon_resume(path)` cold-restores the loop identically. Common gotchas: - **Suspend is cooperative**, not preemptive. The flag is honored at the next turn boundary — not mid-tool-call, not mid-LLM-request. Cap long-running tools with `tool_call_timeout`. - **Conditions are optional.** A bare `agent_await_resumption("waiting")` parks the worker open; only the parent agent, an operator, or `resume_agent(...)` can wake it. - **Snapshots survive process restart.** Both the snapshot file and any registered trigger conditions are durable. `harn run --resume ` rehydrates the worker in a fresh process. - **Double-resume is detected** (`HARN-SUS-006`); the second caller can retry against the now-running handle. - **Closing a suspended worker is terminal** — a later `resume_agent` raises `HARN-SUS-010`. - **Graceful stop hands work back.** `agent_stop(handle, {graceful: true})` returns `{status: "stopped", handoff, children, handoffs, worker}`. Use hard cancel (`graceful: false` or `close_agent`) when no takeover summary is needed. Diagnostic codes for the suspend/resume namespace are `HARN-SUS-001..010` — see `docs/src/agent-lifecycle.md#diagnostic-codes` for the full table. ### Durable agent channels Use `harness.channels.append(name, payload, options?)` for cross-run facts that should land in the active event log. Bare names default to tenant scope: `harness.channels.append("pr.merged", payload)` resolves to `tenant::pr.merged`. Prefixes select a scope: `session:foo`, `pipeline:foo`, `tenant:foo`, `tenant::foo`, or `org::foo`; org scope currently fails with `HARN-CHN-002` until org grants exist. Distinct `tenant_id`, `session_id`, or `pipeline_id` values resolve to distinct topics, so cross-scope readers see an empty view. The resolver also returns `HARN-CHN-001` for `pipeline:` outside a pipeline, `HARN-CHN-003` for malformed names, and `HARN-CHN-004` when an explicit `options.session_id` or `options.pipeline_id` conflicts with the active context. ```harn const receipt = harness.channels.append( "session:worker.ready", {worker: "lint"}, { id: "worker-ready-lint", ttl: 10m, }) harness.stdio.log(receipt.event_id) harness.stdio.log(receipt.emitted_at.signature.starts_with("sha256:")) ``` Each stored event includes `id`, fully resolved `name`, `payload`, `emitted_at` (signed), `emitted_by`, available `pipeline_id`, `session_id`, or `tenant_id`, and `ttl_ms` when `options.ttl` is provided. Reusing the same `options.id` on the same resolved channel is idempotent and returns the original `event_id`. Use `harness.channels.events(name, options?)` for tests and local inspection. Use `harness.channels.subscribe(name, options?)` for live readers that need the same scope resolution as `harness.channels.append(...)`. This matters for session channels: `harness.channels.subscribe("worker.ready", {scope: "session", session_id: "sess-1"})` observes the in-process session channel log, while raw `event_log.subscribe(...)` only sees the active EventLog backend. Use `harness.channels.consumer_cursor(...)` and `harness.channels.ack(...)` for durable consumers that need a high-water cursor without deleting shared channel events. ### Coordination ledger (`std/coordination`) Use `std/coordination` when agents need a durable, replayable coordination room instead of a host-local mailbox or assistant-visible prose protocol. The module wraps `harness.channels.append(...)`, `harness.channels.events(...)`, `event_log.subscribe`, `harness.channels.subscribe(...)`, and `std/memory` with a stable `harn.coordination.message.v1` envelope. ```harn import { coord_ack, coord_inbox, coord_post, coord_read, coord_remember, coord_send, coord_subscribe, } from "std/coordination" const receipt = coord_post( "session", "release", { kind: "claim", subject: "release ownership", body: "Codex-2 owns v0.8.167", }, {id: "release-claim", session_id: "agent-session-1"}, ) const messages = coord_read( "session", "release", {session_id: "agent-session-1"}, ) const newer = coord_read("session", "release", { session_id: "agent-session-1", since_seq: receipt.message.seq, }) const stream = coord_subscribe( "session", "release", {session_id: "agent-session-1"}, ) const memory_receipt = coord_remember( receipt, {namespace: "coordination/release"}, ) const request = coord_send("workspace", "release", "build-agent", { kind: "request", subject: "verify release", body: "Please audit the new patch release.", }) const inbox = coord_inbox( "workspace", "release", {consumer_id: "build-agent"}, ) coord_ack("workspace", "release", "build-agent", inbox.next_cursor) ``` Scopes are `session`, `pipeline`, `tenant`, `workspace`, and `task`. `workspace` stores under the active tenant namespace and includes `workspace_id` in the channel name. `task` stores on the current session channel and includes `task_id` in the channel name. Message kinds are `status`, `claim`, `handoff`, `blocker`, `decision`, `request`, and `fact`. `coord_send` and `coord_reply` add addressing and thread metadata; `coord_inbox` scans addressed messages without acknowledging; `coord_ack` advances the consumer cursor after processing. `coord_post` only writes the ledger; `coord_remember` is explicit opt-in when a coordination message should become recallable memory. Every normalized coordination message has runtime-owned `ts` (the signed UTC channel append time) and `seq` (the monotonic per-room channel cursor). `coord_read` accepts exclusive `since_seq` and `since_ts` cursors. Never supply `ts`, `created_at`, or `seq` to a write; imported source timestamps belong in message data such as `data.claimed_ts`. Existing rows are projected from their channel event metadata without a history migration. Subscribe to channel emits with a `channel.emit` trigger (provider `channel`). `match.events` accepts `"channel:"` selectors: ```harn,ignore fn main(harness: Harness) { harness.runtime.trigger_register({ id: "release-on-pr-merge", kind: "channel.emit", provider: "channel", match: {events: ["channel:pr.merged"]}, handler: { harness, event -> kick_release(event.provider_payload.payload) }, }) } ``` Add `batch: {count, window, key?, expire_action?}` to fire after N matching emits (Inngest-shape fire-after-N — no other major durable- execution platform owns this primitive). `key` is a dotted JSON path that partitions counters; `expire_action` is `"fire_partial"` (default) or `"discard"`. On dispatch `event.batch` holds the constituent events; the buffer is per-process thread-local, capped at 1024 events per partition, and replay reconstructs the batch from the recorded `constituent_event_ids`. ```harn,ignore fn main(harness: Harness) { harness.runtime.trigger_register({ id: "release-on-3-merges", kind: "channel.emit", provider: "channel", match: {events: ["channel:pr.merged"]}, batch: {count: 3, window: "1h", key: "repo"}, handler: { harness, event -> cut_release(event.batch) }, }) } ``` Pair `batch` with `ReminderInject({target, body, tags?, ttl_turns?, dedupe_key?})` to land a periodic reminder on a running session without spawning or resuming it. `target` is `"current"`, `"parent"`, a literal session id, or a closure; `body` is a `.harn.prompt` template against `{{ event }}`, `{{ match }}`, and `{{ batch }}`. Missing targets drop gracefully with a `triggers.reminder_inject.audit` audit entry. See `docs/src/agent-channels.md` for the full surface and `docs/src/cookbooks/channels.md` for runnable recipes. Pick the right primitive: | Goal | Use | |---|---| | Hand off to one specific agent | Handoffs (`handoff(...)`, `@handoff`) | | Wait for an external event (GitHub, Slack, cron) | Provider trigger | | Park one agent until a specific event with a declared resume condition | Suspend/resume (`agent_await_resumption(reason, conditions)`) | | Emit a typed event to many subscribers | **Channels** (`harness.channels.append(...)`) | | Periodic reminder into a running loop | **Channels + `batch` + `ReminderInject`** | Diagnostic codes: `HARN-CHN-001` (`pipeline:` outside a pipeline), `HARN-CHN-002` (cross-tenant emit / disabled `org:`), `HARN-CHN-003` (malformed name), `HARN-CHN-004` (scope ambiguous — `options.session_id`/`pipeline_id` conflicts with active context), `HARN-CHN-005` (malformed `batch` config). Replay-oracle codes are `HARN-REP-CHN-001..003` — see `docs/src/observability/replay-benchmarks.md`. Channel guardrails (`harness.channels.guardrail_register(config)` and `std/channel_guardrails` presets) run before the durable journal append. Each guardrail returns `allow` / `warn` / `block`; worst verdict wins; blocked emits never persist but the block decision does on `lifecycle.channel.audit`. Built-ins ship `prompt_injection_scanner` and `llm_risk_classifier`; `register_guardrail` accepts any custom closure. Pass `autonomy_budget` to cap how many autonomous decisions an agent can make per UTC hour / UTC day. The check fires at loop entry, before any LLM/MCP work — scripts can't bypass it. When the cap is exhausted, `agent_loop` returns `status: "approval_required"` with a HITL approval request id, emits an `autonomy.budget_exceeded` lifecycle event, and appends an `autonomy.tier_transition` trust-graph record from `act_auto` to `act_with_approval`: ```harn import { AgentSpec } from "std/agent/options" const budgeted_opts: AgentSpec = { autonomy_budget: { per_hour: 10, per_day: 100, key: "captain.persona", reviewer: "oncall", }, } agent_loop(harness, task, system, budgeted_opts) ``` `key` defaults to the loop's `session_id`; pick a stable identity (e.g. persona name) when each call mints a fresh session. `reviewer` defaults to `"operator"`. Setting both `per_hour` and `per_day` to `nil` disables the budget. See `docs/src/triggers/budgets.md` for the matching trigger-side cap and audit trail shape. ### `post_turn_callback` (judge / reflection pattern) Every `agent_loop` turn fires the optional `post_turn_callback` closure *after* tool dispatch and before the next LLM call. It is the canonical hook for judges, reflection passes, and graders — no second `agent_loop`-flavored builtin required. The closure receives one dict argument with these keys (stable wire shape; new keys are additive): ```text { session_id: string, // live agent_session id (use this with agent_session_*) iteration: int, // 0-based turn index has_tool_calls: bool, dispatch: list | nil, tool_count: int, // calls dispatched this turn tool_results: list, // structured per-call results available_tool_names: list, // current turn's usage-narrowed surface claimable_tool_names: list, // canonical authority capped by explicit policy successful_tool_names: list, // excludes typed result payloads with ok/success=false rejected_tool_names: list, session_successful_tools: list, session_rejected_tools: list, text: string, visible_text: string, } ``` The return value drives the loop. Accepted shapes: - `nil` / `""` — no-op, loop continues - `string s` — inject as runtime feedback for the next turn - `bool b` — set the stop flag - dict with any combination of: - `message: string` — same as the bare-string shape - `stop: bool` — terminate the loop after this turn - `next_tool_claim: {tool_name: string}` — constrain exactly the immediately following model turn to a tool in `claimable_tool_names` and its registry-owned argument schema; claims are validated against canonical pre-usage-narrow authority capped by explicit policy, then clear after that one turn - `next_options: dict` — merge into the next loop iteration's options - `llm_options: dict` — merge into the next LLM call's `llm_options` Because `session_id` is exposed, the closure can call any `agent_session_*` builtin against the live transcript. The minimal "every-N-turns judge" pattern: ```harn const judge = { info -> if info.iteration % 3 != 0 { return nil } // skip 2/3 turns const snapshot = harness.agent.snapshot(info.session_id) const verdict = harness.llm.call("...grade this transcript...", { // cheaper reflection model provider: "openai", model: "gpt-5-mini", messages: [{role: "user", content: json_encode(snapshot)}], schema: {approved: "bool", feedback: "string"}, }) if !verdict.approved { return {message: "judge: " + verdict.feedback} } if verdict.approved && info.iteration > 5 { return {stop: true} } nil } agent_loop( harness, task, system, {tools: registry, post_turn_callback: judge}, ) ``` Hooks can also shape the next model turn. For example, once the required tool evidence exists, ask the provider to stop calling tools and synthesize: ```harn const finalize_after_evidence = { info -> if info?.session_successful_tools?.contains("read_file") { return { message: "Use the gathered evidence and produce the final" + " answer now.", llm_options: {tool_choice: "none"}, } } nil } ``` When a policy knows the exact next action, prefer a typed tool claim over prose or a provider-specific tool-choice option: ```harn const verify_after_edit = { info -> if info?.successful_tool_names?.contains("edit") { return { message: "Verify the edit before taking another action.", next_tool_claim: {tool_name: "verify"}, } } nil } ``` `available_tool_names` reports the current turn's already-narrowed surface; `claimable_tool_names` reports the canonical pre-usage-narrow authority after explicit tool policy. Harn rejects a name outside the latter set at the callback boundary. An applied claim emits a `typed_checkpoint` carrying `schema: "harn.agent_next_tool_claim_receipt.v1"`, and the subsequent turn is unconstrained unless a new verdict makes another claim. Other strategies compose from existing primitives — no new runtime mechanics required: - **Terminal-only review** — gate the body on `info.iteration == expected_max - 1`, or check `info.session_successful_tools` for a terminal tool name. Skip the early turns and judge once at the end. - **Branch-and-replay** — call `harness.agent.fork_at(info.session_id, k)` to checkpoint at a known-good turn, then return `{stop: true}` to halt the live loop. The enclosing pipeline rebuilds with the branch (see snippet below). The runtime intentionally does *not* swap the live loop's session mid-run — that would race with in-flight tool dispatches. ```harn const s = harness.agent.open() const main = agent_loop(harness, task, sys, { session_id: s, tools: registry, post_turn_callback: { info -> if judge_says_redo_from(info) { const branch = harness.agent.fork_at(info.session_id, judged_k) harness.agent.inject(branch, {role: "system", content: "Redo from turn ${judged_k} with: ${redirection}"}) // Stash the branch id so the caller can pick it up. save_branch_id(branch) return {stop: true} } nil }, }) if main.status == "stopped" { agent_loop( harness, task, sys, {session_id: load_branch_id(), tools: registry}, ) } ``` - **Fork-and-race** — fork at the start (or any turn) and race two variants. Reuse the existing concurrency primitives — no race scaffolding lives in `agent_loop`: ```harn const base = harness.agent.open() const branch = harness.agent.fork(base) harness.agent.inject(branch, { role: "system", content: "Try the brute-force approach.", }) const sessions = [base, branch] const outcomes = parallel settle sessions with {max_concurrent: 2} { sess -> agent_loop(harness, task, sys, { session_id: sess, tools: registry, max_iterations: 10, }) } const winner = pick_first_done(outcomes.results) ``` Use `parallel settle` (vs. `parallel each`) so a failure on one branch doesn't cancel the other. `max_concurrent: 2` keeps both branches running concurrently without unbounded fan-out if you generalize the list. The closure runs in a child VM (separate `output` buffer) and its return is parsed by `interpret_post_turn_callback_verdict`. Any captured `harness.stdio.log()` output flows back to the parent VM unchanged. The callback is awaited synchronously per turn, so it can be a heavy LLM call without races. Keep broad review strategies in `post_turn_callback` when the policy needs custom timing, branching, or multiple competing judges; use `turn_end_condition` for the built-in sentinel-only completion gate. ### Resume conditions Self-parking agents use a shared `ResumeConditions` shape for `agent_await_resumption(reason, conditions?)` and `spawn_agent({options: {resume_when: ...}})`. Call `parse_resume_conditions(conditions?)` or `agent_await_resumption(reason, conditions?)` from `std/agent/workers` when you need to validate or normalize the shape without spawning a worker. ```harn import { parse_resume_conditions, spawn_agent } from "std/agent/workers" const resume_when = parse_resume_conditions({ trigger: { kind: "review.approved", provider: "github", match: {events: ["review.approved"]}, }, timeout: {duration_minutes: 30, on_timeout: "resume_with_summary"}, on_event: "operator.resume", }) const worker_node = { kind: "subagent", mode: "llm", model_policy: {provider: "mock"}, output_contract: {output_kinds: ["summary"]}, } spawn_agent({ task: "wait for review", node: worker_node, options: {resume_when: resume_when}, }) ``` `trigger` reuses the trigger spec parser from `std/triggers` rather than defining a second trigger DSL. `timeout.duration_minutes` must be a positive integer, `timeout.on_timeout` defaults to `"resume_with_summary"` and may be `"fail"` or `"resume_with_input"`, and `on_event` must be a non-empty EventLog topic. Invalid fields raise `HARN-SUS-002` with the failing field path. ### Sessions (persistent conversations) Pass `session_id` to `agent_loop` to resume a multi-turn conversation: prior messages are loaded as a prefix before the call runs, and the final transcript is persisted back under the same id on exit. Calls without a `session_id` (or with an empty string) mint an anonymous id and never touch the store — the one-shot call shape is preserved. ```harn const s = harness.agent.open() // mint UUIDv7 harness.agent.inject(s, {role: "user", content: "hi"}) const a = agent_loop( harness, "continue", nil, {session_id: s, provider: "mock"}, ) const b = agent_loop( harness, "remember me?", nil, {session_id: s, provider: "mock"}, ) const branch = harness.agent.fork(s) // counterfactual // branch from a rebuilt prefix const replay = harness.agent.fork_at(s, 1) harness.agent.close(branch) harness.agent.close(replay) ``` Lifecycle builtins (all hard-error on unknown ids except `exists`, `open`, `snapshot`, `ancestry`): - `harness.agent.open(id?, opts?)` / `_close(id)` / `_exists(id)`. `opts` may include `workspace_anchor` and `workspace_policy: {default_mount_mode}`. - `harness.agent.current_id()` returns the innermost active session id or `nil`. - `harness.agent.actor_chain(id?)` returns the RFC 8693 `{sub, act}` actor chain for `id`, or for the current active session when `id` is omitted. - `harness.agent.workspace_anchor(id)` / `_set_workspace_anchor(id, anchor)` read and replace the typed anchor. - `harness.agent.workspace_policy(id)` / `_set_workspace_policy(id, policy)` read and update the default mount mode used when mounted roots omit `mount_mode`. - `harness.agent.add_root(id, root, opts?)` / `_remove_root(id, root)` mount or unmount additional roots. `opts.mount_mode` defaults from the session workspace policy. - `harness.agent.list_roots(id)` returns `{primary, additional}` for the current mounted roots. - `harness.agent.reanchor(id, new_anchor, opts?)` atomically swaps the primary anchor mid-run. `opts.carry_transcript` (default true) keeps the transcript; `false` forks into a fresh empty session. `opts.compact: true` runs compaction before the swap (requires `carry_transcript: true`). Emits an `AnchorChanged` transcript event and `AgentEvent::AnchorChanged`. - `sub_agent_run` accepts an `anchor` option. The runtime rejects a child anchor that escapes the parent's anchor + mounted roots. - `harness.runtime.register_path_scope_guard(opts?)` / `harness.runtime.clear_path_scope_guard()` install a singleton PreToolUse hook that denies (or emits a `` reminder for) tool calls whose path args escape the session anchor. - `harness.agent.reset(id)` / `_fork(src, dst?)` / `_fork_at(src, keep_first, dst?)` / `_trim(id, keep_last)` - `harness.agent.inject(id, {role, content, …})` — missing `role` errors. - `harness.agent.seed_from_jsonl(path, opts?)` creates a new session from a replayable `llm_transcript.jsonl` sidecar. Useful opts: `truncate_to_last`, `drop_tool_calls`, `rename_session`, `validate`, `provider`, `model`, `source_agent`, `source_session_id`, `source_kind`, `source_label`, `source_provenance`, `recommend_compaction`. - `harness.agent.compact(id, opts)` — supports LLM/truncate/observation-mask/custom compaction, accepts the same compaction policy fields as `transcript_auto_compact`, and errors on unknown option keys. - `harness.agent.length(id)` / `_snapshot(id)` / `_ancestry(id)` for read-only inspection. - `harness.agent.cancel_in_flight_tool_call(session_id, call_id, opts?)` — abort one in-flight tool call without closing the session. `opts.reason` is surfaced to the model, `opts.inject_reminder` (default `true`) queues a system reminder so the model knows it was stopped, and `opts.timeout_ms` (default `5000`) bounds how long to wait for the dispatch to unwind. Returns `{status, call_id, tool, reason}` where status is `"cancelled"`, `"already_cancelled"`, `"not_found"`, or `"timeout"`. The cancelled call returns to the loop as `status: "cancelled"` so the model can distinguish "the host stopped me" from "the tool errored". The same surface is exposed over ACP as `session/cancel_tool_call`. Session snapshots include `metadata.transcript_budget` after hard retention budget pressure. `last_action` records whether Harn rejected, trimmed, or compacted the transcript, along with before/after message and event counts. ### Daemon wrappers Use the daemon stdlib wrappers when you want a first-class handle around `agent_loop(harness, ..., {daemon: true})`: - `harness.agent.daemon_spawn(config)` starts a persistent daemon and returns `{id, status, persist_path, ...}`. - `harness.agent.daemon_trigger(handle, event)` appends a durable FIFO trigger event. - `harness.agent.managed_daemon_snapshot(handle)` returns the persisted daemon snapshot plus queue fields such as `pending_event_count`, `queued_event_count`, `inflight_event`, and `event_queue_capacity`. - `harness.agent.daemon_stop(handle)` preserves state and re-queues any in-flight trigger. - `harness.agent.daemon_resume(path)` resumes from the daemon state directory. `harness.agent.daemon_spawn` accepts daemon-loop options like `wake_interval_ms`, `watch_paths`, and `idle_watchdog_attempts`, plus `event_queue_capacity` (default `1024`). ### Bridge-only builtins (IDE host integration) These builtins are only meaningful when a Harn script runs inside a host with a `HostCallBridge` attached. Outside a bridge session they raise an error — don't call them from `harn run` in a plain terminal. - `harness.tools.list_registered()` returns `list<{name, description, schema}>` — every tool the attached host has registered. Call once per script; cache the result. - `harness.tools.invoke(name, args)` invokes a host tool with a dict of arguments. Returns an opaque value — narrow it yourself before field access (strict types mode treats this as an untyped boundary). ### Filesystem extras - Import `replace_text[_result]` or `replace_bytes[_result]` from `std/fs` when publishing complete state under an observed SHA-256 lease. Receipts are `created`, `replaced`, `no_op`, or `stale`; stale never mutates the file. - `harness.fs.replace_text[_result]` and `replace_bytes[_result]` are the capability-aware primitives. Options make create, overwrite, parent creation, and `namespace`/`flush` durability explicit. Symlink destinations fail closed. - `harness.fs.glob(pattern, base?)` → list of matching paths. Pattern is matched against forward-slash paths relative to `base` (defaults to script source dir); `**` glob is supported. - `harness.fs.glob(pattern, base?)` is the capability-aware form and returns the same matches as `harness.fs.glob(...)`. - `harness.fs.workspace_temp_dir()` returns the sandbox-visible workspace scratch directory, creating it lazily. - `harness.fs.mkdtemp_in_workspace(prefix?)` creates a unique directory under that workspace scratch root. Prefer it for intermediate files used by sandboxed workflows. - `harness.fs.mkdtemp(prefix?)` creates a uniquely named directory under the host temp dir. Use it only for host-temp work that does not need to be sandbox-visible; callers own cleanup with `harness.fs.delete(path)`. - `harness.fs.walk(root, opts?)` → list of `{path, is_dir, is_file, depth}`. `opts.max_depth: int` and `opts.follow_symlinks: bool` are honored. - `harness.fs.rename(src, dst)` — `rename` with cross-filesystem copy+delete fallback. - `harness.fs.read_lines(path)` → list of lines (no trailing newline). Handles CRLF correctly. - Direct runs can keep sandboxing on while writing outside the project with `harn run --write-root script.harn`; the path is added to `workspace_roots`. Use `--read-only-root ` for additive read scope. - Use `--sandbox-write-root ` or `--sandbox-read-root ` when only spawned subprocesses need the extra path; Harn filesystem builtins stay scoped to the workspace roots. ### Document helpers Import with `import { pdf_bytes, write_pdf, extract_text, pdf_capabilities } from "std/document"`. - `pdf_bytes(source, options?)` renders text, HTML, or Markdown to PDF bytes using Harn's dependency-free `builtin_text_pdf` renderer. Options include `source_format`, `title`, `page_width_pt`, `page_height_pt`, `margin_pt`, `font_size_pt`, `line_height_pt`, and `max_line_chars`. - `write_pdf(path, source, options?)` writes those bytes through `harness.fs.write_bytes`, so normal sandbox `workspace_roots` apply. - `extract_text(source, {source_format?})` normalizes text-like document input. With `source_format: "pdf"`, it accepts PDF bytes and extracts embedded text with the portable `builtin_pdf_text` extractor. Malformed, encrypted, and image-only PDFs throw a structured `document_extract_error`; image-only failures set `ocr_candidate: true` so callers can choose an explicit OCR fallback. - `pdf_capabilities()` reports available renderers and extractors, including supported formats, resource limits, external dependencies, and whether OCR is available. The built-in renderer is portable and text-layout oriented, not browser-grade CSS. ### Diff helpers `std/diff` exposes `diff_lines`, `unified_diff`, `colorize_diff`, `diff_summary`, `render_diff_stat`, `structural_diff`, and `changeset_summary`. `structural_diff(ast, path_a, path_b, language_or_options?)` parses both files with the hostlib tree-sitter registry and returns changed syntax-node spans for human review. It is not patch-applicable. On unsupported languages, parse errors, or `max_bytes` / `max_nodes` / `max_graph_edges` limits, it returns `result: "fallback"`, `mode: "line"`, and a `line_diff` payload. `changeset_summary(ast, files)` accepts the narrow `HarnessAst` handle plus `{path, before?, after?}` file images and returns `harn.review_changeset.v1`: structural versus reshaped-only files, named symbol changes, and name-matched candidate `CALLS` relations explicitly labeled as heuristic. Unsupported inputs remain visible as degraded entries. ### CSV ```harn csv_parse("name,age\nalice,30\n", {headers: true}) // → [{name: "alice", age: "30"}] csv_stringify([{name: "alice", age: 30}], {headers: true}) // → "age,name\n30,alice\n" ``` Options: `headers: bool` (default false), `delimiter: ","` as one ASCII character. Without headers, `csv_parse` returns list-of-lists; with headers, list of dicts (keys are sorted on stringify for determinism). ### URL parsing ```harn url_parse("https://api.example.com:8080/v1/items?q=hi#frag") // → {scheme: "https", host: "api.example.com", port: 8080, // path: "/v1/items", query: "q=hi", fragment: "frag", ...} url_build({scheme: "https", host: "example.com", path: "/api", query: "x=1&y=2"}) // → "https://example.com/api?x=1&y=2" query_parse("?key=alpha&key=beta") // → [{key: "key", value: "alpha"}, {key: "key", value: "beta"}] query_stringify([{key: "name", value: "ali ce"}]) // → "name=ali+ce" ``` ### Modern crypto - Hashes: `sha3_256`, `sha3_512`, `blake3` (in addition to existing SHA-2 family + MD5). - Harness-scoped content addressing: `harness.crypto.sha256(value) -> string` accepts strings or bytes and returns lowercase SHA-256 hex. `sha256_hex(value)` remains as a compatibility alias. - Ed25519 signatures: `harness.random.ed25519_keypair() -> {private, public}` (hex), `ed25519_sign(priv, msg) -> string` (hex sig), `ed25519_verify(pub, msg, sig) -> bool`. - X25519 key agreement: `harness.random.x25519_keypair() -> {private, public}`, `x25519_agree(priv, peer_pub) -> string` (hex shared secret). - JWT verification: `jwt_verify(alg, token, key)` (HS256 / RS256 / ES256). Pairs with the existing `jwt_sign`. ### Date/time builtins - `harness.clock.now() -> {year, month, day, hour, minute, second, weekday, timestamp, iso8601}`. - `harness.clock.date_iso() -> string` returns current UTC as RFC 3339. - `date_parse(str) -> int | float` parses RFC 3339 / ISO 8601 first, then falls back to legacy digit extraction for malformed date-ish strings. - `date_format(ts, fmt?, tz?) -> string` supports chrono/strftime codes including `%A`, `%B`, `%Z`, `%z`, `%:z`, `%f`, `%3f`, and `%s`; negative pre-epoch timestamps work. - `date_in_zone(ts, "America/Los_Angeles") -> dict` and `date_to_zone(ts, tz) -> string` convert through IANA timezone names. - `date_from_components({year, month, day, hour?, minute?, second?}, tz?) -> int | float`. - Durations: `duration_ms/seconds/minutes/hours/days(n) -> duration`, `date_add(ts, d)`, `date_diff(a, b) -> duration`, `duration_to_seconds(d)`, `duration_to_human(d)`. - `weekday_name(ts, tz?)` and `month_name(ts, tz?)` return localized English names. ### HTTP builtins - `http_get/post/put/patch/delete/request` return `{status, headers, body, ok}` for outbound HTTP calls. - `harness.net.download(url, dst_path, options?)` streams a response body to disk and returns `{bytes_written, status, headers, ok}`. - `http_stream_open/read/info/close` expose pull-based response streaming; `http_stream_read` returns `bytes` chunks and then `nil` at EOF. - Common options: `timeout_ms` (alias `timeout`), `total_timeout_ms`, `connect_timeout_ms`, `read_timeout_ms`, `retry: {max, backoff_ms}`, legacy `retries` / `backoff`, `retry_on`, `retry_methods`, `headers`, `auth`, `follow_redirects`, `max_redirects`, `proxy`, `proxy_auth: {user, pass}`, `decompress`, and `tls: {ca_bundle_path?, client_cert_path?, client_key_path?, client_identity_path?, pinned_sha256?}`. - `http_post/put/patch` accept either `(url, body, options?)` or `(url, options)` when the request is driven entirely by options such as `multipart`. - `multipart` accepts a list of part dicts with `name` plus one of `value`, `value_base64`, or `path`, along with optional `filename` and `content_type`. - Default retries cover `408`, `429`, `500`, `502`, `503`, and `504` for idempotent methods only. `Retry-After` is honored on `429` / `503`. - `http_mock(method, url_pattern, response)` can script multiple responses with `{responses: [...]}` and `http_mock_calls()` records each attempt. ### `std/web` grounding helpers Import with `import { web_fetch, web_search, verify_imports, web_grounding_tools } from "std/web"`. - `web_fetch(url, options?)` wraps the HTTP stack with source provenance, conditional fetch support, and `{ok, status, body, headers, source_url, final_url, fetched_at, cache_status}` envelopes. - `web_search(query, options?)` normalizes curated `index` / `results`, configured JSON `api`, `provider_results`, or `HARN_WEB_SEARCH_URL` search backends into ranked results with per-result provenance. Result envelopes expose only public backend metadata, not configured API headers or bodies. - `verify_imports(paths, options?)` checks Python, JavaScript/TypeScript, Rust, and Harn imports against nearby manifests, `installed_packages`, and registry evidence with optional `symbols`, `trust_score`, and package age metadata. Treat `package_not_found` and `symbol_not_found` as blockers; `low_trust_package`, `fresh_package`, and `symbol_unverified` are warnings. - `web_grounding_tools(registry?, options?)` registers read-only `web_search` and `verify_imports` tools plus capability-gated model guidance for unfamiliar packages, APIs, or post-edit import verification. ### Connector HTTP policy Import `std/connectors/http` for provider API calls: ```harn import { connector_http_json } from "std/connectors/http" const response = connector_http_json( harness.clock, harness.net, "POST", url, { headers: { Authorization: "Bearer " + token, Accept: "application/json", }, body: json_stringify(payload), idempotency_key: "create:" + payload.id, retry: {max_attempts: 3, base_ms: 250, cap_ms: 30000}, provider: "example", operation: "create_item", }) ``` `connector_http_request` returns a non-throwing envelope. Success: `{ok: true, status, headers, body, retry_after_ms?}`. Failure: `{ok: false, status?, retryable, retry_after_ms?, error}` where `error.category` is stable for branching. `connector_http_json` adds `json` on valid JSON and returns `error.category == "invalid_json"` on parse failure. `POST`/`PATCH` retries require an existing or supplied `Idempotency-Key`, unless `retry_unsafe: true` is explicit. `connector_http_header` and `connector_http_rate_limit` cover case-insensitive header lookup plus `Retry-After`, `RateLimit-*`, and `X-RateLimit-*` extraction. For narrow AWS connector calls, use `aws_sigv4_headers(spec)` to sign one request with explicit credentials, then pass `signed.headers` into `harness.net.request(...)`. This is not an AWS SDK: there is no credential chain, paginator, service client, or live AWS test requirement. `timestamp` is required for deterministic signing, and temporary credentials use `session_token` / `X-Amz-Security-Token`. ```harn const body = "{\"TableName\":\"Items\"}" const url = "https://dynamodb.us-east-1.amazonaws.com/" http_mock("POST", url, {status: 200, body: "{\"ok\":true}", headers: {}}) const signed = aws_sigv4_headers({ method: "POST", url: url, service: "dynamodb", region: "us-east-1", body: body, access_key_id: access_key_id, secret_access_key: secret_access_key, session_token: session_token, headers: {"Content-Type": "application/x-amz-json-1.0"}, timestamp: "20260429T120000Z", }) const response = harness.net.request( "POST", url, {body: body, headers: signed.headers}, ) ``` ### Human-in-the-loop primitives `harness.interaction.ask_user`, `.request_approval`, `.dual_control`, and `.escalate_to` are typed methods on the `interaction` capability. Envelopes are signed by the VM; quorum requires distinct principals; replay is deterministic. Shared type aliases live in `std/hitl`. Arguments are **positional** — Harn has no keyword-argument call syntax. Optional settings go in a trailing options record. Writing `harness.interaction.ask_user(prompt: "x")` is a parse error, not an alternative form: `HARN-PAR-001: expected expression, found :`. ```harn,ignore const answer = harness.interaction.ask_user( "choose A or B", {schema: schema_of(Choice)}, ) const record = harness.interaction.request_approval( "merge_pr", { args: {pr: 123}, quorum: 2, reviewers: ["alice", "bob", "carol"], }, ) const result = harness.interaction.dual_control( 2, 3, destructive_step, ["alice", "bob", "carol"], ) const handle = harness.interaction.escalate_to( "oncall", "deploy failed", ) ``` - `harness.interaction.ask_user(prompt, options?: {schema?, timeout?, default?}) -> T` - `harness.interaction.request_approval(action, options?)` where `options` is `{args?, detail?, quorum?, reviewers?, deadline?, principal?, evidence_refs?, undo_metadata?, capabilities_requested?}` -> `{approved, reviewers, approved_at, reason, signatures}` - `harness.interaction.dual_control(n, m, action: fn() -> T, approvers?) -> T` - `harness.interaction.escalate_to(role, reason) -> {request_id, role, reason, trace_id, status, accepted_at, reviewer}` - `harness.interaction.hitl_pending({since?, until?, kinds?, agent?, limit?} | nil) -> list<{request_id, request_kind, agent, prompt, trace_id, timestamp, approvers, metadata}>` Operational semantics: - Approval deadlines default to 24 hours. - Timeouts append `hitl.timeout` and either return the supplied default or throw `HumanTimeoutError`. - Denials throw `ApprovalDeniedError`. - Replay reads recorded HITL responses from the event log instead of asking a live host again. Host contract: - Notification: `harn.hitl.requested` - Resolution method: `harn.hitl.respond` ### Trigger stdlib Use the trigger stdlib wrappers when a script needs to inspect or manually exercise the live trigger registry: - `harness.runtime.trigger_list()` returns `list`. - `harness.runtime.trigger_register(config)` hot-installs a dynamic trigger and returns a `TriggerHandle`. `config.retry` accepts `{max, backoff}` with `backoff: "svix" | "immediate"`. `config.when_budget` accepts `{max_cost_usd, tokens_max, timeout}` when `config.when` calls `harness.llm.call(...)`. - `harness.runtime.trigger_fire(handle, event)` injects a synthetic `TriggerEvent` and returns a `DispatchHandle`. - `harness.runtime.trigger_replay(event_id)` fetches an event from `triggers.events` and re-dispatches it through the trigger dispatcher, preserving `replay_of_event_id`. - `harness.runtime.trigger_inspect_dlq()` returns `list` with retry history. - `harness.runtime.trigger_inspect_lifecycle(kind?)` returns lifecycle records including `predicate.evaluated`, `predicate.budget_exceeded`, and `predicate.daily_budget_exceeded`. Shared types live in `std/triggers`: `TriggerConfig`, `TriggerBinding`, `TriggerHandle`, `DispatchHandle`, `DlqEntry`, and `TriggerEvent`. Trust-graph helpers also live in `std/triggers`: - `harness.runtime.handler_context()` returns the active trigger dispatch context or `nil`. - `harness.runtime.trust_record(agent, action, approver, outcome, tier)` appends a manual trust record. - `harness.runtime.trust_query(filters)` queries historical trust records, including `limit` and `grouped_by_trace`. - `TriggerConfig.autonomy_tier` and manifest `[[triggers]].autonomy_tier` accept `shadow | suggest | act_with_approval | act_auto`. - `harn trust query`, `harn trust promote`, and `harn trust demote` expose the same substrate from the CLI. Current caveats: - LLM-gated predicates are fail-closed. Single-evaluation budget overruns, daily budget exhaustion, provider failures, and circuit-breaker-open states all short-circuit the handler to `false`. - Example: ```harn import "std/triggers" fn about_outages(event: TriggerEvent) -> bool { const result = harness.llm.call( "Is this message about outages? " + event.kind, nil, {provider: "mock", model: "gpt-5.4-mini"}, ) return contains(result.text.lower(), "yes") } const handle = trigger_register({ id: "slack-outage-gate", kind: "slack.message", provider: "slack", handler: fn(harness: Harness, event) { return event.kind }, when: about_outages, when_budget: {max_cost_usd: 0.001, tokens_max: 500, timeout: "5s"}, retry: nil, match: {events: ["slack.message"]}, events: nil, dedupe_key: nil, filter: nil, budget: {daily_cost_usd: 1.0, max_concurrent: nil}, manifest_path: nil, package_name: nil, }) ``` - `trigger_fire` / `trigger_replay` now reuse the dispatcher for local handlers, retries, and DLQ transitions. `a2a://...` returns either an inline remote result or a pending task handle, while `worker://...` returns an enqueue receipt for the durable worker queue job. - `trigger_replay` is not the full deterministic T-14 replay engine yet: it replays the recorded trigger event through today’s dispatcher/runtime state rather than a sandboxed drift-detecting environment. ### Triage inbox stdlib Use `std/triage` to turn Slack, Notion, GitHub, or generic connector payloads into host-renderable inbox cards while retaining raw provider payloads for audit: ```harn import { triage_start_my_day } from "std/triage" const connector_events = [] const feed = triage_start_my_day(connector_events, {emit: true}) for event in feed.events { harness.stdio.log(event.summary) } ``` - `triage_normalize(input, options?)` returns `harn.triage_event.v1` with `source_url`, normalized actors, card copy, action intents, privacy flags, a stable `dedupe_key`, and separate `raw_payload`. - `triage_dedupe_key(provider, source_kind, source_url, source_id?)` hashes source provenance, not transport delivery ids. - `triage_dedupe_events(events)` keeps first-seen order while dropping duplicate triage keys. - `triage_emit(input, options?)` validates the envelope and appends `kind = "triage_event"` to `triage.inbox.events` by default. - Non-navigation action intents must set `requires_approval: true`; hosts own write execution for dismiss, snooze, and convert-to-task actions. ### Interactive app stdlib Use `std/ui` for new interactive apps whose behavior stays in Harn: ```harn import * as ui from "std/ui" const resource = ui.app_resource( "ui://example/decision-card", "Decision Card", "decision.handle_event", ) fn handle_event(raw) { const event = ui.event(raw.event) // Change Harn-owned state from the checked event. return ui.update(ui.document("Decision Card", revision, elements)) } ``` - `ui.document` checks element IDs, parent order, heading levels, and canvas sizes before a browser sees the document. - `ui.event` checks browser input and canvas coordinates once at the Harn boundary. - `ui.update` returns the next document plus `send_event`, `capture_canvas`, or `download` actions for the shared renderer. - `ui.tool_metadata` and `ui.mcp_resource` return the exact records needed by `tool_define` and `harness.tools.mcp_resource`. - `ui.test.run` drives an event handler and follows scheduled events in process without sleeping. See `examples/apps/decision-card.harn` for a small form and `examples/apps/logo-studio.harn` for drawing, model jobs, restart recovery, and writing result files. ### MCP Apps UI resource stdlib Use `std/ui_resource` to package interactive widgets as `ui://` resources for MCP Apps hosts while keeping text/structured fallbacks first-class: ```harn import { ui_resource, ui_select_for_host, ui_structured_fallback, ui_tool_result, } from "std/ui_resource" const resource = ui_resource( "ui://harn-dashboard/kpis@v1", "Weekly KPIs", weekly_kpi_html, { permissions: ["tools/call"], capabilities: ["tools/call", "context/read"], }, ) const result = ui_tool_result(resource, { structured_fallback: ui_structured_fallback({signups: 42, churn: 3}), }) const rendered = ui_select_for_host(result, host_capabilities) ``` - `ui_resource(uri, name, html, options?: UiResourceOptions)` produces `UiResource` (`harn.ui_resource.v1`) with `mime_type: "text/html;profile=mcp-app"`, a content hash, CSP/sandbox policy, and an embedded `std/artifact/web` validation summary. `allow_host_bridge: true` is the default so `parent.postMessage` to the host counts as an expected MCP Apps bridge call rather than a finding. - `ui_tool_meta(resource, options?: UiToolMetaOptions)` returns a `_meta.ui` block; `ui_tool_meta_to_mcp(meta)` serializes it into the MCP `resourceUri` / `visibility` / `initialView` shape MCP Apps hosts read from `tools/list`. - `ui_tool_result(resource, options?: UiToolResultOptions)` wraps the resource with a mandatory text fallback (default: `web_artifact_text_fallback` of the HTML) and an optional `UiStructuredFallback`. Wrap raw fallback data with `ui_structured_fallback(data, options?: UiStructuredFallbackOptions)`. Invalid resources are stripped automatically unless the caller passes `allow_invalid_resource: true`. - `ui_select_for_host(result, capabilities?)` picks `ui_resource`, `structured_fallback`, or `text_fallback` from the same record based on host capability advertisements. `ui_host_capabilities` accepts the current MCP extension entry, older `client_capabilities.apps` shapes, OpenAI Apps SDK `ui.apps`, or bare `{apps: true}` records through `UiHostCapabilityInput`. - `ui_tool_call_envelope(name, params?, options?)` and `ui_context_update_envelope(key, value, options?)` build the JSON-RPC envelopes a sandboxed iframe sends through `window.parent.postMessage`. - `ui_resource_csp_header(csp)` and `ui_resource_sandbox_attr(csp)` project the resource's CSP into header and sandbox attribute strings hosts can apply directly. - `ui_tool_result_validate(result)` enforces schema versions, the text fallback contract, and refuses to ship a resource whose HTML failed validation. Examples: `examples/ui_resource/dashboard-widget.harn`, `examples/ui_resource/review-form.harn`. ### Profile bulletins stdlib Use `std/personas/bulletins` when an agent learns a durable fact about a person, project, team, or task. Bulletins are proposals — they never silently enter durable context, and hosts emit separate decision events so the review trail is replayable: ```harn import { bulletin_propose, bulletin_emit, bulletin_accept, bulletin_render_for_prompt, } from "std/personas/bulletins" const bulletin = bulletin_propose({ scope: "user", scope_key: "kenneth@example.com", subject: "kenneth", persona: "burin_home", assertion: "prefers concise responses without trailing summaries", confidence: 0.92, source: {agent: "burin_home_curator"}, evidence: [{kind: "user_msg", ref: "msg-42"}], privacy: {sync: "local_only"}, }) const _proposal = bulletin_emit(bulletin) const _accepted = bulletin_accept(bulletin, {decided_by: "user"}) ``` - `bulletin_propose(input, options?)` returns `harn.profile_bulletin.v1` with `id`, `scope`, `scope_key`, `subject`, `assertion`, `status` (always `proposed` by default), `confidence` in `[0, 1]`, structured `evidence`, `source`, `privacy`, `proposed_at`, optional `expires_at` and `review_after`, and optional `supersedes` list. - `bulletin_emit(input, options?)` always writes status `proposed` to `personas.bulletins.proposed`, even when the input has a different status. - `bulletin_accept` / `bulletin_reject` / `bulletin_expire` / `bulletin_supersede` build and emit a typed `harn.profile_bulletin_decision.v1` envelope on `personas.bulletins.decisions`. `bulletin_supersede` requires at least one prior bulletin id. - `bulletin_active(bulletins, now?)` returns only `accepted` bulletins still within their TTL; `bulletin_render_for_prompt(bulletins, options?)` renders prompt-ready text that visibly separates accepted facts from proposals pending review. Pass `{include_proposed: false}` to drop proposals. - `bulletin_accept(b, {embed: true, memory_root?, embed_model_hint?})` also writes the accepted bulletin into the scope-partitioned memory namespace (`bulletin_memory_namespace(b)` — `personas/bulletins//`) with eager embedding, so persona prompts can `memory_recall` past decisions semantically. ### Durable memory (`std/memory`) ```harn import { memory_open, memory_store, memory_recall, memory_summarize, memory_forget, } from "std/memory" // Optional: configure the namespace once. Defaults to deterministic BM25. harness.memory.open("workspace/acme", { backend: "hybrid", embed_dim: 1024, embed_model_hint: "voyage-2", }) harness.memory.store( "workspace/acme", "alice-profile", {text: "prefers Rust"}, ["profile"], ) const hits = harness.memory.recall( "workspace/acme", "rust", 5, {mode: "semantic"}, ) const summary = harness.memory.summarize("workspace/acme", {limit: 10}) harness.memory.forget("workspace/acme", {tag: "stale"}) ``` - Append-only event log at `.harn/memory//events.jsonl`. Pass `{root: "path"}` in options to override. - `memory_open` writes a config event (latest wins) — backends: `"bm25"` (default), `"vector"`, `"hybrid"`. Hybrid weights default to `0.5 / 0.5` and are tunable via `bm25_weight` / `cosine_weight`. - `memory_recall` accepts `options.mode` (`lexical` / `semantic` / `hybrid`) to override the namespace default for one query. Returned records carry a `score` field. - Vector and hybrid recall call the typed host capability `memory.embed({text, model_hint})` and cache the result on disk at `.harn/memory//vectors//.json`. Replays with the same event log and cache are deterministic without the host being attached. - In tests, register the embedder on the exact harness: `harness.testing.respond("memory", "embed", {vector: [...], dim: N, model: "..."}, {text, model_hint})`. The optional matcher selects per-record vectors, and `harness.testing.calls()` proves which fixture fired. ### Durable steps (`step.run`) `step.run(key, input?, handler, options?)` memoizes a completed handler result in the active EventLog. On replay, the script runs from the top but matching steps return the persisted result without invoking the handler: ```harn,ignore const loaded = step.run("load-user", {user_id: id}, { input -> return load_user(input.user_id) }, {namespace: "signup-" + id}) ``` - Match key: `(namespace, key, occurrence_number, deterministic_inputs_hash)`. - Pass `options.namespace` for production workflows; the source path default is mainly for local scripts. - Replaying the same key/occurrence with a different input hash throws a deterministic input mismatch. - `step.inspect(namespace_or_options?)` returns completed records for audit. - Inputs and results are persisted under `step.run.` in the active EventLog, so avoid secrets unless the EventLog storage is allowed to hold them. Workflow stages pick up a session id from `model_policy.session_id`; two stages sharing an id share their conversation automatically. The pre-0.7 `transcript_policy` dict (with `mode: "reset" | "fork"`) was removed — call the lifecycle verbs explicitly. ### Lifecycle hooks Three concentric surfaces: - `harness.tools.register_hook({pattern, deny?, max_output?, pre?, post?})` — tool-level `PreToolUse` / `PostToolUse`. `pre` and `post` are closures that receive `{event, tool, result?}` payloads; `pre` can return `{deny}` or `{args}`, and `post` can return a string, `{result}`, or `{result, truncated: true, dropped_bytes: N}`. Use the typed truncation shape whenever source bytes were dropped so later hooks and runtime observers retain exact loss metadata even if another hook appends text. - `harness.agent.register_persona_hook(persona_pattern, event, handler)` — persona `PreStep` / `PostStep` / `OnApprovalRequested` / `OnHandoffEmitted` / `OnPersonaPaused` / `OnPersonaResumed` / `OnBudgetThreshold(pct)`. - `harness.agent.register_session_hook(event, handler)` — whole-session lifecycle: `session_start`, `session_end`, `user_prompt_submit`, `pre_compact`, `post_compact`, `post_turn`, `permission_asked`, `permission_replied`, `file_edited`, `session_error`, `session_idle`, `pre_finish`, `post_finish`, `on_unsettled_detected`, plus the agent-lifecycle events `pre_suspend`, `post_suspend`, `pre_resume`, `post_resume`, `pre_drain`, `post_drain`, `on_drain_decision` (harn#1859). Veto with `{block: true, reason}`; short-circuit a permission with `{decision: "allow"|"deny"|"ask", reason}`. Lifecycle-gate events also accept `{modify: payload}` to rewrite the dispatched event (`pre_suspend` rewrites the reason, `pre_resume` amends the resume input, `pre_drain` amends the drain spec, `on_drain_decision` rewrites the tool call, `on_unsettled_detected` amends the unsettled snapshot). `pre_finish` rejects `{block: true}` and surfaces a runtime error pointing at `OnFinish.block_until_settled`; use that preset to delay finish until unsettled work clears. The full per-event return semantics: | Event | Allow | Deny / Block | Modify | Reminder | |------------------------|-------|-----------------------------------------|-------------------------|-------------| | `pre_suspend` | yes | cancel suspend, worker keeps running | rewrite reason | inject only | | `post_suspend` | yes | n/a | n/a | inject only | | `pre_resume` | yes | stay suspended | amend resume input | inject only | | `post_resume` | yes | n/a | n/a | inject only | | `pre_drain` | yes | skip drain | amend drain spec | inject only | | `post_drain` | yes | n/a | n/a | inject only | | `on_drain_decision` | yes | block tool call | rewrite tool call | inject only | | `on_unsettled_detected`| yes | block finish until settled | amend unsettled payload | inject only | | `pre_finish` | yes | INVALID — use `OnFinish.block_until_settled` | n/a | inject only | | `post_finish` | yes | n/a (advisory) | n/a | inject only | Tape captures every invocation under `hook_call` / `hook_returned` / `hook_vetoed`. - Any tool, persona, step, or session hook can also emit a typed reminder for the active session transcript. Return `{reminder: {body, tags?, dedupe_key?, ttl_turns?, preserve_on_compact?, propagate?, authority?, role_hint?}, then?}` to combine the reminder with an existing action, return a bare reminder spec such as `{body: "Refresh context", tags: ["context"]}`, or return a session-hook effect list like `[{reminder: {...}}]`. - `harness.agent.register_reminder_provider({id, subscribes_to, evaluate})` registers a Harn-defined provider for `post_tool_use`, `on_budget_threshold`, `post_compact`, or `session_idle`; `harness.agent.clear_reminder_providers()` clears user-defined providers. - `harness.agent.pipeline_on_finish(callback)` — register a `fn(harness, return_value)` callback that runs between `pre_finish` and `post_finish` on the main VM (its stdout reaches the host capture buffer). The callback's return value replaces the pipeline's return value, so a custom `on_finish` can wrap, redact, or audit the result. Four canonical presets ship in `std/lifecycle`: - `on_finish_abandon(harness, return_value)` — reproduces today's no-callback behavior; emits `pipeline_abandoned_unsettled` when work is left behind. - `on_finish_drain(harness, return_value)` — recommended default; emits `pipeline_finalized` when nothing is deferred, otherwise delegates to `harness.spawn_settlement_agent` (settlement-agent loop tracked under harn#1856). - `on_finish_block_until_settled(timeout, fallback?)` — factory that waits until everything settles or the timeout elapses, then delegates to `fallback` (default `on_finish_drain`). - `on_finish_handoff_to(target_pipeline, options?)` — factory that packages unsettled state into an envelope and hands it to `target_pipeline` via `harness.handoff_to`. Presets are pure functions / pure factories; they compose freely. See `docs/src/stdlib/lifecycle.md` for an example chain. - `std/lifecycle/combinators` exports six pure factories that wrap any `(harness, return_value) -> return_value`-shaped callback (hook handler, `resume_by`, `on_finish`, ...): - `compose(callbacks)` — invoke each callback sequentially, threading each return value into the next callback's `return_value`; returns the last entry's value. - `first_available(callbacks)` — invoke in order; return the first non-nil result. Skips remaining callbacks after the first non-nil. - `with_telemetry(callback, span_name?)` — wrap with a `SpanKind::FnCall` OTel span and paired `{span_name}_started` / `_completed` / `_errored` audit entries. - `with_timeout(callback, ms)` — soft, clock-aware deadline; on overrun returns `{__timed_out: true, timeout_ms, elapsed_ms, return_value}` and emits a `lifecycle_callback_timed_out` audit. - `if_unsettled(callback)` — only invoke when `harness.unsettled_state()` is non-empty; one snapshot per call. - `when(predicate, callback)` — only invoke when `predicate(harness, return_value)` is truthy; otherwise pass the inbound value through unchanged. - `std/observability` exports `obs()`, a unified facade for user-space spans, logs, metrics, and structured events. Configure once with `import { obs } from "observability"` then `obs().configure({backend: obs().Backend.auto})`, use `obs().span("name", attrs, { -> ... })` for scoped auto-close, or `start_span` / `log_in_span` / `end_span` for imperative flows. Backends include OTel, Splunk HEC, Honeycomb, pretty stderr, `compose([...])`, and env-driven `auto`. - `std/timing` is the scoped-duration primitive that replaces hand-rolled `let started = harness.clock.now_ms(); work(); let dur = harness.clock.now_ms() - started`. Use `timed("op", attrs, { -> work() })` for callback-scoped auto-close (returns `{result, timing}` with `timing.duration_ms` from the monotonic clock and `timing.started_at_ms` / `timing.ended_at_ms` from the wall clock for external correlation). Use `start_timing` / `timing_event` / `end_timing` for flows that cross callbacks, branches, or async-ish lifecycle boundaries. Duplicate `end_timing` is idempotent. Timing spans are emitted under `SpanKind::UserTiming` (`kind: "user_timing"`), so `harness.obs.trace_spans()` and `harn run --profile-json` surface them as their own bucket without colliding with LLM/tool spans. - `std/lifecycle/on_budget` exports three named callback strategies for the `OnBudgetThreshold` event. Each takes `(harness, budget_state)` and composes with the combinators above: - `terminate(harness, budget_state)` — emits a `budget_exceeded` audit, then throws `{category: "budget_exceeded", kind: "terminal", reason: "on_budget_terminate", strategy: "terminate", budget_state, message}` so the surrounding agent loop / pipeline unwinds. - `graceful_exit(harness, budget_state)` — emits a `budget_graceful_exit` audit; returns a deterministic envelope `{status: "budget_exhausted", strategy: "graceful_exit", reason: "on_budget_graceful_exit", budget_state, message}` instead of throwing. - `warn_and_continue(harness, budget_state)` — emits a `budget_warn_and_continue` audit, injects a 1-turn `budget_warning` system_reminder via `tool_hooks_inject_reminder`, and returns the original `budget_state` unchanged (passthrough for combinator chains). - `OnBudget()` returns the namespace dict so callers can use dotted access (`OnBudget.terminate`, etc.) after a single import. - `harness.unsettled_state()` returns a stable dict with `suspended_subagents`, `queued_triggers`, `partial_handoffs`, `in_flight_llm_calls`, and `pool_pending_tasks` lists. `harness.is_empty(state?)`, `harness.counts(state?)`, and `harness.summary(state?)` summarize that shape; `std/lifecycle` exports equivalent `unsettled_state(harness)`, `is_empty(state)`, `counts(state)`, and `summary(state)` helpers. Suspended subagents, partial handoffs, in-flight LLM calls, and pool pending tasks are populated from live VM registries, while queued triggers are reconstructed from the active trigger inbox and worker-queue event-log records. - Lifecycle action methods exist on the root harness for drain callbacks: `resume_subagent`, `cancel_subagent`, `handoff_to`, `acknowledge_trigger`, `defer_trigger`, `acknowledge_handoff`, `wait_for_any_settlement`, `emit_audit`, `finalize`, `spawn_settlement_agent`, and `current_pipeline_id`. `resume_subagent` and `cancel_subagent` delegate to host worker primitives; trigger acknowledgements use existing dispatcher cancel requests or worker-queue ack records; handoff acknowledgement removes the partial envelope; `emit_audit`, `handoff_to`, and `finalize` record into the per-pipeline-run lifecycle registries. `spawn_settlement_agent` remains the P-03 handoff point and returns a typed `{status: "unsupported", method, reason}` receipt until harn#1856 lands. - `harness.obs.pipeline_lifecycle_audit_log_take()` and `harness.obs.pipeline_lifecycle_audit_log_snapshot()` drain or peek at the per-pipeline-run audit log that `harness.emit_audit` writes. Each entry is `{seq, kind, payload, pipeline_id}`. `std/lifecycle` re-exports them as `lifecycle_audit_log_take` / `lifecycle_audit_log_snapshot`. ### Pipeline lifecycle: drain, on_finish, composable handlers Every lifecycle boundary in a Harn pipeline is a callback. Presets in `std/lifecycle` cover the common dispositions; combinators in `std/lifecycle/combinators` compose them; the harness exposes a single read-side surface (`unsettled_state`) and a dozen write-side actions for custom drain logic. Full prose: `docs/src/pipeline-lifecycle.md`. Cookbook recipes: `docs/src/cookbooks/lifecycle.md`. Per-preset stdlib reference: `docs/src/stdlib/lifecycle.md`. `harness.agent.pipeline_on_finish(callback)` registers a `fn(harness, return_value)` that runs between `pre_finish` and `post_finish` on the main VM. The return value replaces the pipeline's return value. Registration is last-write-wins and one-shot per run — a stale registration cannot leak. `OnFinish.*` presets (`std/lifecycle`): | Preset | Behavior | |---|---| | `on_finish_abandon` | Today's default. Emits `pipeline_abandoned_unsettled` when work survives. | | `on_finish_drain` | Recommended. Walks unsettled buckets via `harness.spawn_settlement_agent` in canonical order with per-item `drain_decision` audits. | | `on_finish_block_until_settled(timeout, fallback?)` | Polls `harness.wait_for_any_settlement` until drained or timeout, then delegates to `fallback` (default `on_finish_drain`). | | `on_finish_handoff_to(target_pipeline, options?)` | Packages unsettled state into a typed envelope and hands it to `target_pipeline` via `harness.handoff_to`. | ```harn,ignore import { on_finish_drain, on_finish_handoff_to, on_finish_block_until_settled, } from "std/lifecycle" fn main(harness: Harness) { harness.agent.pipeline_on_finish(on_finish_drain) harness.agent.pipeline_on_finish(on_finish_handoff_to("nightly-drain")) harness.agent.pipeline_on_finish( on_finish_block_until_settled(30s, on_finish_drain), ) } ``` `Combinator.*` factories (`std/lifecycle/combinators`) wrap any `(harness, return_value) -> return_value` callback (presets, hook handlers, `resume_by`, custom drain). All six are pure factories: | Combinator | Behavior | |---|---| | `compose([cb, ...])` | Sequential; threads each return value into the next. | | `first_available([cb, ...])` | Returns the first non-nil result. | | `with_telemetry(cb, span_name?)` | OTel `SpanKind::FnCall` + paired `{span_name}_started` / `_completed` / `_errored` audits. | | `with_timeout(cb, ms)` | Soft deadline; on overrun returns `{__timed_out, timeout_ms, elapsed_ms, return_value}` and emits `lifecycle_callback_timed_out`. | | `if_unsettled(cb)` | Only when `harness.unsettled_state()` is non-empty (one snapshot per call). | | `when(predicate, cb)` | Only when `predicate(harness, return_value)` is truthy. | ```harn,ignore import { on_finish_drain } from "std/lifecycle" import { compose, if_unsettled, with_telemetry, with_timeout, } from "std/lifecycle/combinators" fn main(harness: Harness) { harness.agent.pipeline_on_finish( if_unsettled( with_telemetry(with_timeout(on_finish_drain, 30000), "drain") ), ) } ``` The drain step is the per-item disposition loop behind `on_finish_drain`. The settlement-agent walks buckets in the documented order — suspended subagents → queued triggers → partial handoffs → in-flight LLM calls → pool pending — applying a default disposition (cancel / acknowledge / defer) per item and firing `OnDrainDecision` for each. The constrained drain tool surface is exposed when `__host_settlement_agent_active()` returns true. The loop is bounded by a per-call budget (default 5, hard-cap 20); on exhaustion a `drain_unsettled_remaining` audit captures the remainder. `harness.acknowledge_trigger` and `acknowledge_handoff` reject out-of-order calls with `HARN-DRN-001`. `OnBudget.*` strategies (`std/lifecycle/on_budget`) for the `OnBudgetThreshold` event, all `(harness, budget_state) -> result`: | Strategy | Behavior | |---|---| | `OnBudget.terminate` | Emits `budget_exceeded`; throws structured terminal error. | | `OnBudget.graceful_exit` | Emits `budget_graceful_exit`; returns deterministic exit envelope (no throw). | | `OnBudget.warn_and_continue` | Emits `budget_warn_and_continue`; injects a 1-turn `budget_warning` reminder; passes `budget_state` through. | Hook-event table for lifecycle gates (`register_session_hook`): | Event | Allow | Deny / Block | Modify | Reminder | |---|---|---|---|---| | `pre_finish` | yes | INVALID — use `OnFinish.block_until_settled` | n/a | inject only | | `post_finish` | yes | n/a (advisory) | n/a | inject only | | `on_unsettled_detected` | yes | block finish until settled | amend unsettled payload | inject only | | `pre_suspend` | yes | cancel suspend | rewrite reason | inject only | | `post_suspend` | yes | n/a | n/a | inject only | | `pre_resume` | yes | stay suspended | amend resume input | inject only | | `post_resume` | yes | n/a | n/a | inject only | | `pre_drain` | yes | skip drain | amend drain spec | inject only | | `post_drain` | yes | n/a | n/a | inject only | | `on_drain_decision` | yes | block tool call | rewrite tool call | inject only | Common patterns: ```harn,ignore import { on_finish_block_until_settled, on_finish_drain, on_finish_handoff_to, } from "std/lifecycle" fn main(harness: Harness) { // Hand unsettled to a nightly settlement pipeline. harness.agent.pipeline_on_finish( on_finish_handoff_to("nightly-settle"), ) // Drain with custom audit per disposition. harness.agent.register_session_hook("on_drain_decision", { event -> external_audit_push(event) return nil }) harness.agent.pipeline_on_finish(on_finish_drain) // Abort cleanly on unsettled state (no silent loss). harness.agent.pipeline_on_finish( on_finish_block_until_settled(60s, { harness, rv -> harness.emit_audit( "aborted_with_unsettled", {state: harness.unsettled_state()}, ) throw {category: "unsettled_at_finish", reason: "timeout"} }), ) } ``` Cross-ref: the suspend/resume primitive that drives `suspended_subagents` is the agent-lifecycle entry above (harn#1836). ### Agent pools `std/lifecycle/pool` provides named, concurrency-bounded worker pools. One named pool, one shared concurrency budget across every submitter. Use a pool when many independent call sites need to share a cap; use `parallel each ... with { max_concurrent: N }` when one call site needs a local cap. ```harn import { Backpressure, fair_round_robin, pool_create, pool_wait, } from "std/lifecycle/pool" const backpressure = Backpressure() const pool = pool_create(harness.agent, { name: "reviews", max_concurrent: 2, queue: fair_round_robin("tenant_id"), backpressure: backpressure.queue(100, "fail_submitter"), }) const handle = pool.submit( { -> agent_loop(harness, "review", "You are a reviewer.") }, { tenant_id: "acme", priority: 10, idempotency_key: "review-pr-1984", }) const result = pool_wait(harness.agent, handle) ``` Pick-the-right-primitive: | Need | Use | |---|---| | Bound concurrency at one call site | `parallel each ... with { max_concurrent }` | | Bound concurrency across many call sites in one VM session | Pool, `scope: "session"` (default) | | Bound across pipeline runs that survive restart | Pool, `scope: "pipeline"` (state in `.harn/pools/`) | | Bound across tenants/orgs | Pool, `scope: "tenant"` / `"org"` (host-managed by the embedding runtime) | | Route trigger events through a shared budget | `SpawnToPool` handler (see below) | Queue strategies (factories from `std/lifecycle/pool`): | Factory | Behavior | |---|---| | `fifo()` | Oldest queued first. | | `priority()` | Highest submit `priority` first, FIFO tiebreak. Default. | | `lifo()` | Newest queued first. | | `fair_round_robin(key = "key")` | Partition by `options.` on submit; round-robin across distinct partitions. Missing field shares a default partition. | Backpressure descriptors are `backpressure.queue(max_depth, on_full)`, `backpressure.fail_fast`, and `backpressure.ring_buffer(capacity)`. `on_full` accepts `block_submitter`, `drop_oldest`, `drop_newest`, or `fail_submitter`. Drop policies return rejected task handles (`status: "rejected"`, `rejection_reason`, `rejection_policy`) and emit `pool_drop` audits on `lifecycle.pool.audit`; fail paths raise `HARN-POL-001` (`fail_submitter`) or `HARN-POL-002` (`fail_fast`). Submit options: | Option | Notes | |---|---| | `priority` | int; higher dequeues sooner under `priority()`. | | `key` | string; generic fairness key for `fair_round_robin("key")`. | | custom key (e.g. `tenant_id`) | When using `fair_round_robin("tenant_id")`, pass the partition under that name. | | `idempotency_key` | Two submits with the same `(pool_id, key)` return the *same* task handle. Pipeline-scope pools persist the index so resubmit after restart short-circuits. | `pool.submit` returns a task handle (`_type: "pool_task"`) with `id`, `pool`, `pool_id`, `status`, `submitted_at`, `key`, `priority`, and (when terminal) `result` / `error` / `rejection_reason`. `pool_wait(harness.agent, handle)` (or a list of handles) blocks until terminal and returns the final snapshot. `wait_agent(handle)` from `std/agent/workers` recognises pool task handles transparently. Inspection: `pool.size()`, `pool.snapshot()` (full dict with `active`, `queued`, `completed`, `failed`, `rejected`, `blocked_submitters`, `total`, selected `queue` / `backpressure`, per-task list, original `config`), `pool_get(harness.agent, name_or_id)`, `pool_list(harness.agent)`. Pipeline-scope pools also reload in-flight tasks past `stale_after_ms` as re-enqueued attempts; `pool_simulate_restart(harness.agent)` drops the in-process registry for conformance tests. Route trigger events through a pool with the `SpawnToPool` handler variant from `std/triggers` (one trigger, one drain rate): ```harn,ignore import { SpawnToPool } from "std/triggers" fn main(harness: Harness) { harness.runtime.trigger_register({ id: "webhook-router", kind: "channel.emit", provider: "channel", match: {events: ["channel:webhook.received"]}, handler: SpawnToPool({ pool: "webhook-work", key_from: "provider_payload.payload.source", priority_from: "provider_payload.payload.urgency", task_factory: { event -> { -> handle_webhook(event) } }, }), }) } ``` `key_from` / `priority_from` are dotted JSON paths into the trigger event. Missing paths fall back to the default partition and `0` priority. The dispatcher records the resulting pool task id on the match receipt so replay verifies the same event mapped to the same task across runs. Full prose: `docs/src/agent-pools.md`. Cookbook recipes (webhook rate-limit, GPU pool, cross-customer fairness, burst absorber): `docs/src/cookbooks/pools.md`. Stdlib API reference: `docs/src/stdlib/lifecycle-pool.md`. ```harn register_session_hook("user_prompt_submit", { event -> if to_string(event?.prompt ?? "").contains("secret") { return {block: true, reason: "policy violation"} } return nil }) register_session_hook("file_edited", { event -> harness.stdio.log("edit: " + to_string(event?.path ?? "")) return nil }) ``` Successful standard filesystem mutations queue automatically; hooks fire at the next agent-loop turn boundary. Call `harness.agent.notify_file_edited(path, metadata?)` to explicitly emit one. For background context refresh/librarian jobs, import `std/context/maintenance` and return `context_maintenance_queue_receipt(...)` from the hook instead of doing slow work inline. ## Stdlib LLM helpers (`std/llm/*`) Nine opinionated modules wrap common LLM patterns: - `std/llm/handlers` — composable middleware: `default_llm_caller`, `with_retry`, `with_fallback`, `with_shadow`, `with_prompt_rewrite`, `with_logging`, `with_budget`, `with_cache`, `with_circuit_breaker`, `with_repair`, `with_coerce`, `with_timeout`, `with_routing`, `compose([...])`. - `std/llm/tool_middleware` — composable middleware around tool execution (parallel to handlers, but for tools): `default_tool_caller`, `compose_tool_callers([...])`, `tools_use_middleware` (schema decorator), `tool_inject_param`, plus the bundled library (`with_required_reason`, `with_audit_log`, `with_consent`, `with_dry_run`, `with_redaction`, `with_idempotency`, `with_rate_limit`, `with_telemetry`, `with_summary`, `with_handoff_artifact`, `with_timeout`). - `std/llm/tool_binder` — experimental natural-language tool binder middleware (`with_natural_language_executor`). OFF by default; opt in via `compose_tool_callers`. Hands the planner-emitted intent + tool JSON Schema to a latency-budgeted binder LLM (Cerebras GPT-OSS-120B is the primary accuracy substrate) and replaces `tool_args` with the binder's structured output. Default `timeout_ms` is `500`; overruns drop the hop and pass through unchanged with `audit.binder.status = "timeout"`. Default `max_tokens` is `1024` so reasoning binders have room to emit structured JSON after their reasoning preamble. See the parent epic [#1696](https://github.com/burin-labs/harn/issues/1696) for the experimental contract. - `std/llm/ensemble` — multi-call quality strategies: `best_of_n`, `self_consistency`, `parallel_judge`, `debate`. Cites Wang 2022 (arxiv:2203.11171) and Du 2023 (arxiv:2305.14325). - `std/llm/refine` — `refine_prompt`, `refine_caller`. One-shot meta-prompt rewrite with a `DIFF:` summary trailer. - `std/llm/budget` — `estimate_text_tokens`, `context_window_for`, `recommend_max_output_tokens`, `budget_summary`, `fits_in_context`. - `std/llm/economics` — `pricing_for(provider?, model)`, `estimate_call_cost`, `estimate_session_cost`, `compare_model_costs`, `cache_break_even`, `volume_cost`, `format_usd`. Unknown pricing surfaces as `pricing_known: false` / `cost_usd: nil` rather than $0. Cached-read usage with no published `cache_read_per_mtok` tier is also unpriceable (`unpriced_reason: "cache_read_rate_unknown"`); it is never silently billed at the full input rate. Only providers explicitly configured to $0 (ollama, local, llamacpp, mlx, vllm, tgi) report cost=$0 with pricing_known=true. - `std/llm/defaults` — `pack_for(opts)` and convenience wrappers (`pack_chat`, `pack_agent`, `pack_refine`, `pack_judge`, `pack_summarize`, `pack_code`, `pack_json`). Resolves catalog defaults, task defaults, and the capability-owned reasoning policy without stdlib model/provider branches. - `std/llm/safe` — `safe_call`, `safe_field`, `dict_get_ci`, `with_case_insensitive_keys`, `structured_envelope_or_default`, `judge_payload`, `verdict_normalize`, `schema_retry_nudge_for`. - `std/llm/prompts` — `system_prelude`, `tool_use_prelude`, `structured_output_preface`. - `std/llm/catalog` — `model_info(selector)`, `execution_contract(selector)`, `resolved_options(opts)`, `named_model_ladder(name)`, `has_capability(model, cap)`, `family_of(model_id)`, `lineage_of(model_id)`, `complementary_reviewer(opts)`. `execution_contract` is the secret-free durable receipt for an effective model route; it omits arbitrary operator overlays. Harn-side names avoid shadowing the same-named builtins. Full reference: [`docs/src/stdlib/llm-handlers.md`](https://harnlang.com/stdlib/llm-handlers.html). ## Resilient LLM patterns `harness.llm.call` throws on transport / schema / budget failures. The thrown value is a dict with the same fields `harness.llm.call_safe` exposes under `r.error`, so scripts can dispatch on a canonical LLM error taxonomy without string-sniffing: ```harn try { const r = harness.llm.call(user_prompt, nil, opts) } catch (e) { // e is {kind, reason, category, message, status?, retry_after_ms?, // provider, model} if e.kind == "transient" && e.reason == "rate_limit" { harness.clock.sleep_ms(e.retry_after_ms ?? 1000) continue } throw e } ``` Three helpers flatten the common recovery boilerplate: ```harn // Non-throwing envelope: the ok/response/error shape eliminates the // try/guard/unwrap/?.data boilerplate at every callsite. const r = harness.llm.call_safe(user_prompt, nil, opts) if !r.ok { harness.stdio.log("llm call failed:", r.error.category, r.error.message) return nil } const data = r.response.data // When the call is a JSON-against-schema extraction, prefer // `harness.llm.call_structured` / `*_safe` instead: `.data` is // pre-unwrapped and the schema-validated-JSON options are forced // by default (no repeated `output: {schema, validation: "error"}` // or `schema_retries` boilerplate at each callsite). const verdict = harness.llm.call_structured( user_prompt, schema, {provider: "auto"}, ) // ...or non-throwing: const r = harness.llm.call_structured_safe( user_prompt, schema, {provider: "auto"}, ) if !r.ok { harness.stdio.log("structured call failed:", r.error.category); return nil } const data = r.data // Scoped permit acquisition + backoff for flaky providers. Retries on // rate_limit / overloaded / transient_network / timeout categories with // exponential backoff (capped at 30s). Composes with // HARN_RATE_LIMIT__RPM/_TPM and provider/model catalog // `rate_limits` fields. const r = harness.llm.with_rate_limit("openai", fn() { harness.llm.call(user_prompt, nil, {provider: "openai"}) }, {max_retries: 5, backoff_ms: 500}) ``` `error.category` (both on the thrown dict and on `r.error.category`) remains for compatibility and is one of the canonical `ErrorCategory` strings: `"rate_limit"`, `"timeout"`, `"overloaded"`, `"server_error"`, `"transient_network"`, `"schema_validation"`, `"auth"`, `"not_found"`, `"circuit_open"`, `"tool_error"`, `"tool_rejected"`, `"cancelled"`, `"generic"`. `retry_after_ms` is set when the provider surfaced a `Retry-After` hint (or `llm_mock` was told to); otherwise omitted. LLM provider failures also include `error.kind` and `error.reason`. `kind` is `"transient"` or `"terminal"`. Transient reasons are `"rate_limit"`, `"server_error"`, `"network_error"`, and `"timeout"`; terminal reasons are `"auth_failure"`, `"context_overflow"`, `"content_policy"`, `"invalid_request"`, `"invalid_response"`, `"model_unavailable"`, and `"unknown"`. `invalid_response` identifies a deterministic provider decode or grammar-format failure. `harness.llm.call` and `agent_loop` spend their retry budget only when `kind == "transient"`. Pair with `harness.llm.mock_enqueue({error: {category, message, retry_after_ms?}})` or the provider-envelope form `harness.llm.mock_enqueue({error: {status, kind, reason?, message?, retry_after_ms?}})` to write deterministic tests for either helper's error path: ```harn harness.llm.mock_enqueue({ error: {category: "rate_limit", message: "429", retry_after_ms: 2500}, }) try { harness.llm.call("hi", nil, {provider: "mock"}) } catch (e) { assert(e.kind == "transient") assert(e.reason == "rate_limit") assert(e.category == "rate_limit") assert(e.retry_after_ms == 2500) } harness.llm.mock_enqueue({ error: {category: "rate_limit", message: "429"} }) const r = harness.llm.call_safe("hi", nil, {provider: "mock"}) assert(!r.ok) assert(r.error.category == "rate_limit") harness.llm.mock_enqueue({ error: {status: 503, kind: "transient", reason: "upstream_unavailable"}, }) const recovered = harness.llm.call_safe("hi", nil, {provider: "mock"}) assert(!recovered.ok) assert(recovered.error.status == 503) assert(recovered.error.kind == "transient") assert(recovered.error.reason == "upstream_unavailable") ``` ## Composable LLM callers `agent_loop` accepts `llm_caller:` — a closure that owns the per-turn `harness.llm.call(...)` invocation. Wrap with middleware from `std/llm/handlers` to compose retry / fallback / shadow / logging / budget behavior without forking the loop: ```harn,ignore import { AgentSpec } from "std/agent/options" import {default_llm_caller} from "std/llm/caller" import {with_retry, with_fallback, compose} from "std/llm/handlers" const caller = compose([ with_retry({max_attempts: 4, backoff: "exponential"}), with_fallback, // pseudo: with_fallback expects a list of callers ])(default_llm_caller()) const resilient_opts: AgentSpec = { loop_until_done: true, llm_caller: caller, } agent_loop(harness, task, system, resilient_opts) ``` The caller signature is `fn(call) -> {ok, value | status, error?}` where `call = {prompt, system, opts, turn: {iteration, session_id, attempt}}`. **Off-by-one in retry semantics:** the removed `llm_retries: 3` historically meant 4 total attempts; `with_retry`'s `max_attempts: N` means N total attempts. To migrate `llm_retries: K`, pass `max_attempts: K + 1`. For role/env model resolution, use `agent_model_options` from `std/agent/options` (the pre-0.10 `std/agent/stack` bundle was removed): ```harn,ignore import {agent_model_options} from "std/agent/options" const route = agent_model_options({ role: "planner", defaults: { provider: "anthropic", model: "claude-sonnet-5", task: "agent", }, }) const caller = with_retry(default_llm_caller(), {max_attempts: 3}) agent_loop( harness, task, system, route.options + {loop_until_done: true, llm_caller: caller}, ) ``` `agent_model_options` resolves explicit options, role env overrides such as `HARN_AGENT_PLANNER_MODEL`, shared `HARN_AGENT_*` / `HARN_LLM_*` settings, and defaults; it then applies model-aware packs and strips unsupported provider-specific knobs before the request reaches the wire. **Persona-shaped chain (cost moat substrate):** the canonical compose for a durable persona is cheap-by-default with frontier escalation, deterministic budget enforcement, and receipt-grade structured logs. `with_routing` is a **base** caller (it picks cheap vs. frontier); budget and logging compose over it. ```harn,ignore const router = with_routing({ default: cheap, // fast inexpensive model routes: [{name: "frontier", when: { call -> call?.opts?.escalate ?? false }, // longer retries + fallback caller: strong}], }) const persona_caller = compose([ with_logging({sink: receipts_sink}), with_budget({max_total_tokens: 250000, max_calls: 200}), ])(router) ``` Full reference: [`docs/src/stdlib/llm-handlers.md`](https://harnlang.com/stdlib/llm-handlers.html). ## First-class routing policy `harness.llm.routing_policy({...})` builds a reusable handle that drives a chain of providers with failover, latency-aware racing, and per-call / session budget caps. Pipe it through `harness.llm.call(... routing: policy ...)` to replace ad-hoc `with_routing` + `with_retry` + `with_fallback` compositions with a single typed primitive. ```harn,ignore fn main(harness: Harness) { const policy = harness.llm.routing_policy({ chain: [ {provider: "anthropic", model: "claude-opus-4-20250514"}, {provider: "openai", model: "gpt-5.4-mini"}, {provider: "ollama", model: "llama4:70b"}, // local fallback ], failover: { on_status: [429, 500, 502, 503, 504], on_timeout_ms: 30000, on_error_kinds: ["rate_limit", "schema_validation"], max_attempts: 3, }, latency: { target_p95_ms: 8000, // race backup after 5s race_after_ms: 5000, }, budget: { // hard ceiling per call per_call_usd: 0.50, // session-wide cap session_usd: 5.00, // or "skip" | "warn" on_exceed: "abort", }, // optional dispatch label observe: {emit_event: "billing.routing_decision"}, // optional verifier chain escalate_on: [ // parse the candidate as Harn {kind: "typecheck"}, { kind: "lint", forbidden_patterns: ["TODO", "unwrap\\("], on_fail: "refine", }, { kind: "test_run", command: ["cargo", "test", "--quiet"], timeout_secs: 60, }, ], // optional, default 1 max_refines_per_link: 1, }) const result = harness.llm.call( "Summarize this PR.", nil, {routing: policy}, ) // result.routing = {policy, selected, session_cost_usd, // attempts: [{provider, model, status, duration_ms, cost_usd, // error?, verifier_outcome?, verifier_signals?}]} } ``` Semantics: - **Failover**: each link is tried in order; an attempt advances when the error matches `on_status` (HTTP code), `on_error_kinds` (category short-name — `rate_limit`, `timeout`, `transient_network`, `server_error`, `schema_validation`, `auth`, `overloaded`, `tool_error`, `tool_rejected`, `egress_blocked`, `cancelled`, `not_found`, `circuit_open`, `budget_exceeded`, `generic`), or the built-in transient defaults (429 / 5xx, rate-limit, overloaded, timeout, transient_network, server_error). Non-failover errors stop the chain immediately. - **Racing**: when `race_after_ms` is set and a second link is available, the executor kicks off the next link in parallel after that delay; the loser is cancelled and recorded with `status: "race_lost"`. - **Budgets**: `per_call_usd` and `session_usd` reuse the catalog pricing in `std/llm/economics`. `on_exceed: "abort"` throws the standard budget-exceeded error, `"skip"` advances to the next chain link, `"warn"` emits an event and proceeds. - **Verifier escalation** (`escalate_on`): each verifier inspects the successful candidate's text. The first non-`accept` signal drives the next decision — `refine` re-runs the **same** link with a tightened prompt (up to `max_refines_per_link` retries; nudge text includes the verifier's reason), `escalate` advances to the next link. If the verifier rejects the last link and no frontier remains, the rejected candidate is returned anyway with `verifier_outcome: "escalate"` on the trace — verifiers gate routing decisions, not correctness. Each `escalate_on` entry is a dict with `kind: "typecheck" | "lint" | "test_run"` plus kind-specific options: - `typecheck`: parses the candidate as Harn (extracting ```harn / ``` fenced blocks by default via `extract_fenced: true`); parse or type errors trigger `on_fail` (default `escalate`). - `lint`: regex-based pattern check with `forbidden_patterns: [...]`, `required_patterns: [...]`, and `max_line_length: N`; any rule violation triggers `on_fail` (default `refine`). - `test_run`: spawns `command: [...]` with the candidate text on stdin (toggle with `pass_via_stdin: false`); non-zero exit triggers `on_fail` (default `escalate`). `timeout_secs` defaults to 30. **Authority lives in the script that builds the policy** — `test_run` shells out under the calling process's permissions. - **Tape events**: `.decision`, `.attempt`, `.race_started`, `.race_won`, `.race_lost`, `.budget_exceeded`, `.verifier_signal`, `.exhausted` (default `dispatch = llm.routing`; override via `observe.emit_event`). - **Replay**: the routing decision rides on the result envelope's `routing_decision` block, so transcripts and replay re-attribute each attempt to the same chain link without re-resolving. The policy is a reusable handle: build it once, pass it to many `harness.llm.call` invocations. ## Model ladders (`models:` / `ladder:`) When you just want a **cheap-first, escalate-on-failure** ladder without hand-building a `routing_policy`, pass `models:` (or `ladder:`) directly to `harness.llm.call`. A ladder is sugar that lowers onto the same routing chain, so it inherits the exact failover classifier, the `result.routing` trace block, and the schema-retry composition described above. ```harn,ignore // Inline ladder: ordered steps, cheapest first. const result = harness.llm.call("Summarize this PR.", nil, { models: [ // string sugar for {model: "haiku"} "haiku", {model: "sonnet", label: "mid"}, {model: "opus", provider: "anthropic", label: "frontier", // per-step generation overrides options: {max_tokens: 4096}}, ], }) // Named ladder resolved from the catalog ([model_ladders.]). const result = harness.llm.call( "Summarize this PR.", nil, {ladder: "frugal"}, ) // Inspect a named ladder without starting a call. const routes = harness.llm.model_ladder("frugal").steps ``` Each step is `{model, provider?, options?, label?}`; a bare string is sugar for `{model: "..."}` (a `"provider:model"` string sets both when the prefix is a registered provider). `provider` is inferred from the model id (or the call's base provider) when omitted, and model aliases resolve normally. Per-step `options` accept the scalar generation/transport knobs `temperature`, `max_tokens`, `top_p`, `top_k`, `seed`, `frequency_penalty`, `presence_penalty`, `timeout_ms`, and `speed`; structural options (tools, output, thinking) belong on the base call and an unsupported per-step key is rejected up front. Composition rules: - **Advance only on transport-class failures.** The ladder moves to the next step exactly when the routing failover classifier fires (connection/timeout/429/5xx/throttled-empty/`circuit_open`). It never advances on a schema-validation failure (that is the model's answer, not a transport fault) or a 4xx policy error (`auth`, content policy) — those stop the ladder and surface the error. - **One attempt per rung by default.** The ladder itself does a single attempt per step. Wrap the whole call with `with_retry` (the caller-seam middleware) if you want per-attempt transport retries around the entire ladder pass. - **Schema retries re-ask the same rung.** With an `output` schema or `harness.llm.call_structured*`, a schema failure re-asks the SAME step's model via the existing `schema_retries` mechanism — it does not escalate the ladder. - **`models:` + `ladder:`, `models:`/`ladder:` + explicit `model:`/`provider:`, and `models:`/`ladder:` + an explicit `routing:` policy are all errors** — the ladder already declares every rung, so any second model-selection surface is ambiguous. - **Observability.** Each step advance emits an `llm_models_advance` trace event (`agent_trace()`) with `{from_index, from_model, to_model, category}`, and the winning rung is surfaced on the existing `result.routing` block (`policy`, `attempts[]`, `selected`). `ladder:` names a catalog ladder declared under `[model_ladders.]` (for example the built-in `frugal` haiku → sonnet → opus escalation), keeping the step list data-driven and shared across surfaces instead of hand-rolled at each call site. `harness.llm.model_ladder(name)` returns the same catalog row for stdlib policy and tooling that need to inspect its ordered steps. ## Composable tool middleware `agent_loop` also accepts `tool_caller:` — the parallel seam for tool **execution**. While `llm_caller` wraps the model call, `tool_caller` wraps every tool dispatch. Combined with the `tools_use_middleware` **schema-time** decorator, you get two composable seams that let you: - force every tool call to provide a `reason` (or any other extra arg) that the harness reasons about, not the tool — and surface that reason as a user-facing chip ("Searched codebase to find rate limiter") - add audit logs / consent prompts / dry-run preview / redaction / rate-limit / telemetry to all tool calls without touching individual tool definitions ```harn,ignore import { AgentSpec } from "std/agent/options" import { with_required_reason, with_audit_log, with_consent, compose_tool_callers, tools_use_middleware, } from "std/llm/tool_middleware" const mw = with_required_reason({schema_required: false}) const registry = tools_use_middleware(my_registry, mw.schema_transform) const caller = compose_tool_callers([ with_audit_log({sink: "both", redact: ["token", "content"]}), with_consent({ call -> ask_human(call) }), mw.caller, ]) const audited_opts: AgentSpec = {tools: registry, tool_caller: caller} agent_loop(harness, task, system, audited_opts) ``` `with_audit_log` emits typed `ToolCallReceipt` records with rationale, status, timing, model/provider, and hashes instead of raw args/results. Use `sink: "local"` for `.harn/receipts/.jsonl`, `sink: "cloud"` for host bridge mirroring, or `sink: "both"` for both paths. The caller signature is `fn(call, next) -> result_dict` where `call = {tool_name, tool_args, call_id, declared_executor, schema, description, turn}` and `next(call)` runs the default dispatch (with any envelope mutations the layer applied — typically `tool_args` rewrites). Short-circuit by returning a result dict without calling `next`. `call.turn.tool_call_index` is the call's position in the turn's emitted batch — useful when middleware fans out (`max_concurrent_tools > 1`) and needs to reorder completions back to source order. For multi-tool turns, set `max_concurrent_tools: N` on `agent_loop` to fan out dispatch across siblings inside one independent effect phase (capped at N). Tool annotations classify calls as observation, mutation, process/verification, terminal, or provider-native. A response that crosses a local effect boundary selects the earliest semantic phase rather than the first emitted phase, executes every call in that phase wherever it sits in the batch, and returns typed deferred results for the rest, forcing the next inference to observe those results before re-proposing the deferred calls. Each proposal emits a `tool_batch_disposition` event with phase, disposition, re-proposal, and monotonic timing fields. Middleware-backed dispatch uses `parallel settle`; the host-batch path uses the same cap. Each middleware sibling invokes its own caller chain in a fresh scope, so `audit.layers` histories don't cross-talk. Results inject in source order regardless of completion order so text tool-call parsers keep working. `with_audit_log` receipts carry an `emit_order` field equal to `turn.tool_call_index` so consumers can re-sort to source order if they store events in completion order. Set `prefetch_next_turn: true` to let the next planner call begin after tool results are in the transcript while local/custom audit receipt sinks finish in the background; the loop drains those flushes before returning. Middleware-attached metadata rides on `result.audit` (free-form dict aligned with A2A `metadata` / ACP `kind` / OpenAI `summary_text` / OTel `gen_ai.tool.description` conventions). Each call also emits a `tool_call_audit` AgentEvent so live ACP/A2A consumers can render chips alongside the standard `tool_call_update` stream. Full reference: [`docs/src/stdlib/tool-middleware.md`](https://harnlang.com/stdlib/tool-middleware.html). ### Catalogue-driven `run_command` hooks `tool_rule`, `catalogue`, and `tool_hooks_registry` (TH-01) declare a versionable corpus of "command faux-pas" rules — rewrite-able shell mistakes (`find . -name`, `cargo build` without `--target-dir`, `git push --force`, etc.) that any agent's `run_command` handler can filter through. `preset_run_command(...)` (TH-02) is the shipped wrapper that turns a registry into a tool handler. ```harn import { preset_run_command, tool_hooks_mode_rewrite_with_audit, } from "std/tool_hooks" const rust_cat = catalogue({ id: "harn-canon/rust", stack: "rust", rules: [ tool_rule({ id: "rust.cargo.target_dir", pattern: "^cargo (build|test)\\b", applies_to: ["rust"], severity: "warning", explanation: "use --target-dir to avoid lockfile thrash", rewrite: { command, _context -> command + " --target-dir target-shared" }, }), ], }) const registry = tool_hooks_register(tool_hooks_registry(), rust_cat) const run_command = preset_run_command({ stacks: ["rust"], registry: registry, // matched before the registry custom_rules: [], mode: tool_hooks_mode_rewrite_with_audit, // default // underlying executor inner: { args -> harness.process.shell(args.command) }, }) agent_loop(harness, message, nil, { tools: {tools: [{name: "run_command", handler: run_command}]}, }) ``` - `stacks` opts catalogues in via `tool_hooks_filter` (catalogues with no `stack` field are universal; per-rule `applies_to` filters further). - `custom_rules` are matched before the registry so harness authors can unconditionally override registered behavior. - Three shipped modes cover the epic's v1 contract: `tool_hooks_mode_rewrite_with_audit` (rewrite + run inner), `tool_hooks_mode_deny_with_explanation` (refuse to dispatch), and `tool_hooks_mode_passthrough_only_audit` (run inner unchanged, tag the result). All three return the same envelope shape so audit consumers can render them uniformly: `{action, command, original_command, rule_id, catalogue_id, severity, explanation, references, result?}`. - Side effects (TH-03 #1896): each shipped mode records a `tool_rewrite` / `tool_denied` / `tool_rule_warning` lifecycle audit entry observable via `lifecycle_audit_log_take()`. The rewrite mode also queues a one-turn `tool_rewritten` system reminder via `tool_hooks_inject_reminder(...)` so the next agent turn sees the corrected command shape. When no agent session is active (headless pipelines, unit tests) the reminder still produces a `tool_hooks.reminder_injected` audit entry so conformance can verify the side effect either way. The underlying primitives `tool_hooks_emit_audit(kind, payload)` and `tool_hooks_inject_reminder({tags, body, ttl_turns, ...})` are exported for custom mode callbacks that want the same audit/reminder plumbing. - Omit `inner` to get decision envelopes without execution — useful for previewing rewrites or testing rule coverage. The audit + reminder side effects still fire in preview mode. - Catalogue auto-seed (TH-04 #1897): omit `registry` and the wrapper builds one from `stacks` via `tool_hooks_seed_registry(stacks)`. The universal catalogue (`git push --force main`, `rm -rf` against `/`, `~`, `..`, `$HOME`, `*`) is always included; per-stack catalogues ship for `rust`, `python`, `typescript` (aliased `ts`), `swift`, `sql`, and `harn`. Unknown stacks are silently skipped so callers opting into a future name don't break. - Optional LLM classifier (TH-05 #1898): pass `llm_classifier: {model, threshold?, meta_prompt?, provider?, cache?, llm_options?}` to consult a small model on any command that didn't hit a deterministic rule. Verdicts at or above `threshold` (default 0.8) dispatch via the mode the verdict implies (`rewrite` → `tool_hooks_mode_rewrite_with_audit`, `deny` → `tool_hooks_mode_deny_with_explanation`); lower confidence or `allow` falls through to `inner` so the loop stays usable when the model is unsure. Every call emits a `tool_hook_classifier_verdict` audit (kind, confidence, scope, cache hit/miss, action) regardless of outcome. Cache TTL accepts `cache.ttl_ms` (preferred for tests) or `cache.ttl_seconds`. The classifier sends the raw command + meta prompt to the model, so redact secrets the same way `run_command` already requires; transport errors degrade gracefully to passthrough. Full reference: [`docs/src/tool-hooks.md`](https://harnlang.com/tool-hooks.html). Recipes per stack: [`docs/src/cookbooks/tool-hooks.md`](https://harnlang.com/cookbooks/tool-hooks.html). Contributing rules: [`docs/src/contributing/preset-hooks.md`](https://harnlang.com/contributing/preset-hooks.html). ## Cancellation `harness.llm.call` and `agent_loop` cooperate with the VM's cancellation token, which the host raises on Ctrl-C, `cancel(task)` inside a Harn program, or an ACP `session/cancel` request: - **Mid-`harness.llm.call`**: the in-flight HTTP request is dropped (best-effort) and the call returns a thrown `VmError::Thrown(cancelled)` that bubbles out of the enclosing pipeline. Non-throwing callers can use `harness.llm.call_safe` to catch it as `{ok: false, error.category: "cancelled"}`. - **Mid-tool-call inside `agent_loop`**: the tool's async handler sees the same cancellation token; async builtins that opted in (`harness.llm.call`, `http_*`, `sleep`, …) short-circuit immediately. The loop finalizes the transcript with the partial turn and exits with `status: "cancelled"`. - **Between turns in `agent_loop`**: the next iteration never starts; the loop returns with its current iteration count, the accumulated transcript, and `status: "cancelled"`. Persistent sessions remain usable — re-invoke `agent_loop` with the same `session_id` to resume. `done_sentinel`, `max_iterations`, and `token_budget` each produce their own non-cancellation statuses; the cancellation path is specifically for external interruption. ## Rate limiting Per-provider and per-model rate limiting is built in: - Set `rate_limits = { rpm = 600, tpm = 1000000 }` in the provider or model entry in `providers.toml` / `harn.toml`. - Or `HARN_RATE_LIMIT_=600` env var (e.g. `HARN_RATE_LIMIT_TOGETHER=600`, `HARN_RATE_LIMIT_LOCAL=60`) for legacy provider RPM. Env overrides config. - Or richer env overrides such as `HARN_RATE_LIMIT_MYPROVIDER_RPM=1000` and `HARN_RATE_LIMIT_MYPROVIDER_TPM=1000000`. - Or `harness.llm.rate_limit("provider", {rpm: 600, tpm: 1000000})` at runtime. - Wrap individual call sites in `harness.llm.with_rate_limit(provider, fn, opts?)` to acquire a permit and auto-retry retryable failures. RPM/TPM shape sustained throughput; route `concurrency` and `max_concurrent` cap simultaneous in-flight work. RPM/TPM buckets are durable across Harn processes by default, using SQLite under Harn's runtime state root. Set `HARN_LLM_RATE_LIMIT_STATE_PATH` only to force an explicit shared path for an eval fleet, and set `HARN_LLM_RATE_LIMIT_DURABLE=0` only for constrained tests or embeddings. Use throughput and concurrency limits together when batching LLM calls at scale. ## Cache (`std/cache`) Content-addressed cache with three backends and a composable wrapper: ```harn import { mem_cache, fs_cache, sqlite_cache, with_cache } from "std/cache" const store = sqlite_cache(state_path("evals.sqlite"), {ttl: "1h"}) const answer = with_cache("key", { -> heavy_work() }, {store: store}) ``` - `mem_cache(opts?)` — thread-local LRU. Does not survive `harn run`. - `fs_cache(path, opts?)` — one JSON file per key under `//`. - `sqlite_cache(path, opts?)` — single sqlite file; many namespaces share it. Common options: `namespace`, `ttl` (string like `"10m"`) or `ttl_seconds`, `max_entries` (LRU bound). TTL honors the unified clock. `with_cache` is also a composable middleware in `std/llm/handlers` — drop it into `compose([...])` to deduplicate identical `(prompt, system, opts)` LLM calls. Tool-bearing calls bypass the cache by default. On a cache hit with `options.session_id` set, both the caller-wrapper and direct-call forms emit `cache_hit` + receipts (`model_calls_avoided`, `tokens_saved`, `latency_saved_ms`) on the agent event tape. The persona value ledger and crystallization receipts read these back. Full reference: [`docs/src/stdlib/cache.md`](https://harnlang.com/stdlib/cache.html). ## Per-harness net policy (`std/net_policy`) Attach an allowlist/denylist to one harness so its `harness.net.*` calls (added by E4.4 / #1769) get gated against your rules. Returns a new `Harness` value bound to the policy — the source handle stays unrestricted, so policies are scoped by where you rebind, not by mutating shared state. Tracked through harn#1913 / epic #1765. ```harn,ignore import { create, domain, domain_wildcard, cidr, host, } from "std/net_policy" const policy = create({ allow: [ domain("github.com"), domain_wildcard("*.github.com"), cidr("10.0.0.0/8"), host("api.anthropic.com", [443]), ], deny: [domain_wildcard("*.competitor.com")], default: "deny", // or "allow" on_violation: "error", // or "audit_only", "quarantine", or a // fn(req) returning one of those }) const restricted = harness.with_net_policy(policy) restricted.net.get("https://github.com/foo") // allowed // throws NetPolicyViolation restricted.net.get("https://example.test/blocked") // sticky after a quarantine deny restricted.is_quarantined() ``` - Rule precedence: `deny` rules fire first, then `allow`, then the `default`. A typed `NetPolicyViolation` (`{type, category, host, port, reason, outcome, matched_rule}`) is thrown for `error` / `quarantine` outcomes; `audit_only` still records the audit and lets the request through. - `on_violation` callbacks receive a `{method, url, host, port, reason, matched_rule}` envelope and must return one of `"error"`, `"audit_only"`, `"quarantine"` (returning a closure is rejected). - Every evaluation — including the `HARN_NET_POLICY_BYPASS=1` short-circuit — emits a `harness.net.policy.audit` event so the trust graph keeps an evidence trail. - The matcher is mock-aware: in mock mode the policy runs ahead of the canned-response lookup, so conformance fixtures exercise the same matcher path as production without touching the network. ## Authentication (OAuth) Harn ships a full OAuth stack: provider catalogue, five interchangeable storage backends, an authorization-code client with PKCE + transparent refresh, RFC 8628 device flow, RFC 7591 dynamic registration, and a token-redaction catalog. The five modules under `std/oauth/*` compose freely — pick a provider, pick a storage, then pick a grant. ```harn,ignore // github, slack, linear, notion, google, microsoft, atlassian, discord, // gitlab, bitbucket, github_enterprise, custom import { providers } from "std/oauth/providers" // memory, file, harn_cloud_*, custom import { memory } from "std/oauth/storage" // RFC 6749 + 7636 + 8693 + 9700 import { client, request, token, token_exchange } from "std/oauth/client" import { delegated_claims, token_type } from "std/oauth/token_exchange" // RFC 8628 (CI / headless) import { device_flow } from "std/oauth/device_flow" // HARN-OAU-001 catalog import { register_pattern } from "std/oauth/redaction" ``` Full reference + per-provider cookbook: [`docs/src/oauth.md`](https://harnlang.com/oauth.html). ## OAuth client (`std/oauth/client`) RFC 6749 authorization-code + RFC 7636 PKCE S256 + RFC 9700 transparent refresh. Build on top of `std/oauth/providers` and `std/oauth/storage`; the client knows nothing about which storage backend it's holding. ```harn import { providers } from "std/oauth/providers" import { memory } from "std/oauth/storage" import { client, exchange_code, request, start_authorization, token, token_exchange, } from "std/oauth/client" const cli = client( providers().github, { client_id: harness.env.get("GH_CLIENT_ID"), client_secret: harness.env.get("GH_CLIENT_SECRET"), scopes: ["read:user", "user:email"], redirect_uri: "http://127.0.0.1:8765/callback", storage: memory(), }, ) // One-shot authorization-code dance (host drives the browser): // pkce.url, pkce.state, pkce.code_verifier const pkce = start_authorization(cli) const token_set = exchange_code(cli, pkce, code, state) // Subsequent calls auto-refresh past 75% TTL: // -> string, valid access token const access = token(cli) // Or let the client own HTTP, with 1x retry on 401: const response = request(cli, "GET", "https://api.github.com/user") ``` - **PKCE always enforced.** `start_authorization` generates a fresh 64-byte CSPRNG verifier (base64url-no-pad → ~86 chars) and a SHA-256 S256 challenge. `code_challenge_method=S256` is hardcoded. - **State always enforced.** `exchange_code` raises on `state` mismatch before issuing the token request. - **Refresh transparency.** `token(cli)` re-reads storage every call and refreshes if the stored TokenSet is past 75% TTL or already expired. `request(cli, ...)` additionally retries once on 401 (forces a refresh between attempts). - **Audit log.** Every successful refresh / exchange emits `oauth.client.audit` with `token_refreshed` / `token_exchanged`. The payload carries presence flags + expiry timestamps; it never includes the new access or refresh token. - **Token exchange.** `token_exchange(cli, opts)` performs RFC 8693: `subject_token` and `subject_token_type` are required; `actor_token` with `actor_token_type` selects delegation, and actor absence selects impersonation. Provider support is data-gated by `std/oauth/token_exchange` capability rows; custom providers opt in with a `token_exchange` row. - **Concurrency.** Storage is the source of truth. Refreshes run under `storage.with_refresh_lock(...)`; waiters re-read inside that lock and reuse another worker's rotated access/refresh token instead of spending a second refresh grant. - **Storage key.** Defaults to `provider.id`; pass `storage_key` to fan out multiple installations of the same provider. Full reference: conformance fixtures at `conformance/tests/stdlib/oauth/oauth_client_*.harn`. ## OAuth token exchange (`std/oauth/token_exchange`) RFC 8693 constants, overlayable capability rows, and nested `act` claim helpers. ```harn,ignore import { token_exchange } from "std/oauth/client" import { delegated_claims, token_type } from "std/oauth/token_exchange" const delegated = token_exchange(cli, { subject_token: human_token, subject_token_type: token_type("access_token"), actor_token: agent_jwt, actor_token_type: token_type("jwt"), requested_token_type: token_type("access_token"), audience: "hr-service", scope: ["employee:read"], }) const claims = delegated_claims( {sub: "user@example.com"}, [ {sub: "https://service16.example.com"}, {sub: "https://service77.example.com"}, ], ) ``` Rows are data in `std/oauth/token_exchange_catalog`, not provider code. `token_exchange_catalog(overlays?)` returns rows keyed by authorization-server id, and `token_exchange_capability(provider_or_id, overlays?)` resolves the effective row. A provider record can carry `token_exchange: {...}` to override or add support for custom enterprise authorization servers. ## OAuth storage (`std/oauth/storage`) Token store for the OAuth client with five interchangeable backends. Every handle is a dict with three closures (`get`, `set`, `delete`) so the client doesn't know the difference between in-process memory, an encrypted file, a cloud platform, or a vault. ```harn import { memory, file, harn_cloud_session, harn_cloud_org, custom, } from "std/oauth/storage" const mem = memory() // ephemeral // AES-256-GCM const disk = file("/var/lib/harn/oauth.bin", harness.env.get("KEY")) const cloud = harn_cloud_session() // per-session const shared = harn_cloud_org() // org-scoped const vault = custom({get: my_get, set: my_set, delete: my_delete}) mem.set("github", {access_token: "abc"}, 3600) // -> TokenSet | nil const token = mem.get("github") mem.delete("github") ``` - `memory()` lives in a thread-local map and never escapes the VM. - `file(path, key)` writes a single AES-256-GCM envelope; the 32-byte AEAD key is derived via HKDF-SHA256 from `key`. Pass high-entropy bytes, not a user passphrase. - `harn_cloud_*()` route through the `oauth_storage` host capability (`cloud_get / cloud_set / cloud_delete` plus refresh-lock acquire/release); a cloud platform enforces RLS and backend-native refresh locking. - `custom({get, set, delete, with_refresh_lock?, id?})` validates that the required handlers are callables and then dispatches to them. Back the closures with a real store (HTTP, MCP, a cloud platform) rather than a captured local, so state survives restarts and is shared across sessions. Full reference: [`docs/src/stdlib/oauth-storage.md`](https://harnlang.com/stdlib/oauth-storage.html). ## OAuth device flow (`std/oauth/device_flow`) RFC 8628 device authorization grant for headless contexts (CI runners, daemons, IDE side panes). Persists the TokenSet into the same storage the authorization-code client reads from, so subsequent `OAuth.client(...)` calls see the same token without re-running the dance. ```harn,ignore import { device_flow } from "std/oauth/device_flow" import { providers } from "std/oauth/providers" import { file } from "std/oauth/storage" const token_set = device_flow(providers().github, { client_id: harness.env.get("GH_CLIENT_ID"), scopes: ["read:user", "repo"], storage: file( "/var/lib/harn/ci.bin", harness.env.get("HARN_OAUTH_KEY"), ), on_user_code: { user_code, verification_uri -> harness.stdio.log( "Open " + verification_uri + " and enter " + user_code ) }, }) ``` - **Polling honors the server's `interval`.** `authorization_pending` is treated as a soft retry; `slow_down` bumps the interval by 5s; `expired_token` and `access_denied` raise. - **Cancellable.** Each inter-poll sleep is a cancellable point. - **Time-mock-friendly.** Polling routes through `harness.clock.sleep_ms(ms)`, which honors `harness.testing.clock_set(...)` / `harness.testing.clock_advance(...)` for tests. - **Audit.** `oauth.device_flow.audit` `token_obtained` with presence flags only — never the `device_code` / `user_code` / access tokens. - **Provider support.** GitHub, Google, Microsoft, GitLab — the rest of the catalog has `device_code_url: nil` and will raise. ## OAuth dynamic registration (`std/oauth/dynamic_registration`) The server side of OAuth. Build RFC 7591 client metadata + RFC 8414 authorization-server metadata, validate incoming registrations, and issue `client_id` / `client_secret` pairs from an in-process store. Embedders (`harn serve`, a cloud platform, custom hosts) mount the well-known endpoints + the registration handler; this module does not host HTTP itself. ```harn,ignore import { providers } from "std/oauth/providers" import { authorization_server_metadata, client_metadata, dynamic_registration_store, register_client, validate_metadata, well_known_paths, well_known_response, } from "std/oauth/dynamic_registration" // {client_metadata, authorization_server_metadata, registration} const paths = well_known_paths() const oas = authorization_server_metadata( providers().github, {registration_endpoint: paths.registration}, ) // {status, content_type, headers, body} const envelope = well_known_response(oas) const store = dynamic_registration_store() const result = register_client( store, {redirect_uris: ["https://app.example/cb"]}, ) // result.client_id, result.client_secret // (returned ONCE), result.client_id_issued_at ``` - **Strict validation.** `redirect_uris` must be absolute `https://` or loopback `http://` per RFC 8252 §7.3; grant / response types and `token_endpoint_auth_method` are restricted to spec-blessed enums. Each validation error is prefixed `HARN-OAU-005:` for pattern matching. - **Secret returned once.** `register_client` includes `client_secret`; `get_client(store, id)` does not. Audit events carry counts only — never the secret. - **Validation surface.** `validate_metadata(metadata)` returns `{ok: bool, errors: list}` without registering. ## OAuth redaction (`std/oauth/redaction`) Runtime ships a default catalog of high-confidence token patterns (JWT, GitHub PAT classic + fine-grained, Slack `xox*`, AWS `AKIA`, OpenAI `sk-`, Stripe `sk_live_`/`sk_test_`, GitLab `glpat-`, npm `npm_`, `Authorization: Bearer ...`). Persisted transcripts / receipts / OTel attrs / system reminders replace matches with `:>`. The original token still flows to the underlying tool — redaction is display-only. ```harn,ignore import { default_patterns, drain_audit, redact, register_pattern, } from "std/oauth/redaction" register_pattern("acme_api_key", "\\bACME-[A-Z0-9]{12}\\b") const display = redact("Bearer ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") for entry in drain_audit() { // entry.code == "HARN-OAU-001" // entry.pattern, entry.match_count, entry.bytes_redacted } ``` - **Per-thread custom patterns** via `register_pattern(name, regex)`. Anchor with `\b` to avoid chewing unrelated identifiers. - **`drain_audit()` is the authoritative compliance contract** — works on every execution backend. Audit entries also fan out to the live event sink and (when multi-threaded Tokio is available) to the `audit.token_redaction` event-log topic. ## Gotchas (friction-log distilled) - Heredoc `<...`. 2. Forcing canonical start tokens (`Start with VERDICT:`). 3. `output: {schema: schema, validation: "error"}` + `schema_retries: 2`. 4. Generous `maxLength` / `maxItems` bounds in the schema. ## Prompt templates (`.harn.prompt` / `.prompt`) Load file-backed templates via `harness.fs.render_prompt("path.prompt", bindings)` or `harness.fs.render_prompt(...)`. Use `harness.fs.render_template(template, bindings)` when the template source lives inline in a string literal. File paths resolve relative to the calling module's directory. **Package-root paths** — prefer `@/...` and `@/...` over `../../partials/foo.harn.prompt`. They anchor at the calling file's project root (nearest `harn.toml`) so refactors that move callers don't break asset references: ```harn,ignore // project-root harness.fs.render_prompt("@/prompts/tool-examples.harn.prompt", bindings) // [asset_roots] alias harness.fs.render_prompt("@partials/tool-examples.harn.prompt", bindings) ``` Define aliases in `harn.toml`: ```toml [asset_roots] partials = "Sources/BurinCore/Resources/pipelines/partials" ``` Both `harness.fs.render_prompt(...)` and `{{ include "@/..." }}` honor the same syntax. `harn check` validates the resolved files exist; bundle manifests and LSP go-to-definition follow `@`-paths to the target file. When an execution policy is active, file-backed templates and includes obey the same `workspace_roots` read boundary as `harness.fs.read_text(...)`. - `{{ name }}` — interpolation; nested with `{{ a.b[0] }}`. - `{{ if expr }}..{{ elif expr }}..{{ else }}..{{ end }}` — expression operators: `==`, `!=`, `<`, `<=`, `>`, `>=`, `and`/`&&`, `or`/`||`, `not`/`!`. - `{{ for x in xs }}..{{ else }}..{{ end }}` — `else` renders when empty. Inside: `{{ loop.index }}`, `.index0`, `.first`, `.last`, `.length`. Dict iteration: `{{ for k, v in dict }}..{{ end }}`. - `{{ include "partial.prompt" }}` or `{{ include "..." with { x: y } }}` — resolves relative to the including file; `{{ include "@/..." }}` resolves from the project root; cycle detection is built in. - Filters: `{{ name | upper | default: "anon" }}`. Built-ins: `upper`, `lower`, `title`, `trim`, `capitalize`, `length`, `first`, `last`, `reverse`, `join:sep`, `default:fallback`, `json`, `indent:n`, `lines`, `escape_md`, `replace:from,to`. - `{{# comments stripped at parse #}}`, `{{ raw }}..literal {{braces}}..{{ endraw }}`, `{{- trim whitespace + one newline -}}`. - Missing *bare* `{{ident}}` passes through the literal source (back-compat). New constructs raise `template at L:C: ...` errors. - **`llm` scope**: inside an LLM-aware frame (`harness.llm.call`, the default handler stack, `agent_loop`) the engine auto-injects `llm = {provider, model, family, capabilities: {...}}` so a single logical prompt can adapt by capability. Branch on `{{ if llm }}` for the bare-render fallback; branch on `{{ if llm.capabilities.native_tools }}` to pick wire envelope. `family` is a normalized token such as `anthropic-claude`, `openai-gpt`, `google-gemini`, `qwen`, `llama`, `mistral`, or `deepseek`. User bindings that already provide an `llm` key win for back-compat and trigger a one-shot warning under `template.llm_scope`. - **Variant resolution transcripts**: a `template.render` event lands in `llm_transcript.jsonl` for every render under an LLM frame, carrying the resolved `llm` snapshot and a per-branch / per-section trace. Surface in the portal under "Variant resolution". - **Drift-prevention lints**: `harn lint` walks `.harn.prompt` files and warns when a template branches on `llm.provider` / `llm.model` / `llm.family` directly (`template-provider-identity-branch`) or when more than three capability-aware conditionals appear in the same file (`template-variant-explosion`). Configure the threshold via `[lint] template_variant_branch_threshold = N`. - Full reference: `docs/src/prompt-templating.md`. ## Discovery - Human cheatsheet: `docs/src/scripting-cheatsheet.md`. - Language spec: `spec/HARN_SPEC.md` (mirrored to `docs/src/language-spec.md`). - Concurrency: `docs/src/concurrency.md` (`max_concurrent`, RPM limits, channels, `select`, `deadline`). - LLM / agent surface: `docs/src/llm-and-agents.md`. - Conformance examples: `conformance/tests/*.harn`. --- ## Read next - [Reuse narrowing checks](https://harnlang.com/narrowing-checks.md) - [Best practices](https://harnlang.com/best-practices.md) --- # Best practices > These habits make Harn programs easier to understand, test, and operate. Website: https://harnlang.com/best-practices.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. --- These habits make Harn programs easier to understand, test, and operate. ## Give each layer one job - Use `harness.llm.call` for one model request. - Use `agent_loop` when the model must act across turns. - Use a workflow for named stages, joins, and verification. - Keep product UI, approval decisions, file mutation, and persistence in the host that owns them. See [the host boundary](./host-boundary.md). ## Keep inputs and prompts small Pass the context that the current step needs. Ask for one clear result. State the output shape, limits, and failure behavior in the prompt or schema. ## Make effects explicit Route model, file, network, and process access through `harness.*`. Keep pure transforms in ordinary functions. Give an agent only the tools and capability scope that it needs. ## Make concurrency readable Use `parallel each` for independent work. Give each worker a clear input and join the results at a visible point. Set limits before you add fan-out. ## Treat completion as a contract Do not treat a model's confident sentence as proof that work is complete. Use a typed result, a verification stage, or an explicit terminal condition. Record the evidence that a critical action ran. ## Test in two modes Use the `mock` provider for deterministic syntax, error, and control-flow tests. Run a small number of real-provider smoke tests for provider wiring and model capability. These prove different things. Before you commit, run: ```bash harn fmt harn check harn lint ``` Use [Testing](./testing.md) for fixtures, replay, evaluation, and evidence standards. --- ## Read next - [LLM quick reference](https://harnlang.com/docs/llm/harn-quickref.md) - [Run an A/B experiment](https://harnlang.com/cookbooks/ab-experiment.md) --- # Run an A/B experiment > You changed a prompt, a cache policy, or a model route, and you want to know whether it actually helped. Harn has three surfaces for that. Pick by what you are comparing. Website: https://harnlang.com/cookbooks/ab-experiment.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. --- You changed a prompt, a cache policy, or a model route, and you want to know whether it actually helped. Harn has three surfaces for that. Pick by what you are comparing. | You are comparing | Use | Gives you | | --- | --- | --- | | Two configurations, and you need a defensible answer | [`std/eval/experiment`](#the-experiment-contract) | randomized-block assignment, anytime-valid decisions, guardrails, a spend ceiling, and an explicit promotion gate | | Two versions of one pipeline | [`harn eval --structural-experiment`](#compare-two-runs-of-one-pipeline) | a paired baseline-vs-variant summary over two runs | | One prompt across several models | [`harn eval prompt --fleet`](#compare-one-prompt-across-models) | per-model rendering, output, and optional judge scoring | The first is a statistical contract you call from Harn code. The other two are CLI commands. They are unrelated implementations — reach for the one that matches your question. ## The experiment contract `std/eval/experiment` is for the case where you will act on the result. It refuses to let you cheat: case sets are frozen at registration, the gate cases cannot be spent during tuning, assignment is deterministic under a seed, the family error budget is split across every candidate and guardrail, and promotion to the holdout set is a separate explicit step. ### Declare the manifest The manifest states the hypothesis, the arms, the metric you care about, the guardrails that must not regress, how trials are assigned, which cases belong to tuning versus the frozen gate, and the ceilings. ```harn,check const MANIFEST = { schema: "harn.experiment.v1", experiment_id: "prompt-cache-policy", hypothesis: "Caching the system prompt improves success without" + " raising cost.", owner: "eval-team", baseline: {id: "baseline", config: {cache: "off"}, complexity: 0}, candidates: [{id: "cached", config: {cache: "on"}, complexity: 1}], decision: {delta: 0.05, epsilon: 0.1, ladder: [3, 5, 10, 80]}, metrics: { primary: {id: "success", direction: "up", bounds: {lo: 0.0, hi: 1.0}}, guardrails: [ { id: "cost", direction: "down", bounds: {lo: 0.0, hi: 5.0}, alarm: {kind: "absolute", threshold: 0.1}, }, ], }, assignment: { mode: "randomized_block", seed: "seed-42", blocking_factors: ["host", "time_slot"], }, splits: { iterate: {id: "tune", digest: "tune-v1", cases: ["case-a", "case-b"]}, gate: {id: "holdout", digest: "holdout-v1", cases: ["case-z"]}, promotion: "explicit", }, budget: {max_spend_usd: 10.0, max_trials_per_case: 80}, } ``` `bounds` on every metric is required, not decoration: anytime-valid inference cannot be honest over an undeclared support. `budget` is a hard ceiling on the whole experiment, and promotion carries the spend already used into the gate phase rather than resetting it. The validation context is what your host supports, and it is checked against the manifest — asking to block on a factor the host cannot observe is rejected at validation rather than producing a quietly meaningless result: ```harn,ignore const CONTEXT = { supported_blocking_factors: ["host", "time_slot"], host_identity_available: true, } ``` ### Register and assign `register_experiment` freezes both case sets. `plan_assignments` produces one balanced block containing the baseline and every candidate exactly once, and it is deterministic — replaying the same case, trial, and block gives the identical plan. `realize_assignment` records which arm a host actually ran and refuses a block other than the one it was assigned. ```harn,ignore const valid = unwrap(validate_experiment_manifest(MANIFEST, CONTEXT)) const registration = register_experiment(valid) const block = {host: "host-a", time_slot: "slot-1"} const plan = plan_assignments(registration, "case-a", 0, block) const realized = realize_assignment(plan, "cached", block) ``` Calling `plan_assignments` with one of the gate cases during the iterate phase throws. That is the point: you cannot spend the holdout set while tuning. ### Decide and promote Feed paired observations to `decide_experiment`. Each observation carries the baseline and treatment value for every metric plus both realized assignments, so the decision keeps its own evidence. ```harn,ignore const decision = decide_experiment( registration, {observations: observations, phase_spend_usd: 0.32, budget_spent: true}, ) const promoted = promote_experiment(registration, decision) ``` Running the whole flow over 160 paired observations: ```text phase=iterate iterate cases=[case-a, case-b] verdict=ITERATE_WINNER winner=cached promotion_required=true gate phase=gate gate cases=[case-z] ``` `promotion_required` is always true under `promotion: "explicit"` — winning the tuning phase does not ship anything. `promote_experiment` builds a second registration scoped to the frozen gate cases and to the baseline plus the single winner, so the holdout run compares two arms rather than re-running the field. Other verdicts you will see: `BASELINE` when a candidate's upper confidence bound falls below the practical-equivalence band, and a per-candidate `regressed_on_primary` status that lets a scheduler stop a losing arm without `std/eval` knowing anything about where work runs. The full surface, including the manifest's every field and the guardrail alarm semantics, is in [`std/eval/experiment`](../modules.md#stdevalexperiment). For turning a stated hypothesis into one of these manifests without letting model output become executable authority, see [Compile a bounded experiment](./compile-hypothesis.md) and [ADR-0007](../adr/0007-hypothesis-compiler-ownership.md). ## Compare two runs of one pipeline When the two things you are comparing are two versions of the same pipeline, `harn eval --structural-experiment` runs it twice in isolated run directories — once as the baseline, once with `HARN_STRUCTURAL_EXPERIMENT=` set — and prints a paired summary: ```bash harn eval --llm-mock fixtures.jsonl --structural-experiment doubled_prompt pipeline.harn ``` ```text Structural experiment: doubled_prompt Cases: 1 - tiny [Say hi.] baseline: PASS variant: PASS diff identical: false stage diffs: 0 tool diffs: 0 observability diffs: 6 Baseline 1 / 1 passed Variant 1 / 1 passed ``` This reads workflow run records, so the pipeline must call `workflow_execute`; a plain `harn run`-style pipeline produces nothing for it to compare and the command reports that one side was empty. Pass `--llm-mock` to keep both runs deterministic. ## Compare one prompt across models `harn eval prompt` renders a `.harn.prompt` against a fleet of models, and optionally runs and scores it: ```bash harn eval prompt prompts/agent.harn.prompt \ --fleet claude-sonnet-5,gpt-5,ollama:qwen3.5 \ --mode judge ``` `--mode render` only renders against each model's capability profile, `run` renders and executes, and `judge` adds LLM-as-judge equivalence scoring. `--fleet-name` uses a named fleet from `[eval.fleets.]` in `harn.toml`, and `--output html -o report.html` writes a shareable report. This is a comparison tool, not an experiment: there is no assignment policy, no confidence sequence, and no spend ceiling. Use it to see how a prompt lands across models, then use the experiment contract above if you need to defend the resulting change. See [`harn eval prompt`](../cli-reference.md#harn-eval-prompt) for the full flag set. --- ## Read next - [Best practices](https://harnlang.com/best-practices.md) - [Coming from LangChain](https://harnlang.com/cookbooks/coming-from-langchain.md) --- # Coming from LangChain > You know how to build an agent. This page maps the LangChain pieces you already reach for onto their Harn equivalents so you can port a working idea rather than relearn the... Website: https://harnlang.com/cookbooks/coming-from-langchain.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. --- You know how to build an agent. This page maps the LangChain pieces you already reach for onto their Harn equivalents so you can port a working idea rather than relearn the vocabulary. For LangGraph specifically — `Node`, `Edge`, `State`, `Channel`, `super-step`, `interrupt` — the vocabulary table in [Coming from elsewhere](../concepts/sota-comparison.md#langgraph) is the cross-reference. This page covers the LangChain side and the day-to-day mechanics. | LangChain | Harn | Where | | --- | --- | --- | | LCEL `a \| b \| c` | the pipe operator, `x \|> f(_) \|> g(_)` | [below](#composition) | | `StateGraph` | `workflow_graph` + `workflow_execute`, or a portable workflow bundle | [below](#graphs-and-workflows) | | `@tool` | an inline `tool` declaration, or `harn tool new` for a shareable one | [below](#tools) | | `with_structured_output(Model)` | `output: {schema: T}` on the call | [below](#structured-output) | | `RunnableRetry` / `.with_retry()` | `with_retry` from `std/llm/handlers` | [below](#retries-and-middleware) | | LangSmith | `harn portal` and `harn usage`, local and included | [below](#tracing-and-cost) | | `checkpointer` | `checkpoint_stage` from `std/checkpoint` | [below](#resuming-after-a-crash) | ## Composition LCEL's `|` chains runnables left to right. Harn's pipe does the same for ordinary functions, with one difference: the placeholder is explicit, so the piped value can land in any argument position rather than only the first. ```harn,check fn double(x: int) -> int { return x * 2 } fn add(x: int, y: int) -> int { return x + y } fn main(harness: Harness) { const out = 3 |> double(_) |> add(_, 1) harness.stdio.log(to_string(out)) // 7 } ``` The reasoning behind the explicit `_` is in [ADR-0001](../adr/0001-pipe-operator.md). Note that Harn's pipe composes plain function calls; it is not a separate runnable protocol with its own streaming and batching semantics. Model calls, retries, and fallbacks compose through [middleware](#retries-and-middleware) instead. ## Graphs and workflows A `StateGraph` becomes a Harn workflow. There are two shapes, and which one you want depends on who runs it. For a graph your own program runs, build it with `workflow_graph` and execute it in-process with `workflow_execute`. Nodes are stages, edges carry a branch label, and retries and verification are node policy rather than hand-rolled loops. See [the workflow runtime](../workflow-runtime.md). For a graph a *host* runs — an IDE, an orchestrator — author a portable workflow bundle instead. It is JSON, it validates to a stable `graph_digest`, and the host executes it. See [Run a workflow bundle from the CLI](../workflow-authoring-quickstart.md). The one thing that does not map cleanly is LangGraph's typed state with reducers. Harn's default state model is workflow artifacts and the transcript, not a typed dict merged per super-step. That gap and its design are described in the [LangGraph table](../concepts/sota-comparison.md#langgraph). ## Tools `@tool` decorates a Python function. The everyday Harn equivalent is a `tool` declaration inside the program that uses it — typed parameters, a description written for the model, and the body inline: ```harn,ignore tool search(pattern: string) -> string { description "Search the project" return harness.process.exec("rg", "--", pattern).stdout ?? "" } ``` When the tool should be shared across projects rather than living in one program, scaffold it as a package: ```bash harn tool new summarize-diff --description "Summarize a git diff" ``` See [Extend Harn](../extend-harn.md) for how tools relate to packages, connectors, and skills. ## Structured output `with_structured_output(Model)` binds a Pydantic model to a call. Harn takes a type on the call's `output` option, and the same option controls provider-level strictness, post-parse validation, and early stream abort: ```harn,ignore type Verdict = {pass: bool, reason: string} const result = harness.llm.call(prompt, nil, { output: { schema: Verdict, strict: true, validation: "error", stream_abort: true, }, schema_retries: 1, }) ``` `result.data` is the narrowed value. `schema_retries` re-asks the model when parsing fails, which is the behavior you would otherwise write around a LangChain output parser. Full option list in [`harness.llm.call`](../llm/llm_call.md). ## Retries and middleware `RunnableRetry` wraps a runnable. Harn wraps the *call*: `agent_loop` accepts an `llm_caller` closure that owns each turn's `harness.llm.call`, and the handlers in `std/llm/handlers` compose around it. ```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: 4}) const result = agent_loop(harness, task, system, { loop_until_done: true, llm_caller: caller, }) ``` Prefer `llm_caller({retry: {max_attempts: 4}})` for the blessed default stack — it is the same composition plus typed reserved-status classification and billed-empty re-dispatch. Fallback, shadow, logging, budget, cache, and circuit breaker are sibling handlers you compose the same way. See [the handlers catalog](../stdlib/llm-handlers.md). ## Tracing and cost LangSmith is a hosted service you sign up for. The Harn equivalents run on your machine and are part of the toolchain: ```bash harn portal # observability UI over persisted runs harn usage # spend and token rollups from the local event log ``` `harn portal` binds `127.0.0.1:4721` by default and reads run records from `.harn-runs/`. `harn usage` aggregates `provider_call_response` events out of the project's `.harn/events.sqlite` and rolls them up by provider, model, or a day/week/month series — it reuses the cost the runtime already computed rather than re-pricing anything. Neither needs an account or sends data anywhere. See [Debugging agent runs](../debugging.md) and [`harn usage`](../usage.md). ## Resuming after a crash A LangGraph checkpointer persists graph state so a thread can resume. Harn's closest everyday primitive is `checkpoint_stage`, which caches a stage's result under a name and skips the work on a resumed run: ```harn,check import { checkpoint_stage } from "std/checkpoint" fn main(harness: Harness) { const data = checkpoint_stage(harness.runtime, "fetch", { -> "raw" }) const cleaned = checkpoint_stage( harness.runtime, "clean", { -> data + "-clean" }, ) harness.stdio.log(cleaned) } ``` The first argument is `harness.runtime` — the capability that owns the checkpoint store. `checkpoint_stage_keyed` adds an identity so the same stage name can be checkpointed per item, and the `_retry` variants add bounded retries. For an agent that parks and resumes rather than crashes and restarts, the mechanism is different: see [the daemon agent tutorial](../tutorial-daemon-agent.md) for `agent_await_resumption`, worker snapshots, and `harn run --resume`. ## What has no direct equivalent - **A retriever abstraction.** Harn has no `VectorStoreRetriever` interface. Retrieval is something you write as a tool or a stage. - **A document loader ecosystem.** There is no `langchain-community` analogue. - **Chain serialization.** LCEL chains do not serialize to a portable format; Harn's portable unit is a workflow bundle or a signed `.harnpack`, which is a different granularity. --- ## Read next - [Run an A/B experiment](https://harnlang.com/cookbooks/ab-experiment.md) - [Compile a bounded experiment](https://harnlang.com/cookbooks/compile-hypothesis.md) --- # Compile a bounded experiment from a hypothesis > Use std/eval/hypothesis when an agent or product needs to turn a question into an experiment without letting model output become executable authority. The planner produces... Website: https://harnlang.com/cookbooks/compile-hypothesis.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. --- Use `std/eval/hypothesis` when an agent or product needs to turn a question into an experiment without letting model output become executable authority. The planner produces typed data. The deterministic compiler accepts only registered adapters, validates trusted host risk, capability, placement, citation, and resource ceilings, and lowers an accepted design into Harn's existing experiment registration contract. This guide shows the control flow. The complete executable fixture is [`eval_hypothesis_compiler.harn`](../../../conformance/tests/stdlib/eval_hypothesis_compiler.harn), with its catalog and intent in [`hypothesis_fixture_lib.harn`](../../../conformance/tests/stdlib/hypothesis_fixture_lib.harn). ## Register what the experiment may do Build an `ExperimentCompileContext` at the host boundary. Its catalog contains the only intervention, outcome, and population adapters the compiler may reference. Its budget ceiling is authority; a requested budget above any ceiling is refused. ```harn,ignore import { compile_experiment_intent } from "std/eval/hypothesis" const context = { owner: "eval-team", seed: "release-42", catalog: { schema: "harn.experiment.adapter_catalog.v1", interventions: [baseline_adapter, candidate_adapter], outcomes: [bounded_success_outcome], populations: [frozen_fixture_population], }, trusted_citations: researched_sources, risk_floor: host_classified_risk, capability_ceiling: host_capability_ceiling, supported_blocking_factors: ["host", "time_slot"], host_identity_available: true, budget_request: requested_budget, budget_ceiling: approved_ceiling, approval_id: nil, provenance: { source: "release-eval", actor: "automation", created_at: "2026-08-08T00:00:00Z", }, } const receipt = compile_experiment_intent(intent, context) if !receipt.ok { for diagnostic in receipt.failure?.diagnostics ?? [] { harness.stdio.eprintln(diagnostic.code + ": " + diagnostic.message) if diagnostic.repair != nil { harness.stdio.eprintln( "repair " + diagnostic.repair.owner + " " + diagnostic.repair.operation + " " + diagnostic.repair.path, ) } } return } ``` The current authored contract is `harn.experiment.intent.v2`. It carries typed alternative hypotheses and a typed decision scope. The compiler also accepts v1 input and normalizes it before fingerprinting, so equivalent v1 and v2 intents produce one plan identity. Future versions fail with `experiment.intent_schema_version`; structural failures use `experiment.intent_schema`. Both can carry a typed repair owner, operation, path, and expected shape. Select `quasi_experimental` only with an explicit `quasi_experiment` contract. Name the non-random assignment mechanism and identifying assumptions. A matched comparison must also name its matching method and pre-treatment covariates. The current compiler emits an `observe_only` plan with an `associational` claim ceiling; it does not reinterpret matching as randomized causal evidence. Use immutable adapter IDs and variants. Put credentials in host-managed secret references, never in the catalog or intent. The capability manifest declares filesystem roots, process commands, network domains, providers, connectors, database scopes, customer-state access, model routing, privacy class and retention, mutation reversibility, and approval requirements. Missing customer-state, model, and privacy fields in a legacy manifest normalize to no authority. New compiled plans carry them explicitly. Executable adapters may also declare one typed `placement` requirement with `mode`, `platform`, `requires_gpu`, and `resource_class`. Baseline and candidate requirements must be identical, and the compiler refuses a host ceiling that cannot enforce them. ## Hand the registration to an enforcing host adapter An accepted receipt contains a stable intent fingerprint and plan fingerprint. If `receipt.plan.kind == "registered_experiment"`, the plan contains the canonical `ExperimentManifest` and `ExperimentRegistration`. It deliberately does not contain an executable workflow. Only a registered host adapter that enforces the plan's capabilities, placement, approval requirement, and remaining resource ceilings may schedule it; that adapter must use Harn's canonical assignment, observation, and decision APIs rather than reconstructing their rules. ```harn,ignore if receipt.plan.kind == "observe_only" { for question in receipt.plan.instrumentation_questions { harness.stdio.println("instrumentation needed: " + question) } return } const plan = receipt.plan if plan.design.approval_required { request_native_approval(plan.design.approval_id, plan.fingerprint) return } require plan.execution_status == "requires_registered_host_adapter", "compiled registrations are not self-executing" registered_experiment_adapter.schedule(plan.registration, { capabilities: plan.design.capabilities, resource_ceiling: plan.design.budget, plan_fingerprint: plan.fingerprint, }) ``` `observe_only` is a successful, non-executable result. It preserves the question and names missing instrumentation instead of manufacturing a causal test. A high-risk randomized design carries a stable approval requirement; compilation does not pretend approval already happened, and an adapter must not execute it without the corresponding native approval. Record that approval as an `approval_recorded` event bound to the exact approval ID and plan fingerprint, then mint its opaque `native_approval` proof from the registered approval adapter. The ledger refuses scheduling before the matching approval. The event payload is an audit record, not evidence that the native approval UI ran. ## Record lifecycle facts once Create typed events with `hypothesis_event`, append them with `hypothesis_ledger_append`, and derive current state with the single-pass `hypothesis_ledger_snapshot`. The ledger is a typed projection over Harn's event log, so it inherits global ordering, integrity hashes, and SQLite, file, or memory persistence. The topic is reserved: generic event-log writes fail, and the specialized append requires a non-serializable authority proof minted by a registered native adapter for that exact event. ```harn,ignore import { hypothesis_event, hypothesis_ledger_append, hypothesis_ledger_snapshot, } from "std/eval/hypothesis" const event = hypothesis_event({ schema: "harn.hypothesis.event.v1", schema_version: 1, event_id: "plan-registered", hypothesis_id: hypothesis_id, plan_id: plan.plan_id, run_id: nil, predecessor_fingerprint: nil, occurred_at: "2026-08-08T00:01:00Z", actor: "automation", source: "release-eval", payload: {kind: "plan_registered", plan: plan}, }) // The native adapter owns this receipt // and returns a tagged success only after // it verifies that the corresponding plan-admission operation completed. const proof = harness.obs.hypothesis_event_authority_request( "plan_admission", event.fingerprint, plan.fingerprint, hypothesis_id, "plan-admission-receipt-01", nil, ) const first = hypothesis_ledger_append(harness.obs, event, proof) const replay = hypothesis_ledger_append(harness.obs, event, proof) require first.cursor == replay.cursor && !replay.inserted, "a retry must return the original durable event" const read = hypothesis_ledger_snapshot(harness.obs, hypothesis_id) require read.integrity.scope == "retained_topic_chain" && read.integrity.verified, "the retained topic chain must verify before projection" const snapshot = read.snapshot ``` The example assumes the host registered `hypothesis.attest_event` and gave this pipeline the narrow `authority.write@plan_admission` effect grant. Harn sends the exact authority kind, fingerprints, IDs, and operation receipt over the host bridge. The adapter must return a JSON-RPC error for a missing, stale, mismatched, reused-with-different-bindings, or denied receipt. It may return the same success for an exact retry. An accepted response is the exact tagged result documented in [Bridge protocol](../bridge-protocol.md#native-hypothesis-attestation). Harn consumes that document inside the scoped builtin and returns only a non-serializable VM resource. An ordinary `host_call` sees the same document as plain data and cannot turn it into authority. In-process Rust embedders may instead create a native attestation with `harn_vm::stdlib::mint_hypothesis_native_attestation` and pass it to `hypothesis_event_authority_mint`. That constructor is not a wire format. Do not grant hypothesis-event authority writes to model-authored code. Give approval, execution, and lifecycle adapters only their corresponding `authority.write@native_approval`, `authority.write@native_observation`, or `authority.write@lifecycle_audit` scope. Mint `native_approval` only after native approval, `native_observation` only after an assigned execution produces its measurement, and `lifecycle_audit` only from the adapter that owns the transition or decision. Every proof is bound to the event fingerprint, plan fingerprint, hypothesis, and optional run; copying its serialized audit headers cannot authorize another append. Each later event names the preceding aggregate fingerprint. Reusing the same hypothesis and event IDs with different content, breaking that predecessor chain, recording an unassigned observation, exceeding the plan budget, or submitting a decision that differs from Harn's canonical recomputation fails closed. Record realized paired observations, execution drift, decisions, invalidations, regressions, and follow-up relationships as events; do not update a parallel JSON document. Reports and host dashboards should project the ledger snapshot rather than becoming independent sources of truth. A `completed` run transition carries typed completion evidence. Use `{kind: "statistical"}` only when `decide_experiment` is already non-`RUNNING` with `budget_spent: false`. Use `{kind: "max_trials"}` only after every frozen candidate, case, and trial cell has an observation. Native budget and wall-clock completion carry a non-empty receipt ID in the fingerprinted transition and in its `receipt_ids`; the native lifecycle attestation binds that exact event. The ledger validates the event and its attestation. It does not query or validate the host's external receipt store. Use `hypothesis_workflow(harness.obs, {kind: "inspect", hypothesis_id: id})` for the same typed read and report projection. `start`, `pause`, `resume`, `advance`, and `stand_down` are state-checked requests. They call the registered `hypothesis.operation` native adapter; when none is present, they return `kind: "adapter_unavailable"` before appending a lifecycle event. `advance` executes one balanced case/trial block. The caller supplies concrete blocking values, Harn freezes and randomizes the arm order with `plan_assignments`, and the adapter returns measurements in exactly that order. Harn realizes the assignments, validates the full block against the resource ceilings, appends only missing cells on retry, and runs `decide_experiment`. The adapter never chooses the assignment or supplies the verdict. ```harn const advanced = hypothesis_workflow( harness.obs, { kind: "advance", hypothesis_id: plan.hypothesis_id, blocking_values: {host: host_id, time_slot: frozen_slot}, }, ) ``` Call `design_hypothesis` separately when natural-language design is needed. ## Use the same boundary from CLI or MCP The [hypothesis control-plane example](../../../examples/hypothesis-control-plane/README.md) exports the planner, compiler, workflow, ledger inspection, and report as typed functions. Run one function through `harn run`, or expose the same functions as structured tools with `harn serve mcp`. The projection adds no scheduler, manifest, decision rule, or write authority. The `compile` tool's generated schema accepts the same v1/v2 intent union, including the v2 quasi-experiment and decision-scope fields. ## Verify the boundary you claim For deterministic compiler changes, run the exact conformance fixture: ```console harn test conformance tests/stdlib/eval_hypothesis_compiler.harn --verbose ``` For an experiment claim, also prove through the native adapter that the registered intervention fired, the realized assignment matched the randomized plan, outcomes came from the declared population and grader, the stopping decision used the frozen experiment registration, and resource totals remained below every ceiling. Passing compiler tests alone does not prove a live intervention worked. --- ## Read next - [Coming from LangChain](https://harnlang.com/cookbooks/coming-from-langchain.md) - [Pipeline lifecycle cookbook](https://harnlang.com/cookbooks/lifecycle.md) --- # Pipeline lifecycle cookbook > End-to-end patterns for the callback-first pipeline lifecycle. Each recipe is a self-contained, copy-paste starting point. For the surface reference see Pipeline lifecycle ;... Website: https://harnlang.com/cookbooks/lifecycle.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. --- End-to-end patterns for the callback-first pipeline lifecycle. Each recipe is a self-contained, copy-paste starting point. For the surface reference see [Pipeline lifecycle](../pipeline-lifecycle.md); for the per-preset stdlib reference see [Pipeline lifecycle presets](../stdlib/lifecycle.md); for the LLM-friendly quickref see the "Pipeline lifecycle" section in `docs/llm/harn-quickref.md`. The recipes use `harn,ignore` fences because they wire up full multi-pipeline topologies (producer, drain, trigger handlers, host glue). Each fragment type-checks; the orchestration loop assumes a host that runs the constituent pipelines, such as `harn orchestrator`. ## Nightly settlement handoff A live ingest pipeline triages events synchronously up to a watermark, then hands deferred work off to a separate nightly settlement pipeline. The handoff envelope carries the unsettled snapshot; the nightly pipeline drains it under its own schedule and budget. ```harn,ignore import { trigger_register } from "std/triggers" import { on_finish_handoff_to } from "std/lifecycle" // --- live ingest pipeline --- pipeline ingest(harness: Harness, events) { // Any deferred items become partial-handoff envelopes via // harness.handoff_to so they show up in unsettled_state(). harness.agent.pipeline_on_finish( on_finish_handoff_to("nightly-settle", { note: "live ingest deferred bucket", }), ) for event in events { const outcome = try_process_now(event) if outcome.deferred { // Stage the item directly on the harness so the on_finish // callback picks it up from harness.unsettled_state(). __handoff_to_settle(event) } } return {status: "ingest_complete", count: len(events)} } // --- nightly settlement pipeline --- pipeline nightly_settle(harness: Harness, envelope) { const items = envelope.unsettled.partial_handoffs for item in items { settle_one(item.payload) } return {status: "settled", count: len(items)} } fn __handoff_to_settle(event) { // The harness builtin is provided by the runtime inside any // pipeline body; declared as a helper so the snippet reads // top-to-bottom. current_harness().handoff_to("nightly-settle", event) } ``` Why `on_finish_handoff_to` over emitting a channel: the handoff envelope carries the full unsettled snapshot, with origin and timing metadata, and the nightly pipeline gets the bucket in one call instead of replaying N channel emits. The handoff is recorded as a `partial_handoff_envelope` audit, so the replay oracle can verify the same envelope crosses both pipelines on a second run. Why a separate pipeline: the nightly run owns its own budget, hook chain, and on_finish policy. A failure mid-settle does not unwind the live ingest run, and a `pre_finish` block on the nightly pipeline holds finish open until the per-item drain completes. ## Audit every drain decision to a custom store A regulated team needs every disposition the settlement-agent loop makes to land in an external audit store (Splunk, BigQuery, S3), not just the per-run `pipeline.lifecycle.audit` topic. A `harness.agent.register_session_hook("on_drain_decision", ...)` callback observes each decision before it persists, and a composed `on_finish` callback runs the drain loop with telemetry around it. ```harn,ignore import { on_finish_drain } from "std/lifecycle" import { compose, with_telemetry } from "std/lifecycle/combinators" pipeline triage(harness: Harness, input) { // 1. Mirror every drain disposition to our external store. harness.agent.register_session_hook( "on_drain_decision", { _hook_harness, event -> audit_store_push({ pipeline: event.pipeline_id, bucket: event.bucket, item_id: event.item_id, disposition: event.disposition, seq: event.seq, at_ms: event.at_ms, }) // Return nil to allow; we are not vetoing or rewriting. return nil }) // 2. Drain on finish, wrapped in an OTel span so dashboards can // pin on the duration distribution. harness.agent.pipeline_on_finish( with_telemetry(on_finish_drain, "triage_drain"), ) return process(input) } fn audit_store_push(record) { // Replace with the real client. __external_audit_emit(record) } ``` Why a hook over inline logging in a custom on_finish: the `on_drain_decision` gate fires once per item the settlement-agent loop processes, regardless of which preset or custom callback drives the loop. A hook captures every disposition uniformly — including from future presets — without forking the drain logic. Why `with_telemetry` outside the drain: the wrapper emits `triage_drain_started` / `_completed` / `_errored` audit entries with a stable span name, so the external store sees a paired pair of records bracketing the per-item entries. ## Long-paused agent with resume-continuity reminder A research agent self-parks via `agent_await_resumption` and may sleep for hours. When it resumes, two things must happen exactly once: a continuity reminder lands on the first resumed turn (the runtime injects this by default), and a custom telemetry envelope fires so the operator dashboard shows the pause duration. ```harn,ignore import { spawn_agent, parse_resume_conditions } from "std/agent/workers" pipeline research_runner(harness: Harness, task) { // Bracket the suspend / resume gates with telemetry. The runtime // also fires its built-in `resume_continuity` reminder; this hook // wraps it with structured operator telemetry. harness.agent.register_session_hook( "post_resume", { _hook_harness, event -> operator_dashboard_emit("agent.resumed", { handle: event.worker.handle, suspended_at_ms: event.suspended_at_ms, resumed_at_ms: event.resumed_at_ms, duration_ms: event.resumed_at_ms - event.suspended_at_ms, reason: event.suspend_reason, resume_cause: event.resume_cause, }) return nil }) const resume_when = parse_resume_conditions({ timeout: {duration_minutes: 240, on_timeout: "resume_with_summary"}, on_event: "operator.resume", }) const worker = spawn_agent({ prompt: task, system: "Pause when you need a long-running external lookup.", options: {resume_when: resume_when}, }) return wait_agent(worker) } ``` Why a `post_resume` hook over polling the worker registry: the hook runs once per resume, on the right session, with the suspend and resume timestamps already paired. Polling drifts and can double-fire across worker restarts. Why parse `resume_when` upfront: the parsed dict is captured on the suspend snapshot, so a cold-restore (`harn run --resume `) reconstitutes the same wake conditions instead of re-evaluating the shape at resume time. ## Hook-based supervision: deny suspend during business hours A SaaS team wants to prevent any agent from self-parking between 9 AM and 5 PM local time (so the on-call rotation can intervene with full context). A `pre_suspend` hook denies the suspend; the runtime keeps the worker running and the agent gets a reminder explaining the policy. ```harn,ignore fn during_business_hours(now_ms) -> bool { const hour = wall_clock_hour_local(now_ms) return hour >= 9 && hour < 17 } pipeline supervised_agents(harness: Harness) { harness.agent.register_session_hook( "pre_suspend", { hook_harness, event -> if !during_business_hours(hook_harness.clock.now_ms()) { return nil // allow } return { block: true, reason: "agents may not self-park during business hours;" + " ask on-call", reminder: { body: "Your suspend was denied (business-hours policy)." + " Continue working or escalate via the on-call channel.", tags: ["policy_violation", "business_hours_no_suspend"], ttl_turns: 1, dedupe_key: "policy.no_suspend_business_hours", }, } }) // ... rest of the setup pipeline; agents spawn under this hook // chain inherit the deny policy. return nil } ``` Why `pre_suspend` over `OnPersonaPaused`: `pre_suspend` is the *gate*; it can veto the suspend and keep the worker running. The `Paused` events fire after the suspend is already committed. Why a paired reminder: the agent that tried to self-park needs to see *why* it was denied, not just continue blindly. The 1-turn TTL ensures the reminder lands on the very next turn and then evaporates so it does not pollute later context. Why the dedupe key: if the agent retries the suspend three times in one turn, the dedupe collapses the reminder to one entry rather than stacking three identical bodies. ## Replay-deterministic test harness for a multi-suspend pipeline A pipeline suspends a worker, waits for an external event, resumes, suspends again, and finally drains. The conformance fixture needs to replay deterministically — same audit entries, same disposition order, same handoff envelopes — across two runs of the same pipeline. ```harn,ignore import { mock_time, advance_time } from "std/clock" import { flush_trigger_aggregations } from "std/triggers/testing" import { on_finish_drain } from "std/lifecycle" import { compose, with_telemetry } from "std/lifecycle/combinators" pipeline multi_suspend_fixture(harness: Harness) { // 1. Pin wall-clock so queued_at_ms / age_ms are reproducible. harness.testing.clock_set(1700000000000) // 2. Capture audits via the per-run log instead of wall-clock spans. harness.agent.pipeline_on_finish( with_telemetry(on_finish_drain, "fixture_drain"), ) // 3. Run the workload. Each suspend / resume / drain step records // a typed audit entry. const worker = spawn_research_worker() emit_external_event("operator.resume", {}) harness.testing.clock_advance(60000) harness.channels.flush_aggregations() const worker2 = spawn_followup_worker(worker) emit_external_event("operator.resume", {}) harness.testing.clock_advance(120000) harness.channels.flush_aggregations() return wait_agent(worker2) } pipeline assert_replay_determinism(harness: Harness) { // 4. Drain the audit log; the conformance harness compares this // byte-for-byte against the recorded fixture. const entries = harness.obs.pipeline_lifecycle_audit_log_take() return {audits: entries, count: len(entries)} } ``` Why `mock_time` + `advance_time` over wall-clock: the harness banlist (`make lint-test-patterns`) prohibits `std::thread::sleep`, `Instant::now()` polling, and short `recv_timeout` calls in tests. Mocking the clock lets `queued_at_ms` and `age_ms` fields land at deterministic offsets that the replay oracle can compare directly. Why `flush_trigger_aggregations` before the second suspend: batched trigger handlers buffer events until the aggregation window closes. Explicit flushes turn an inherently time-driven boundary into a synchronous one so each step's audit entries land before the next step opens. Why `pipeline_lifecycle_audit_log_take` over snapshot: the take variant drains the log so a subsequent assertion starts from a clean slate. The conformance fixture asserts on the drained list as a whole, not on a snapshot that might include leftovers from a previous test. ## Picking the right primitive For any of these recipes, the wrong tool is also worth knowing: - **Don't use a `post_finish` hook to block.** `post_finish` is advisory; the value is already captured. Use `pre_finish` (which rejects `block` and points you at the right primitive) or `on_finish_block_until_settled` to delay finish until work drains. - **Don't write a custom drain loop unless you have to.** `on_finish_drain` already walks the buckets in the canonical order, respects `HARN-DRN-001` ordering enforcement, and fires `on_drain_decision` per item. A custom loop has to re-derive every one of those properties. - **Don't reach for `pre_suspend` denials to throttle suspends.** The runtime emits an audit per attempt; a deny chain quickly becomes noise. Use a worker-side budget (`OnBudget.terminate` or `graceful_exit`) instead. - **Don't poll `harness.unsettled_state()` from a tight loop.** Each call walks the live registries plus the event log. Use `harness.wait_for_any_settlement(max_duration)` or `on_finish_block_until_settled` to wait on a single coalesced snapshot. --- ## Read next - [Compile a bounded experiment](https://harnlang.com/cookbooks/compile-hypothesis.md) - [Tool hooks cookbook](https://harnlang.com/cookbooks/tool-hooks.md) --- # Tool hooks cookbook > Copy-paste recipes for the preset_run_command wrapper. Drop one into a pipeline default(harness: Harness, task) { ... } body, wire the returned closure into your agent_loop 's... Website: https://harnlang.com/cookbooks/tool-hooks.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. --- Copy-paste recipes for the [`preset_run_command`](../tool-hooks.md) wrapper. Drop one into a `pipeline default(harness: Harness, task) { ... }` body, wire the returned closure into your `agent_loop`'s `run_command` handler, and you have working catalogue-driven command guards. For the full surface reference see [Preset tool hooks](../tool-hooks.md). To extend the shipped catalogues see [Contributing preset hooks](../contributing/preset-hooks.md). The recipes use `harn,ignore` fences because they show full agent-loop topologies (handler + dispatch + transcript wiring) that need an active session to type-check end-to-end. Each fragment matches a conformance fixture under `conformance/tests/stdlib/tool_hooks/tool_hooks_*.harn`. They intentionally wrap a command-string tool so the preset catalogues can rewrite or deny shell input. Prefer argv-style `harness.process.exec(...)` calls for tools whose interface does not require shell syntax. ## Rust agent: safe `cargo` A Rust coding agent should never thrash the workspace lockfile, should keep `println!` output visible from `cargo test`, and should never force-push `main`. The shipped catalogues cover all three; opting in to `stacks: ["rust"]` is enough. ```harn,ignore import { preset_run_command } from "std/tool_hooks" pipeline default(harness: Harness, task) { const run_command = preset_run_command({ stacks: ["rust"], inner: { args -> harness.process.shell(args.command) }, }) agent_loop(harness, task, { tools: {tools: [{name: "run_command", handler: run_command}]}, system: "You are a Rust contributor. Run cargo commands through" + " run_command.", }) } ``` What fires here: - `rust.cargo.target_dir_conflict` rewrites bare `cargo build|test|check|run` to add `--target-dir target-shared`. - `rust.cargo.test_no_capture_default` appends `-- --nocapture` to `cargo test`. - `rust.cargo.clippy_full_workspace` appends `--workspace` to `cargo clippy`. - `rust.cargo.fmt_check_vs_apply` warns (no rewrite) on bare `cargo fmt`. - The universal catalogue's `git push --force main` and `rm -rf /` denies fire regardless of `stacks`. ## Python agent: virtualenv-friendly defaults Same shape, different opt-in. The Python catalogue rewrites `find` to `rg --files`, warns on system-wide `pip install`, and keeps `pytest` output visible. ```harn,ignore import { preset_run_command } from "std/tool_hooks" pipeline default(harness: Harness, task) { const run_command = preset_run_command({ stacks: ["python"], inner: { args -> harness.process.shell(args.command) }, }) agent_loop(harness, task, nil, { tools: {tools: [{name: "run_command", handler: run_command}]}, }) } ``` ## TypeScript agent: refuse `--legacy-peer-deps` The TypeScript catalogue's `ts.npm_install_force_resolution` rule is informational by default (warning severity, no rewrite). For a strict-CI agent you can promote it to a deny by switching the mode: ```harn,ignore import { preset_run_command, tool_hooks_mode_deny_with_explanation, } from "std/tool_hooks" pipeline default(harness: Harness, task) { const run_command = preset_run_command({ stacks: ["typescript"], mode: tool_hooks_mode_deny_with_explanation, inner: { args -> harness.process.shell(args.command) }, }) agent_loop(harness, task, nil, { tools: {tools: [{name: "run_command", handler: run_command}]}, }) } ``` With `tool_hooks_mode_deny_with_explanation` as the wrapper-wide mode, **every** match denies — not just the npm one. For per-rule gradations, use the default rewrite-with-audit mode and write deny-only `custom_rules` (see the SQL recipe below). ## Swift agent: protect `.build/` from accidental cleans The Swift catalogue ships one rule by default. Compose it with custom rules for project-specific extensions: ```harn,ignore import { preset_run_command } from "std/tool_hooks" pipeline default(harness: Harness, task) { const xcodebuild_no_destination = tool_rule({ id: "swift.xcodebuild_no_destination", pattern: { command, _context -> if !regex_match("^xcodebuild\\b", command) { return false } return !contains(command, "-destination") }, applies_to: ["swift"], severity: "warning", explanation: "Run `xcodebuild` with `-destination` so the simulator" + " or device target is explicit.", references: [ "https://developer.apple.com/library/archive/technotes/tn2339" + "/_index.html", ], }) const run_command = preset_run_command({ stacks: ["swift"], custom_rules: [xcodebuild_no_destination], inner: { args -> harness.process.shell(args.command) }, }) agent_loop(harness, task, nil, { tools: {tools: [{name: "run_command", handler: run_command}]}, }) } ``` ## SQL agent: deny `SELECT *` without a `LIMIT` The shipped `sql.select_star_warning` rule is `severity: "warning"` without a rewrite — it surfaces the pattern but doesn't block. To make it block for an autonomous reporting agent, promote it with a priority override in a custom rule that matches first: ```harn,ignore import { preset_run_command, tool_hooks_mode_deny_with_explanation, } from "std/tool_hooks" pipeline default(harness: Harness, task) { const deny_unbounded_select_star = tool_rule({ id: "harness.sql.unbounded_select_star", pattern: { command, _context -> const lc = lowercase(command) if !regex_match( "^\\s*select\\s+\\*\\s+from\\s+\\S+", lc, ) { return false } return !(contains(lc, " where ") || contains(lc, " limit ")) }, applies_to: [], severity: "error", explanation: "Unbounded `SELECT *` scans entire tables. Add a WHERE" + " or LIMIT clause.", }) const run_command = preset_run_command({ stacks: ["sql"], custom_rules: [deny_unbounded_select_star], mode: tool_hooks_mode_deny_with_explanation, inner: { args -> harness.process.shell(args.command) }, }) agent_loop(harness, task, nil, { tools: {tools: [{name: "run_command", handler: run_command}]}, }) } ``` Custom rules are matched before the registry, so the deny fires ahead of the catalogue's softer warning. The default mode would otherwise let unbounded `SELECT *` through with a warning audit. ## Harn dogfood agent: silence `cargo run --bin harn` The Harn catalogue patches CLAUDE.md's "always pass `--quiet`" guidance into the dispatcher itself. Opt-in is one line: ```harn,ignore import { preset_run_command } from "std/tool_hooks" pipeline default(harness: Harness, task) { const run_command = preset_run_command({ stacks: ["harn"], inner: { args -> harness.process.shell(args.command) }, }) agent_loop(harness, task, nil, { tools: {tools: [{name: "run_command", handler: run_command}]}, }) } ``` A bare `cargo run --bin harn -- run examples/hello.harn` rewrites to `cargo --quiet run --bin harn -- run examples/hello.harn`, and the audit envelope explains why. ## Multi-stack agent with classifier fallback A polyglot agent needs every shipped catalogue plus a model-driven fallback for ad-hoc commands. The classifier is opt-in — leaving `llm_classifier: nil` preserves passthrough semantics exactly. ```harn,ignore import { preset_run_command } from "std/tool_hooks" pipeline default(harness: Harness, task) { const run_command = preset_run_command({ stacks: ["rust", "python", "typescript", "swift", "sql", "harn"], llm_classifier: { model: "haiku", provider: "anthropic", threshold: 0.85, cache: {ttl_seconds: 3600}, }, inner: { args -> harness.process.shell(args.command) }, }) agent_loop(harness, task, nil, { tools: {tools: [{name: "run_command", handler: run_command}]}, }) } ``` What the classifier adds: - Every command that misses every deterministic rule goes to the small model with a JSON-shaped safety verdict. - Verdicts at or above `threshold` (default `0.8`) dispatch via the verdict's mode — `rewrite` → `tool_hooks_mode_rewrite_with_audit`, `deny` → `tool_hooks_mode_deny_with_explanation`. - `allow` and sub-threshold verdicts pass through to `inner`, audited as `action: "passthrough"`. - Cache TTL deduplicates verdicts within the configured window so a loop hitting the same command repeatedly only pays the LLM cost once. - Every call (cache hit or miss, verdict or transport error) emits a `tool_hook_classifier_verdict` audit entry so you can see exactly which decisions were model-driven. ## Audit-only rollout: preview, then enforce Roll a new catalogue out by running it in `passthrough_only_audit` mode first; collect the `tool_rule_warning` audit entries; once the false-positive rate is acceptable, switch to the default mode. ```harn,ignore import { preset_run_command, tool_hooks_mode_passthrough_only_audit, } from "std/tool_hooks" pipeline default(harness: Harness, task) { const run_command = preset_run_command({ stacks: ["python"], mode: tool_hooks_mode_passthrough_only_audit, inner: { args -> harness.process.shell(args.command) }, }) const result = agent_loop(harness, task, nil, { tools: {tools: [{name: "run_command", handler: run_command}]}, }) const audits = harness.obs.pipeline_lifecycle_audit_log_take() const warnings = audits |> filter({ a -> a.kind == "tool_rule_warning" }) harness.stdio.log("matched (audit-only): " + to_string(len(warnings))) } ``` `harness.obs.pipeline_lifecycle_audit_log_take()` drains the in-memory buffer; combine with your transcript persistence to retain the rule-firings beyond the pipeline run. ## Preview without executing Omit `inner` to get decision envelopes back without running anything. Useful for unit tests, dry-runs, and replaying a transcript with different rule shapes: ```harn,ignore import { preset_run_command } from "std/tool_hooks" pipeline default(harness: Harness) { const preview = preset_run_command({stacks: ["rust"]}) const envelope = preview("cargo build --release") harness.stdio.log(envelope.action) // "rewrite" // "cargo build --release --target-dir target-shared" harness.stdio.log(envelope.command) // "rust.cargo.target_dir_conflict" harness.stdio.log(envelope.rule_id) harness.stdio.log(envelope.severity) // "warning" } ``` Audit + reminder side effects still fire in preview mode, so a test asserting on `harness.obs.pipeline_lifecycle_audit_log_take()` sees the rule firings even though no command actually executed. ## Composing with `register_tool_hook` `preset_run_command` is the in-tool wrapper for `run_command`-shaped tools. The general [`register_tool_hook`](../extensibility/hooks.md#tool-lifecycle-hooks-register_tool_hook) surface still applies around every dispatch, including `run_command`. Both layers compose cleanly — preset rewrites happen inside the handler, while `register_tool_hook` PreToolUse / PostToolUse fire around it: ```harn,ignore import { preset_run_command } from "std/tool_hooks" pipeline default(harness: Harness, task) { harness.tools.register_hook({pattern: "*", max_output: 4000}) const run_command = preset_run_command({ stacks: ["rust"], inner: { args -> harness.process.shell(args.command) }, }) agent_loop(harness, task, nil, { tools: {tools: [{name: "run_command", handler: run_command}]}, }) } ``` That gives you "catalogue-driven safety inside the handler, output caps and pattern bans outside the handler" in eleven lines. --- ## Read next - [Pipeline lifecycle cookbook](https://harnlang.com/cookbooks/lifecycle.md) - [Channel cookbook](https://harnlang.com/cookbooks/channels.md) --- # Channel cookbook > End-to-end patterns for agent channels. Each recipe is a self-contained, copy-paste starting point. For the surface reference see Agent channels ; for the LLM-friendly quickref... Website: https://harnlang.com/cookbooks/channels.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. --- End-to-end patterns for agent channels. Each recipe is a self-contained, copy-paste starting point. For the surface reference see [Agent channels](../agent-channels.md); for the LLM-friendly quickref see the "Durable agent channels" section in `docs/llm/harn-quickref.md`. The recipes use `harn,ignore` fences because they wire up full multi-pipeline topologies (publisher + subscriber). Each fragment type-checks; the orchestration loop assumes a host that runs both pipelines, such as `harn orchestrator`. ## Release handshake One agent waits for another's emit before proceeding. The PR agent emits `pr.merged` on each merge; a release agent batches three of them into a single release run; downstream merge-captain agents in dependent repos subscribe to the release's `harn-release.shipped` event so they can rebase their queues. ```harn,ignore // --- harn-pr-agent --- pipeline pr_agent_on_merge(harness: Harness, pr) { harness.channels.append("pr.merged", { repo: "burin-labs/harn", number: pr.number, sha: pr.merge_commit_sha, target_branch: pr.target_branch, }) } // --- release agent --- pipeline release_agent_setup(harness: Harness) { harness.runtime.trigger_register({ id: "release-after-3-merges", kind: "channel.emit", provider: "channel", match: {events: ["channel:pr.merged"]}, when: { _harness, event -> event.provider_payload.payload.target_branch == "main" }, batch: { count: 3, window: "2h", key: "repo", expire_action: "fire_partial", }, handler: { harness, event -> const merged = event.batch const repo = merged[0].provider_payload.payload.repo const shas = merged |> map({ e -> e.provider_payload.payload.sha }) const release = cut_release(repo, shas) harness.channels.append("harn-release.shipped", { repo: repo, version: release.version, merged_prs: len(merged), }) }, }) } // --- merge captains in dependent repos --- pipeline merge_captain_setup(harness: Harness) { harness.runtime.trigger_register({ id: "rebase-on-harn-release", kind: "channel.emit", provider: "channel", match: {events: ["channel:harn-release.shipped"]}, handler: { harness, event -> const release = event.provider_payload.payload rebase_queued_prs_against(release.version) }, }) } ``` Why channels over a direct handoff: there is no single named recipient. The release agent doesn't know about merge captains, and each downstream service registers its own subscriber. The journal is the contract. Why `batch` over a counter in the handler: the count is process state; the buffer is durable. A restart between PRs 2 and 3 doesn't lose the batch. Why a signed receipt: the replay oracle verifies the same three SHAs fire the same release across two runs of the same pipeline. See [CH-07 replay receipts](../agent-channels.md#observability). ## Periodic check-in via tool-call counting Inject a reflection prompt into a running agent loop every 30 tool calls. The agent emits `tool_call.completed` on each tool use (via a tool hook); a batched trigger drops a reminder onto the same session when the counter hits 30. ```harn,ignore import { ReminderInject } from "std/triggers" pipeline reflection_agent(harness: Harness, task) { // 1. Emit a channel event from a tool hook so we don't have to // instrument every tool definition by hand. harness.tools.register_hook({ pattern: "*", post: { ctx -> harness.channels.append("tool_call.completed", { tool: ctx.tool_name, session: ctx.session_id, }) return nil }, }) // 2. Subscribe to the channel with a batched ReminderInject. The // reminder lands on the *same* session at the next turn boundary. harness.runtime.trigger_register({ id: "reflect-every-30-tools", kind: "channel.emit", provider: "channel", match: {events: ["channel:tool_call.completed"]}, batch: {count: 30, window: "1h", key: "session"}, handler: ReminderInject({ target: "current", body: "You have used 30 tools without a checkpoint. Take" + " this turn to summarize progress, re-read the spec, and adjust" + " the plan if needed.", tags: ["reflection_nudge"], ttl_turns: 1, dedupe_key: "reflection_nudge", }), }) // 3. Run the loop. The reflection nudge arrives transparently. agent_loop( harness, task, "You are a careful engineering agent. Reflect when nudged.", ) } ``` Why channels + `batch` + `ReminderInject` over a `post_turn_callback` counter: the callback runs after every turn; this fires after every 30 tool *uses* regardless of how they distribute across turns. Two tools in turn N and 28 across turn N+1 trips at the same point. The batch counter resets cleanly after fire, and the durable buffer survives a worker restart. Why `dedupe_key`: if the agent stalls mid-reflection and 30 more tools are dispatched, the next reminder replaces (rather than stacks on) the pending one. ## Multi-agent feedback loop A planner agent drafts a plan; reviewer agents critique it; the planner subscribes to the critiques and revises. Channels make this a declarative cycle rather than a hand-rolled coordination dance. ```harn,ignore import { ReminderInject } from "std/triggers" // --- planner --- pipeline planner_loop(harness: Harness, task) { const session = harness.agent.open("planner") const draft = harness.llm.call( task, "Write a one-page plan.", {session_id: session}, ) harness.channels.append("plan.draft", { plan: draft, revision: 1, session_id: session, }) // Watch for reviewer feedback addressed at our session. harness.runtime.trigger_register({ id: "planner-on-feedback-" + session, kind: "channel.emit", provider: "channel", match: {events: ["channel:plan.feedback"]}, when: { _harness, event -> event.provider_payload.payload.target_session == session }, handler: ReminderInject({ target: session, body: "Reviewer feedback: {{" + " event.provider_payload.payload.critique }}", tags: ["plan_feedback"], ttl_turns: 2, }), }) agent_loop( harness, task, "Revise the plan when reminders arrive. Re-emit plan.draft when" + " revision is complete.", {session_id: session}, ) } // --- reviewers --- pipeline reviewer_setup(harness: Harness) { harness.runtime.trigger_register({ id: "reviewer-on-draft", kind: "channel.emit", provider: "channel", match: {events: ["channel:plan.draft"]}, handler: { harness, event -> const draft = event.provider_payload.payload const critique = harness.llm.call( "Critique this plan in 3 bullets:\n" + draft.plan, "You are a careful reviewer.", ) harness.channels.append("plan.feedback", { target_session: draft.session_id, critique: critique, revision: draft.revision, }) }, }) } ``` Why channels over handoffs: both directions are 1-to-N — multiple reviewers, multiple revision rounds — and neither side knows the others' session ids ahead of time. The `target_session` field in the feedback payload + a `when` predicate routes each critique back to the right planner without a central registry. Why `ReminderInject` for the inbound feedback: the planner is *already running* in `agent_loop`. Spawning a new task would lose its transcript; injecting a reminder lets it incorporate the critique on the next turn. ## Pipeline progress dashboard Every step in every pipeline emits `pipeline.step.completed`; a monitoring agent tenant-wide subscribes and maintains a live dashboard. The producers don't know the monitor exists — they just emit. ```harn,ignore // --- producers: any pipeline registering this hook gets free // instrumentation --- pipeline producer_setup(harness: Harness) { harness.agent.register_step_hook({ pattern: "*", post: { ctx -> harness.channels.append("pipeline.step.completed", { pipeline: ctx.pipeline_name, step: ctx.step_name, duration_ms: ctx.duration_ms, success: ctx.success, }) return nil }, }) } // --- dashboard: one subscriber, scoped to the whole tenant --- pipeline dashboard_setup(harness: Harness) { harness.runtime.trigger_register({ id: "dashboard-on-step", kind: "channel.emit", provider: "channel", // Bare channel names default to tenant scope, so this catches // emits from any pipeline running for this tenant. match: {events: ["channel:pipeline.step.completed"]}, handler: { harness, event -> const row = event.provider_payload.payload dashboard_upsert(row.pipeline, row.step, { last_run_at: event.occurred_at, last_duration_ms: row.duration_ms, last_success: row.success, }) }, }) } ``` Why tenant scope: a bare `harness.channels.append("pipeline.step.completed", ...)` resolves to `tenant::pipeline.step.completed`. The dashboard subscribes to the same name without prefix and automatically receives emits from every pipeline running for that tenant. Cross- tenant isolation is automatic — a sibling tenant's emits go to a different topic. Why a trigger over `event_log.subscribe`: triggers carry the dispatcher's retry policy, DLQ routing, and replay receipts. If the dashboard handler throws, the channel emit still lands cleanly on `lifecycle.channel.audit`; the failed match goes to `trigger.dlq`. ## Cross-pipeline coordination via drain handoff Pipeline A processes work synchronously up to a watermark, then drains deferred items by emitting a channel event. A nightly settlement pipeline (B) subscribes and picks up the deferred work. ```harn,ignore // --- pipeline A: live ingest --- pipeline ingest_pipeline(harness: Harness, events) { const deferred = [] for event in events { const result = try_process_now(event) if result.deferred { deferred = deferred + [event] } } // On clean drain, hand the deferred bucket off to pipeline B. harness.agent.pipeline_on_finish({ ctx -> if ctx.status == "completed" && len(deferred) > 0 { harness.channels.append("pipeline.drained", { source_pipeline: "ingest", deferred_count: len(deferred), deferred_payload: deferred, drained_at: harness.clock.timestamp(), }) } }) } // --- pipeline B: nightly settlement --- pipeline settlement_setup(harness: Harness) { harness.runtime.trigger_register({ id: "settlement-on-drain", kind: "channel.emit", provider: "channel", match: {events: ["channel:pipeline.drained"]}, when: { _harness, event -> event.provider_payload.payload.source_pipeline == "ingest" }, handler: { harness, event -> const bucket = event.provider_payload.payload.deferred_payload for deferred in bucket { settle(deferred) } }, }) } ``` Why channels over a queue table: the `lifecycle.channel.audit` receipt is the durable contract. Pipeline B doesn't need a polling loop or a DB cursor; the dispatcher delivers exactly once per emit (subject to the normal trigger retry policy), and the replay oracle can reproduce the handoff across two runs. Why `pipeline_on_finish` rather than emitting inline: a partial drain on a failure mid-loop would publish an inconsistent bucket. Emitting from the finish hook ensures pipeline A reached a clean terminal state first. ## Picking the right primitive For any of these recipes, the wrong tool is also worth knowing: - **Don't use `harness.runtime.channel(...)` with `harness.runtime.send`/`harness.runtime.receive`** (the in-process concurrency channel) for cross-pipeline or cross-process pub/sub — it lives only inside one VM. Reach for `emit_channel` whenever the publisher and subscriber could be different runs. - **Don't use a webhook trigger for in-cluster events.** Webhook triggers carry HMAC verification and delivery-id dedupe that are pointless for trusted internal emits. Channels short-circuit both. - **Don't use suspend/resume for "wake me when N things happen."** A suspended worker holds its transcript and process slot. Use a `batch` trigger + `ReminderInject` so the worker keeps running and only the reminder arrives. --- ## Read next - [Tool hooks cookbook](https://harnlang.com/cookbooks/tool-hooks.md) - [Pool cookbook](https://harnlang.com/cookbooks/pools.md) --- # Pool cookbook > End-to-end patterns for agent pools. Each recipe is a self-contained, copy-paste starting point. For the surface reference see Agent pools ; for the LLM-friendly quickref see... Website: https://harnlang.com/cookbooks/pools.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. --- End-to-end patterns for agent pools. Each recipe is a self-contained, copy-paste starting point. For the surface reference see [Agent pools](../agent-pools.md); for the LLM-friendly quickref see the "Agent pools" section in `docs/llm/harn-quickref.md`. The recipes use `harn,ignore` fences because they wire up full multi-pipeline topologies (producer, pool-draining pipeline, trigger handlers). Each fragment type-checks; the orchestration loop assumes a host that runs the constituent pipelines, such as `harn orchestrator`. ## Rate-limited webhook processor Every webhook source posts to a single `channel:webhook.received` channel. A pool with `max_concurrent: 10`, a per-source fair queue, and a `ring_buffer(500)` overflow policy drains them. Bursty senders cannot starve quieter ones; an unbounded spike drops the oldest queued task (a `pool_drop` audit lands on `lifecycle.pool.audit`) but never the actively-running ones. ```harn,ignore import { SpawnToPool } from "std/triggers" import { Backpressure, fair_round_robin, pool_create, } from "std/lifecycle/pool" pipeline webhook_intake_setup(harness: Harness) { const bp = Backpressure() // One named pool, shared across every webhook source. pool_create(harness.agent, { name: "webhook-work", max_concurrent: 10, queue: fair_round_robin("source"), backpressure: bp.ring_buffer(500), scope: "pipeline", }) // Generic webhook connector emits `channel:webhook.received` for // every inbound payload. Route the channel into the pool. harness.runtime.trigger_register({ id: "webhook-router", kind: "channel.emit", provider: "channel", match: {events: ["channel:webhook.received"]}, handler: SpawnToPool({ pool: "webhook-work", key_from: "provider_payload.payload.source", priority_from: "provider_payload.payload.urgency", task_factory: { event -> const payload = event.provider_payload.payload return { -> process_webhook(payload) } }, }), }) } fn process_webhook(payload) { // Idempotent handler — see "Burst absorber for nightly batch jobs" // below for the durable-key pattern. return upsert_event(payload.source, payload.body) } ``` Why a pool over per-source rate limiters: ten sources times one limiter each is ten queues with no shared budget. A single pool with `fair_round_robin("source")` enforces both the *global* cap (ten workers, period) and *per-source* fairness in one primitive. Why `ring_buffer(500)` over `block_submitter`: blocking the webhook intake fiber stalls the entire connector. Dropping the oldest queued task is the correct backpressure shape for an overloaded webhook endpoint — the audit trail tells you who got dropped, and the underlying retry policy of the sender re-delivers later. Why `scope: "pipeline"`: the connector pipeline restarts on every deploy. Pipeline scope reloads queued tasks from `.harn/pools/__webhook-work.jsonl` so an in-flight burst does not vanish on restart. ## GPU-routed inference pool A multi-tenant inference service has four GPUs. Each inference call must run on exactly one GPU; the pool's `max_concurrent` is sized to the GPU count and the worker tier is pinned via a cloud worker selector. Tenants share the budget fairly, and any cross-tenant burst queues rather than spilling onto non-GPU hosts. ```harn,ignore import { SpawnToPool } from "std/triggers" import { Backpressure, fair_round_robin, pool_create, pool_wait, } from "std/lifecycle/pool" pipeline inference_pool_setup(harness: Harness) { const bp = Backpressure() // discovered from the host worker tier in real deployments const gpu_count = 4 pool_create(harness.agent, { name: "gpu-inference", max_concurrent: gpu_count, queue: fair_round_robin("tenant_id"), backpressure: bp.queue(1000, "fail_submitter"), scope: "tenant", // host routes to the GPU-tier worker pool }) harness.runtime.trigger_register({ id: "inference-router", kind: "channel.emit", provider: "channel", match: {events: ["channel:inference.requested"]}, handler: SpawnToPool({ pool: "gpu-inference", key_from: "provider_payload.payload.tenant_id", task_factory: { event -> const req = event.provider_payload.payload return { -> // Per-request closure; runs on a GPU-tier worker because the // pool's scope routes through the host GPU worker tier. return run_gpu_inference(req.model, req.prompt, req.params) } }, }), }) } // Synchronous caller path that wraps the trigger flow for in-process use. pipeline inference_call( harness: Harness, tenant_id, model, prompt, params, ) { const pool = pool_get(harness.agent, "gpu-inference") const handle = pool.submit({ -> return run_gpu_inference(model, prompt, params) }, {tenant_id: tenant_id}) return pool_wait(harness.agent, handle).result } ``` Why one pool over per-GPU pools: rebalancing across four pools when one tenant is idle is your problem; rebalancing within one pool is the runtime's. `max_concurrent: 4` plus `fair_round_robin` gives both the hard cap and the dynamic balance for free. Why `fail_submitter` over `block_submitter`: an inference request that backs up for more than ~1000 deep is almost certainly going to time out at the caller anyway. Failing fast with `HARN-POL-001` lets the caller pick a fallback model or shed load deliberately. Why `scope: "tenant"`: the pool routes through a host-managed GPU worker tier. The in-process runtime rejects this scope until an embedding host provides tenant pool routing; you can run the same code with `scope: "session"` for local development and flip the scope at deploy time. ## Cross-customer fairness A SaaS backend runs per-customer agent tasks (PR review, doc gen, test triage). Without fairness, a single noisy customer's batch of 500 PRs starves every other customer for hours. `fair_round_robin` on the customer id flips the worst case from "one customer drains the queue" to "all active customers interleave one task at a time." ```harn,ignore import { SpawnToPool } from "std/triggers" import { Backpressure, fair_round_robin, pool_create, } from "std/lifecycle/pool" pipeline tenant_work_setup(harness: Harness) { const bp = Backpressure() pool_create(harness.agent, { name: "tenant-agents", max_concurrent: 20, queue: fair_round_robin("tenant_id"), backpressure: bp.queue(5000, "block_submitter"), scope: "pipeline", }) // Direct submits from a connector loop. harness.runtime.trigger_register({ id: "tenant-agent-router", kind: "channel.emit", provider: "channel", match: {events: ["channel:agent_task.requested"]}, handler: SpawnToPool({ pool: "tenant-agents", key_from: "provider_payload.payload.tenant_id", priority_from: "provider_payload.payload.priority", task_factory: { event -> const req = event.provider_payload.payload return { -> run_agent_task(harness, req.tenant_id, req.task_kind, req.input) } }, }), }) } fn run_agent_task(harness: Harness, tenant_id, kind, input) { return agent_loop(harness, input, "You are the " + kind + " agent.") } ``` Tenant A submits 500 tasks, tenant B submits 10. Under FIFO, tenant B's first task waits behind 500 of tenant A's; under `fair_round_robin("tenant_id")`, tenant B's first task runs on the *second* free slot. Once tenant B drains, tenant A keeps the full 20 workers to itself. Why `block_submitter` over a drop policy: cross-customer fairness only matters when *both* customers have work to submit. Blocking the submitter at 5000 queued is a backstop against runaway producers without losing any work. Why `scope: "pipeline"`: the queue depth at restart can be tens of thousands of tasks. Pipeline scope reloads them and resumes draining; the per-task idempotency key from "Burst absorber" catches the brief window where in-flight tasks are re-enqueued. ## Burst absorber for nightly batch jobs A nightly cron triggers thousands of report-generation tasks. They need to all *eventually* run, but only a few at a time to avoid saturating a downstream SaaS API. A small `max_concurrent` plus a large queue depth turns a "thundering herd" into a slow drain. ```harn,ignore import { SpawnToPool } from "std/triggers" import { Backpressure, fifo, pool_create, pool_wait, } from "std/lifecycle/pool" pipeline nightly_report_setup(harness: Harness) { const bp = Backpressure() pool_create(harness.agent, { name: "nightly-reports", max_concurrent: 3, // small drain rate queue: fifo(), // run in submission order backpressure: bp.queue(50000, "fail_submitter"), // large absorber scope: "pipeline", }) // Cron-triggered fan-out: one submit per customer report. harness.runtime.trigger_register({ id: "nightly-report-cron", kind: "cron", provider: "cron", match: {schedule: "0 2 * * *"}, // 02:00 UTC nightly handler: { harness, event -> const pool = pool_get(harness.agent, "nightly-reports") const customers = list_active_customers() for customer in customers { // Idempotency key = (run date, customer). A retry of the cron // for the same date short-circuits to the same task handle // instead of double-running. const key = event.occurred_at_date + ":" + customer.id pool.submit({ -> return generate_report(customer.id, event.occurred_at_date) }, {idempotency_key: key}) } }, }) } fn generate_report(customer_id, run_date) { // slow SaaS call const data = fetch_customer_data(customer_id, run_date) return upload_report(customer_id, run_date, data) } ``` Why `fifo` over `priority`: every report has the same priority; ties under `priority()` are FIFO anyway, but `fifo()` documents intent and saves the priority comparator on every dequeue. Why a 50000-deep queue: nightly cron can fan out tens of thousands of tasks in a single burst. A small queue would force the cron handler to either block or drop; either is the wrong shape for a once-a-night job. The queue absorbs the burst and drains it across the rest of the night at 3-at-a-time. Why `idempotency_key`: the cron may retry if the orchestrator restarts mid-handler. Without the key, every customer would get two reports for the same date. With `(date, customer_id)` as the key, the second submit returns the first task's handle and the closure runs exactly once per night per customer. Why `scope: "pipeline"`: the orchestrator can restart mid-night and the remaining queue picks back up where it left off. Pipeline scope plus idempotency keys is the canonical "at-least-once with deduplication" pattern. --- ## Read next - [Channel cookbook](https://harnlang.com/cookbooks/channels.md) - [Rename a symbol cookbook](https://harnlang.com/cookbooks/rename-symbol.md) --- # Rename a symbol across the workspace > edit_rename_symbol is the safe alternative to grep + per-file text replace for cross-file renames. It uses the typed symbol graph from std/code_librarian to resolve the seed... Website: https://harnlang.com/cookbooks/rename-symbol.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. --- `edit_rename_symbol` is the safe alternative to grep + per-file text replace for cross-file renames. It uses the typed symbol graph from [`std/code_librarian`](../stdlib/code-librarian.md) to resolve the seed symbol, walks every file in scope with tree-sitter to find identifier-context occurrences (skipping comments and string literals), and refuses to write if the new name already exists as an identifier in any rewritten file. When the host runs with a staged-fs session (`harness.fs.set_mode`), every touched file lands in the overlay; one `harness.fs.commit_staged` flips them atomically. Without a session, the host still buffers the full plan in memory and only writes after pre-flight passes, so a clean run is all-or-nothing. Supported languages (first batch): Harn, Rust, TypeScript/TSX, JavaScript/JSX, Python, Swift, Go. ## Recipe — rename a Rust struct across the workspace The workspace defines `Widget` in one file and uses it from another. We rename it to `Gadget` with a single call, then commit the staged writes. ```harn,ignore import { edit_rename_symbol } from "std/edit" pipeline rename_widget_to_gadget(harness: Harness, session_id: string) { // Workspace must already be indexed; usually the host does this on // startup. From a script you can run // `harness.code_index.rebuild` first. harness.fs.set_mode({ session_id: session_id, mode: "staged" }) const plan = edit_rename_symbol(harness.code_index, harness.code_index, { symbol_ref: { name: "Widget", path: "src/lib.rs", kind: "Type" }, new_name: "Gadget", scope: "workspace", session_id: session_id, dry_run: true, }, ) if !plan.ok { harness.stdio.println( "rename refused: " + plan.result + " — " + (plan.details ?? ""), ) return plan } // Review the staged plan before committing. `touched_files[*].edits[*]` // exposes byte and (row, col) spans on both sides of the edit. for file in plan.touched_files { harness.stdio.println( "would rewrite " + file.path + " (" + str(len(file.edits)) + " edits)", ) } // Drop dry_run to actually stage the writes, then commit. const applied = edit_rename_symbol(harness.code_index, harness.code_index, { symbol_ref: { name: "Widget", path: "src/lib.rs", kind: "Type" }, new_name: "Gadget", scope: "workspace", session_id: session_id, }, ) if !applied.ok { return applied } return harness.fs.commit_staged({ session_id: session_id }) } ``` ## Conflict simulation If `Gadget` already exists as an identifier in any file the rename would touch, the host short-circuits with `result: "conflict"` and never writes: ```harn,ignore import { edit_rename_symbol } from "std/edit" pipeline rename_would_shadow(harness: Harness) { // `src/main.rs` defines both `Widget` and `Gadget`. Renaming Widget // to Gadget would create two `Gadget` definitions in the same file — // the host rejects before touching disk and surfaces the shadow site. const result = edit_rename_symbol(harness.code_index, harness.code_index, { symbol_ref: { name: "Widget", path: "src/main.rs", kind: "Type" }, new_name: "Gadget", scope: "workspace", }, ) assert(result.result == "conflict") for site in result.conflicts { harness.stdio.println( "shadow at " + site.path + ":" + str(site.row + 1) + ":" + str(site.col + 1), ) } return result } ``` ## Result shape `result` is one of: | `result` | meaning | |-------------------------|---------------------------------------------------------------| | `"applied"` | rename succeeded (or, with `dry_run`, would have). | | `"conflict"` | `new_name` shadows an existing identifier in a rewritten file.| | `"no_match"` | `symbol_ref` did not resolve in the typed graph. | | `"ambiguous_symbol"` | multiple symbols share `symbol_ref.name`; pass `line`/`kind`. | | `"unsupported_language"`| an in-scope file uses a grammar outside the first batch. | | `"invalid_identifier"` | `new_name` is not a valid identifier token. | | `"syntax_error"` | a rewritten file failed re-parse with `validate=true`. | Each entry in `touched_files` carries the workspace-relative `path`, the detected `language`, `before_sha256` / `after_sha256` (over the full file body, so the caller can detect concurrent edits), and an `edits` list of `{start_byte, end_byte, start_row, start_col, end_row, end_col, before, after}` per occurrence. For the surface reference see [`std/edit`](../stdlib/edit.md); for the underlying graph see [`std/code_librarian`](../stdlib/code-librarian.md). --- ## Read next - [Pool cookbook](https://harnlang.com/cookbooks/pools.md) - [Structured refactorings cookbook](https://harnlang.com/cookbooks/structured-refactorings.md) --- # Structured refactorings > The std/edit module ships compound, language-aware refactorings built on top of the AST-precise edit primitives ( edit_apply_node , edit_insert_at_anchor , edit_safe_text_patch... Website: https://harnlang.com/cookbooks/structured-refactorings.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. --- The `std/edit` module ships compound, language-aware refactorings built on top of the AST-precise edit primitives ([`edit_apply_node`](../stdlib/edit.md), `edit_insert_at_anchor`, `edit_safe_text_patch`, `edit_dry_run`). They are the "burin-like" edits an agent loop should reach for instead of regenerating files or hand-patching call sites: each one resolves structure with tree-sitter, previews as a unified diff, and commits atomically through the staged-fs overlay ([#1722](https://github.com/burin-labs/harn/issues/1722)). | Function | What it does | |---|---| | `edit_extract_variable` | Lift a single-line expression into a named local. | | `edit_extract_function` | Lift a statement range into a new function; free variables become parameters. | | `edit_change_signature` | Replace a function's whole parameter list. | | `edit_add_parameter` | Insert one parameter; fill the argument at every call site. | | `edit_reorder_parameters` | Permute parameters and every call's arguments together. | | `edit_change_return_type` | Rewrite a function's declared return type. | | `edit_inline` | Inline a zero-parameter, single-return function and delete it. | | `edit_move_decl` | Move a named top-level declaration or Harn binding into another file. | ## Shared contract Every refactoring returns the same shape: ```text { ok, applied, result, // result ∈ applied | no_op | conflict // | unsupported | invalid_params operation, language, dry_run, touched_files, // files that actually changed unified_diff, // [{path, diff, lines_added, lines_removed}] summary: {files_touched, lines_added, lines_removed}, conflicts, // B.3-style [{code, message, path?}] errors, warnings, provenance } ``` Three knobs are common to all of them: - **`dry_run: true`** — stage the edit into a throw-away overlay and return the per-file `unified_diff` without writing a byte. Always preview first. - **`session_id`** — stage into a caller-owned staged-fs session (the caller commits). Omit it and the refactoring opens its own transient session and commits atomically — all files flip together, or none do on the first conflict. - **Capability matrix** — when a language lacks the structure a refactoring needs, the call returns `result: "unsupported"` with a reason instead of guessing. These all require the `tools:deterministic` capability. ## Recipe — move a Harn setting into a config module Use `edit_move_decl` when a top-level Harn `const` or `let` belongs in another module. The move is structural: Harn selects the named binding from the syntax tree, stages both file changes, and then commits them together. Local bindings and destructuring patterns are not selected by name. ```harn,ignore import { edit_move_decl } from "std/edit" pipeline default(harness: Harness) { const preview = edit_move_decl( harness.fs, harness.random, harness.ast, { path: "src/github.harn", symbol: { name: "GITHUB_API_URL" }, target_file: "src/config.harn", dry_run: true, }, ) harness.stdio.log(preview.unified_diff[0].diff) } ``` Review the preview, then repeat the call without `dry_run` to commit the move. See the [`std/edit` structured-refactoring reference](../stdlib/edit.md#structured-refactorings) for parameters, result fields, and language coverage. ## Recipe — extract a function Pull a contiguous range of statements out of a top-level function. Free variables of the block (computed from the AST) become parameters; names that resolve to the module level (other functions, imports) stay referenced rather than parameterized. ```harn,ignore import { edit_extract_function } from "std/edit" pipeline default(harness: Harness) { // def report(base, qty): // subtotal = base * qty <- line 1 // audit(subtotal) <- line 2 // ... const preview = edit_extract_function( harness.fs, harness.random, harness.ast, { path: "billing.py", range: { start_line: 1, end_line: 2 }, new_name: "compute_subtotal", dry_run: true, }, ) harness.stdio.log(preview.unified_diff[0].diff) // def compute_subtotal(base, qty): <- `base`/`qty` captured, // subtotal = base * qty <- `audit` left as a free call // audit(subtotal) } ``` Supported: python, javascript, jsx, typescript, tsx, ruby. The generated function is `void`; if the block produces a value used afterward, thread it back by hand. ## Recipe — change a signature across every caller This is where structured edits earn their keep: add a parameter to a function and fill the argument at all of its call sites in one atomic transaction. ```harn,ignore import { edit_add_parameter } from "std/edit" pipeline default(harness: Harness) { // fn scale(value: i64, factor: i64) -> i64 { ... } // called as scale(2, 3), scale(4, 5), scale(6, 7) const result = edit_add_parameter( harness.fs, harness.random, harness.ast, { path: "src/lib.rs", symbol: { name: "scale" }, param: "offset: i64", default: "0", // default_fill: inserted at each call site }, ) if !result.ok { harness.stdio.log( "refused: " + result.result + " — " + (result.details ?? "") ) return } harness.stdio.log( "updated " + to_string(result.summary.files_touched) + " file(s)" ) // fn scale(value: i64, factor: i64, offset: i64) -> i64 { ... } // scale(2, 3, 0), scale(4, 5, 0), scale(6, 7, 0) } ``` `callsite_strategy` controls how callers are handled: - `default_fill` (default for `add_parameter`) inserts `default` at each call. - `strict` refuses with `result: "conflict"` when callers exist, so you can rewrite the definition only when it is safe. `edit_change_signature` takes the full `new_params` text for arbitrary changes; `edit_reorder_parameters` permutes parameters and every call's arguments together (refusing on an argument-count mismatch). ## Verify the result Refactorings re-parse the rewritten file with tree-sitter before committing, so a syntactically broken edit surfaces as `result: "conflict"` rather than landing on disk. To verify behavior after an apply, run the project's own checks — for the `scale` example above: ```harn,ignore const check = run_command({ cmd: ["cargo", "check"], cwd: "." }) harness.stdio.log( check.exit_code == 0 ? "callers still compile" : check.stderr ) ``` Supported languages for the signature family and return-type rewrites: rust, python, typescript, tsx, javascript, jsx, go (JavaScript/JSX have no return-type slot, so `edit_change_return_type` reports `unsupported` there). --- ## Read next - [Rename a symbol cookbook](https://harnlang.com/cookbooks/rename-symbol.md) - [Rule engine cookbook](https://harnlang.com/cookbooks/rules-engine.md) --- # Rule engine cookbook — scan, lint, and codemod > The Harn rule engine matches and rewrites code structurally (by syntax tree), not by regex. A rule is a small TOML file; you run it read-only with harn scan , or as a codemod... Website: https://harnlang.com/cookbooks/rules-engine.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. --- The Harn **rule engine** matches and rewrites code *structurally* (by syntax tree), not by regex. A rule is a small TOML file; you run it read-only with `harn scan`, or as a codemod with `harn codemod`. Under the hood it is the `harn-rules` crate, exposed to `.harn` as `std/rules`. > **Languages:** rules target a tree-sitter grammar: Harn, TypeScript/JS, Rust, > Go, Python, Java, C/C++, Ruby, and more. ## How do I search for a code shape? Use an inline pattern with `$VAR` holes. This finds every optional-chain + nullish-coalesce site (the "destructure with defaults" shape): ```console $ harn scan '$X?.$K ?? $D' src --lang typescript src/config.ts:12:18: cfg?.timeout ?? 30 [D=30 K=timeout X=cfg] src/config.ts:13:18: cfg?.retries ?? 3 [D=3 K=retries X=cfg] 2 match(es) in 1 file(s) ``` The same structural search works over Harn source: ```console harn scan '$X?.$K ?? $D' crates/harn-stdlib --lang harn ``` - `$X`, `$K`, `$D` are **metavariables** — each binds a sub-tree and is printed in `[...]`. A repeated `$X` must bind identical text. - Add `--report-only` for per-file counts instead of each match, or `--json` for a machine envelope. - Narrow a hole to a syntactic class with a **typed placeholder**: `harn scan 'harness.stdio.log($A:identifier)' src --lang typescript` matches `harness.stdio.log(x)` but not `harness.stdio.log(f())`. ## How do I write a reusable rule? Put the matcher in a TOML file. Scalars (`id`, `language`, `message`, `fix`, `safety`) come **before** the `[rule]` table: ```toml # destructure-defaults.toml id = "destructure-defaults" language = "typescript" message = "Collapse `?.x ?? default` into a destructure with a default" fix = "{ $K = $D } = $X" # presence of `fix` makes this a codemod safety = "behavior-preserving" # → machine-applicable [rule] pattern = "$X?.$K ?? $D" ``` Run it read-only with `harn scan --rule destructure-defaults.toml src`, or apply it (below). A rule with a `message` but no `fix` is a **lint**; a bare matcher is a **search**. See `crates/harn-rules/README.md` for the full model: relational keys (`inside` / `has` / `follows` / `precedes`), composite keys (`all` / `any` / `not` / `matches`), `[[where]]` predicates, and `[transform.NAME]`. For Harn source, `[[where]]` can also filter by resolved binding identity or capture type. This distinguishes two same-named call sites by the declaration they actually resolve to: ```toml id = "global-target-call" language = "harn" [rule] pattern = "$FN($ARG)" [[where]] metavar = "FN" resolvesTo = { name = "target", kind = "fn", line = 1 } [[where]] metavar = "ARG" type = "int" ``` The JSON output keeps `captures` as text and adds `capture_metadata` with `resolved` and `type` entries when the Harn resolver can supply them. ## How do I apply a codemod? `harn codemod` is **dry-run by default** — it prints a unified diff per file and writes nothing: ```console $ harn codemod --rule destructure-defaults.toml src would change src/config.ts [safety=BehaviorPreserving, idempotent=true] --- before +++ after @@ -12,2 +12,2 @@ -const timeout = cfg?.timeout ?? 30; +const { timeout = 30 } = cfg; ... 1 file(s) would change (dry run; pass --apply to write) ``` Pass `--apply` to write. Applying is **capability-gated** and respects the rule's `safety`: only `format-only` and `behavior-preserving` fixes apply automatically; anything riskier needs `--allow-unsafe`. ```console $ harn codemod --rule destructure-defaults.toml src --apply rewrote src/config.ts [safety=BehaviorPreserving] 1 file(s) rewritten (1 changed) ``` Re-running a folded file changes nothing — fixes are checked for idempotency. Point `--rule-pack ` at a directory or installed package to run its top-level `*.toml` rules. Installed packages work by name, and built-in packs live under `std/rules`. ```console harn codemod --rule-pack std/rules/destructure-defaults src --apply ``` The built-in `destructure-defaults` pack handles Harn statement runs such as `let x = input?.x ?? d` / `let alias = input?.field ?? d`, where a sequence fold needs more context than a single-node TOML rule can express. ## How do I run my project's rules without naming them? Declare the rule directories in your `harn.toml` and `harn scan` / `harn codemod` discover them automatically: ```toml # harn.toml [rules] ruleDirs = ["rules"] ``` ```console harn scan src # runs every rule under rules/ over src/ harn codemod src # applies the codemod rules; lints are skipped ``` With `[rules] ruleDirs` set, `harn scan ` needs no inline pattern — a pattern is signalled by `--lang`, so its absence means "use the project's rules." `harn codemod` applies only the rules that have a `fix`; lint/search rules in the same pack are ignored. Paths are resolved relative to the `harn.toml` directory. Each `ruleDirs` entry loads top-level `*.toml` files; put utility rules or fixtures in nested directories unless the manifest names that directory explicitly. ## How do I publish and install a rule pack? A rule pack is a Harn package that declares its rule directories: ```toml [package] name = "acme-rules" version = "0.1.0" [rules] ruleDirs = ["rules"] ``` Publish it through the rule-specific package alias: ```console harn rule publish --registry-name @acme/rules harn rule search acme harn add @acme/rules@0.1.0 harn scan --rule-pack @acme/rules src ``` `harn rule publish` uses the same tag + package-index PR flow as `harn publish`, but first validates the rule files and writes rule-pack metadata to the registry index. `harn rule search` lists only rule packs and includes the pack description, languages, rule count, and safety summary. After `harn add`, `--rule-pack` accepts either the dependency alias or the canonical registry name recorded in `harn.lock`. ## How do I run a rule from `.harn`? `std/rules` is the same engine, callable inline — an agent can author and run a rule without recompiling: ```harn,ignore import { rules_search, rules_apply } from "std/rules" const rule = "id = \"calls\"\nlanguage = \"typescript\"\n[rule]\npattern" + " = \"$FN()\"\n" fn inspect_rules(rules: HarnessRules, stdio: HarnessStdio) { const found = rules_search( rules, {rule: rule, source: "foo();\nbar();\n", language: "typescript"}, ) stdio.println(found.match_count) // 2 // Applying a codemod is explicit // authority; dry-run remains the default. const result = rules_apply( rules, {rule: codemod_rule, paths: ["src/a.ts"], dry_run: false}, ) } ``` For logic a declarative rule can't express, `rules_visit({rule, ..., on_match: fn(node, ctx) { ... }})` calls a visitor per match; the visitor *returns* its report(s) (`nil`/`false` to skip, a `{message, fix, safety}` dict, or a list). ## How do I write a custom lint rule in Harn? For a project convention that a declarative rule can't capture, author an imperative rule in Harn — the ESLint-plugin equivalent. Drop a `*.lint.harn` module into a `ruleDirs` directory; it exports `lint(source)` and returns either a finding, a list of findings, or a `rules_diagnostics(...)` result: ```harn,ignore // rules/no-todo.lint.harn pub fn lint(source) -> list { if source.contains("TODO") { return [ { column: 1, line: 1, message: "TODO markers are banned", severity: "error", } ] } return [] } ``` ```console harn lint src # discovers rules/*.lint.harn and runs them per file ``` `harn lint` discovers these alongside the built-in rules and merges their findings into the normal output — same exit code, same `--json` report, same `disable` filtering. A finding is a dict with a required `message` plus optional `severity` (`"error"`/`"warning"`/`"info"`, default `"warning"`), `line` / `column` (default `1`), and `start_byte` / `end_byte`. A script rule can also delegate to the structural engine and return the `rules_diagnostics(...)` result directly: ```harn,ignore import { rules_diagnostics } from "std/rules" pub fn lint(source) { const rule = "id = \"no-foo\"\nlanguage = \"harn\"\nmessage = \"no" + " foo\"\n[rule]\npattern = \"foo()\"\n" return rules_diagnostics({language: "harn", rule: rule, source: source}) } ``` Rules run in a read-only sandbox — the language, stdlib, and the structural rule engine, but no filesystem / network / process access. A buggy rule **fails safe**: a load error, runtime throw, or malformed return becomes a diagnostic attributed to the rule, never a linter crash. ## How do I load a native lint rule library? Native lint rules are the trusted-code escape hatch for rare cases that need compiled Rust. Build a dynamic library that exports `harn_native_lint_register_v1`, then point `harn.toml` at the directory that contains the library: ```toml [rules] nativeRuleDirs = ["native-rules"] ``` ```console harn lint src # loads native-rules/*.dylib|*.so|*.dll for this platform harn lint --fix src # applies native rule fixes through the normal lint path ``` The native ABI lives in `harn_lint::native`. A library registers one or more `HarnNativeRuleDescriptor` values and emits `HarnNativeDiagnostic` values with the same message, severity, span, suggestion, and fix fields used by built-in and declarative lint rules. Diagnostics carry the registered rule id, so `[lint] disabled = ["your-rule-id"]` and `[lint.severity]` overrides work the same way. Loading native code is an explicit trust decision. Harn only loads libraries from configured `nativeRuleDirs`; it does not search environment variables, global plugin paths, or package caches. ## See also - [Structured refactorings cookbook](./structured-refactorings.md) — the AST-edit primitives the engine builds on. - [Destructure with defaults cookbook](./destructure-with-defaults.md) — the flagship codemod in depth. - The `harn-rules` skill (`harn skill get harn-rules --full`). --- ## Read next - [Structured refactorings cookbook](https://harnlang.com/cookbooks/structured-refactorings.md) - [Destructure with defaults cookbook](https://harnlang.com/cookbooks/destructure-with-defaults.md) --- # Replace input?.x ?? default blocks with destructuring > The single most repeated shape in Harn-using code is optional-field extraction with a fallback: Website: https://harnlang.com/cookbooks/destructure-with-defaults.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. --- # Replace `input?.x ?? default` blocks with destructuring The single most repeated shape in Harn-using code is optional-field extraction with a fallback: ```harn,ignore const input = pipeline_input() ?? {} const path = input?.path ?? "" const namespace = input?.namespace ?? nil const retries = input?.retries ?? 0 const opts = input?.opts ?? {} ``` Every line repeats the source and the `?.` / `??` dance. Harn supports [destructuring with defaults](../spec/language/05-destructuring-patterns.md#default-values), so the whole block collapses to one bind: ```harn,ignore const { namespace = nil, opts = {}, path = "", retries = 0, } = pipeline_input() ?? {} ``` The two forms are equivalent — including the types each binding receives. ## The mechanical rewrite A run of `let = ?. ?? ` statements that share the same `` becomes a single dict pattern over that source: ```harn,ignore // before const cfg = load_config() ?? {} const host = cfg?.host ?? "0.0.0.0" const port = cfg?.port ?? 8080 const tls = cfg?.tls ?? false // after const { host = "0.0.0.0", port = 8080, tls = false } = load_config() ?? {} ``` Rules that keep the rewrite behavior-preserving: - **A missing key binds to its default.** `cfg?.host ?? "0.0.0.0"` and `{ host = "0.0.0.0" }` both apply the default when `host` is absent *or* `nil` — destructuring uses the same nil-coalescing semantics. - **A `nil` default stays optional.** `let { namespace = nil } = src` infers the same `T | nil` type as `src?.namespace ?? nil`, so downstream nil-checks still type-check. - **Rename when the binding name differs from the key.** `let id = src?.userId` becomes `let { userId: id } = src` (the `key: alias` form). Rest collects the leftovers: `let { host, ...rest } = src`. - **Dict-pattern keys are written alphabetically.** `harn fmt` orders them, so emit `{ host, port, tls }` not `{ port, host, tls }`. - **Rest elements take no default.** `...rest` always binds (`{}` / `[]` when empty), so it never needs a fallback. ## Type inference matches the hand-written form The binding types are inferred *exactly* as the `?.` / `??` expression would produce them, so migrating never loses precision under the type checker: ```harn,ignore const { port = 8080 } = load_config() ?? {} const p: int = port // ok — `port` infers `int` from the default const q: string = port // error: expected string, found int ``` When the source is a typed shape, present fields keep their declared type (`{ host: string }` ⇒ `host: string`); when the source is an untyped dict, the default's type carries through (`{ port = 8080 }` ⇒ `port: int`). > **Note:** positional/tuple-precise element types for *list* destructuring > (`let [a, b = 0] = xs`) are not yet inferred element-by-element — list > bindings take the homogeneous element type. Dict destructuring, where nearly > all of the savings are, is fully inferred. ## Migrating at scale For a whole codebase, drive the rewrite with the AST-precise edit primitives (see [Structured refactorings](./structured-refactorings.md)) rather than hand edits: match consecutive `let X = SRC?.K ?? D` statements sharing `SRC`, fold them into one dict pattern, and let `harn fmt` normalize key order. --- ## Read next - [Rule engine cookbook](https://harnlang.com/cookbooks/rules-engine.md) - [Burin compass cookbook](https://harnlang.com/cookbooks/burin-compass.md) --- # Burin compass: choose safer edit tools > Harn ships a set of AST-precise edit primitives — see the structured refactorings cookbook . They pay off when structural addressing, parse validation, or semantic-neighbor... Website: https://harnlang.com/cookbooks/burin-compass.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 ships a set of **AST-precise edit primitives** — see the [structured refactorings cookbook](./structured-refactorings.md). They pay off when structural addressing, parse validation, or semantic-neighbor updates prevent a real failure mode. For an exact localized change, a hash-guarded text patch can be simpler and equally safe. The **burin compass** helps specialized coding agents make that choice. It is a built-in [system-reminder](../system-reminders.md) provider, `compass_ast_edits`, that injects a standing reminder at session start (and on resume): > Choose the simplest safe edit mechanism for each change. Use the > AST-precise tools when structural addressing or semantic reach materially > reduces risk; use `edit_safe_text_patch` for exact localized changes even > when grammar support is available. Preview risky or multi-operation plans > with `edit_dry_run`. Both the reminder and routing layer ship **opt-in**. Unlike the other canonical providers — which are conditional and stay silent until they have something to say (project facts, a workspace anchor, token pressure) — a steer toward code edits is only wanted in code-editing sessions, not in every sub-agent or one-shot loop. So coding-agent personas and configs turn it on explicitly; once enabled it reaches every agent surface that runs the Harn agent loop — TUI, IDE, cloud-supervised. The reminder is marked `preserve_on_compact`, so the guidance survives a context compaction and keeps steering through a long session. ## The active routing layer The reminder is the *steer*; the **tool-rewrite router** is the active half of the compass (#2612). It is a per-tool-call hook in the agent loop that observes a freeform edit *before it runs* and acts on it. It sits at the single chokepoint every agent surface funnels through — after permission and pre-tool hooks, before the tool is dispatched — so it reaches the TUI, the IDE, and cloud-supervised loops with no per-surface wiring. It recognises a freeform / whole-file edit on a *parseable* source file: - a `str_replace`-shape call (`{path, old_text, new_text}` or a `hunks` array), - a whole-file `write_file` / `create_file`, - a single-hunk edit on a rename-capable language that reads like a symbol rename. For anything else — a structural call, a file with no tree-sitter grammar, an arg shape it doesn't understand — the router is inert and the call dispatches unchanged. It is **conservative by construction**: it never touches a call it cannot reason about. It runs in one of two modes: - **`suggest`** (the default after enabling Compass) — advisory. The router injects a one-turn system reminder naming the structural primitive the edit maps to (`edit_apply_node` for a node, `edit_rename_symbol` for a rename, or the hash-guarded `edit_safe_text_patch`), then dispatches the original call unchanged. The model stays in control; nothing is rewritten. - **`rewrite`** — silent substitution, but only when the structural form is *provably equivalent* to the freeform call. The one substitution the router can prove without reading the file is a raw text replace → `edit_safe_text_patch`: identical `old_text → new_text` matcher, but with a stale-base hash guard and staged-fs atomicity. A rename or a whole-file write is **not** byte-equivalent (a project-wide rename touches other files; a whole-file write rewrites untouched bytes), so the router falls back to a suggestion rather than guess. ### Observability Every decision increments a `harn.compass.*` counter via the standard `counter(...)` instrument, tagged with `harn.compass.persona`, `harn.compass.tool` (the freeform tool), and `harn.compass.target` (the structural tool): - `harn.compass.suggested` — an advisory routing decision fired. - `harn.compass.rewritten` — a call was silently substituted. - `harn.compass.fell_back` — `rewrite` mode considered a substitution but could not prove equivalence, so the original freeform call ran. The router also emits a live `compass_routing_decision` agent event before dispatch. ACP surfaces it on `_harn/agentEvent` with `toolCallId`, `mode`, `action` (`suggested`, `rewritten`, or `fell_back`), `persona`, `originalTool`, `routedTool`, `targetTool`, and `path` when the edit call named a file. The event carries routing metadata only; it does not include old or new file contents. These surface in eval dashboards as the agent-loop edit-reliability signal. ## Why a reminder, not a hard rewrite In `suggest` mode the compass *steers* rather than *rewrites*. A reminder keeps the model in control: it can choose an exact text patch when that is the simplest safe fit, and the router never silently changes the bytes a tool call would produce. That makes the behaviour predictable and auditable — the reminder is visible in the transcript like any other system reminder. `rewrite` mode is opt-in for exactly this reason: it only ever substitutes a provably-equivalent call. ## Turning it on The compass is a normal reminder provider, so the standard controls apply: - **Enable it** for a session or persona via the reminder config: `reminders.providers.compass_ast_edits = true`. It is registered as a canonical provider but ships `default_enabled: false`, so this opt-in is what activates the steer. - **Inspect it** alongside the other canonical providers — `compass_ast_edits` appears in the provider metadata listing with the purpose of helping coding agents choose the safest edit primitive for each change. ## Configuring the router (escape hatches) The router is off by default. Enable and configure it with the `compass` option passed alongside the agent-loop tools: - omitted, `compass: false`, or `compass: null` — off. Edit calls dispatch with no observation, reminder, or counter. - `compass: true` — enable advisory `suggest` mode. - `compass: {enabled: false}` or `compass: {mode: "off"}` — equivalent off switch in dict form. - `compass: {mode: "suggest"}` — advisory reminders only. - `compass: {mode: "rewrite"}` — silent substitution of provably-equivalent calls, with a fall-back-to-suggest safety net. - `compass: {prefer: ["edit_apply_node", ...]}` — a persona's ordering hint. The router consumes the `edit_strategy.prefer` list a persona already declares (e.g. `personas/fixer/manifest.harn`), so a persona that prefers node-level editing nudges a plain hunk edit toward `edit_apply_node` in its suggestion. When no `compass.prefer` override is set the router reads `edit_strategy.prefer` directly. The model can always ignore an advisory suggestion. A `suggest` reminder never blocks or alters the call, and even a `rewrite` only ever swaps in a behaviourally identical patch — so a deliberate localized text edit, unsupported file, or one-off raw write is always available. ## Composes with - [Structured refactorings cookbook](./structured-refactorings.md) — the tools the compass points at. - [Rename a symbol cookbook](./rename-symbol.md) — the cross-file rename the compass calls out by name. - [System reminders](../system-reminders.md) — the delivery mechanism. --- ## Read next - [Destructure with defaults cookbook](https://harnlang.com/cookbooks/destructure-with-defaults.md) - [Replay time-travel cookbook](https://harnlang.com/cookbooks/replay-time-travel.md) --- # Replay time-travel cookbook > harn replay rehydrates a recorded agent session from a SQLite EventLog and projects it deterministically. With --at you can rewind to any past event and replay the... Website: https://harnlang.com/cookbooks/replay-time-travel.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 replay` rehydrates a recorded agent session from a SQLite EventLog and projects it deterministically. With `--at ` you can rewind to **any past event** and replay the session as it stood at that point — the foundation for auditing "what had the agent seen by the time it made this decision?". ## Replay a whole session Every agent session writes its events to a durable EventLog. Point `harn replay` at that database and a session id: ```bash harn replay --session-id sess_42 --events-db ./.harn/agent-events.db ``` The command reconstructs the run record from the session's events, replays it, and reports the stages, transitions, and the replay-fixture verdict. Add `--json` for the structured `JsonEnvelope` shape (see [the CLI JSON contract](../cli-json-contract.md)). ## Rewind to a past event with `--at` Agent-session events carry a monotonically increasing `event_id`. Pass `--at ` to rehydrate only the prefix up to **and including** that event — the session is replayed exactly as it stood at that moment, with everything after the cutoff dropped: ```bash # Replay sess_42 as it was right after event 7. harn replay --session-id sess_42 --events-db ./.harn/agent-events.db --at 7 ``` The cutoff is inclusive and need not name an event that exists — `--at 5` over a session whose events are `[2, 4, 6]` keeps events `2` and `4`. A cutoff that precedes the first recorded event is rejected with a clear error rather than producing a silent empty replay. In `--json` mode the source summary records the cutoff: ```json { "source": { "kind": "event_log_session", "session_id": "sess_42", "events_db": "./.harn/agent-events.db", "at_event_id": 7 } } ``` The replay report's `transcript_event_count` reflects the truncated prefix, so you can diff the determinism of "the session up to event N" against the full run. ## Ask "what if?" with `--counterfactual` Rewinding shows you the state the agent saw. The next question is *"what if it had edited differently?"* — answer it without mutating the recorded session or the workspace. `--counterfactual ` evaluates an alternate edit plan after the session has been rehydrated at the `--at` cutoff and reports the **divergent file set**: the files the plan's edits would touch. ```bash harn replay --session-id sess_42 --events-db ./.harn/agent-events.db \ --at 7 --counterfactual ./what-if.harn ``` The `.harn` plan `return`s an edit plan — the same ordered list of typed ops [`edit_dry_run`](../stdlib/edit.md#edit_dry_run--preview-a-multi-op-plan) accepts. (A bare trailing expression returns `nil` in Harn, so the plan must use `return`.) ```harn // what-if.harn — the edit the agent *could* have made at event 7. return [ { op: "safe_text_patch", path: "src/lib.rs", old_text: "fn greet()", new_text: "fn greeter()", }, { op: "apply_node", path: "src/lib.rs", query: "(function_item body: (block) @target)", replacement: "{ format!(\"hi {name}!\") }", select: "first", }, ] ``` (A single plan that prefers to call `edit_dry_run` itself works too — `return edit_dry_run({plan: [...]})` — the divergence is read off the same `per_file_unified_diff` / `summary` shape.) The plan runner installs a copy-on-write filesystem overlay while it evaluates the `.harn` file, then runs the returned ops through `edit.dry_run`, which opens and immediately discards a throw-away **staged-fs** overlay. Accidental `harness.fs.write_text(...)` / hostlib writes in the plan program do not touch the working tree. The human output lists the divergent files: ```text Time-travelled to event 7: replaying the session as it stood at that point. Replay: sess_42 ... Counterfactual: ./what-if.harn (ok) would touch 1 file(s) (+2 / -2 lines, 2 op(s) applied, 0 rejected): modified src/lib.rs (+2 / -2) ``` In `--json` mode the divergence rides on the replay report under `data.counterfactual`: ```json { "data": { "counterfactual": { "plan_path": "./what-if.harn", "plan_paths": ["./what-if.harn"], "step_count": 1, "result": "ok", "diverged": [ { "path": "src/lib.rs", "status": "modified", "lines_added": 2, "lines_removed": 2 } ], "files_touched": 1, "lines_added": 2, "lines_removed": 2, "ops_applied": 2, "ops_rejected": 0 } } } ``` Each file's `status` is `created`, `modified`, or `deleted`, classified from its line deltas. **Counterfactual chains** can be one longer plan or repeated `--counterfactual` flags. Repeated flags are evaluated in order and their returned edit-op lists are concatenated into one `edit_dry_run`, so the shared staged overlay collapses the cumulative effect into one diff per file: ```bash harn replay --session-id sess_42 --events-db ./.harn/agent-events.db \ --at 7 \ --counterfactual ./rename.harn \ --counterfactual ./follow-up.harn ``` ## Audit a past run, ask what-if, ship the fix The typical loop: 1. **Find the decision.** Replay the whole session (`--json`) and scan the stages/transitions for the step you want to interrogate; note its `event_id`. 2. **Rewind.** Replay again with `--at ` to see exactly the context the agent had at that point — no later events leak in. 3. **Vary and verify.** Re-run the slice while changing the workspace or inputs the agent saw, and compare the new replay against the recorded one to confirm your fix changes the outcome you expected and nothing else. Because this projection is deterministic and the EventLog is append-only, the audit is reproducible: the same `--session-id … --at N` always rehydrates the same prefix. ## Re-execute a coding turn offline A run record answers "what did the saved run say?" An offline coding replay answers the stronger question: "does the recorded read, edit, and verification trajectory still produce the same result from a clean workspace?" Put the program, its recorded `--llm-mock` JSONL, and the initial files under one seed directory. The program receives the recreated workspace path as `argv[0]` and must return its `AgentResult`, including the producer-owned typed terminal. Keep the files whose mutations matter under `effect_root`; run artifacts can live elsewhere in the seed. ```json { "_type": "offline_coding_replay", "schema_version": "harn.offline-coding-replay.v1", "workspace_seed": "coding-turn", "program": "turn.harn", "llm_mock": "turn.llm-mock.jsonl", "effect_root": "repo", "expected": { "tools": [ { "id": "call-read", "name": "read_file", "status": "completed", "args_blake3": "1111111111111111111111111111111111111111111111111111111111111111", "result_blake3": "2222222222222222222222222222222222222222222222222222222222222222" }, { "id": "call-edit", "name": "edit_file", "status": "completed", "args_blake3": "3333333333333333333333333333333333333333333333333333333333333333", "result_blake3": "4444444444444444444444444444444444444444444444444444444444444444" }, { "id": "call-verify", "name": "run_command", "status": "completed", "args_blake3": "5555555555555555555555555555555555555555555555555555555555555555", "result_blake3": "6666666666666666666666666666666666666666666666666666666666666666", "exit_code": 0 } ], "effects": [ { "path": "src/lib.rs", "before_blake3": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "after_blake3": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ], "terminal": { "outcome": {"kind": "natural", "reason": "natural", "owner": "agent"}, "exit_code": 0 } } } ``` Run it through the normal replay command: ```bash harn replay --fixture fixtures/coding-turn.replay.json --json ``` Harn copies the seed to a new temporary workspace for every run, installs only the recorded LLM fixture, runs through the ordinary sandbox with process network disabled, and computes the final tree diff. The universal JSON envelope keeps replay data under `data`; `data.producer` identifies the CLI version and, for attested builds, source revision that performed the run. Each expected tool carries BLAKE3 digests of its canonical JSON arguments and semantic result. Harn normalizes workspace paths and runtime-only identifiers before hashing results. Terminal tool-event status, result digest, and any declared command exit code prove the execution outcome, while typed mock-consumption checkpoints distinguish the CLI tape from builtin fallback. The receipt names all five comparisons: tool sequence, workspace effects, terminal verdict, provider isolation, and network isolation. `pending_comparisons` and `missing_comparisons` are always present. A missing returned terminal, empty expected tool/effect evidence, an unmatched LLM prompt, an unexpected file change, or any mismatch exits non-zero. Use `--runs N` for repeated clean-room executions. Each run gets a new copy of the seed, so a previous run cannot make a later comparison pass. The `data.repeatability` receipt also compares every run's tools, effects, terminal, and exit code with the first run. --- ## Read next - [Burin compass cookbook](https://harnlang.com/cookbooks/burin-compass.md) - [Run a FLUX.2 Klein image job with ComfyUI](https://harnlang.com/cookbooks/run-comfyui-model-job.md) --- # Run a FLUX.2 Klein image job with ComfyUI > This guide runs Harn's checked-in image example against a local or remote GPU. The result is a PNG stored by SHA-256 plus a receipt that can be replayed without ComfyUI. Website: https://harnlang.com/cookbooks/run-comfyui-model-job.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. --- This guide runs Harn's checked-in image example against a local or remote GPU. The result is a PNG stored by SHA-256 plus a receipt that can be replayed without ComfyUI. ## Install the model Install ComfyUI, then follow its [FLUX.2 Klein guide](https://docs.comfy.org/tutorials/flux/flux-2-klein). The example expects these filenames: - `models/text_encoders/qwen_3_4b.safetensors` - `models/diffusion_models/flux-2-klein-4b-fp8.safetensors` - `models/vae/flux2-vae.safetensors` The [FLUX.2 Klein 4B model card](https://huggingface.co/black-forest-labs/FLUX.2-klein-4B) lists the Apache-2.0 license and GPU memory requirements. Start ComfyUI on `127.0.0.1:8188`. ComfyUI's API has no authentication, so do not bind it to a public interface. For a remote GPU, forward the loopback port: ```sh ssh -N -L 8188:127.0.0.1:8188 gpu-host ``` Confirm that the forwarded service responds: ```sh curl --fail http://127.0.0.1:8188/system_stats ``` ## Run the image job From the Harn repository root: ```sh COMFYUI_URL=http://127.0.0.1:8188 \ MODEL_JOB_IMAGE_PROMPT='A simple blue botanical emblem on a warm cream background, no text' \ harn run --no-sandbox examples/model-jobs/flux2-klein.harn ``` The example prints lifecycle events to stderr and a JSON receipt to stdout. The first `assets` entry contains the verified PNG path and its `asset://sha256/...` identity. This image came from the command above with seed `42` on an RTX 5090: ![A flat cornflower-blue botanical emblem generated through the Harn model-job adapter](./assets/model-job-flux2-klein-proof.png) The example uses `--no-sandbox` because it is a standalone file that downloads output from a loopback service. A packaged application should grant only its ComfyUI origin and output directory. See [Sandboxing](../sandboxing.md) for the filesystem and network policy controls. Set `MODEL_JOB_IMAGE_WIDTH`, `MODEL_JOB_IMAGE_HEIGHT`, or `MODEL_JOB_IMAGE_SEED` to override the 1024×1024 size and seed `42`. Keep each dimension supported by the model and available GPU memory. ## Test without a GPU Use `model_job_fake_backend` with an exact list of observations. The normal run loop still emits progress and stores output, but it makes no HTTP request. Use `harness.testing.http_mock` to test a ComfyUI backend response. This crosses the real submit, history, and output-decoding path without starting ComfyUI. The conformance fixture at `conformance/tests/stdlib/model_job_comfyui.harn` is a complete example. ## Replay a receipt Pass a completed receipt to `model_job_replay_backend`, then call `model_job_run_result` with the original request. Replay emits the recorded states and reads the recorded assets. A changed request or asset fails with a typed error. Read the [model-job reference](../stdlib/model-jobs.md) for the request, event, receipt, and error fields. --- ## Read next - [Replay time-travel cookbook](https://harnlang.com/cookbooks/replay-time-travel.md) - [Run an OpenAI image job](https://harnlang.com/cookbooks/run-openai-image-job.md) --- # Run an OpenAI image job > This guide sends one image request through Harn's model-job lifecycle. The OpenAI Responses API returns the image; Harn verifies and stores it by SHA-256. Website: https://harnlang.com/cookbooks/run-openai-image-job.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. --- This guide sends one image request through Harn's model-job lifecycle. The OpenAI Responses API returns the image; Harn verifies and stores it by SHA-256. ## Set the credential Make `OPENAI_API_KEY` available to the Harn process. Do not put the value in a script, checked-in configuration, command argument, or receipt. The API organization may need [verification](https://help.openai.com/en/articles/10910291-api-organization-verification) before it can use GPT Image models. ## Run the example From the Harn repository root: ```sh MODEL_JOB_IMAGE_PROMPT='A flat blue flower emblem on a cream background, no text' \ harn run --no-sandbox examples/model-jobs/openai-image.harn ``` The example uses `gpt-5.6-sol` with the Responses API image-generation tool and requests low-quality PNG output. Set `OPENAI_IMAGE_RESPONSE_MODEL` to choose a different Responses API model that supports the tool. OpenAI selects the GPT Image model behind that tool. The [OpenAI image-generation guide](https://developers.openai.com/api/docs/guides/image-generation) documents supported inputs, output controls, and current pricing. It recommends the Responses API for conversational or multi-step image editing. Use the Image API when the application needs a direct, single-request GPT Image model choice. The receipt is printed to stdout. Its first `assets` entry contains the output path and `asset://sha256/...` identity. ## Confirm the result This PNG came from the example's low-quality hosted path on August 1, 2026. Harn stored 898,270 bytes with SHA-256 `ba1826d69bb02712af24ced31800cc1197b37f963a034a4a8671142407917469`. ![Cornflower-blue botanical emblem generated through the OpenAI model-job adapter](assets/model-job-openai-proof.png) Check `job.state == "succeeded"`, then call `media_asset_verify_result` on the first asset before using it as an edit input or copying it to a product-owned location. ## Edit an image Set the request task to `image.edit` and pass one or more verified `MediaAsset` values in `request.inputs`. The adapter sends each asset as a base64 data URL. It rejects changed asset bytes before the network request. For a continued Responses API edit, set `request.params.previous_response_id` to the response ID stored in the first output asset's metadata. This keeps the prior image in API context. Read the [model-job reference](../stdlib/model-jobs.md) for lifecycle, error, and replay behavior. --- ## Read next - [Run a FLUX.2 Klein image job with ComfyUI](https://harnlang.com/cookbooks/run-comfyui-model-job.md) - [Build an interactive Harn app](https://harnlang.com/cookbooks/build-interactive-app.md) --- # Build an interactive Harn app > This guide builds a stateful app without app-specific JavaScript or Rust. The complete small example is examples/apps/decision-card.harn . Website: https://harnlang.com/cookbooks/build-interactive-app.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. --- This guide builds a stateful app without app-specific JavaScript or Rust. The complete small example is [`examples/apps/decision-card.harn`](https://github.com/burin-labs/harn/blob/main/examples/apps/decision-card.harn). ## 1. Declare a shared-renderer resource ```harn import * as ui from "std/ui" import { UiElement } from "std/ui/contracts" const resource = ui.app_resource( "ui://example/decision-card", "Decision Card", "decision.handle_event", ) ``` `ui.app_resource` packages the shared renderer as a standard `text/html;profile=mcp-app` resource. The third argument names the one Harn tool that receives UI events. ## 2. Render state as a document ```harn fn render(choice: string, revision: int) { const elements: list = [ {id: "card", kind: "column", variant: "panel"}, { id: "choice", kind: "select", parent: "card", label: "Decision", value: choice, options: [ {value: "approve", label: "Approve"}, {value: "revise", label: "Request changes"}, ], }, ] return ui.update(ui.document("Decision Card", revision, elements)) } ``` Parents must be rows or columns and appear before their children. IDs are unique and stable across renders. `ui.document` rejects invalid IDs and parent links before they reach a browser. ## 3. Handle typed events ```harn fn handle_event(raw) { const event = ui.event(raw.event) if event.kind == "input" && event.target == "choice" { choice = event.value ?? "approve" } revision = revision + 1 return render(choice, revision) } ``` Treat `raw` as untrusted. `ui.event` checks the schema, event kind, target, and canvas coordinates from `0.0` to `1.0` once at the boundary. ## 4. Register the tool and resource Give the tool an object return schema. Harn then sends the `UiUpdate` as MCP `structuredContent`, which avoids parsing presentation text. ```harn tools = tool_define( tools, "decision.handle_event", "Apply one UI event", { parameters: {event: {type: "object", required: true}}, returns: {type: "object"}, handler: handle_event, meta: ui.tool_metadata(resource, {visibility: ["app"]}), }) harness.tools.mcp_tools(tools) harness.tools.mcp_resource(ui.mcp_resource(resource)) ``` Use `visibility: ["app"]` when the model does not need to call the event tool. ## 5. Run and test ```sh harn app run examples/apps/decision-card.harn ``` Test the Harn event handler in process with `ui.test.run`. It follows scheduled `send_event` effects immediately, so polling and recovery tests do not use real sleeps. Use the standalone host for the final pointer, focus, accessibility, and screenshot checks. For a larger app that restores drawings after restart, runs local and hosted image models, cancels work, preserves exact logo text as a deterministic layer, exports a reopenable design document plus editable SVG and PNG, and replays recorded output, run: ```sh harn app run examples/apps/logo-studio.harn ``` The replay model is offline. Local editing uploads the verified canvas capture to the ComfyUI server named by `COMFYUI_URL`, then runs FLUX.2 Klein against that reference image. Hosted editing expects `OPENAI_API_KEY` in the process environment. The app paints its working state before sending the hosted call. Unlike the queued ComfyUI path, that hosted request cannot be canceled after it has been sent. Exact lettering belongs in `std/media/composition`, not in the stochastic image prompt. Keep a dedicated logo-text field, ask the model to polish the mark without rendering words, then export with `design_document_export_result`. The design document and SVG keep real text nodes; the PNG is the verified image layer. Edit jobs also record the sketch URI on each candidate's `parents` list. Prove the path in process with `conformance/tests/stdlib/logo_studio_path.harn` (`ui.test.run` plus a fake model backend). Use the standalone host for pointer, focus, accessibility, and screenshot checks. The local path below used a mouse sketch, two directions, and FLUX.2 Klein on an RTX 5090. The app uploaded the verified canvas capture, showed the returned image, and exported the layered design document, editable SVG, and PNG through the selected output stem. ![Logo Studio after a local FLUX.2 image edit](assets/logo-studio-local-flux.png) ## 6. Check interaction and recovery Use stable element IDs so focus, tests, and host actions address the same control after each update. Give every field a label, use heading levels in order, give images useful alternative text, and provide buttons for canvas actions that cannot be done with a keyboard. Keep the last successful result visible when a new model call fails. Show the failure in the app status, leave the retry action available, and disable any setting that would change the backend of a running job. If cancellation fails, keep tracking the job and try another status check. Before writing a result file, create its parent directory and turn file errors into app status instead of a broken tool call. Restart the host during the manual test and confirm that strokes, directions, the selected model, a running job, and the last verified image reopen from the app's work record. The output field accepts a path relative to the app's working directory or an absolute path allowed by the host's file policy. Keep that policy narrow when embedding an app that should only write inside one project or export folder. Check these edges before shipping: - a click without pointer movement still creates a visible canvas dot; - one stroke never sends more than 4,096 points; - invalid element IDs, parent links, heading levels, canvas sizes, stroke widths, and non-finite coordinates fail before rendering; - an unreachable provider returns a typed model-job error and a later retry can succeed; - data-image previews load under the host and resource security policies; - the write button reports an unwritable path without losing the generated asset. --- ## Read next - [Run an OpenAI image job](https://harnlang.com/cookbooks/run-openai-image-job.md) - [Run Harn app logic in the browser](https://harnlang.com/cookbooks/run-app-logic-in-browser.md) --- # Run Harn app logic in the browser > Use a browser reducer when pointer, keyboard, or form events should update the view without a server round trip. The same compiled Harn program also runs in the server... Website: https://harnlang.com/cookbooks/run-app-logic-in-browser.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. --- Use a browser reducer when pointer, keyboard, or form events should update the view without a server round trip. The same compiled Harn program also runs in the server fallback, so the app has one behavior to test and maintain. The complete example is [`examples/apps/portable-counter.harn`](https://github.com/burin-labs/harn/blob/main/examples/apps/portable-counter.harn). ## 1. Write a reducer A reducer receives the current state and one event. It returns the next state and one `UiUpdate`: ```harn fn reduce(input) { let count = input.state.count if input.event.kind == "click" && input.event.target == "add" { count = count + 1 } const state = {count: count, revision: input.state.revision + 1} return { state: state, update: { schema: "harn.ui_update.v1", document: render(state), effects: [], }, } } ``` Keep the reducer deterministic. Browser objects, files, network clients, and model clients stay outside its state. Use plain records, lists, strings, numbers, booleans, bytes, and `nil`. ## 2. Compile it once Compile the reducer with `std/portable` and stop on a diagnostic: ```harn import * as portable from "std/portable" const compiled = portable.compile(REDUCER_SOURCE, "reduce") if !is_ok(compiled) { throw "reducer did not compile: " + json_stringify(unwrap_err(compiled)) } const program = unwrap(compiled) ``` This artifact is the behavior shared by the browser and server. Do not write a second JavaScript or Rust reducer. ## 3. Keep the server fallback on the same artifact The host passes the browser's latest state when it must fall back to the event tool. Run the same artifact with that state and return the complete reducer result: ```harn let state = {count: 0, revision: 0} fn handle_event(raw) { const input_state = raw.state ?? state const execution = portable.start( program, {state: input_state, event: raw.event}, ) if execution.status != "completed" { throw "reducer failed: " + json_stringify(execution) } state = execution.value.state return execution.value } ``` Using `raw.state` prevents a worker restart from rewinding earlier browser events. Keep the local `state` value for hosts that call only the server tool. ## 4. Register one app resource `ui.portable_app_resource` packages the artifact, initial state, and fallback tool with the shared renderer: ```harn import * as ui from "std/ui" const resource = ui.portable_app_resource( "ui://example/counter", "Counter", "counter.handle_event", program, state, ) ``` Register `counter.handle_event` and `resource` as shown in [Build an interactive Harn app](./build-interactive-app.md#4-register-the-tool-and-resource). The app view cannot create workers or fetch the Harn runtime. The standalone host owns a worker in its trusted sandbox and accepts only the typed portable messages. An MCP Apps host without that worker support uses the standard event tool immediately. ## 5. Call a host tool when needed Pass `["tools.invoke"]` as the capability list when the reducer must call a registered tool. The portable kernel pauses, the host performs the exact tool call, and the kernel continues with its typed result. No other browser capability is accepted by `std/ui` today. Keep state changes after the tool result. Make externally visible tool actions safe to retry because a browser or process can stop after the action succeeds but before the final view update arrives. ## 6. Run and prove both paths Start the app: ```console harn app run examples/apps/portable-counter.harn ``` For a browser claim, click the controls and confirm the view reports the portable runtime in its `data-runtime` attribute. A changed counter alone is not proof because the server fallback produces the same pixels. Test the reducer through `portable.start` with exact state and events. Then call the fallback tool with the same state and compare its `{state, update}` result. The browser worker tests run through `make wasm-check`; the fast worker ordering and suspend/resume tests run through `make check-app-host`. See the [`std/ui` reference](../stdlib/ui.md#browser-reducers) for the exact resource signature and the [portable kernel contract](../portable-kernel-reference.md) for supported Harn constructs, limits, grants, and diagnostics. --- ## Read next - [Build an interactive Harn app](https://harnlang.com/cookbooks/build-interactive-app.md) - [OAuth client + provider cookbook](https://harnlang.com/oauth.md) --- # OAuth > Harn ships a complete OAuth 2.x client stack as part of the standard library. One handle covers the human authorization-code dance, headless device flow, transparent refresh,... Website: https://harnlang.com/oauth.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 ships a complete OAuth 2.x client stack as part of the standard library. One handle covers the human authorization-code dance, headless device flow, transparent refresh, server-side dynamic client registration, pluggable token storage, and token-shape redaction. Every piece is RFC-shaped so it slots into existing identity providers without provider-specific Rust code. | Module | RFC | Purpose | |---|---|---| | `std/oauth/providers` | — | Catalogue of preconfigured providers + `custom(...)` factory | | `std/oauth/token_exchange_catalog` | 8693 | Shipped token-exchange capability row data | | `std/oauth/token_exchange` | 8693 | Token-exchange row loading + `act` claim helpers | | `std/oauth/storage` | — | Five interchangeable token stores (memory, file, cloud session/org, custom) | | `std/oauth/client` | 6749 + 7636 + 8693 + 9700 | Authorization-code flow with PKCE S256, RFC 8693 token exchange, transparent refresh, 401-retry | | `std/oauth/device_flow` | 8628 | Headless device authorization grant | | `std/oauth/dynamic_registration` | 7591 + 8414 | Worker-side metadata + dynamic client registration | | `std/oauth/redaction` | — | OAuth-token catalog + `HARN-OAU-001` audit ring | The CLI surface (`harn connect `) is documented separately in [Connector OAuth](./orchestrator/oauth.md); this page covers the scripting API for code that runs *inside* a Harn pipeline. ## The 30-second tour ```harn,ignore import { providers } from "std/oauth/providers" import { memory } from "std/oauth/storage" import { client, exchange_code, request, start_authorization, token, token_exchange, } from "std/oauth/client" const cli = client( providers().github, { client_id: harness.env.get("GITHUB_OAUTH_CLIENT_ID"), client_secret: harness.env.get("GITHUB_OAUTH_CLIENT_SECRET"), scopes: ["read:user", "user:email"], redirect_uri: "http://127.0.0.1:8765/callback", storage: memory(), }, ) // One-time: send a human through the browser dance. const pkce = start_authorization(cli) // (host: open pkce.url, capture redirected code+state, then) const _ = exchange_code(cli, pkce, code, state) // Steady state: client owns refresh + 1x retry on 401. const res = request(cli, "GET", "https://api.github.com/user") ``` ## Providers (`std/oauth/providers`) A `Provider` is a dict that captures every endpoint, scope default, and documented quirk the rest of the OAuth stack needs to drive a real IdP. Built-in providers (returned by named factories or the `providers()` namespace) are validated against vendor docs, and you can override any field per-call: ```harn,ignore import { atlassian, github, github_enterprise, providers, } from "std/oauth/providers" // public github.com const gh = github() // enterprise server const ghe = github_enterprise("https://ghe.example.com") const conf = atlassian({ default_scopes: ["read:jira-work", "offline_access"] }) // {github, slack, ..., custom, github_enterprise} const all = providers() ``` Built-ins: `github`, `github_enterprise`, `slack`, `linear`, `notion`, `google`, `microsoft`, `atlassian`, `discord`, `gitlab`, `bitbucket`. Plus `custom(config, overrides?)` for in-house IdPs. A provider record carries: | Field | Notes | |---|---| | `id` / `label` | Used as the default storage key and for diagnostics | | `auth_url` / `token_url` | Authorization-code endpoints | | `device_code_url?` | Present iff the provider supports RFC 8628 device flow | | `revoke_url?` | RFC 7009 best-effort; the client always discards locally | | `userinfo_url?` | OIDC `/userinfo` analogue when one exists | | `default_scopes` | Used when `opts.scopes` is not set | | `pkce_required` | Informational; PKCE is unconditionally used by the client | | `refresh_handling` | `{strategy, refresh_grant, rotates_refresh_token, notes}` | | `token_exchange?` | RFC 8693 support row; custom providers can opt in with data | | `documented_quirks` | Provider-specific constraints and gotchas | | `documentation_url` | Vendor doc link for "go read the source" | `provider("github", overrides?)` is a string-keyed factory for the same records, and `provider_catalog(overrides?)` returns all ten built-ins keyed by id. ## Storage (`std/oauth/storage`) Token storage is a single three-closure protocol: ```text storage.get(key) -> TokenSet | nil storage.set(key, token_set, ttl_seconds = nil) -> nil storage.delete(key) -> nil storage.with_refresh_lock(key, { -> ... }) -> any ``` Pick a backend; the OAuth client never knows the difference. | Constructor | Persists? | Best for | |---|---|---| | `memory()` | no | tests, short-lived scripts | | `file(path, key)` | yes (AES-256-GCM) | local development, single-host operators | | `harn_cloud_session()` | yes (host cap) | one user's cloud-managed agent | | `harn_cloud_org()` | yes (host cap) | shared org credentials ("the org's GitHub bot") | | `custom({get, set, delete, with_refresh_lock?, id?})` | depends | vaults, KMS, platform keychains | ```harn,ignore import { custom, file, harn_cloud_org, memory } from "std/oauth/storage" const dev = memory() const disk = file( "/var/lib/harn/oauth.bin", harness.env.get("HARN_OAUTH_KEY"), ) // org-shared bot const cloud = harn_cloud_org() const vault = custom({ get: { key -> vault_get("oauth/" + key) }, set: { key, token_set, ttl_seconds = nil -> vault_put("oauth/" + key, token_set) }, delete: { key -> vault_delete("oauth/" + key) }, with_refresh_lock: { key, body -> vault_with_lock("oauth/" + key, body) }, }) ``` A custom backend MUST delegate to a real store (HTTP/MCP/vault) inside its closures rather than a captured local, so state survives process restarts and is shared across sessions. See the full reference in [OAuth storage stdlib](./stdlib/oauth-storage.md). ## Authorization-code client (`std/oauth/client`) `client(provider, opts)` builds a handle that owns the token lifecycle. ```harn,ignore import { client, exchange_code, refresh, request, revoke, start_authorization, token, token_exchange, } from "std/oauth/client" const cli = client(provider, { client_id: string, storage: , // confidential clients only client_secret?: string, // default: provider.default_scopes scopes?: list, // required for start_authorization redirect_uri?: string, // defaults to provider.id storage_key?: string, token_auth_method?: "none" | "client_secret_post" | "client_secret_basic", // appended to /authorize, /device audience?: string, // raw passthrough on /authorize extra_auth_params?: dict, }) ``` The handle exposes seven operations. Each one is also re-exported as a standalone helper that takes the handle as the first argument: | Helper | Behavior | |---|---| | `start_authorization(cli)` | Returns `{url, state, code_verifier, code_challenge, ...}` | | `exchange_code(cli, pkce, code, state)` | Validates PKCE + state, persists the TokenSet | | `token(cli)` | Returns a valid access token (refresh on >=75% TTL) | | `refresh(cli)` | Forces a refresh, ignoring TTL | | `request(cli, method, url, opts?)` | Token-bearing HTTP with 1x 401 retry | | `token_exchange(cli, opts)` | RFC 8693 token exchange; `actor_token` present means delegation, absent means impersonation | | `revoke(cli)` | RFC 7009 best-effort + local storage delete | | `cli.current_token()` | Reads the stored TokenSet without refresh | ### What the client guarantees - **PKCE S256 is unconditional.** `start_authorization` generates a 64-byte CSPRNG verifier (base64url-no-pad, ~86 chars) and a SHA-256 S256 challenge. `code_challenge_method=S256` is hardcoded. - **State always enforced.** `exchange_code` raises on `state` mismatch before touching the token endpoint. - **Transparent refresh.** `token(cli)` re-reads storage every call and refreshes if the stored TokenSet is past 75% TTL or already expired. Refreshes run under `storage.with_refresh_lock(...)` and re-read inside that transaction, so TTL-triggered, explicit, and post-401 callers sharing a storage key collapse to one refresh grant. - **One retry on 401.** `request(cli, ...)` performs a refresh and replays the request exactly once when the server returns 401. If another worker already rotated the token while this request waited for the storage lock, the retry uses the fresh stored token without spending another refresh grant. - **Token exchange is data-gated.** `token_exchange(cli, opts)` validates subject, actor, and requested token types against `std/oauth/token_exchange` capability rows. Custom providers opt in with a `token_exchange` row; the grant returns a TokenSet and only persists it when `store: true` and `storage_key` are supplied. - **Refresh-token preservation.** Token responses that omit a fresh `refresh_token` keep the prior one (relevant for Google + Slack + Discord, which only rotate on consent). - **Audit log without secrets.** Refresh / exchange / revoke each emit `oauth.client.audit` (`token_refreshed` / `token_exchanged` / `token_revoked`) with presence flags + expiry timestamps. The access token never lands in the audit payload. - **Storage is the source of truth.** Concurrent `token(cli)` or `refresh(cli)` calls may observe the same token, but only the lock holder issues the refresh. Waiters re-read inside the lock and reuse the stored rotated access/refresh token. - **Storage key defaults to `provider.id`.** Pass `storage_key` to fan out multiple installations of the same provider (e.g. one GitHub OAuth app per tenant). ### Diagnostic codes | Code | Source | Meaning | |---|---|---| | `HARN-OAU-001` | `std/oauth/redaction` | A persisted sink redacted an OAuth-shaped token | | `HARN-OAU-002` | `std/oauth/client` | No refresh_token available; re-run authorization | | `HARN-OAU-005` | `std/oauth/dynamic_registration` | RFC 7591 metadata validation rejected a candidate | `HARN-OAU-002` is the signal to drive a fresh `start_authorization` (or `device_flow`) — refresh failure is terminal until human consent runs again. ## Token exchange (`std/oauth/token_exchange`) RFC 8693 lets an OAuth client trade one token for another at the token endpoint. Harn keeps provider support in overlayable data rows under `std/oauth/token_exchange_catalog`, not provider-specific code. ```harn,ignore import { client, token_exchange } from "std/oauth/client" import { custom } from "std/oauth/providers" import { memory } from "std/oauth/storage" import { delegated_claims, token_type } from "std/oauth/token_exchange" const provider = custom({ id: "enterprise-as", auth_url: "https://idp.example/authorize", token_url: "https://idp.example/token", token_exchange: { supported: true, token_url: "https://idp.example/token", subject_token_types: [token_type("access_token")], actor_token_types: [token_type("jwt")], requested_token_types: [token_type("access_token")], issued_token_types: [token_type("access_token")], delegation: true, impersonation: true, }, }) const cli = client( provider, {client_id: "agent-client", storage: memory()}, ) const delegated = token_exchange(cli, { subject_token: user_access_token, subject_token_type: token_type("access_token"), actor_token: agent_jwt, actor_token_type: token_type("jwt"), requested_token_type: token_type("access_token"), audience: "hr-service", scope: ["employee:read"], }) ``` `actor_token` present selects delegation; omitting it selects impersonation. `resource`, `audience`, and `scope` accept the RFC 8693 targeting parameters, and `extra_params` carries deployment-specific fields. Returned tokens are not written to the client's normal `storage_key`; pass `{store: true, storage_key: "..."}` when the delegated token should be persisted separately. The companion `delegated_claims(subject_claims, actors)` helper builds RFC 8693 nested `act` claims with actors ordered current-to-prior, so `delegated_claims({sub: "user"}, [{sub: "svc16"}, {sub: "svc77"}])` produces `{sub: "user", act: {sub: "svc16", act: {sub: "svc77"}}}`. The shipped catalog also records fast-moving identity-chain drafts so provider overlays can opt in without changing runtime code: | Row | Detection/tracking | | --- | --- | | `id-jag` | Detects `urn:ietf:params:oauth:token-type:id-jag` in `identity_chaining_requested_token_types_supported` and tracks `draft-ietf-oauth-identity-assertion-authz-grant-04` plus `draft-ietf-oauth-identity-chaining-14`. | | `txn-token` | Tracks `draft-ietf-oauth-transaction-tokens-08` and the agent-context companion `draft-araut-oauth-transaction-tokens-for-agents-02`. Uses `requested_token_type = urn:ietf:params:oauth:token-type:txn_token`. | | `wimse-wit-wpt` | Tracking-only row for `draft-ietf-wimse-workload-creds-01` and `draft-ietf-wimse-wpt-01`. WIT/WPT are proof-of-possession workload credentials, not a bearer token-exchange profile. | Rows expose `provider_metadata_fields` and `tracking.drafts` so applications can surface provider capability matches, keep draft links near policy decisions, and replace a tracking row with a stricter provider-specific overlay when an authorization server publishes concrete support. ## Device flow (`std/oauth/device_flow`) For CI runners, daemons, and IDE side panes that cannot redirect a browser, `device_flow(provider, opts)` runs the full RFC 8628 dance and persists the resulting TokenSet into the same storage backend the authorization-code client uses. ```harn,ignore import { device_flow } from "std/oauth/device_flow" import { providers } from "std/oauth/providers" import { memory } from "std/oauth/storage" const token_set = device_flow( providers().github, { client_id: harness.env.get("GITHUB_OAUTH_CLIENT_ID"), scopes: ["read:user"], storage: memory(), on_user_code: { user_code, verification_uri -> harness.stdio.log( "Open " + verification_uri + " and enter " + user_code ) }, }, ) ``` `on_user_code` is optional — the default writes the URL and code to stderr so an operator can complete the dance manually. The poll loop honors the server-supplied `interval`, treats `authorization_pending` as a soft retry, adds 5s on `slow_down`, and raises on `expired_token` or `access_denied`. The TokenSet is persisted before `device_flow` returns, so the very next `client(...)` handle that targets the same storage + `storage_key` picks it up without further authorization. Audit: every successful exchange emits `oauth.device_flow.audit` `token_obtained`. The `device_code` / `user_code` are never persisted or logged. ## Dynamic registration (`std/oauth/dynamic_registration`) This module is the *server side* of OAuth — covers the case where Harn acts as a resource (or auxiliary service) that other agents register clients against. It does not itself host HTTP; embedders (a cloud platform, `harn serve`, custom hosts) mount the returned metadata documents and the registration handler. ```harn,ignore import { authorization_server_metadata, client_metadata, dynamic_registration_store, register_client, validate_metadata, well_known_paths, well_known_response, } from "std/oauth/dynamic_registration" import { providers } from "std/oauth/providers" // {client_metadata, authorization_server_metadata, registration} const paths = well_known_paths() const oas = authorization_server_metadata( providers().github, {registration_endpoint: paths.registration}, ) // {status, content_type, headers, body} const oas_response = well_known_response(oas) const store = dynamic_registration_store() const body = register_client(store, { redirect_uris: ["https://app.example/cb"], client_name: "Acme Agent", }) // body.client_id, body.client_secret (returned ONCE), // body.client_id_issued_at, ... ``` `validate_metadata(metadata)` returns `{ok, errors}` against RFC 7591 §2; each error is prefixed `HARN-OAU-005:` for stable pattern matching. Validation is strict by default — `redirect_uris` must be absolute `https://` or loopback `http://` per RFC 8252 §7.3, and grant / response types and `token_endpoint_auth_method` are restricted to the spec-blessed enums. `get_client(store, client_id)` reads a registration back without `client_secret` — the secret is only ever returned by the original `register_client` call. ## Redaction (`std/oauth/redaction`) The redaction module recognizes a catalog of high-confidence token patterns (JWT, GitHub PAT classic + fine-grained, Slack `xox*`, AWS `AKIA`, OpenAI `sk-`, Stripe `sk_live_`/`sk_test_`, GitLab `glpat-`, npm `npm_`, `Authorization: Bearer ...`). Persisted transcripts, audit receipts, OTel span attributes, and system reminders run every string through the catalog and replace matches with `:>`. The original token still flows to the underlying tool — redaction is display-only. ```harn,ignore import { clear_custom_patterns, custom_patterns, default_patterns, drain_audit, redact, register_pattern, } from "std/oauth/redaction" register_pattern("acme_api_key", "\\bACME-[A-Z0-9]{12}\\b") const display = redact("ACME-DEADBEEF1234 calling") for entry in drain_audit() { // entry.code == "HARN-OAU-001" // entry.pattern, entry.match_count, entry.bytes_redacted } ``` `drain_audit()` is the authoritative compliance contract — it works on every execution backend. Audit entries are also forwarded to the live event-sink pipeline and (when a multi-threaded Tokio runtime is available) appended to the `audit.token_redaction` event-log topic. ## Provider cookbook Each recipe is a complete authorization-code or device-flow snippet plus the provider-specific gotcha you usually only discover by reading the vendor docs. ### GitHub ```harn,ignore import { client, exchange_code, request, start_authorization, } from "std/oauth/client" import { providers } from "std/oauth/providers" import { memory } from "std/oauth/storage" const cli = client( providers().github, { client_id: harness.env.get("GITHUB_OAUTH_CLIENT_ID"), client_secret: harness.env.get("GITHUB_OAUTH_CLIENT_SECRET"), scopes: ["read:user", "user:email", "repo"], redirect_uri: "http://127.0.0.1:8765/callback", storage: memory(), }, ) const pkce = start_authorization(cli) // host: open pkce.url, capture code + state from the redirect const _ = exchange_code(cli, pkce, code, state) const user = request(cli, "GET", "https://api.github.com/user") ``` GitHub OAuth-app access tokens may be long-lived without a `refresh_token`. If you need explicit expiry + refresh, register an **expiring user-to-server token** under the OAuth app settings; the catalog handles both shapes transparently and falls back to the "no-refresh" branch when the token response omits `expires_in`. GitHub device flow uses the same client — pass `providers().github` into `device_flow(...)` instead of `client(...)`. **Note:** device flow must be enabled on the app registration (Developer settings → OAuth Apps → "Enable Device Flow") before the device endpoint will issue codes. ### Slack ```harn,ignore import { client, exchange_code, request, start_authorization, } from "std/oauth/client" import { providers } from "std/oauth/providers" import { file } from "std/oauth/storage" const cli = client( providers().slack, { client_id: harness.env.get("SLACK_CLIENT_ID"), client_secret: harness.env.get("SLACK_CLIENT_SECRET"), scopes: ["app_mentions:read", "chat:write"], redirect_uri: "https://app.example/oauth/slack/callback", storage: file( "/var/lib/harn/slack.bin", harness.env.get("HARN_OAUTH_KEY"), ), }, ) ``` Token rotation gotcha: when Slack token rotation is enabled, the issued `refresh_token` is **single-use**. Two concurrent `request(cli, ...)` calls that both decide to refresh will race on the storage `set` — the second writer wins and the first refresh is effectively wasted. The client's 75% TTL pre-refresh window keeps the race narrow, but pin refresh to a single worker if you have a high-fanout deployment. Slack's bot scopes and user scopes are separate from the "Sign in with Slack" identity scopes — pick the right scope family before requesting consent. ### Linear ```harn,ignore import { client, exchange_code, request, start_authorization, } from "std/oauth/client" import { providers } from "std/oauth/providers" import { harn_cloud_org } from "std/oauth/storage" const cli = client( providers().linear, { client_id: harness.env.get("LINEAR_CLIENT_ID"), client_secret: harness.env.get("LINEAR_CLIENT_SECRET"), scopes: ["read", "write", "issues:create"], redirect_uri: "https://app.example/oauth/linear/callback", storage: harn_cloud_org(), // per-team isolation storage_key: "linear:" + team_id, }, ) ``` Two Linear quirks the catalog handles for you: - **Scopes are comma-separated** in Linear's authorization URL (not space-separated like the rest of OAuth-land). The provider record sets the scope separator automatically. - **User info is a GraphQL query**, not a REST endpoint. After the exchange, run `request(cli, "POST", "https://api.linear.app/graphql", {body: "{\"query\":\"{viewer{id name}}\"}"})` instead of GET-ing a `/me` URL. Use `storage_key: "linear:" + team_id` to keep per-team installations isolated under the same provider record. ### Notion ```harn,ignore import { client, exchange_code, request, start_authorization, } from "std/oauth/client" import { providers } from "std/oauth/providers" import { harn_cloud_session } from "std/oauth/storage" const cli = client( providers().notion, { client_id: harness.env.get("NOTION_CLIENT_ID"), client_secret: harness.env.get("NOTION_CLIENT_SECRET"), redirect_uri: "https://app.example/oauth/notion/callback", storage: harn_cloud_session(), // user-owned public connection extra_auth_params: {owner: "user"}, }, ) const pages = request( cli, "GET", "https://api.notion.com/v1/users/me", {headers: {"Notion-Version": "2022-06-28"}}, ) ``` Two Notion-specific things to remember: - **Database / page access is not OAuth scopes.** The user picks pages during the Notion page-picker flow on `/authorize`; subsequent API calls can only see what the user granted. Plan your UX around the picker, not around incremental scope upgrades. - **`Notion-Version` is required on every API call** after the OAuth dance completes. Add it to the `opts.headers` you pass into `request(...)` or set it inside a tiny wrapper. ### Google ```harn,ignore import { client, exchange_code, refresh, request, start_authorization, } from "std/oauth/client" import { providers } from "std/oauth/providers" import { file } from "std/oauth/storage" const cli = client( providers().google, { client_id: harness.env.get("GOOGLE_CLIENT_ID"), client_secret: harness.env.get("GOOGLE_CLIENT_SECRET"), scopes: [ "openid", "email", "profile", "https://www.googleapis.com/auth/drive.readonly", ], redirect_uri: "http://127.0.0.1:8765/callback", storage: file( "/var/lib/harn/google.bin", harness.env.get("HARN_OAUTH_KEY"), ), extra_auth_params: {access_type: "offline", prompt: "consent"}, }, ) ``` Google's refresh-token model is the most surprising one in the catalog: - **Refresh tokens are only issued on first consent** (or when `prompt=consent` is forced). Subsequent grants reuse the existing refresh token. The catalog preserves the prior `refresh_token` across refreshes that don't include one — but if you delete the storage entry and re-run authorization without `prompt=consent`, you will get back an access token with no refresh capability. - **Workspace consent screens** require the OAuth client app to be marked "Internal" or to go through verification before users outside the publishing project can grant the scopes. - **Incremental authorization** is preferred for product-specific scopes (Gmail, Drive, Calendar). Set `extra_auth_params: {include_granted_scopes: "true"}` and request additional scopes via fresh authorization rounds instead of asking for everything up front. ### Microsoft ```harn,ignore import { client, request, start_authorization } from "std/oauth/client" import { microsoft } from "std/oauth/providers" import { harn_cloud_org } from "std/oauth/storage" const tenant = harness.env.get("MS_TENANT_ID") const host = "https://login.microsoftonline.com/" const base = host + tenant + "/oauth2/v2.0" const cli = client( microsoft({ auth_url: base + "/authorize", token_url: base + "/token", }), { client_id: harness.env.get("MS_CLIENT_ID"), client_secret: harness.env.get("MS_CLIENT_SECRET"), scopes: [ "openid", "profile", "email", "offline_access", "User.Read", "Mail.Read", ], redirect_uri: "https://app.example/oauth/microsoft/callback", storage: harn_cloud_org(), }, ) const me = request(cli, "GET", "https://graph.microsoft.com/v1.0/me") ``` Microsoft Identity has two ergonomic traps: - **Graph delegated scopes are not OIDC claims.** `openid`, `profile`, `email`, `offline_access` are claim scopes (your access token still needs them for refresh + ID-token contents). `User.Read`, `Mail.Read`, etc. are *resource permissions* on Microsoft Graph — granting one without the matching resource permission yields a token Graph cannot use. - **Audience claim ≠ access token target.** The token returned for the Graph audience is *not* valid against custom-API audiences. If you also need to call a custom resource, run a second `client(...)` with the per-resource scopes (Microsoft does not issue multi-audience tokens). Use `storage_key` to keep the two TokenSets distinct. Use a tenant-specific URL (above) when an app is single-tenant. The default `/common` route is the right choice for multi-tenant apps; it only resolves the user's home tenant at consent time. ### Atlassian (Jira + Confluence) ```harn,ignore import { client, exchange_code, request, start_authorization, } from "std/oauth/client" import { providers } from "std/oauth/providers" import { harn_cloud_org } from "std/oauth/storage" const cli = client( providers().atlassian, { client_id: harness.env.get("ATLASSIAN_CLIENT_ID"), client_secret: harness.env.get("ATLASSIAN_CLIENT_SECRET"), scopes: [ "read:jira-work", "read:confluence-content.summary", "offline_access", ], redirect_uri: "https://app.example/oauth/atlassian/callback", storage: harn_cloud_org(), audience: "api.atlassian.com", }, ) // 1) Resolve accessible cloud sites (one token covers Jira AND Confluence // on each). const sites = request( cli, "GET", "https://api.atlassian.com/oauth/token/accessible-resources", ) // 2) Use the returned cloudid for product calls: // https://api.atlassian.com/ex/jira//rest/api/3/myself // https://api.atlassian.com/ex/confluence/ // /wiki/rest/api/user/current ``` Atlassian's 3LO flow is one OAuth client for both products — Jira and Confluence share scopes, the same `audience=api.atlassian.com`, and the same token. The provider record sets the audience for you; you only need to request the union of scopes the agent will use across both products. Refresh tokens **rotate on every refresh** — the prior refresh token is invalidated as soon as the new one is issued. The OAuth client persists the new refresh on each successful refresh; do not cache a copy in your own code. ### Discord ```harn,ignore import { client, exchange_code, request, start_authorization, } from "std/oauth/client" import { providers } from "std/oauth/providers" import { file } from "std/oauth/storage" const cli = client( providers().discord, { client_id: harness.env.get("DISCORD_CLIENT_ID"), client_secret: harness.env.get("DISCORD_CLIENT_SECRET"), // user scope scopes: ["identify", "email"], redirect_uri: "https://app.example/oauth/discord/callback", storage: file( "/var/lib/harn/discord.bin", harness.env.get("HARN_OAUTH_KEY"), ), }, ) ``` Discord has a sharp split between **bot tokens** (long-lived, scoped to a guild via the `bot` scope on a single one-time installation) and **user tokens** (the OAuth dance above, with `identify` / `email` / `guilds` user scopes). Mixing them throws off intent: a bot token does not respond to user-API endpoints, and a user token cannot drive bot gateway events. If you need both (e.g. an OAuth-authorized agent that also runs as a bot), use two `client(...)` instances with different `storage_key`s and keep the token tracks separate. ### GitLab (cloud + self-hosted) ```harn,ignore import { client, exchange_code, request, start_authorization, } from "std/oauth/client" import { custom, providers } from "std/oauth/providers" import { file } from "std/oauth/storage" // Cloud: const cloud_cli = client( providers().gitlab, { client_id: harness.env.get("GITLAB_CLIENT_ID"), client_secret: harness.env.get("GITLAB_CLIENT_SECRET"), scopes: ["read_api", "read_user", "openid", "profile", "email"], redirect_uri: "https://app.example/oauth/gitlab/callback", storage: file( "/var/lib/harn/gitlab.bin", harness.env.get("HARN_OAUTH_KEY"), ), }, ) // Self-hosted: same /oauth paths under your instance base URL. const base = "https://gitlab.acme.example" const self_hosted = custom({ id: "gitlab", label: "GitLab (self-hosted)", auth_url: base + "/oauth/authorize", token_url: base + "/oauth/token", device_code_url: base + "/oauth/authorize_device", revoke_url: base + "/oauth/revoke", userinfo_url: base + "/oauth/userinfo", default_scopes: ["read_api", "openid"], pkce_required: true, }) ``` GitLab's refresh response **rotates both tokens** — the prior access token is invalidated alongside the prior refresh token. This is the RFC-spec-strict behavior; the client handles it transparently. The catch: if a refresh succeeds but the caller crashes before persisting the new TokenSet, the old refresh token is gone. Use a durable storage backend (`file(...)` or `harn_cloud_*()`) in production rather than `memory()`. Device flow is available on GitLab 17.1+ (generally available in 17.9+); the catalog enables it on `providers().gitlab` and on the custom self-hosted record above when you target an instance that's new enough. ### Bitbucket (workspace-scoped) ```harn,ignore import { client, exchange_code, request, start_authorization, } from "std/oauth/client" import { providers } from "std/oauth/providers" import { file } from "std/oauth/storage" const cli = client( providers().bitbucket, { client_id: harness.env.get("BITBUCKET_CLIENT_KEY"), client_secret: harness.env.get("BITBUCKET_CLIENT_SECRET"), scopes: ["account", "repository", "issue"], redirect_uri: "https://app.example/oauth/bitbucket/callback", storage: file( "/var/lib/harn/bitbucket.bin", harness.env.get("HARN_OAUTH_KEY"), ), }, ) const workspaces = request( cli, "GET", "https://api.bitbucket.org/2.0/workspaces", ) ``` Bitbucket Cloud OAuth supports **authorization-code and client-credentials grants only** — there is no device flow. For workspace-scoped automation (running as a service account against a single workspace), prefer client credentials over a long-lived user OAuth token: register the OAuth consumer with the workspace, then exchange `client_credentials` outside Harn's authorization-code helpers since the grant has no human in the loop. A refreshed Bitbucket token response includes a new refresh token that the catalog stores automatically; the old one expires shortly after use. ## Cross-cutting cookbook ### Headless CI agent (device flow) ```harn,ignore import { device_flow } from "std/oauth/device_flow" import { providers } from "std/oauth/providers" import { file } from "std/oauth/storage" const store = file( "/var/lib/harn/ci-token.bin", harness.env.get("HARN_OAUTH_KEY"), ) const token_set = device_flow( providers().github, { client_id: harness.env.get("GH_OAUTH_CLIENT_ID"), scopes: ["read:user", "repo"], storage: store, on_user_code: { user_code, verification_uri -> // Surface to the CI log + a chat webhook so an operator can // complete the dance. const _ = harness.stdio.log( "Visit " + verification_uri + " and enter " + user_code, ) const _ = harness.net.post( harness.env.get("SLACK_WEBHOOK_URL"), json_stringify({ text: "CI auth pending: open " + verification_uri + " and enter `" + user_code + "`", }), {headers: {"Content-Type": "application/json"}}) nil }, }, ) // On subsequent CI runs the file backend // already has the token; skip device_flow. ``` The same pattern works for Google, Microsoft, and GitLab. Slack, Linear, Notion, Atlassian, Discord, and Bitbucket do not advertise device endpoints — `device_flow(...)` raises on construction if `provider.device_code_url` is nil. ### Org-shared GitHub bot (`harn_cloud_org`) ```harn,ignore import { client, request } from "std/oauth/client" import { providers } from "std/oauth/providers" import { harn_cloud_org } from "std/oauth/storage" const cli = client( providers().github, { client_id: harness.env.get("ORG_GITHUB_CLIENT_ID"), client_secret: harness.env.get("ORG_GITHUB_CLIENT_SECRET"), scopes: ["read:org", "repo"], redirect_uri: harness.env.get("ORG_REDIRECT_URI"), storage: harn_cloud_org(), storage_key: "github:org-bot", }, ) const issues = request( cli, "GET", "https://api.github.com/orgs/burin-labs/issues", ) ``` `harn_cloud_org()` routes through the `oauth_storage.cloud_*` host capability with `scope = "org"`, including the refresh-lock operations used by transparent token rotation. A cloud platform is responsible for tenant-scoped storage (RLS), so two agents running in the same org share the same authenticated client without either of them being able to read tokens for a different org. The `storage_key` is per-purpose, not per-user: one entry covers every consumer of the bot. ### Custom enterprise OIDC provider ```harn,ignore import { client, exchange_code, request, start_authorization, } from "std/oauth/client" import { custom } from "std/oauth/providers" import { file } from "std/oauth/storage" const acme = custom({ id: "acme-oidc", label: "Acme Enterprise OIDC", auth_url: "https://idp.acme.example/oauth2/authorize", token_url: "https://idp.acme.example/oauth2/token", revoke_url: "https://idp.acme.example/oauth2/revoke", userinfo_url: "https://idp.acme.example/oauth2/userinfo", default_scopes: ["openid", "profile", "email", "offline_access"], pkce_required: true, }) const cli = client( acme, { client_id: harness.env.get("ACME_OIDC_CLIENT_ID"), client_secret: harness.env.get("ACME_OIDC_CLIENT_SECRET"), scopes: [ "openid", "profile", "email", "offline_access", "acme.api.read", ], redirect_uri: "https://app.acme.internal/oauth/callback", storage: file( "/var/lib/harn/acme.bin", harness.env.get("HARN_OAUTH_KEY"), ), audience: "https://api.acme.example", extra_auth_params: {prompt: "select_account"}, }, ) ``` The fields on `custom({...})` mirror the built-in provider records. If your IdP advertises `.well-known/openid-configuration`, copy the URLs from there verbatim; the `refresh_handling` record can stay at the default unless your IdP does something unusual (mTLS, JAR/JARM, custom grant types). For inhouse IdPs that *don't* speak OAuth 2.x (e.g. legacy SAML), use a `custom(...)` storage backend to wrap your existing token broker instead of teaching the OAuth client about a non-OAuth protocol. ## Related - [OAuth storage stdlib](./stdlib/oauth-storage.md) — full storage reference. - [Connector OAuth](./orchestrator/oauth.md) — the `harn connect` CLI on top of this stack. - [Redaction policy](./redaction.md) — what's automatically scrubbed from persisted transcripts and receipts. - Conformance fixtures: `conformance/tests/stdlib/oauth/oauth_*.harn`. --- ## Read next - [Run Harn app logic in the browser](https://harnlang.com/cookbooks/run-app-logic-in-browser.md) - [Playground](https://harnlang.com/playground.md) --- # Playground > harn playground runs a pipeline against a Harn-native host module in the same process. harn try is an alias for the same command. It is intended for fast pipeline iteration... Website: https://harnlang.com/playground.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 playground` runs a pipeline against a Harn-native host module in the same process. `harn try` is an alias for the same command. It is intended for fast pipeline iteration without wiring a JSON-RPC host or booting a larger app shell. ## Quick start The repo ships with a minimal example: ```bash harn playground \ --host examples/playground/host.harn \ --script examples/playground/echo.harn \ --task "Explain this repository in plain English" ``` `--task` is exposed to the script through the `HARN_TASK` environment variable, so the example reads it with `harness.env.get_or("HARN_TASK", "")`. On a fresh install, playground runs that call provider-backed LLM builtins such as `harness.llm.call`, `harness.llm.stream_call`, or `agent_loop` detect local Ollama at `http://127.0.0.1:11434/api/tags` and offer to write `~/.config/harn/providers.toml` with Ollama as the default provider. Use `--yes` to accept that setup in non-interactive runs. If you want an offline smoke test, force the mock provider: ```bash harn playground \ --host examples/playground/host.harn \ --script examples/playground/echo.harn \ --task "Say hello" \ --llm mock:mock ``` For deterministic end-to-end iteration, `harn playground` also accepts the same JSONL fixture flags as `harn run`: ```bash harn playground \ --host examples/playground/host.harn \ --script examples/playground/echo.harn \ --task "Explain this repository" \ --llm-mock fixtures/playground.jsonl ``` Use `--llm-mock-record ` once to capture a replayable fixture, then switch back to `--llm-mock ` while you iterate on control flow. ## Host modules A playground host is just a `.harn` file that exports the functions your pipeline expects: ```harn pub fn build_prompt(task_text) { return "Task: " + task_text + "\nWorkspace: " + harness.fs.cwd() } pub fn request_permission(tool_name, request_args) -> bool { return true } ``` The playground command loads those exported functions and makes them available to the entry script during execution. If the script calls a host function that the module does not export, the command fails with a pointed error naming the missing function and the caller location. ## Watch mode Use `--watch` to re-run when either the host module or the script changes: ```bash harn playground --watch --task "Refine the prompt" ``` The watcher tracks the host and script parent directories recursively and debounces save bursts before re-running. ## Starter project Use the built-in scaffold when you want a dedicated scratchpad: ```bash harn new pipeline-lab-demo --template pipeline-lab cd pipeline-lab-demo harn playground --task "Summarize this project" ``` --- ## Read next - [OAuth client + provider cookbook](https://harnlang.com/oauth.md) - [Debugging agent runs](https://harnlang.com/debugging.md) --- # Debugging agent runs > Harn provides several tools for inspecting, replaying, and evaluating agent runs. This page walks through the debugging workflow. Website: https://harnlang.com/debugging.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 provides several tools for inspecting, replaying, and evaluating agent runs. This page walks through the debugging workflow. ## Source-level debugging For step-through debugging, start the Debug Adapter Protocol server. It speaks DAP over stdio and is normally launched by an editor, but any DAP client can drive it: ```bash harn dap ``` The standalone `harn-dap` binary also starts the same server. In VS Code, the Harn extension contributes a `harn` debug configuration automatically. The equivalent `launch.json` entry is: ```json { "type": "harn", "request": "launch", "name": "Debug Current Harn File", "program": "${file}", "cwd": "${workspaceFolder}" } ``` This supports line breakpoints, variable inspection, stack traces, and step in / over / out against `.harn` files. ### Privileged host-call bridge (`harnHostCall`) The debug adapter advertises `supportsHarnHostCall: true` in its `Capabilities` response. Trusted, provenance-stamped host bridge modules may use the privileged `host_call(capability, operation, params)` wire; it is not a general script API and cannot be imported or re-exported by ordinary modules. When such a bridge call has no built-in handler, the adapter forwards it to the DAP client as a **reverse request** named `harnHostCall` — mirroring the DAP `runInTerminal` pattern: ```json {"seq": 17, "type": "request", "command": "harnHostCall", "arguments": {"capability": "workspace", "operation": "project_root", "params": {}}} ``` The client replies with a normal DAP response: ```json {"seq": 18, "type": "response", "request_seq": 17, "command": "harnHostCall", "success": true, "body": {"value": "/Users/x/proj"}} ``` On `success: true`, the adapter returns the body's `value` field (or the whole body when `value` is absent) to the script. On `success: false`, the adapter throws `VmError::Thrown(message)` so scripts can `try` / `catch` the failure like any other Harn exception. Clients that do not implement `harnHostCall` still work — the script just sees the standalone fallbacks (`workspace.project_root`, `workspace.cwd`, etc.). ### LLM telemetry output events During `run` / step-through, the adapter forwards every `harness.llm.call` the VM makes as a DAP `output` event with `category: "telemetry"` and a JSON body: ```json {"category": "telemetry", "output": "{\"call_id\":\"…\",\"model\":\"…\",\"input_tokens\":…,\"output_tokens\":…,\"cost_usd\":…,\"cache_read_tokens\":…,\"cache_write_tokens\":…,\"duration_ms\":…,\"iteration\":…}"} ``` IDEs can parse these to show a live LLM-call ledger alongside the debug session. These are the same normalized accounting fields returned by `harness.llm.call`; the debugger does not re-price or rename them. ## Run views Every completed `harn run` invocation writes a run record under `.harn-runs/`. Agent loops and `workflow_execute()` add their richer lifecycle records there. Inspect records through the stable `harn.run_view.v1` / `harn.session_view.v1` projections rather than depending on private record fields. ```bash # List recent runs ls .harn-runs/ # Inspect a stable run view harn runs view --json .harn-runs/.json ``` The view command shows a structured summary: stages executed, tools called, token usage, timing, final output, and redacted execution evidence. Its `evidence.trace_spans` tree preserves ordered span events, so an IDE or trace viewer can show named sub-phases without reading Harn's private run-record format. ## Record the exact code path Use the flight recorder when a normal span tree doesn't show which branch or instruction ran: ```bash harn run --flight-recorder main.harn ``` Harn prints the artifact path to stderr and links the same artifact from the automatic run record. The recording stores source locations, function and task identities, instruction offsets, and opcode names. It never stores runtime values, arguments, results, or stack contents. The recorder keeps the newest 250,000 events in memory. Older events increment `dropped_events`; their absence never looks like a complete trace. Harn keeps the newest 16 default-location files. Change those bounds for one run: ```bash harn run --flight-recorder \ --flight-recorder-max-events 1000000 \ --flight-recorder-retain 32 \ main.harn ``` Use `--flight-recorder-out recording.json` when another tool owns the artifact path. Harn won't rotate other JSON files beside a caller-selected path. The artifact is written when the VM returns, exits, or fails. A force-killed process can lose its in-memory recording. ## Correlating delegated runs Build one report from the root run when several agents participated: ```bash harn runs report .harn-runs/.json > run-report.json jq '.agents[] | {agent_id, status, usage, visible_output}' run-report.json jq '.delegations, [.checks[] | select(.status != "passed")]' run-report.json ``` The report follows each typed `child_runs[].run_path`, checks the child's back-pointer, and keeps the source hash beside the projected evidence. Add `--events-db ` when the run used a SQLite event log. Canonical join receipts make `coordination.unjoined` exact for terminal children and separate the three costs a slow delegation can be paying: `observed_wait_ms` for scheduler wait, `observed_join_ms` for terminal-to-collection lag, and `observed_result_processing_ms` for the parent collapsing the result. All remain `null` when event evidence is absent, malformed, or truncated, and a duplicate receipt clears all three rather than only the lag. The report never turns missing timing into zero. Each timeline includes `coverage.returned`, `coverage.available`, and `coverage.truncated`. Treat a missing event as evidence only when `truncated` is `false`. A truncated run report also contains a `timeline_truncated` warning. Query `harn.session_timeline.query` with a higher `limit` when you need more nodes. `available: null` means Harn stopped after proving truncation, before it could count every matching node. Ask for a quick qualitative assessment after inspecting the deterministic checks: ```bash harn runs review --run-record .harn-runs/.json \ --events-db .harn/events.sqlite > run-review.json jq '{verdict, confidence, findings, limitations, actions}' run-review.json ``` Use `--report run-report.json` instead when a report already exists. The two inputs are explicit and mutually exclusive; Harn does not guess from a file's contents. Use `--rubric rubric.md` to supply a project-specific rubric and `--model ` to pin a model route. The review records both hashes and the resolved route. It cites evidence by JSON Pointer and fails if a pointer does not resolve inside the report. Coverage limits from the report remain explicit in the review; the model cannot fill those gaps by reading other files. Harn projects large arrays and strings into bounded first/last samples or previews before the call. The review records every omission's original JSON Pointer, count, and hash plus the source and projected byte counts, and repeats omissions as deterministic limitations. If this auditable projection still exceeds the 48,000-token estimate, review fails before the model call. ## Comparing runs Compare two stable views with your normal JSON diff tool to identify regressions: ```bash harn runs view --json .harn-runs/new.json > new.view.json harn runs view --json .harn-runs/old.json > old.view.json diff -u old.view.json new.view.json ``` This highlights differences in tool calls, outputs, and token consumption. ## Replay A run-record input reconstructs and checks the saved record. It does not run the program or its tools again: ```bash harn replay .harn-runs/.json ``` This view shows each saved stage transition and checks the embedded fixture. Use an `offline_coding_replay` fixture when you need to prove the recorded read, edit, and verification steps still reproduce in a fresh workspace. That mode runs the Harn program with its recorded LLM tape, provider access removed, and process network disabled, then checks the tool sequence, final diff, and producer-written agent terminal outcome. See the [replay cookbook](./cookbooks/replay-time-travel.md#re-execute-a-coding-turn-offline). ## Visualizing a pipeline When you want a quick structural view instead of a live debug session, render a Mermaid graph from the AST: ```bash harn viz main.harn harn viz main.harn --output docs/main.mmd ``` The generated graph is useful for reviewing branch-heavy pipelines, match arms, parallel blocks, and nested retries before you start stepping through them. ## Evaluation The `harn eval` command scores a run or set of runs against expected outcomes: ```bash # Evaluate a single run harn eval .harn-runs/.json # Evaluate all runs in a directory harn eval .harn-runs/ # Evaluate using a manifest harn eval eval-suite.json ``` ### Custom metrics Use `eval_metric()` in your pipeline to record domain-specific metrics: ```harn eval_metric("accuracy", 0.95, {dataset: "test-v2"}) eval_metric("latency_ms", 1200) ``` These metrics appear in run records and are aggregated by `harn eval`. ### Token usage tracking Track LLM costs during a run: ```harn const usage = harness.obs.llm_usage() harness.stdio.log( "Tokens used: ${usage.input_tokens + usage.output_tokens}" ) harness.stdio.log("LLM calls: ${usage.total_calls}") ``` ## Portal The Harn portal is an interactive web UI for inspecting runs: ```bash harn portal ``` This opens a dashboard showing all runs in `.harn-runs/`, with drill-down into individual stages, tool calls, and transcript snapshots. ## Tips - **Add `eval_metric()` calls** to your pipelines early — they're cheap to record and invaluable for tracking quality over time. - **Use replay** for debugging non-deterministic failures: record the failing run, then replay it locally to step through the logic. - **Compare baselines** when refactoring prompts or changing tool definitions to catch regressions before they ship. --- ## Read next - [Playground](https://harnlang.com/playground.md) - [Editor setup (VS Code, Neovim, Zed)](https://harnlang.com/editor-setup.md) --- # Editor setup > Harn ships a capable language server, harn-lsp , that powers diagnostics, completions, go-to-definition, hover, rename, formatting, and code actions. The install script puts... Website: https://harnlang.com/editor-setup.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 ships a capable language server, `harn-lsp`, that powers diagnostics, completions, go-to-definition, hover, rename, formatting, and code actions. The [install script](./getting-started.md) puts `harn-lsp` on your `PATH` alongside `harn` and `harn-dap`, so any editor with a generic LSP client can light up Harn support by pointing at that one binary. `harn-lsp` takes no arguments and speaks the Language Server Protocol over stdin/stdout. Every editor below launches it the same way — `harn-lsp` with no flags — and maps the `.harn` file extension to the `harn` language id. For the full capability matrix (every LSP feature, the DAP debugger, and the tree-sitter grammar) see [Editor integration](./editor-integration.md). ## VS Code The bundled extension in `editors/vscode/` adds syntax highlighting, snippets, the language server, the debugger, and Harn commands. Install it from source until a registry release is available. ### Install the extension from source Install the [Harn toolchain](./getting-started.md#install-harn) first. Then clone the Harn repository, build a VSIX package, and install it: ```bash git clone https://github.com/burin-labs/harn.git cd harn/editors/vscode npm ci npm run compile npx @vscode/vsce package --out harn-lang.vsix code --install-extension harn-lang.vsix ``` You can instead run **Extensions: Install from VSIX...** from the command palette and select `harn-lang.vsix`. The extension starts `harn-lsp` from your `PATH` by default. Set `harn.lspPath` in your settings if the binary lives somewhere else. ### Format on save The extension formats `.harn` files and applies Harn autofixes when you save. It doesn't change save behavior for other languages. To make those settings explicit or restore them after a workspace override, add this block to your VS Code `settings.json`: ```json { "[harn]": { "editor.defaultFormatter": "burin-labs.harn-lang", "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.harn": "always" } } } ``` Open a `.harn` file and run **Format Document With...** once. Select **Harn Language** if VS Code asks you to choose a formatter. Change the file's spacing and save it. The extension should reformat the file. If Harn reports an autofix, saving should apply it. If formatting does not run, open **View: Output**, select **Harn Language Server**, and confirm that `harn-lsp` started. Set `harn.lspPath` to the full binary path when VS Code cannot find it through `PATH`. ### Cursor and other VS Code forks Build the VSIX with the commands above. Install it with `cursor --install-extension harn-lang.vsix`, or use **Extensions: Install from VSIX...** from the command palette. The same format-on-save settings work in Cursor. ### `.harn.prompt` files Files ending in `.harn.prompt` or `.prompt` open as **Harn Prompt** templates. The extension gives them: - Highlighting for directives (`{{ if }}`, `{{ for }}`, `{{ include }}`, `{{ section }}`, `{{ raw }}`), `{{# comments #}}`, literals, [filters](./prompt-templating.md#filters), and the built-in section names. - Folding for `{{ if }}` / `{{ for }}` / `{{ section }}` / `{{ raw }}` blocks and their matching `{{ end }}` / `{{ endsection }}` / `{{ endraw }}`. - `{{` → `}}` auto-closing and surrounding, plus `{{#` / `#}}` as the comment pair so **Toggle Comment** works inside a template. The keyword, filter, and section vocabulary in that grammar is generated from the runtime's template engine, so the editor accepts exactly what `harness.fs.render_prompt(...)` does. Contributors changing the template language should edit `crates/harn-vm/src/stdlib/template/vocabulary.rs` and run `make gen-prompt-grammar` rather than hand-editing the grammar; CI fails on drift. ## Neovim Neovim's built-in LSP client (0.11+) can start `harn-lsp` directly. First register the `.harn` filetype, then configure and enable the server. Add this to your config (for example `~/.config/nvim/init.lua`): ```lua -- Map the .harn extension to a `harn` filetype. vim.filetype.add({ extension = { harn = "harn" } }) -- Configure the Harn language server (harn-lsp must be on your PATH). vim.lsp.config("harn", { cmd = { "harn-lsp" }, filetypes = { "harn" }, root_markers = { "harn.toml", ".git" }, }) vim.lsp.enable("harn") ``` On older Neovim releases that ship `nvim-lspconfig`, the equivalent is: ```lua vim.filetype.add({ extension = { harn = "harn" } }) local configs = require("lspconfig.configs") local lspconfig = require("lspconfig") if not configs.harn then configs.harn = { default_config = { cmd = { "harn-lsp" }, filetypes = { "harn" }, root_dir = lspconfig.util.root_pattern("harn.toml", ".git"), }, } end lspconfig.harn.setup({}) ``` Open any `.harn` file and run `:LspInfo` (or `:checkhealth lsp`) to confirm the `harn` client attached. Diagnostics, `gd` (go-to-definition), `K` (hover), and `vim.lsp.buf.format()` will all work through `harn-lsp`. ## Zed Zed configures external language servers through its `settings.json` (**Zed → Settings**, or `~/.config/zed/settings.json`). Map the `.harn` extension to a language and register `harn-lsp` as its server: ```json { "file_types": { "Harn": ["harn"] }, "lsp": { "harn-lsp": { "binary": { "path": "harn-lsp", "arguments": [] } } }, "languages": { "Harn": { "language_servers": ["harn-lsp"] } } } ``` Zed resolves a bare `path` against your `PATH`, so the installed `harn-lsp` binary is picked up with no absolute path. Reopen a `.harn` file and the Harn language server starts automatically, giving you diagnostics, completions, hover, and go-to-definition. ## Other editors Any editor with a generic LSP client — Helix, Emacs (`eglot`/`lsp-mode`), Sublime Text (LSP), Kate — follows the same recipe: launch `harn-lsp` with no arguments for `.harn` files. See [Editor integration](./editor-integration.md) for the full capability list and the tree-sitter grammar that adds syntax highlighting in tree-sitter-based editors. --- ## Read next - [Debugging agent runs](https://harnlang.com/debugging.md) - [Use Harn from ACP editor hosts](https://harnlang.com/acp-editor-hosts.md) --- # Use Harn from ACP editor hosts > Harn can run as a native Agent Client Protocol (ACP) coding agent for editors that host external ACP agents, including Zed, JetBrains IDEs, Lumide, and other ACP clients. Website: https://harnlang.com/acp-editor-hosts.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 can run as a native Agent Client Protocol (ACP) coding agent for editors that host external ACP agents, including Zed, JetBrains IDEs, Lumide, and other ACP clients. ## Install The canonical external-host artifact is the `harn` binary: ```bash brew tap burin-labs/burin brew install harn harn --version ``` Every editor should launch the same stdio command: ```bash harn serve acp ``` This starts Harn's file-less ACP attach server. The host editor sends `initialize` and session setup over stdin/stdout after spawning the process; the launch command does not need a project path or `.harn` file. ## Zed Until the ACP Registry entry is merged, configure Harn as a custom Zed agent: ```json { "agent_servers": { "harn": { "type": "custom", "command": "harn", "args": ["serve", "acp"], "env": {} } } } ``` After the registry entry lands, install Harn from Zed's agent registry picker. ## JetBrains IDEs JetBrains reads custom ACP agents from `~/.jetbrains/acp.json`: ```json { "default_mcp_settings": {}, "agent_servers": { "Harn": { "command": "harn", "args": ["serve", "acp"], "env": {} } } } ``` Reload the IDE's AI Assistant agent list after editing the file. Once the ACP Registry entry lands, prefer **Settings -> Tools -> AI Assistant -> Agents -> Install From ACP Registry** so release metadata updates through the registry. ## Lumide Lumide 0.14.0 advertises custom ACP-compatible agents. Its public repo confirms the custom ACP surface, but does not publish a stable JSON config-file schema. Use the custom ACP agent UI with these launch fields: | Field | Value | |---|---| | Name | `Harn` | | Command | `harn` | | Arguments | `serve acp` | | Environment | Empty, or provider-specific variables such as `ANTHROPIC_API_KEY` | Prefer the registry picker once Harn is listed there. ## Smoke test Before relying on an editor integration, test a disposable project: ```bash mkdir -p /tmp/harn-acp-smoke cd /tmp/harn-acp-smoke printf 'status = "before"\n' > smoke.txt ``` Launch Harn from the editor and ask: ```text Change smoke.txt so the status value is "after", then verify the file. ``` A healthy integration lets Harn read `smoke.txt`, write the edit, and run a verification command or explain why verification is unavailable. If the editor cannot find `harn`, replace `command: "harn"` with the absolute path from `which harn` in the shell environment that launches the editor. ## Registry metadata The checked-in ACP Registry manifest lives at `spec/acp-registry/harn/agent.json`. It pins the current release binary archives, launches `harn serve acp`, and is mirrored into the upstream `agentclientprotocol/registry` submission. Keep the manifest and Homebrew formula on the latest published Harn release before refreshing the upstream PR. --- ## Read next - [Editor setup (VS Code, Neovim, Zed)](https://harnlang.com/editor-setup.md) - [Run a portable reducer in a browser](https://harnlang.com/portable-kernel-browser.md) --- # Run a portable reducer in a browser > This guide builds the core WebAssembly adapter and runs a two-module reducer package in a dedicated browser worker. It does not depend on the interactive app stack. Website: https://harnlang.com/portable-kernel-browser.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. --- This guide builds the core WebAssembly adapter and runs a two-module reducer package in a dedicated browser worker. It does not depend on the interactive app stack. ## Prerequisites - the repository setup from `make setup` - a current Node.js installation - a current Chrome installation for the automated worker test The Make targets install pinned `wasm-pack` and `wasm-tools` binaries plus the Rust Wasm target outside the checkout. Mutable Cargo and Wasm outputs remain worktree-local. ## Build and verify From the repository root: ```console make wasm-check ``` This validates the WIT projection, builds the release `wasm32-unknown-unknown` module, rejects unreviewed imports, and runs the conformance corpus in a real headless Chrome dedicated worker. It does not substitute Node tests for the browser path. ## Open the reducer Start the cross-platform demo server: ```console make wasm-demo ``` Open `http://127.0.0.1:8765`. The page loads `demo/worker.js`; the worker loads the generated ES module and `demo/package.json`, compiles the root reducer plus its imported math module with `compilePackage`, and retains reducer state between typed events. “Restore saved state” sends a structured clone back to the worker to demonstrate serializable application state. The manifest is generated from `demo/package-root.harn` and `demo/package-reducer-math.harn`; run `make check-portable-demo-package` when changing either source. The page owns HTML, controls, and presentation. Harn owns reducer policy. The Wasm kernel owns deterministic execution. No DOM or canvas object crosses the worker boundary. For a quick manual proof, press **Increment** and then **Add ten**. The worker returns count `11` with history `[1, 11]`. Press **Reset**, then **Restore saved state**; the structured-cloned initial state returns. Expanding the source panel also shows syntax highlighting generated from Harn's canonical language vocabulary rather than a demo-owned keyword list. The adapter does not require `SharedArrayBuffer` or Wasm threads. For parallel dispatch, create multiple dedicated workers, reuse the same artifact bytes, and keep each worker's reducer state independent. Do not run CPU-heavy transitions on the browser main thread. ## Use the generated adapter The worker path is intentionally small: ```js import init, { compilePackage, start } from "../pkg/harn_wasm.js" await init() const manifest = await fetch("./package.json").then((response) => response.text()) const compiled = compilePackage(manifest, "reduce", "function") if (!compiled.ok) throw new Error(compiled.diagnosticsJson()) const result = start( compiled.artifactBytes(), JSON.stringify({ state, event }), '{"capabilities":[]}', ) if (result.status === "completed") { state = JSON.parse(result.valueJson()) } ``` Do not silently ignore `suspended` or `failed`. A privileged reducer must pass an exact grant document with a host-generated snapshot key, retain the returned snapshot bytes, perform the typed request outside Wasm, and call `resume` with the matching result. For native and browser latency measurements, receipt fields, and a recorded worker procedure, see [Benchmark the portable kernel](./portable-kernel-benchmarking.md). --- ## Read next - [Use Harn from ACP editor hosts](https://harnlang.com/acp-editor-hosts.md) - [Portable kernel artifacts](https://harnlang.com/migrations/portable-kernel-v1.md) --- # Migrate to the Portable Harn Kernel > Portable Kernel v1 replaces the former harn-wasm source interpreter. The old adapter parsed and evaluated a subset of Harn independently; it is removed. This is an intentional... Website: https://harnlang.com/migrations/portable-kernel-v1.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. --- Portable Kernel v1 replaces the former `harn-wasm` source interpreter. The old adapter parsed and evaluated a subset of Harn independently; it is removed. This is an intentional breaking cutover. | Removed surface | Replacement | |---|---| | `run`, `execute`, `executePureComponent` | `compile`, then `start` or `resume` | | `check` | `compile(...).diagnosticsJson()` | | `tokenize`, `format_code` | Canonical lexer/formatter tooling outside the browser runtime | | `harn-nativec`, `harn-codegen` | Removed; future acceleration must consume `ProgramArtifact` | ## Browser callers Replace the old synchronous source-to-output call with three explicit steps: 1. Call `compile(source, entry, entryKind)` and retain `artifactBytes()`. 2. Call `start(artifact, inputJson, grantsJson)` for each fresh execution. 3. If the outcome is suspended, retain `snapshotBytes()` and call `resume` after the host produces the matching typed capability result. Do not cache source-specific JavaScript objects. Cache artifact bytes together with their digest and invalidate them when the artifact/semantic ABI changes. Run the adapter in a Web Worker. The interface is synchronous within one transition and returns to JavaScript at every completion, suspension, or failure boundary. Running it on the browser main thread can still block rendering for CPU-heavy pure work. Parallel browser work uses multiple dedicated workers with independent execution state. The v1 adapter intentionally does not require Wasm threads, shared memory, or cross-origin isolation. Native callers may share a decoded immutable artifact across operating-system threads, but must not share a live execution state or suspension snapshot between invocations. ## Native embedders Use `harn_vm::portable::start` and `harn_vm::portable::resume` when consuming portable artifact bytes. These functions decode with the same untrusted-input limits and delegate to `harn-kernel`; they do not maintain a native copy of the portable evaluator. The full native VM remains the hostful Harn runtime. Portable v1 is not a drop-in replacement for programs using modules, orchestration, concurrency, generators, streams, or unsupported builtins. Typed default parameters compiled to `unsupported_portable_typed_default` in v1. They are supported from artifact v2 onward: the default is evaluated through the same shared guard the native runtime uses, so an omitted typed parameter yields its declared default rather than a diagnostic. Supplied typed parameters continue to use the shared native/portable structural type matcher. ## Former native-codegen callers The experimental `harn-codegen` crate and `harn-nativec` command are removed. They compiled and evaluated a language subset and were not load-bearing. A future accelerator must consume a validated `ProgramArtifact` and preserve the kernel's opcode, value, capability, and diagnostic contracts; it must not parse or evaluate Harn independently. ## Compatibility policy Portable artifacts reject unknown versions, feature bits, semantic ABI fingerprints, and unsupported semantics. Recompile from source when compatibility fails. There is no best-effort decoding and no fallback to the former interpreter. ## Artifact version 2 Version 2 is the current artifact version. It adds the packaged module closure and the `NamespaceImportMembers` opcode to the shared bytecode ABI. **Version 1 artifacts must be recompiled from source.** The kernel fails closed rather than reading them: ```text artifact_version: artifact version 1 is not supported; expected 2 ``` Recompile a single-file program with `harn portable compile`. When the program has imports, run `harn portable package` first and compile the resulting manifest, so the artifact carries its whole module closure and the host never re-resolves an import. The adapter interface is unchanged: `compile`, then `start` or `resume`. --- ## Read next - [Run a portable reducer in a browser](https://harnlang.com/portable-kernel-browser.md) - [Agent plane cutover](https://harnlang.com/migrations/agent-plane-cutover.md) --- # Migrate to the single agent plane > This migration removes public wrappers that owned overlapping loop, chat, and editor-completion behavior. Migrate each call to the capability that owns its lifecycle. Website: https://harnlang.com/migrations/agent-plane-cutover.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. --- This migration removes public wrappers that owned overlapping loop, chat, and editor-completion behavior. Migrate each call to the capability that owns its lifecycle. ## Replace `AgentLoopOptions` Rename the annotation to `AgentSpec`. The fields remain flat, so existing values need no reshaping. ```harn import { AgentSpec, agent_options } from "std/agent/options" const spec: AgentSpec = agent_options({ provider: "openai", model: "gpt-5-mini", loop_until_done: true, }) ``` Use the component records when a function accepts only part of the contract: `AgentModelSpec`, `AgentExecutionSpec`, `AgentCapabilitySpec`, `AgentLifecycleSpec`, `AgentContextSpec`, and `AgentObservabilitySpec`. ## Replace `agent_turn` Call `agent_loop` and set completion policy explicitly. Use `turn_end_condition` when a judge must approve completion. ```harn const result = agent_loop(harness, task, system, agent_options({ provider: "openai", model: "gpt-5-mini", loop_until_done: true, turn_end_condition: true, })) ``` Read judge decisions from `judge_decision` session events. The removed wrapper's separate `iterations` and `judge_decisions` result summaries are not part of `AgentResult`. ## Replace `agent_llm_turn` Use `harness.llm.call(prompt, system?, options?)`. One request belongs to `HarnessLlm`; adding an agent-named wrapper does not make it an agent loop. ## Replace `agent_chat_loop` Keep input handling, slash commands, and presentation in the host. Invoke `agent_loop` once per prompt turn. Pass `history` when the host owns conversation storage, or reuse a `session_id` when the Harn session owns it. ```harn const result = agent_loop(harness, message, system, agent_options({ session_id: conversation_id, history: stored_messages, tools: tools, })) ``` Use typed HITL or `agent_await_resumption` for suspension. Do not recreate the removed `wait_for_user` convention as a terminal string. ## Move editor completions to the host `std/agent/completions` is removed. Editors own cursor state, suggestion UI, acceptance decisions, and product telemetry. Call `harness.llm.completion` for one completion request and store host-specific envelopes in the product layer. ## Update result handling Annotate loop results with `AgentResult` from `std/agent/contracts`. Branch on `result.terminal.kind`; retain `stop_reason` only for diagnostics. `agent_loop` no longer returns an untyped nullable dictionary. Remove defensive optional access on the result itself (`result?.status` becomes `result.status`); keep optional access only for fields whose declared type is optional. If a reader imports `AgentResult` from `std/agent/artifacts`, rename that annotation to `AgentResultArtifact`. The artifact type remains compatible with persisted v1/v2 files; `AgentResult` now names only the live loop contract from `std/agent/contracts`. ```harn import { AgentResult } from "std/agent/contracts" const result: AgentResult = agent_loop(harness, task, system, spec) if result.terminal.kind == "natural" { return result.visible_text } throw "agent stopped: " + result.terminal.kind + ": " + result.terminal.reason ``` --- ## Read next - [Portable kernel artifacts](https://harnlang.com/migrations/portable-kernel-v1.md) - [0.6.x → 0.7.0](https://harnlang.com/migrations/v0.7.md) --- # Migrating from 0.6.x to 0.7.0 > Harn 0.7.0 replaces the implicit transcript_policy dict with first-class sessions . Session lifecycle is driven by imperative builtins, and unknown inputs hard-error instead of... Website: https://harnlang.com/migrations/v0.7.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 0.7.0 replaces the implicit `transcript_policy` dict with first-class [sessions](../sessions.md). Session lifecycle is driven by imperative builtins, and unknown inputs hard-error instead of silently no-op'ing. This guide lists every removed surface with a side-by-side rewrite. ## `transcript_policy` on workflow nodes The per-node policy dict is gone. Its fields moved to two dedicated setters plus lifecycle verbs. ### Before (0.6) ```harn,ignore workflow_set_transcript_policy(graph, "summarize", { mode: "reset", visibility: "public", auto_compact: true, compact_threshold: 8000, compact_strategy: "truncate", keep_last: 6, }) ``` ### After (0.7) ```harn // Shape the node's compaction behavior: workflow_set_auto_compact(graph, "summarize", { auto_compact: true, compact_threshold: 8000, compact_strategy: "truncate", keep_last: 6, }) workflow_set_output_visibility(graph, "summarize", "public") // To reset the stage's conversation explicitly before execution, // open a caller-controlled session and wire it into the node's // model_policy: const sid = harness.agent.open("summarize-v2") workflow_set_model_policy(graph, "summarize", {session_id: sid}) harness.agent.reset(sid) ``` `mode: "fork"` maps to `harness.agent.fork(src, dst?)` called before `workflow_execute`, wiring the fork id into the node's `model_policy.session_id`. `mode: "continue"` is the new default — two stages sharing a `session_id` share a conversation automatically. ## `transcript_id` / `transcript_metadata` on `harness.llm.call` Both keys were removed. Session id subsumes them. ### Before ```harn const result = harness.llm.call("hi", nil, { transcript_id: "chat-42", transcript_metadata: {user: "ada"}, }) ``` ### After ```harn // `session_id` is honored by `agent_loop`; // `harness.llm.call` is single-shot. For conversational // continuity, move to agent_loop: const sid = harness.agent.open("chat-42") const result = agent_loop(harness, "hi", nil, {session_id: sid}) ``` If you relied on the `transcript_metadata` bag, attach it to the session via your own store or pass per-call context through the `metadata` field of injected messages. `transcript_summary` (per-call summary injection for mid-loop compaction output) is unchanged. ## `transcript` option on `harness.llm.call` / `agent_loop` Passing a raw transcript dict through the `transcript` option is now a hard error. ### Before ```harn const t = transcript() const result = agent_loop( harness, "task", nil, {transcript: t, provider: "mock"}, ) ``` ### After ```harn const sid = harness.agent.open() const result = agent_loop( harness, "task", nil, {session_id: sid, provider: "mock"}, ) // `harness.agent.snapshot(sid)` if you // want the transcript back as a dict. ``` The loop loads prior messages from the session store as a prefix before running and persists the final transcript back on exit. ## Lifecycle via dict (`mode: "reset" | "fork"`) Previously some call sites accepted a lifecycle dict. That pattern is gone — call the verbs explicitly: - `mode: "reset"` → `harness.agent.reset(id)` - `mode: "fork"` → `let dst = harness.agent.fork(src)` (optionally with a caller-provided `dst` id) - `mode: "continue"` → no-op; just reuse the same `session_id` ## Subscribers `CLOSURE_SUBSCRIBERS` (thread-local in `agent_events.rs`) was removed. Subscribers now live on `SessionState.subscribers`. - `agent_subscribe(id, cb)` opens the session lazily and appends. - `agent_session_fork` does **not** copy subscribers — a fork is a conversation branch, not an event fanout. - `clear_session_sinks` only clears external ACP-style sinks now; it no longer evicts sessions. ## Unknown-key / unknown-id behavior A class of silent pass-throughs is now an error: - Unknown `agent_session_compact` option keys. - Missing `role` on `agent_session_inject`. - Negative `keep_last`. - `reset` / `fork` / `close` / `trim` / `inject` / `length` / `compact` called against an unknown session id. `exists`, `open`, and `snapshot` remain tolerant of unknown ids by design. ## `agent_loop` terminal status `max_iterations` reached without a natural break now reports `status = "budget_exhausted"` (previously `"done"`). If your host keys off `"done"` to detect "agent is finished," add `"budget_exhausted"` to the accept list — the loop ran out of rope, not out of work. Daemon loops in the same condition no longer silently relabel to `"idle"`. See the [Sessions](../sessions.md) chapter for the full model and the 0.7.0 entry in the [changelog](https://github.com/burin-labs/harn/blob/main/CHANGELOG.md) for the complete breaking-change list. --- ## Read next - [Agent plane cutover](https://harnlang.com/migrations/agent-plane-cutover.md) - [Migrating to 0.10](https://harnlang.com/migrations/v0.10.md) --- # Migrating to 0.10 > Harn 0.10 removes long-deprecated resilience and composition surfaces. There are no compatibility shims. harn check and the runtime reject removed LLM option names and report... Website: https://harnlang.com/migrations/v0.10.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 0.10 removes long-deprecated resilience and composition surfaces. There are no compatibility shims. `harn check` and the runtime reject removed LLM option names and report the canonical replacement. ## Tool registry construction uses one options record `tool_registry_from` now accepts registry-wide metadata through one typed options record. Replace positional `info` and `components` arguments: ```harn,ignore const registry = tool_registry_from(specs, info, components) ``` with named fields: ```harn,ignore const registry = tool_registry_from(specs, { info: info, components: components, cli: cli, }) ``` Calls that pass only `specs` do not change. There is no positional compatibility shim. The record keeps registry-wide schema components and CLI parent-command metadata extensible without adding another positional parameter. ## Pace decisions become cut rules Pace decisions are no longer part of `std/agent/governors`. Update imports and names in the same change that updates Harn. There are no compatibility exports. | Removed | Replacement | | --- | --- | | `std/agent/governors::governor_pace_decision` | `std/agent/cut_rules::pace_cut_rule_decision` | | `std/agent/governors::governor_pace_extend_max` | `std/agent/cut_rules::pace_cut_rule_extend_max` | | `std/agent/governors::governor_pace_check_max_injections` | `std/agent/cut_rules::pace_cut_rule_check_max_injections` | | `std/agent/governors::pace_action_of` | `std/agent/cut_rules::pace_cut_rule_action_of` | | `GovernorPolicy` fields `extend_max` and `pace_check_max` | `PaceCutRulePolicy` fields with the same names | Apply the migration in this order: 1. Publish the Harn release that contains `std/agent/cut_rules`. 2. In one consumer change, update the Harn version, imports, and function names. 3. Keep budget governor policies separate from `PaceCutRulePolicy` values. 4. Run `harn check` on each updated package before merging that change. 5. Release the consumer only after it uses the new Harn version and names. The decision inputs, action values, reason values, and default limits do not change. Only module ownership and public names change. ## Effects move onto the harness This is the largest change in 0.10, and `harn fix` does nearly all of it for you. Skip to [Run the migration](#run-the-migration) if you just want the commands. A function used to be able to read a file, print a line, or call a model without saying so in its signature. Every one of those effects was a global you could call from anywhere: ```harn,ignore fn load(path: string) -> string { return read_file(path) } ``` Nothing in `load`'s signature tells you it touches the disk. You had to read the body, and the bodies it calls, to know what authority the function needs. In 0.10 an effect is reached through a capability handle that the function receives as an argument. A signature now states what a function can do: ```harn,ignore fn load(harness: HarnessFs, path: string) -> string { return harness.read_text(path) } ``` Authority enters at your entrypoint, which still takes the whole `Harness`, and flows down to each function as the narrowest handle that covers its work. A function holding a `HarnessFs` cannot open a socket, and you can see that without reading it. Tests get the same benefit: pass a stub handle instead of mocking a global. ### Run the migration `harn fix` rewrites the calls, adds the parameters, and threads the handles through every caller in one pass: ```bash harn fix ./src --apply --dry-run --capability-migrations-only --safety surface-changing harn fix ./src --apply --capability-migrations-only --safety surface-changing ``` Start with `--dry-run` to see the repair count without writing. Drop it to apply. `--capability-migrations-only` keeps the pass to this migration and leaves unrelated repairs alone. The migration changes public signatures, so it needs `--safety surface-changing`. The pass converges: it reports `post-apply diagnostics: 0` when nothing is left to migrate. When a typed helper now requires a leading capability argument, the same pass adds or widens one carrier and updates local and imported callers. Multiple requirements become one closed capability bundle instead of parallel parameters. Every edited Harn file is formatted with the nearest project's `[fmt]` settings before the command returns. Commit, then run `harn check ./src` to confirm. Flow predicates are a deliberate boundary. Flow evaluation injects `HarnessAst`, so the migration can thread AST authority into an `@invariant`. It stops with an explicit error if that predicate also reaches another effect, such as the filesystem. Move that effect outside the predicate rather than granting authority the evaluator cannot supply. `harn check` enforces the same rule on manually authored `@invariant` parameters: only a leading `HarnessAst` capability is valid. ### What the rewrite looks like Given a file where two helpers use ambient globals: ```harn,ignore fn log_line(message: string) { println(message) } fn load(path: string) -> string { return read_file(path) } fn main(harness: Harness) { log_line(load("notes.txt")) } ``` `harn fix` produces: ```harn,ignore fn log_line(harness: HarnessStdio, message: string) { harness.println(message) } fn load(harness: HarnessFs, path: string) -> string { return harness.read_text(path) } fn main(harness: Harness) { log_line(harness.stdio, load(harness.fs, "notes.txt")) } ``` Each helper takes the one handle it uses, and `main` splits its root grant at the call sites. A function spanning two capabilities takes a record of the handles it needs: ```harn,ignore fn describe(harness: {fs: HarnessFs, system: HarnessSystem}) -> string { return harness.system.platform().os + " " + harness.fs.home_dir() } fn main(harness: Harness) { harness.stdio.println( describe({fs: harness.fs, system: harness.system}) ) } ``` Keep the root `Harness` at entrypoints and at boundaries that genuinely coordinate several capabilities. Everywhere else, take the narrowest handle. ### Globals that became a field on a snapshot A few globals returned one value where the capability now returns a structured snapshot. `harn fix` handles these too, but they are worth recognizing in a diff: | Removed global | Replacement | |---|---| | `platform()` | `harness.system.platform().os` | | `arch()` | `harness.system.platform().arch` | | `username()` | `harness.system.identity().username` | | `read_file(path)` | `harness.fs.read_text(path)` | | `metadata_get(dir, ns)` | `harness.project.metadata_get({dir: dir, namespace: ns})` | ### If you need to migrate by hand The linter names the capability for each remaining call. `HARN-LNT-071` covers globals generally; `HARN-LNT-052` through `HARN-LNT-057` call out clock, stdio, fs, env, random, and net specifically. Each explanation is available with `harn explain `. 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, JsonValue>`, and it now **requires** `_meta`. Every request must carry `io.modelcontextprotocol/protocolVersion`, `io.modelcontextprotocol/clientInfo`, and `io.modelcontextprotocol/clientCapabilities`. A request that omits them is rejected where it previously fell through to a default. - `McpRequestMetadata` is now `rmcp::model::RequestMetaObject`, so `metadata.protocol_version` is a **method** returning `Option`, not a `Option` field. - A header that contradicts the JSON-RPC body now returns the SDK's `HEADER_MISMATCH_CODE` (`-32020`) rather than `-32600`. ### Two traps worth naming Both of these compile cleanly and fail at run time, so a green `cargo check` is not evidence you have finished the migration. **`HeaderName::from_static` panics on these constants.** The header names were Harn-owned and lowercase (`"mcp-protocol-version"`, `"mcp-method"`, `"mcp-name"`); they now come from the SDK and are canonically cased (`"MCP-Protocol-Version"`, `"Mcp-Method"`, `"Mcp-Name"`). `from_static` requires an already-lowercase name, so a call site that was fine for years starts panicking on a pure constant rename. Parse it instead: ```rust,ignore // Panics: "HeaderName::from_static with invalid bytes" HeaderName::from_static(mcp_protocol::MCP_HEADER_PROTOCOL_VERSION) // Correct HeaderName::from_bytes(mcp_protocol::MCP_HEADER_PROTOCOL_VERSION.as_bytes()) ``` The same applies when you read a captured header out of a plain map: `HeaderName::as_str()` lowercases, so indexing that map with the constant finds nothing. `HeaderMap::get` is case-insensitive and needs no change. **`server_discover_result` has a different shape than `initialize` did.** It advertises `supportedVersions` (a list) rather than a single `protocolVersion`, and it reports identity under `_meta["io.modelcontextprotocol/serverInfo"]` rather than a top-level `serverInfo`. Indexing a `Value` at a missing key yields `Null`, so a test comparing the old path to a concrete version fails with `Null` on the left — a confusing message for a path that simply moved. ## Handle a missing session store Session-store reads no longer make a missing store look empty. The public read functions and the matching `harness.agent` methods now return `SessionStoreRead`: ```harn const read = session_store_events(harness.agent, session_id) if read.state == "absent" { // Choose whether a fresh workspace is valid or the root is wrong. return } const events = read.value ``` An existing empty store returns `{state: "present", value: []}`. A missing store returns `{state: "absent"}` and has no `value` field. This also applies to list, payload, projection, verification, and search reads. Reads still do not create files. Call `session_store_append` or start the session lifecycle before a read when your code intends to create the store. For `session_store_project_value`, the default applies only when the store is present. Decide what an absent store means before reading `value`. ## Read builtin contracts as schema version 3 `harn contracts builtins` now emits schema version 3. Every builtin row adds a `runtime_control_plane` boolean, which distinguishes Harn-owned session state from mutations of the user's workspace or an external system. Consumers that validate the top-level version must accept `3` before reading the field. Rust consumers of `harn-builtin-meta` must construct `BuiltinContract` through `BuiltinContract::harness`, `BuiltinContract::harness_with_effect_authorization`, or the other public constructors. Direct struct literals can no longer create contracts because the runtime-control classification is checked at the constructor boundary. ## `std/json.pick` is now the global `pick` `pick` is a global builtin. It no longer lives in `std/json`, so an `import { pick } from "std/json"` fails to check. Remove `pick` from the import and call it directly. Replace `json.pick(data, keys)` with `pick(data, keys)`. Two behaviors changed: - The old helper dropped `nil` values. The builtin keeps them. To drop them, call `pick_keys(data, keys, {drop_nil: true})` from `std/collections`. - The old helper returned `{}` for a source that wasn't a dictionary. The builtin raises a runtime error, and the checker rejects a source it can see is wrong. The result also carries the picked fields' types instead of a single dictionary type. See [Pick fields from a record](../pick.md). ### Before ```harn,ignore import { pick } from "std/json" const config = pick(settings, ["model", "temperature"]) ``` ### After ```harn,ignore const config = pick(settings, ["model", "temperature"]) ``` --- ## Read next - [0.6.x → 0.7.0](https://harnlang.com/migrations/v0.7.md) - [`const`/`let` keyword scheme](https://harnlang.com/migrations/const-let.md) --- # Migrating to the const/let keyword scheme > Harn's variable-binding keywords follow the TypeScript and Swift convention. Migrate every .harn source file that predates this change. Website: https://harnlang.com/migrations/const-let.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. --- # Migrating to the `const`/`let` keyword scheme Harn's variable-binding keywords follow the TypeScript and Swift convention. Migrate every `.harn` source file that predates this change. | Before | After | Meaning | |---|---|---| | `let x = …` (immutable) | `const x = …` | Immutable binding (the default) | | `var x = …` (mutable) | `let x = …` | Mutable binding (reassignable) | | `const NAME = …` (compile-time) | `const NAME = …` | Unchanged spelling; see below | | `var` keyword | Removed | Using it is a compile error with a migration hint | The old scheme used `let` for immutable bindings and `var` for mutable ones. Now `const` is the immutable default and `let` is mutable, matching TypeScript and Swift. ## Semantics - **`const` means this binding's value never changes.** It is the common case: reach for it by default and use `let` only when the value must change. - **`let` is a mutable binding.** Reassignment and field/index mutation are allowed (`let o = {}; o.a = 1`). - **The keyword *spelling* follows TypeScript; the *rule* follows Swift.** The two agree on reassignment but disagree on collection contents, so it is worth being exact. TypeScript's `const o = {}; o.a = 1` is legal, because a TS object is a *reference* and `const` constrains only the binding. Harn's collections are **values**, so `o.a = 1` changes `o`'s whole value and requires `let`, the same position Swift takes for the same reason. Methods such as `appending` return a new value and modify nothing, so they remain fine on a `const`. See [Binding mutability](../language-spec.md) for the full rule. - **`const` now accepts any initializer.** Previously `const` was a strict compile-time constant that *rejected* impure or non-foldable initializers. Because `const` is now the default immutable binding, that restriction is gone: `const user = fetch_user()` is fine. When the initializer happens to be in the pure const-eval subset it is still folded at compile time, but this is a transparent optimization. It never changes observable behavior, and an impure or erroring initializer is simply not folded (it is not a compile error). `const z = 1 / 0` errors at runtime, exactly like `let z = 1 / 0`. - **`var` is removed.** It is retained as a reserved word only so that using it produces a clear migration diagnostic pointing at `let`/`const`. ### Before ```harn,ignore let name = "ada" // immutable; old `let` was the immutable keyword var count = 0 // mutable; old `var` was the mutable keyword count = count + 1 ``` ### After ```harn,ignore const name = "ada" // immutable let count = 0 // mutable count = count + 1 ``` ## Automated migration The rename is fully mechanical. Use `harn codemod` with these two rules, run in order (`let`→`const` first, then `var`→`let`): ```toml # 01-let-to-const.toml id = "harn-let-to-const" language = "harn" fix = "const" fixTarget = "kw" [rule] query = '(let_binding "let" @kw) @__match' ``` ```toml # 02-var-to-let.toml id = "harn-var-to-let" language = "harn" fix = "let" fixTarget = "kw" [rule] query = '(var_binding "var" @kw) @__match' ``` ```sh harn codemod --apply --allow-unsafe --rule 01-let-to-const.toml . harn codemod --apply --allow-unsafe --rule 02-var-to-let.toml . ``` These rules rewrite only the binding keyword. Type annotations, destructuring patterns, and initializers are preserved byte-for-byte, and text inside strings and comments is never touched. Run `let`→`const` first: running `var`→`let` first would let the first rule re-match the freshly-minted `let`s and wrongly turn mutable bindings into `const`. --- ## Read next - [Migrating to 0.10](https://harnlang.com/migrations/v0.10.md) - [Pure collection method names](https://harnlang.com/migrations/pure-collection-methods.md) --- # Migrating pure collection method names > Harn collection methods return new values and never modify their receivers. Their names now make that value semantics explicit: Website: https://harnlang.com/migrations/pure-collection-methods.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 collection methods return new values and never modify their receivers. Their names now make that value semantics explicit: | Legacy spelling | Canonical replacement | |---|---| | `list.push(value)` | `list.appending(value)` | | `list.pop()` | `list.dropping_last()` | | `list.sort()` | `list.sorted()` | | `list.sort_by(key)` | `list.sorted_by(key)` | | `list.reverse()` | `list.reversed()` | | `string.reverse()` | `string.reversed()` | | `set.add(value)` | `set.adding(value)` | | `set.remove(value)` / `set.delete(value)` | `set.removing(value)` | | `dict.merge(other)` | `dict.merging(other)` | | `dict.remove(key)` | `dict.removing(key)` | | `dict.rekey(fn)` | `dict.rekeyed(fn)` | Only the names changed. Return values, ordering, copy-on-write behavior, and error behavior are unchanged. In particular, `dropping_last()` returns a list without its last item; use `last()` to read the last item. The legacy spellings remain behavior-compatible aliases so existing scripts can upgrade without a flag day. New code should use the canonical spellings. Replace legacy collection method calls, then run: ```sh harn check . harn lint . harn fmt --check . ``` Apply replacements to collection receivers, not unrelated APIs or user methods. Calls such as `git.push(...)`, `stream.merge(...)`, storage `delete(...)`, and a user-defined `add(...)` retain their existing names. --- ## Read next - [`const`/`let` keyword scheme](https://harnlang.com/migrations/const-let.md) - [Prompt templates: v2](https://harnlang.com/migrations/template-engine-v2.md) --- # Prompt templates: v2 migration > The prompt-template engine used by harness.fs.render_prompt(...) now supports else / elif , loops, includes, filters, comments, raw blocks, and whitespace trim markers.... Website: https://harnlang.com/migrations/template-engine-v2.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. --- The prompt-template engine used by `harness.fs.render_prompt(...)` now supports `else`/`elif`, loops, includes, filters, comments, raw blocks, and whitespace trim markers. Existing templates keep rendering unchanged — this is a strict superset. But many pre-v2 workarounds can now be simplified. ## If / else **Before** — mutually-exclusive `{{ if }}` blocks with inverted flags: ```harn-prompt {{if expected_output}} Expected: {{expected_output}} {{end}}{{if no_expected_output}} (no expected output provided) {{end}} ``` **After**: ```harn-prompt {{if expected_output}} Expected: {{expected_output}} {{else}} (no expected output provided) {{end}} ``` ## Loops instead of hand-rolled list concatenation **Before** — build a string in `.harn` and inject it as a single variable: ```harn,ignore const block = "" for sample in samples { block = "${block}###" + " ${sample.path}\n\`\`\`\n${sample.content}\n\`\`\`\n\n" } const prompt = harness.fs.render_prompt( "enrichment.prompt", {block: block, ...}, ) ``` ```harn-prompt # enrichment.prompt ## Samples {{block}} ``` **After** — iterate in the template: ```harn,ignore const prompt = harness.fs.render_prompt( "enrichment.prompt", {samples: samples, ...}, ) ``` ````harn-prompt # enrichment.prompt ## Samples {{for s in samples}} ### {{s.path}} ``` {{s.content}} ``` {{end}} ```` ## Shared prose → `{{ include }}` When multiple repair-stage prompts share the same boilerplate ("self-verification instructions", system rules, etc.), extract the shared text into a partial: ```harn-prompt # lib/partials/self-verify.harn.prompt Before responding, verify your answer against: {{verification_hint}} ``` Call it from each repair stage: ```harn-prompt {{include "partials/self-verify.harn.prompt"}} ...stage-specific instructions... ``` Pass stage-specific overrides with `with`: ```harn-prompt {{ include "partials/self-verify.harn.prompt" with { verification_hint: "compile output" } }} ``` ## Filters instead of pre-processing **Before** — uppercase, join lists, JSON-stringify in `.harn` before rendering: ```harn,ignore const tags_str = join(map(tags, fn(t) { return uppercase(t) }), ", ") harness.fs.render_prompt("x.prompt", {tags: tags_str}) ``` **After**: ```harn-prompt Tags: {{tags | join: ", " | upper}} ``` ## Comments and raw blocks Add `{{# authoring notes #}}` to document a template without leaking the note into the final prompt. Wrap literal `{{` / `}}` (e.g. examples of another template language embedded in a prompt) in a `{{ raw }} ... {{ endraw }}` block. ## Whitespace trim `{{- ... -}}` markers strip whitespace and one newline on the respective side. Use them to keep source templates readable without introducing blank lines in the rendered output: ```harn-prompt Items: {{- for x in xs -}} {{ x }}, {{- end -}} DONE ``` See [Prompt templating](../prompt-templating.md) for the full reference. --- ## Read next - [Pure collection method names](https://harnlang.com/migrations/pure-collection-methods.md) - [Package-root prompt assets](https://harnlang.com/migrations/package-root-prompt-assets.md) --- # Migration: package-root prompt assets > Harn supports two refactor-safe forms for addressing .harn.prompt assets: Website: https://harnlang.com/migrations/package-root-prompt-assets.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 supports two refactor-safe forms for addressing `.harn.prompt` assets: - `@/` — anchored at the calling file's project root (the nearest `harn.toml` ancestor). - `@/` — anchored at a `[asset_roots]` entry in `harn.toml`. Existing `harness.fs.render_prompt(...)` calls keep their source-relative behavior unchanged. Migrating brittle `../../...` paths to package-root form is optional but stops asset references from breaking when callers move. ## Before — relative paths break on file moves ```harn,ignore // pipelines/lib/runtime/workflow/graph-stages.harn harness.fs.render_prompt( "../../../partials/tool-examples.harn.prompt", bindings, ) ``` A pure refactor that relocates `graph-stages.harn` silently breaks the asset path with no compile-time signal — `harn check` will not catch it because the source-relative resolver still produces a path that points at a non-existent file. ## After — project-root form ```harn,ignore harness.fs.render_prompt( "@/pipelines/partials/tool-examples.harn.prompt", bindings, ) ``` Verbose but resilient: the path resolves the same regardless of where the caller lives. `harn check` validates it during preflight. ## Better — `[asset_roots]` alias Define an alias in the project's `harn.toml`: ```toml [asset_roots] partials = "pipelines/partials" ``` Then: ```harn,ignore harness.fs.render_prompt("@partials/tool-examples.harn.prompt", bindings) ``` Aliases are resolved against the project root, so they work from any file in the workspace. ## What changed in the runtime - `harness.fs.render_prompt(...)`, `harness.fs.render_prompt_with_provenance(...)`, the `template.render` host capability, and `{{ include "..." }}` directives now recognize the `@/...` and `@/...` forms. - `harn check` reports a `preflight: ...` diagnostic when: - the calling file has no `harn.toml` ancestor; - an `@/...` reference targets an alias that isn't defined in `[asset_roots]`; - the resolved file does not exist. - `harn contracts bundle` records every resolved `@`-path under `prompt_assets` so packagers don't need to maintain a separate file list. - The Harn LSP go-to-definition jumps from a literal `harness.fs.render_prompt("@/...")` argument straight to the target prompt file. ## Safety Both forms reject `..` segments and absolute targets. A `harness.fs.render_prompt("@/../escape")` call fails with `invalid project-root asset path`, so a package-rooted asset cannot reach outside the project root. ## Related - Reference: [modules.md](../modules.md#package-root-prompt-assets) - Templating: [prompt-templating.md](../prompt-templating.md#package-root-paths) - Spec: `[asset_roots]` table in `spec/HARN_SPEC.md` --- ## Read next - [Prompt templates: v2](https://harnlang.com/migrations/template-engine-v2.md) - [Schema-as-type](https://harnlang.com/migrations/schema-as-type.md) --- # Migration — schema-as-type (type aliases drive output) > Prior to this change, Harn had two parallel representations for structured LLM output: Website: https://harnlang.com/migrations/schema-as-type.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. --- # Migration — schema-as-type (`type` aliases drive `output`) Prior to this change, Harn had two parallel representations for structured LLM output: 1. **Harn-native types** — `type Foo = {verdict: string, ...}`. 2. **Raw JSON-Schema dicts** — passed as `output: {type: "dict", properties: {...}, required: [...]}` to `harness.llm.call`, and consumed by `schema_is`, `schema_expect`, `schema_parse`, and friends. The two representations drifted. A grader script that declared a type alias for documentation and a separate schema dict for validation had no compile-time check that the two agreed. This release unifies them. A single `type` alias now feeds: - Static type-checking on the values that flow through it. - JSON-Schema emission for `harness.llm.call` structured output. - `schema_is` / `schema_expect` narrowing on runtime-typed values (`unknown`, unions, parsed JSON). - ACP `ToolAnnotations.args` compatibility (same emitted schema). ## Migrating a grader script Before — duplicated surface, no cross-check: ```harn const grader_schema = { type: "object", required: ["verdict", "summary"], properties: { verdict: {type: "string", enum: ["pass", "fail", "unclear"]}, summary: {type: "string"}, }, } const r = harness.llm.call(prompt, nil, { model: routing.model, output: grader_schema, schema_retries: 2, }) // No compile-time guarantee that r.data carries these fields, so the // nilable access still needs `?.` to satisfy the checker. harness.stdio.log("verdict=${r.data?.verdict}") ``` After — one alias, two uses: ```harn type GraderOut = { verdict: "pass" | "fail" | "unclear", summary: string, } const r = harness.llm.call(prompt, nil, { model: routing.model, output: GraderOut, // compiled to the JSON-Schema dict schema_retries: 2, }) if schema_is(r.data, GraderOut) { // r.data is narrowed to GraderOut here. harness.stdio.log("verdict=${r.data.verdict}") } ``` ## What translates mechanically | Old schema key | New type grammar | |---|---| | `{type: "string"}` | `string` | | `{type: "int"}` / `"integer"` | `int` | | `{type: "bool"}` / `"boolean"` | `bool` | | `{type: "list", items: T}` | `list` | | `{type: "dict", additional_properties: V}` | `dict` | | `{type: "string", enum: ["a","b"]}` | `"a" \| "b"` | | `{type: "int", enum: [0,1,2]}` | `0 \| 1 \| 2` | | `{properties, required}` with `additional_properties: false` | `type T = {field: type, optional?: type}` | | `{union: [A, B]}` / `{oneOf: [A, B]}` | `A \| B` | | `{nullable: true}` wrapping `T` | `T \| nil` | ## Staying with raw schema dicts Nothing forces you to migrate. `output: dict_literal` still works and is still the right tool when you need schema features Harn's type grammar does not yet express (regex `pattern`, `min_length`, numeric `min`/`max`, `const`, nested `$ref`, etc.). You can mix: ```harn type Name = {first: string, last: string} const r = harness.llm.call(prompt, nil, { output: { type: "dict", properties: { name: schema_of(Name), // alias → schema dict email: {type: "string", pattern: "^[^@]+@[^@]+$"}, }, required: ["name", "email"], }, }) ``` ## Caveats - `schema_of(T)` materializes top-level aliases across file and embedded standard-library module boundaries. Dynamic construction (`let T = ...`) falls back to the runtime `schema_of` builtin, which is a dict-passthrough — it does not look up alias names at runtime. - The compiler-level alias emitter handles shapes, lists, `dict`, literal-string/int unions, functions, nested aliases, applied generic aliases, and open-record tails. Imported aliases may be used directly or embedded in a consumer-owned alias. - `response.data` of `harness.llm.call(..., {output: T})` is not yet automatically narrowed to `T` by the type checker. Use `if schema_is(r.data, T) { ... }` in the interim — the narrowing there is exact. --- ## Read next - [Package-root prompt assets](https://harnlang.com/migrations/package-root-prompt-assets.md) - [Rust connectors → Harn packages](https://harnlang.com/migrations/rust-connectors-to-harn-packages.md) --- # Migrating Rust provider connectors to pure-Harn packages > GitHub, Slack, Linear, and Notion provider business logic now ships in pure-Harn connector packages. Harn core keeps only the shared connector primitives, so cloud platforms... Website: https://harnlang.com/migrations/rust-connectors-to-harn-packages.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. --- GitHub, Slack, Linear, and Notion provider business logic now ships in pure-Harn connector packages. Harn core keeps only the shared connector primitives, so cloud platforms and self-hosted orchestrators can adopt connector fixes, new event families, and provider API changes without waiting for a Harn core release. This guide is the migration path for an orchestrator that still has an old manifest or deployment model from before the pure-Harn package cutover. The cron, generic webhook, A2A push, and stream connectors stay in Harn core and are **not** affected by this migration. HMAC verification, raw-body access, signature header constants, and signing primitives also stay in core so that pure-Harn connectors do not need to reimplement them. ## What is changing Each first-party provider has a pure-Harn replacement that exposes the same event shape through the connector contract v1 surface: | Provider | Pure-Harn package | |---|---| | GitHub | | | Slack | | | Linear | | | Notion | | Manifests opt into the pure-Harn replacement by declaring a `[[providers]]` table that points `connector = { harn = "..." }` at the package's connector module. Provider-specific Rust fallbacks are no longer registered for GitHub, Slack, Linear, or Notion, so the override is the canonical path for those providers. ## Cutover checklist The cutover is intentionally per-provider so that an orchestrator can soak one provider on the pure-Harn implementation before moving the rest. 1. **Install the package.** Add the package as a dependency and run `harn install --locked`. ```sh harn add github.com/burin-labs/harn-github-connector@v0.2.0 harn install --locked ``` 2. **Run the contract check.** Confirm the package matches the connector contract and your supported event families. ```sh harn connector check . --provider github ``` For Notion, also exercise the poll path: ```sh harn connector check . --provider notion --run-poll-tick ``` 3. **Add a `[[providers]]` override.** Tell the orchestrator to load the pure-Harn module for this provider. ```toml [[providers]] id = "github" connector = { harn = "vendor/harn-github-connector/src/lib.harn" } ``` Leave existing trigger entries unchanged. Triggers with `provider = "github"` automatically resolve through the new connector once the override is in place. 4. **Run a fixture check.** Feed canonical webhook bodies through the pure-Harn package and assert the resulting `TriggerEvent` `kind` / `dedupe_key` / `provider_payload` shapes match the payloads your handlers expect. The connector testkit (`docs/src/connectors/testkit.md`) has the primitives needed to stage a `RawInbound` and capture the normalized event in tests. First-party connector packages run a parity matrix against the Rust payload shapes in their own CI. If your handlers depend on a vendor field that is not in the parity fixtures, add it to your local `[connector_contract]` fixture set before cutover. 5. **Roll out and verify.** Deploy the manifest change. The orchestrator logs a one-line confirmation when it loads the Harn module. `harn doctor` reports `trigger:` as `via `, so existing health checks keep working. ## Cloud platform specifics Managed cloud orchestrators load pure-Harn connector packages through the same `[[providers]]` mechanism documented above. Connector packages are resolved through the package manager so the cutover is a manifest change, not a cloud-platform release. ## What stays in core The following primitives stay in Harn core and continue to be the only supported way to express their respective concerns: - The `cron` connector (`docs/src/connectors/cron.md`). - The generic `webhook` connector with HMAC verification, including the `webhook-signature` / `webhook-timestamp` / `webhook-id` Standard Webhooks-style headers (`docs/src/connectors/webhook.md`). - HMAC verification helpers under `harn_vm::connectors::hmac`, including the canonical signature-header constants used by GitHub, Slack, Linear, Notion, Stripe, and Standard Webhooks. - The A2A push connector and the stream connector for queue-shaped ingress. - Raw HTTP request access (`raw_body`, headers) and signing primitives. Pure-Harn provider connectors compose these primitives — they do not duplicate them. ## Removal status The Harn core prerequisites are complete: - The connector contract conformance harness validates pure-Harn replacements through the same adapter path cloud platforms and self-hosted orchestrators use. - `NormalizeResult` v1, `poll_tick`, hot-path effect policy, transport primitives, structured concurrency, and the connector testkit are in core. - The OAuth / connect CLI and package manager give connector packages a stable install + auth path. - First-party package CI owns parity fixtures for GitHub, Slack, Linear, and Notion so provider payload behavior can evolve with package releases. Do not add provider-specific Rust connector business logic in this repository; service connectors should be packages that register with `connector = { harn = "..." }`. --- ## Read next - [Schema-as-type](https://harnlang.com/migrations/schema-as-type.md) - [harn-hostlib host contracts](https://harnlang.com/migrations/harn-hostlib-host-contracts.md) --- # Migration: harn-hostlib host contracts > harn-hostlib began as a migration path for code-intelligence and tool surfaces that had lived in an external IDE host's Swift code-intelligence modules. That history explains... Website: https://harnlang.com/migrations/harn-hostlib-host-contracts.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-hostlib` began as a migration path for code-intelligence and tool surfaces that had lived in an external IDE host's Swift code-intelligence modules. That history explains the early parity tests and the initial schema names, but it is no longer the ownership model. The current contract is Harn-owned: - JSON schemas under `crates/harn-hostlib/schemas/` define request and response compatibility for every hostlib method. - `HostlibRegistry` is the authoritative runtime catalog of registered modules and methods. - Consumer repositories should treat their bridge tests as compatibility checks against Harn's published contract, not as the source of truth for hostlib behavior. During migration, keep any consumer-specific bridge notes in this page or in the consumer repository. Public hostlib docs, schema descriptions, and module comments should describe the neutral Harn contract first. --- ## Read next - [Rust connectors → Harn packages](https://harnlang.com/migrations/rust-connectors-to-harn-packages.md) - [Language basics](https://harnlang.com/language-basics.md) --- # Language basics > This reference covers Harn's core syntax and semantics. A top-level function named main is a file entrypoint. main isn't a reserved keyword, but Harn calls this function... Website: https://harnlang.com/language-basics.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. --- This reference covers Harn's core syntax and semantics. A top-level function named `main` is a file entrypoint. `main` isn't a reserved keyword, but Harn calls this function automatically when the file has no named pipeline. Its only parameter must be named `harness` and have type `Harness`: ```harn,check title="hello.harn" fn main(harness: Harness) { harness.stdio.println("Hello from Harn") } ``` Run the file from its directory: ```bash harn run hello.harn ``` It prints: ```text Hello from Harn ``` Files can also use top-level statements or named pipelines. Harn's entrypoint rules for both forms follow. ## Implicit pipeline When a file has no named pipeline, Harn executes its top-level statements in source order. This is the implicit pipeline. If the file also declares a top-level `main`, Harn calls it after those statements. ```harn const x = 1 + 2 harness.stdio.log(x) fn double(n) { return n * 2 } harness.stdio.log(double(5)) ``` Use top-level statements for short scripts and experiments. Use `main` when you want a clear callable boundary without declaring a pipeline. ## Pipelines For larger programs, organize code into named pipelines. The runtime executes the pipeline named `default`, or the first one declared. When a file declares any pipeline, Harn uses this rule instead of calling a top-level `main`. ```harn,check title="pipelines.harn" pipeline default(harness: Harness) { harness.stdio.log("Hello from the default pipeline") } pipeline other(harness: Harness) { harness.stdio.log("This only runs if called or if there's no default") } ``` Declare only the pipeline parameters you use. If the entry pipeline declares `task`, Harn binds it to `context.task`. If it declares `project`, Harn binds it to `context.projectRoot`. There is no single `task` type; the value and its useful shape depend on the host. A `context` dict with `task`, `project_root`, and `task_type` is always available. See [Pipeline parameters](./spec/language/06-evaluation-order.md#pipeline-parameters) for typed callable pipelines. ## Variables `const` creates a binding whose value never changes. `let` creates one whose value may change. ```harn const name = "Alice" let counter = 0 counter = counter + 1 // ok name = "Bob" // error: immutable assignment ``` That rule covers collections too, and it is the one place Harn surprises people arriving from JavaScript or Python. Collections are **values**, not references, so writing into one changes the binding's whole value and needs `let`: ```harn let scores = {} scores["alice"] = 1 // ok: changes the value of `scores`, so `let` const frozen = {} frozen["alice"] = 1 // error: `const` means this value never changes ``` Methods never modify the receiver — `appending` returns a *new* list — so they are fine on a `const`, and you build a collection by assigning the result back: ```harn let items = [] items = items.appending("a") // `items` is now ["a"] const base = [] // error[HARN-LNT-066]: no effect — `base` is still [] base.appending("a") ``` See [Binding mutability](./spec/language/04-scope-rules.md#binding-mutability) in the language spec for the full rule. Bindings are lexically scoped. Each `if` branch, loop body, `catch` body, and explicit `{ ... }` block gets its own scope. A binding declared inside a block does not replace a binding outside it: ```harn,check title="scope.harn" fn main(harness: Harness) { const status = "ready" const detailed = harness.env.get("APP_VERBOSE") == "1" if detailed { const detail = "inputs checked" harness.stdio.log(detail) } harness.stdio.log(status) } ``` Harn also permits an inner binding to shadow an outer name. `harn lint` reports that pattern as `HARN-LNT-009` because distinct names are easier to follow. If you want to update an outer binding from inside a block, declare it with `let` outside the block and assign to it inside the branch or loop body. ## Types and values Harn is dynamically typed with optional type annotations. ### Read diagnostics at the source The checked examples below are rendered from Harn's own diagnostics. The underline marks the exact source span; the visible detail names the severity, stable code, message, suggested help, and registry-owned repair. If the compiler, linter, or repair registry changes, the documentation check requires this projection to be regenerated before the site can ship. An annotation makes a mismatched initializer a type error: ```harn,diagnostic-check fn main(harness: Harness) { const value: string = 42 harness.stdio.println(value) } ``` The linter also explains code that is valid but says more than it needs to: ```harn,diagnostic-lint fn main(harness: Harness) { let total = 2 harness.stdio.println(total) } ``` | Type | Example | Notes | |---|---|---| | `int` | `42` | Platform-width integer | | `float` | `3.14` | Double-precision | | `decimal` | `decimal("0.10")` | Exact base-10 (money); see below | | `string` | `"hello"` | UTF-8, supports interpolation | | `bool` | `true`, `false` | | | `nil` | `nil` | Null value | | `list` | `[1, 2, 3]` | Heterogeneous, ordered | | `tuple` | `tuple("retries", 3)` | Fixed-length positional list | | `dict` | `{name: "Alice"}` | String-keyed map | | `closure` | `{ x -> x + 1 }` | First-class function | | `duration` | `5s`, `100ms` | Time duration | ### Type annotations Annotations are optional and checked at compile time: ```harn const x: int = 42 const name: string = "hello" const nums: list = [1, 2, 3] const pair: tuple = ["retries", 3] fn add(a: int, b: int) -> int { return a + b } ``` Supported type expressions: `int`, `float`, `decimal`, `string`, `bool`, `nil`, `list`, `list`, `tuple`, `dict`, `dict`, union types (`string | nil`), and structural shape types (`{name: string, age: int}`). ### Fixed-arity tuples Use a tuple when length and position are part of the contract: ```harn const row = tuple("retries", 3) // tuple const name: string = row[0] const count: int = row[-1] fn retry_count(row: tuple) -> int { return row[1] } // bracket literal is contextually a tuple retry_count(["timeout", 5]) ``` Ordinary bracket literals still infer lists. A `tuple<...>` annotation or parameter can contextually type a bracket literal, and `tuple(...)` requests tuple inference explicitly. Constant indexes preserve the exact positional type and an out-of-bounds constant is a static error. A dynamic index is the union of all positions plus `nil`. Tuples use the same value-semantic runtime representation and operations as lists. They widen to lists when an operation changes arity, and can be passed to `list` when every position satisfies `T`. A general list cannot narrow to a tuple because it does not prove a fixed length. ### Decimal (exact arithmetic) `decimal` is an exact base-10 number (96-bit, up to 28–29 significant digits) for money and other values where binary-float rounding is unacceptable — `decimal("0.1") + decimal("0.2")` is exactly `0.3`, not `0.30000000000000004`. Construct one with the `decimal(value)` builtin from a string (exact parse), an int (exact), a float (an explicit opt-in to the lossy binary→decimal step), or another decimal. Unlike `to_int`/`to_float`, `decimal` **throws** on an un-parseable value rather than returning `nil`, so a bad money string fails loud. ```harn const price = decimal("19.99") const total = price * 3 // 59.97 — int operands promote exactly const half = decimal("1") / decimal("2") // 0.5 ``` Decimal is a distinct type. It arithmetic-promotes `int` operands, but **`decimal` and `float` never mix** — `decimal("1") + 1.5` is a compile-time error; convert explicitly with `decimal(x)` or `to_float(x)`. For equality/ordering, `decimal` only compares against `decimal` (scale-insensitive, so `decimal("1.5") == decimal("1.50")`); `decimal("1") == 1` is `false`. Decimals cross the host/JSON boundary as strings to preserve precision, and bind natively to Postgres `NUMERIC`/`DECIMAL` columns. Parameter type annotations for primitive types (`int`, `float`, `string`, `bool`, `list`, `dict`, `set`, `nil`, `closure`) are checked before the program runs. Calling a function with the wrong type reports `HARN-TYP-006`: ```harn,ignore fn add(a: int, b: int) -> int { return a + b } add("hello", "world") // error[HARN-TYP-006]: argument 1 `a`: expected int, found string ``` ### Structural types (shapes) Shape types describe the expected fields of a dict. The type checker verifies that required fields are present with compatible types. Extra fields are allowed (width subtyping). ```harn const user: {name: string, age: int} = {name: "Alice", age: 30} const config: {host: string, port?: int} = {host: "localhost"} fn greet(u: {name: string}) -> string { return "hi ${u["name"]}" } greet({name: "Bob", age: 25}) ``` Use `type` aliases for reusable shape definitions: ```harn type Config = {model: string, max_tokens: int} const cfg: Config = {model: "gpt-4", max_tokens: 100} ``` ### Truthiness These values are falsy: `false`, `nil`, `0`, `0.0`, `""`, `[]`, `{}`. Everything else is truthy. ## Strings ### Interpolation ```harn const name = "world" harness.stdio.log("Hello, ${name}!") harness.stdio.log("2 + 2 = ${2 + 2}") ``` Any expression works inside `${}`. ### Raw strings Raw strings use the `r"..."` prefix. No escape processing or interpolation is performed -- backslashes and dollar signs are taken literally. Useful for regex patterns and file paths: ```harn const pattern = r"\d+\.\d+" const path = r"C:\Users\alice\docs" ``` Raw strings cannot span multiple lines. ### Multi-line strings ```harn const doc = """ This is a multi-line string. Common leading whitespace is stripped. """ ``` Multi-line strings support `${expression}` interpolation with automatic indent stripping: ```harn const name = "world" const greeting = """ Hello, ${name}! Welcome to Harn. """ ``` ### Escape sequences `\n` (newline), `\t` (tab), `\\` (backslash), `\"` (quote), `\$` (dollar sign). ### String methods ```harn "hello".count // 5 "hello".empty // false "hello".contains("ell") // true "hello".replace("l", "r") // "herro" "a,b,c".split(",") // ["a", "b", "c"] " hello ".trim() // "hello" "hello".starts_with("he") // true "hello".ends_with("lo") // true "hello hello".rfind("lo") // 9 "hello".uppercase() // "HELLO" "hello".lowercase() // "hello" "hello world".substring(0, 5) // "hello" ``` ## Operators Ordered by precedence (lowest to highest): | Precedence | Operators | Description | |---|---|---| | 1 | `\|>` | Pipe | | 2 | `? :` | Ternary conditional | | 3 | `\|\|` | Logical OR (short-circuit) | | 4 | `&&` | Logical AND (short-circuit) | | 5 | `==` `!=` | Equality | | 6 | `<` `>` `<=` `>=` `in` `not in` | Comparison, membership | | 7 | `+` `-` | Add, subtract, string/list concat | | 8 | `??` | Nil coalescing | | 9 | `*` `/` `%` | Multiply, divide, modulo | | 10 | `!` `-` | Unary not, negate | | 11 | `**` | Exponentiation | | 12 | `.` `?.` `[]` `?.[]` `[:]` `()` `?` | Member access, optional chaining, subscript, optional subscript, slice, call, try | Integer division truncates toward zero. Integer division (and any modulo) by zero raises a catchable runtime error, while float division by zero follows IEEE-754 (`±inf`, or `NaN` for `0.0 / 0.0`). Arithmetic operators are strictly typed — mismatched operands (e.g. `"hello" + 5`) produce a `TypeError`. Use `to_string()` or string interpolation (`"value=${x}"`) for explicit conversion. `??` binds tighter than comparisons and logical operators but looser than multiplication, so `xs?.count ?? 0 > 0` means `(xs?.count ?? 0) > 0`. `harn fmt` adds clarifying parentheses when `??` is mixed with looser binary operators. ### Optional chaining (`?.`) Access properties, indexes, or call methods on values that might be nil. Returns nil instead of erroring when the receiver is nil: ```harn const user = nil harness.stdio.log(user?.name) // nil (no error) harness.stdio.log(user?.greet("hi")) // nil (method not called) harness.stdio.log(user?.["name"]) // nil (subscript not evaluated) const d = {name: "Alice"} harness.stdio.log(d?.name) // Alice harness.stdio.log(d?.["name"]) // Alice ``` Chains propagate nil: `a?.b?.[0]?.c` returns nil if any step is nil. ### List and string slicing (`[start:end]`) Extract sublists or substrings using slice syntax: ```harn const items = [10, 20, 30, 40, 50] harness.stdio.log(items[1:3]) // [20, 30] harness.stdio.log(items[:2]) // [10, 20] harness.stdio.log(items[3:]) // [40, 50] harness.stdio.log(items[-2:]) // [40, 50] const s = "hello world" harness.stdio.log(s[0:5]) // hello harness.stdio.log(s[-5:]) // world ``` Negative indices count from the end. Omit start for 0, omit end for length. ### Try operator (`?`) The postfix `?` operator works with `Result` values (`Ok` / `Err`). It unwraps `Ok` values and propagates `Err` values by returning early from the enclosing function: ```harn fn divide(a, b) { if b == 0 { return Err("division by zero") } return Ok(a / b) } fn compute(x) { const result = divide(x, 2)? // unwraps Ok, or returns Err early return Ok(result + 10) } fn compute_zero(x) { const result = divide(x, 0)? // divide returns Err, ? propagates it return Ok(result + 10) } harness.stdio.log(compute(20)) // Result.Ok(20) harness.stdio.log(compute_zero(20)) // Result.Err(division by zero) ``` Multiple `?` calls can be chained in a single function to build pipelines that short-circuit on the first error. ### Membership operators (`in`, `not in`) Test whether a value is contained in a collection: ```harn // Lists harness.stdio.log(3 in [1, 2, 3]) // true harness.stdio.log(6 not in [1, 2, 3]) // true // Strings (substring containment) harness.stdio.log("world" in "hello world") // true harness.stdio.log("xyz" not in "hello") // true // Dicts (key membership) const data = {name: "Alice", age: 30} harness.stdio.log("name" in data) // true harness.stdio.log("email" not in data) // true // Sets const s = set(1, 2, 3) harness.stdio.log(2 in s) // true harness.stdio.log(5 not in s) // true ``` ## Control flow ### if/else ```harn if score > 90 { harness.stdio.log("A") } else if score > 80 { harness.stdio.log("B") } else { harness.stdio.log("C") } ``` Can be used as an expression: `let grade = if score > 90 { "A" } else { "B" }` ### for/in ```harn for item in [1, 2, 3] { harness.stdio.log(item) } // Dict iteration yields {key, value} entries sorted by key for entry in {a: 1, b: 2} { harness.stdio.log("${entry.key}: ${entry.value}") } ``` ### while ```harn let i = 0 while i < 10 { harness.stdio.log(i) i = i + 1 } ``` Safety limit of 10,000 iterations. ### match ```harn match status { "active" -> { harness.stdio.log("Running") } "stopped" -> { harness.stdio.log("Halted") } } ``` Patterns are expressions compared by equality. First match wins. No match returns `nil`. ### guard Early exit if a condition isn't met: ```harn guard x > 0 else { return "invalid" } // x is guaranteed > 0 here ``` ### Ranges Harn has a single range keyword: `to`. Ranges are **inclusive by default** — `1 to 5` is `[1, 2, 3, 4, 5]` — because that matches how the expression reads aloud. Add the trailing `exclusive` modifier when you want the half-open form. ```harn for i in 1 to 5 { // inclusive: 1, 2, 3, 4, 5 harness.stdio.log(i) } for i in 0 to 3 exclusive { // half-open: 0, 1, 2 harness.stdio.log(i) } ``` For Python-compatible 0-indexed iteration there is also a `range()` stdlib builtin. `range(n)` is equivalent to `0 to n exclusive`; `range(a, b)` is `a to b exclusive`. Both forms always produce half-open integer ranges. ```harn for i in range(5) { harness.stdio.log(i) } // 0, 1, 2, 3, 4 for i in range(3, 7) { harness.stdio.log(i) } // 3, 4, 5, 6 ``` ### Iteration patterns Prefer destructuring and stdlib helpers over integer-indexed loops — they read better and avoid off-by-one bugs. ```harn // enumerate(): yields a list of {index, value} dicts. for {index, value} in ["a", "b", "c"].enumerate() { harness.stdio.log("${index}: ${value}") } // zip(): yields [a, b] pairs — use list destructuring. for [name, score] in names.zip(scores) { harness.stdio.log("${name}: ${score}") } // Dict iteration yields {key, value} entries sorted by key. for {key, value} in {a: 1, b: 2}.entries() { harness.stdio.log("${key} -> ${value}") } ``` `for` heads accept a bare name or one of three destructuring patterns, each matching the *shape* the iterable yields: - a **pair** pattern `(a, b)` — for iterables that yield `Pair` values: `iter(x).enumerate()`, `iter(x).zip(...)`, and `dict.iter()`; - a **list** pattern `[a, b]` — for `list.zip(other)`, which yields `[a, b]` lists; - a **dict** pattern `{index, value}` — for `list.enumerate()` (yields `{index, value}`) and `entries()` (yields `{key, value}`). Using a pair pattern over a non-`Pair` item (e.g. `for (i, x) in list.enumerate()`, whose items are `{index, value}` dicts) now fails loudly instead of silently binding both names to `nil`. ## Functions and closures ### Named functions ```harn fn double(x) { return x * 2 } fn greet(name: string) -> string { return "Hello, ${name}!" } ``` Functions can be declared at the top level (for library files) or inside pipelines. ### Rest parameters Use `...name` as the last parameter to collect any remaining arguments into a list: ```harn fn sum(...nums) { let total = 0 for n in nums { total = total + n } return total } harness.stdio.log(sum(1, 2, 3)) // 6 fn report(level, ...parts) { harness.stdio.log("[${level}] ${join(parts, " ")}") } report("INFO", "server", "started") // [INFO] server started ``` If no extra arguments are provided, the rest parameter is an empty list. A type annotation on a rest parameter describes each extra argument, and the binding inside the function has the corresponding list type: `...nums: int` accepts only integer extras and binds `nums` as `list`. ### Closures ```harn const square = { x -> x * x } const add = { a, b -> a + b } harness.stdio.log(square(4)) // 16 harness.stdio.log(add(2, 3)) // 5 ``` Closures capture the enclosing bindings they reference. ### Calling returned functions Call postfixes can chain on the same line. If a function returns another function, call the result directly: ```harn fn make_adder(base: int) -> fn(int) -> int { return { value: int -> base + value } } const answer = make_adder(40)(2) // 42 const also = (make_adder(39))(3) // 42 ``` The opening parenthesis must stay on the callee's line. A newline starts a new statement, so `const add = make_adder(40)\n(2)` binds `add` and then evaluates `2`; it does not call `add`. ### Capture semantics Closures capture the enclosing bindings they reference, by reference. A closure that reassigns a captured `let` (a rebind like `n = n + 1`, a compound assignment, or an in-place container write such as `xs[i] = ...` or `d.field = ...`) mutates the same binding the enclosing scope holds, and later calls see the running value. This is how JavaScript and Python behave. ```harn let n = 0 const bump = { -> n = n + 1 } bump() bump() harness.stdio.log(n) // 2 ``` Capture shares bindings, not values. Distinct variables stay independent: `let b = a` copies, so mutating `b` leaves `a` untouched. Parameters and `const` bindings are immutable, so a closure can read them but never rebind them. A captured variable can change whenever a closure runs, so the type checker does not narrow (by `!= nil`, `type_of`, and the like) any variable that a nested closure reassigns. TypeScript and Flow use the same rule. Reach for optional chaining or a non-null assertion on such a variable instead of a guard: ```harn let x: string? = "config" const clear = { -> x = nil } if x != nil { clear() // x may be nil again after this call // x is not narrowed here; use ?. (or x!) rather than x.len() harness.stdio.log(x?.len()) } ``` Reassigning a captured variable from concurrent `parallel` or `spawn` branches writes through one shared cell, so the branches race on it. The `mutable-capture-across-parallel` lint (`HARN-LNT-064`) flags this. Return each branch's result and combine after the fan-out instead. ### Higher-order functions ```harn const nums = [1, 2, 3, 4, 5] nums.map({ x -> x * 2 }) // [2, 4, 6, 8, 10] nums.filter({ x -> x > 3 }) // [4, 5] nums.reduce(0, { acc, x -> acc + x }) // 15 nums.find({ x -> x == 3 }) // 3 nums.any({ x -> x > 4 }) // true nums.all({ x -> x > 0 }) // true nums.flat_map({ x -> [x, x] }) // [1, 1, 2, 2, 3, 3, 4, 4, 5, 5] ``` ### Lazy iterators Collection methods like `.map` and `.filter` above are *eager* — each call allocates a new list and walks the whole input. That's fine for small inputs, but wastes work when you only need the first few results, or when you want to compose several transforms. Harn also ships a lazy iterator protocol. Call `.iter()` on any iterable source (list, dict, set, string, generator, channel) to lift it into an `Iter` — a single-pass, fused iterator. Combinators on an `Iter` return a new `Iter` without running any work. Sinks drain the iter and return an eager value. ```harn,ignore const xs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] const first_three_doubled_evens = xs .iter() .filter({ x -> x % 2 == 0 }) .map({ x -> x * 2 }) .take(3) .to_list() harness.stdio.log(first_three_doubled_evens) // [4, 8, 12] ``` Use `.enumerate()` to get `(index, value)` pairs in a for-loop: ```harn,ignore const items = ["a", "b", "c"] for (i, x) in items.iter().enumerate() { harness.stdio.log("${i}: ${x}") } ``` `.iter()` on a dict yields `Pair(key, value)` values — destructure them in a for-loop: ```harn,ignore for (k, v) in {a: 1, b: 2}.iter() { harness.stdio.log("${k}: ${v}") } ``` A direct `for entry in some_dict` still yields the usual `{key, value}` dicts (back-compat). `pair(a, b)` also exists as a builtin for constructing pairs explicitly. **Lazy combinators** (return a new `Iter`): `.map`, `.filter`, `.flat_map`, `.take(n)`, `.skip(n)`, `.take_while`, `.skip_while`, `.zip`, `.enumerate`, `.chain`, `.chunks(n)`, `.windows(n)`. **Sinks** (drain the iter, return a value): `.to_list()`, `.to_set()`, `.to_dict()` (requires `Pair` items), `.count()`, `.sum()`, `.min()`, `.max()`, `.reduce(init, f)`, `.first()`, `.last()`, `.any(p)`, `.all(p)`, `.find(p)`, `.for_each(f)`. **When to use which**: reach for eager list/dict/set methods for simple one-shot transforms where you want a collection back. Reach for `.iter()` when you're composing multiple transforms, taking the first N results of a large input, consuming a generator lazily, or driving a for-loop over combined sources. Iterators are **single-pass and fused** — once exhausted, they stay exhausted. Iteration takes a **snapshot** of the backing collection, so mutating the source after `.iter()` does not affect the iter. Printing an iter renders `` without draining it. Numeric ranges (`a to b`, `range(n)`) participate in the lazy iter protocol directly: `.map / .filter / .take / .zip / .enumerate / ...` on a Range return a lazy iter with no upfront allocation, so `(1 to 10000000).map(fn(x) { return x * 2 }).take(5).to_list()` finishes instantly. Range still keeps its O(1) fast paths for `.len / .first / .last / .contains(x)` and `r[k]` subscript — those don't round-trip through iter. ## Pipe operator The pipe operator `|>` passes the left side as the argument to the right side: ```harn const result = data |> { list -> list.filter({ x -> x > 0 }) } |> { list -> list.map({ x -> x * 2 }) } |> json_stringify ``` ### Pipe placeholder (`_`) Use `_` to control where the piped value is placed in the call: ```harn "hello world" |> split(_, " ") // ["hello", "world"] [3, 1, 2] |> _.sorted() // [1, 2, 3] items |> len(_) // length of items "world" |> replace("hello _", "_", _) // "hello world" ``` Without `_`, the value is passed as the sole argument to a closure or function name. ## Multiline expressions Binary operators, method chains, and pipes can span multiple lines: ```harn,ignore const message = "hello world" const result = items .filter({ x -> x > 0 }) .map({ x -> x * 2 }) const valid = check_a() && check_b() || fallback() const name = nil ?? "unknown" const same = 1 == 1 ``` Note: `-` does not continue across lines because it doubles as unary negation. Keyword operators `in`, `not in`, and `to` also require an explicit backslash continuation. A backslash at the end of a line forces the next line to continue the current expression, even when no operator is present: ```harn,ignore const long_value = some_function( \ arg1, arg2, arg3 \ ) ``` ## Destructuring Destructuring extracts values from dicts and lists into local variables. Use `_` when a position should be evaluated and ignored without creating a real variable. ### Dict destructuring ```harn const person = {name: "Alice", age: 30} const {name, age} = person harness.stdio.log(name) // "Alice" harness.stdio.log(age) // 30 const {name, debug: _} = {name: "Alice", debug: true} harness.stdio.log(name) // "Alice" ``` ### List destructuring ```harn const items = [1, 2, 3, 4, 5] const [first, ...rest] = items harness.stdio.log(first) // 1 harness.stdio.log(rest) // [2, 3, 4, 5] const [_, second, _] = [10, 20, 30] harness.stdio.log(second) // 20 ``` ### Renaming Use `:` to bind a dict field to a different variable name: ```harn const data = {name: "Alice"} const {name: user_name} = data harness.stdio.log(user_name) // "Alice" ``` ### Destructuring in for-in loops ```harn const entries = [{key: "a", value: 1}, {key: "b", value: 2}] for {key, value} in entries { harness.stdio.log("${key}: ${value}") } for [_, value] in [[0, "x"], [1, "y"]] { harness.stdio.log(value) } ``` ### Default values Pattern fields can specify defaults with `= expr`. The default is used when the value would otherwise be `nil`: ```harn const { name = "anon", role = "user" } = { name: "Alice" } harness.stdio.log(name) // Alice harness.stdio.log(role) // user const [a = 0, b = 0, c = 0] = [1, 2] harness.stdio.log(c) // 0 // Combine with renaming const { name: display = "Unknown" } = {} harness.stdio.log(display) // Unknown ``` ### Missing keys and empty rest Missing keys destructure to `nil` (unless a default is specified). A rest pattern with no remaining items gives an empty collection: ```harn const {name, email} = {name: "Alice"} harness.stdio.log(email) // nil const [only, ...rest] = [42] harness.stdio.log(rest) // [] ``` ## Collections ### Lists ```harn const nums = [1, 2, 3] nums.count // 3 nums.first // 1 nums.last // 3 nums.empty // false nums[0] // 1 (subscript access) ``` Lists support `+` for concatenation: `[1, 2] + [3, 4]` yields `[1, 2, 3, 4]`. Assigning to an out-of-bounds index throws an error. ### Dicts ```harn const user = {name: "Alice", age: 30} user.name // "Alice" (property access) user["age"] // 30 (subscript access) user.missing // nil (missing keys return nil) user.has("email") // false user.keys() // ["age", "name"] (sorted) user.values() // [30, "Alice"] user.entries() // [{key: "age", value: 30}, ...] user.merging({role: "admin"}) // new dict with merged keys user.map_values({ v -> to_string(v) }) user.filter({ v -> type_of(v) == "int" }) ``` Computed keys use bracket syntax: `{[dynamic_key]: value}`. Quoted string keys are also supported for JSON compatibility: `{"content-type": "json"}`. The formatter normalizes simple quoted keys to unquoted form and non-identifier keys to computed key syntax. Keywords can be used as dict keys and property names: `{type: "read"}`, `op.type`. Dicts iterate in **sorted key order** (alphabetical). This means `for k in dict` is deterministic and reproducible, but does not preserve insertion order. ### Sets Sets are unordered collections of unique values. Duplicates are automatically removed. ```harn const s = set(1, 2, 3) // create from individual values const s2 = set([4, 5, 5, 6]) // create from a list (deduplicates) const tags = set("a", "b", "c") // works with any value type ``` Set operations are provided as builtin functions: ```harn const a = set(1, 2, 3) const b = set(3, 4, 5) set_contains(a, 2) // true set_contains(a, 99) // false set_union(a, b) // set(1, 2, 3, 4, 5) set_intersect(a, b) // set(3) set_difference(a, b) // set(1, 2) -- items in a but not in b set_add(a, 4) // set(1, 2, 3, 4) set_remove(a, 2) // set(1, 3) ``` Sets support iteration with `for..in`: ```harn let sum = 0 for item in set(10, 20, 30) { sum = sum + item } harness.stdio.log(sum) // 60 ``` Convert a set to a list with `to_list()`: ```harn const items = to_list(set(10, 20)) type_of(items) // "list" ``` ## Enums and structs ### Enums ```harn enum Status { Active Inactive Pending(reason) Failed(code, message) } const s = Status.Pending("waiting") match s.variant { "Pending" -> { harness.stdio.log(s.fields[0]) } "Active" -> { harness.stdio.log("ok") } "Inactive" -> { harness.stdio.log("inactive") } "Failed" -> { harness.stdio.log(s.fields[1]) } } ``` ### Structs ```harn struct Point { x: int y: int } const p = {x: 10, y: 20} harness.stdio.log(p.x) ``` Structs can also be constructed with the struct name as a constructor, using named fields directly: ```harn struct Point { x: int y: int } const p = Point { x: 10, y: 20 } harness.stdio.log(p.x) // 10 ``` Structs can declare type parameters when fields should stay connected: ```harn struct Pair { first: A second: B } const pair: Pair = Pair { first: 1, second: "two" } harness.stdio.log(pair.second) // two ``` ### Impl blocks Add methods to a struct with `impl`: ```harn struct Point { x: int y: int } impl Point { fn distance(self) { return sqrt(self.x * self.x + self.y * self.y) } fn translate(self, dx, dy) { return Point { x: self.x + dx, y: self.y + dy } } } const p = Point { x: 3, y: 4 } harness.stdio.log(p.distance()) // 5.0 harness.stdio.log(p.translate(10, 20)) // Point({x: 13, y: 24}) ``` The first parameter must be `self`, which receives the struct instance. Methods are called with dot syntax on values constructed with the struct constructor. ## Interfaces Interfaces let you define a contract: a set of methods that a type must have. Harn uses **implicit satisfaction**, just like Go. A struct satisfies an interface automatically if its `impl` block has all the required methods. You never write `implements` or `impl Interface for Type`. ### Step 1: define an interface An interface lists method signatures without bodies: ```harn interface Displayable { fn display(self) -> string } ``` This says: any type that has a `display(self) -> string` method counts as `Displayable`. Interfaces can also be generic, and individual interface methods may declare their own type parameters when the contract needs them: ```harn interface Repository { fn get(id: string) -> T fn map(value: T, f: fn(T) -> U) -> U } ``` Interfaces may also declare associated types when the contract needs to name an implementation-defined type without making the whole interface generic: ```harn interface Collection { type Item fn get(self, index: int) -> Item } ``` ### Step 2: create structs with matching methods ```harn struct Dog { name: string breed: string } impl Dog { fn display(self) -> string { return "${self.name} the ${self.breed}" } } struct Cat { name: string indoor: bool } impl Cat { fn display(self) -> string { const status = if self.indoor { "indoor" } else { "outdoor" } return "${self.name} (${status} cat)" } } ``` Both `Dog` and `Cat` have a `display(self) -> string` method, so they both satisfy `Displayable`. No extra annotation is needed. ### Step 3: use the interface as a type Now you can write a function that accepts any `Displayable`: ```harn,ignore fn introduce(animal: Displayable) { harness.stdio.log("Meet: ${animal.display()}") } const d = Dog({name: "Rex", breed: "Labrador"}) const c = Cat({name: "Whiskers", indoor: true}) introduce(d) // Meet: Rex the Labrador introduce(c) // Meet: Whiskers (indoor cat) ``` The type checker verifies at compile time that `Dog` and `Cat` satisfy `Displayable`. If a struct is missing a required method, you get a clear error at the call site. ### Interfaces with multiple methods Interfaces can require more than one method: ```harn interface Serializable { fn serialize(self) -> string fn byte_size(self) -> int } ``` ### `guard`, `require`, and `assert` These three forms serve different jobs: - `guard condition else { ... }` handles expected control flow and narrows types after the guard. - `require condition, "message"` enforces runtime invariants in normal code and throws on failure. - `assert`, `assert_eq`, and `assert_ne` are for test pipelines. The linter warns when you use them in non-test code, and it nudges test pipelines away from `require`. ```harn guard user != nil else { return "missing user" } require len(user.name) > 0, "user name cannot be empty" ``` A struct must implement all listed methods to satisfy the interface. ### Generic constraints You can also use interfaces as constraints on generic type parameters: ```harn fn log_item(item: T) where T: Displayable { harness.stdio.log("[LOG] ${item.display()}") } ``` The `where T: Displayable` clause tells the type checker to verify that whatever concrete type is passed for `T` satisfies `Displayable`. If it does not, a compile-time error is produced. Generic parameters must also bind consistently across arguments, so `fn(a: T, b: T)` cannot be called with mixed concrete types such as `(int, string)`. Container bindings like `list` preserve and validate their element type at call sites too. ### Variance: `in T` and `out T` Type parameters on user-defined generics may be marked `in` (the parameter is contravariant — it appears only in input positions) or `out` (covariant — only in output positions). Unannotated parameters default to **invariant**: `Box` and `Box` are unrelated unless `Box` declares `out T` and uses `T` only covariantly. ```harn,ignore type Reader = fn() -> T // T is produced interface Sink { fn accept(v: T) -> int } // T is consumed ``` Built-in containers carry variance matching their semantics: `iter` and value-semantic `list` are covariant. Fixed-arity tuples are covariant position by position; `dict` is invariant in `K` and covariant in `V`. Function types are contravariant in their parameters and covariant in their return type — `fn(float)` can stand in for `fn(int)`, but not the other way around. The full variance table lives in the spec under "Subtyping and variance". Declarations are checked at the definition site: a `type Box = fn(T) -> int` is rejected because `T` appears in a contravariant position despite the `out` annotation. ## Spread in function calls The spread operator `...` expands a list into individual function arguments: ```harn fn add(a, b, c) { return a + b + c } const nums = [1, 2, 3] harness.stdio.log(add(...nums)) // 6 ``` You can mix regular arguments and spread arguments: ```harn fn add(a, b, c) { return a + b + c } const rest = [2, 3] harness.stdio.log(add(1, ...rest)) // 6 ``` Spread works in method calls too: ```harn,ignore const point = Point({x: 0, y: 0}) const deltas = [10, 20] const moved = point.translate(...deltas) ``` ## Try-expression The `try` keyword without a `catch` block is a try-expression. It evaluates its body and wraps the outcome in a `Result`: ```harn const result = try { json_parse(raw_input) } // Result.Ok(parsed_data) -- if parsing succeeds // Result.Err("invalid JSON: ...") -- if parsing throws ``` This is the complement of the `?` operator. Use `try` to enter Result-land (catching errors into `Result.Err`), and `?` to exit Result-land (propagating errors upward): ```harn fn safe_divide(a, b) { return try { a / b } } fn compute(x) { const half = safe_divide(x, 2)? // unwrap Ok or propagate Err return Ok(half + 10) } ``` No `catch` or `finally` is needed. If a `catch` follows `try`, it is parsed as the traditional `try`/`catch` statement instead. ## Ask expression The `ask` expression is syntactic sugar for making an LLM call. It takes a set of key-value fields and returns the LLM response as a string: ```harn,ignore const answer = ask { system: "You are a helpful assistant.", user: "What is 2 + 2?" } harness.stdio.log(answer) ``` Common fields include `system` (system prompt), `user` (user message), `model`, `max_tokens`, and `provider`. The `ask` expression is equivalent to building a dict and passing it to `harness.llm.call`. ## Duration literals ```harn const d1 = 500ms // 500 milliseconds const d2 = 5s // 5 seconds const d3 = 2m // 2 minutes const d4 = 1h // 1 hour ``` Durations can be passed to `harness.clock.sleep_ms()` and used in `deadline` blocks. ## Math constants `pi` and `e` are global constants (not functions): ```harn harness.stdio.log(pi) // 3.141592653589793 harness.stdio.log(e) // 2.718281828459045 const area = pi * r * r ``` ## Named format placeholders The `format` builtin supports both positional `{}` placeholders and named `{key}` placeholders when the second argument is a dict: ```harn // Positional harness.stdio.log(format("Hello, {}!", "world")) // Named harness.stdio.log( format("Hello {name}, you are {age}.", {name: "Alice", age: 30}) ) ``` For simple cases, string interpolation with `${}` is usually more convenient: ```harn const name = "Alice" harness.stdio.log("Hello, ${name}!") ``` ## Comments ```harn // Line comment /** HarnDoc comment for a public API. Use a `/** ... */` block directly above `pub fn`. */ pub fn greet(name: string) -> string { return "Hello, ${name}" } pub pipeline deploy(task) { return } pub enum Result { Ok(value: string) Err(message: string) } pub struct Config { host: string port?: int } /* Block comment /* Nested block comments are supported */ Still inside the outer comment */ ``` --- ## Read next - [harn-hostlib host contracts](https://harnlang.com/migrations/harn-hostlib-host-contracts.md) - [Error handling](https://harnlang.com/error-handling.md) --- # Error handling > Harn provides try / catch / throw for error handling and retry for automatic recovery. Website: https://harnlang.com/error-handling.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 provides `try`/`catch`/`throw` for error handling and `retry` for automatic recovery. > **Reading errors.** Most type-check and runtime errors around shapes, > structs, schemas, and nilable values follow a small set of patterns > that point straight at the fix. See > [Reading shape diagnostics](./reading-shape-diagnostics.md) for a tour, > or the [Diagnostic codes catalog](./diagnostics.md) for the full > `HARN--` reference. ## throw Any value can be thrown as an error: ```harn throw "something went wrong" throw {code: 404, message: "not found"} throw 42 ``` ## try/catch Catch errors with an optional error binding: ```harn try { const data = json_parse(raw_input) } catch (e) { harness.stdio.log("Parse failed at ${e.line}:${e.column}: ${e.message}") } ``` The error variable is optional: ```harn fn risky_operation() { throw "boom" } try { risky_operation() } catch { harness.stdio.log("Something failed, moving on") } ``` ### What gets bound to the error variable - If the error was created with `throw`: `e` is the thrown value directly (string, dict, etc.) - If the error is an internal runtime error: `e` is the error's description as a string ### return inside try A `return` statement inside a `try` block is **not** caught. It propagates out of the enclosing pipeline or function as expected. ```harn,ignore fn find_user(id) { try { const user = lookup(id) return user // this returns from find_user, not caught } catch (e) { return nil } } ``` ## Typed catch Catch specific error types using enum-based error hierarchies: ```harn enum AppError { NotFound(resource) Unauthorized(reason) Internal(message) } try { throw AppError.NotFound("user:123") } catch (e: AppError) { match e.variant { "NotFound" -> { harness.stdio.log("Missing: ${e.fields[0]}") } "Unauthorized" -> { harness.stdio.log("Access denied") } "Internal" -> { harness.stdio.log("Internal: ${e.fields[0]}") } } } ``` Errors that don't match the typed catch propagate up the call stack. ## require The `require` statement checks a condition and throws an error if it is false. An optional second argument provides the error message: ```harn require len(items) > 0, "items list must not be empty" require user != nil, "user is required" require score >= 0 // throws a generic error if false ``` `require` is useful at the top of a function to validate preconditions before proceeding. If the condition is falsy, execution stops with a thrown error that can be caught by `try`/`catch` or will surface as a runtime error. ## guard The `guard` statement provides an early-return pattern. If the condition is false, the `else` block executes. The `else` block must exit the current scope (typically via `return` or `throw`): ```harn fn process(input) { guard input != nil else { return "no input" } guard type_of(input) == "string" else { throw "expected string, got ${type_of(input)}" } // input is guaranteed non-nil and a string here return input.uppercase() } ``` After a `guard` statement, the type checker narrows the variable's type based on the condition. For example, `guard x != nil` ensures `x` is non-nil in subsequent code. ## retry Automatically retry a block up to N times: ```harn retry 3 { const response = harness.net.post(url, payload) const parsed = json_parse(response.body) parsed } ``` - Any error in the body triggers a retry. `retry` doesn't inspect the error, so a malformed response and an unreachable host are treated the same way. - If the body succeeds on any attempt, that result is returned immediately. - `retry N` makes at most N attempts, not N retries after a first try. - If every attempt fails, the last error propagates. Wrap the block in `try`/`catch`, or use a `try` expression, if you want to handle exhaustion rather than let it escape. - `return` inside a retry block propagates out (not retried). For a retry that reacts to *why* a call failed, use [`harness.llm.with_rate_limit`](./builtins.md#llm), which backs off only on `rate_limit`, `overloaded`, `transient_network`, and `timeout`. ## Try-expression The `try` keyword without a `catch` block acts as a try-expression. It evaluates the body and returns a `Result`: - On success: `Result.Ok(value)` - On error: `Result.Err(error)` ```harn const result = try { json_parse(raw_input) } ``` This is useful when you want to capture an error as a value rather than crashing or needing a full `try`/`catch`: ```harn const parsed = try { json_parse(input) } if is_err(parsed) { harness.stdio.log("Bad input, using defaults") parsed = Ok({}) } const data = unwrap(parsed) ``` ## Try/catch expression `try { ... } catch (e) { ... }` is also usable as an expression — the whole form evaluates to the try body's tail value on success, or the catch handler's tail value on a caught throw. The lub of the two branch types is inferred automatically, and an explicit type annotation on the `let` binds the result: ```harn,ignore const parsed: dict = try { json_parse(input) } catch (e) { default_config() } ``` Typed catches work identically in expression position; when the thrown error's type does not match the catch's type filter, the throw propagates past the expression and the `let` binding is never established: ```harn,ignore const user: User = try { fetch_user(id) } catch (e: NetworkError) { cached_user(id) } // Any non-`NetworkError` throw surfaces out of this block unchanged. ``` A `finally { ... }` tail is optional on either form and runs once for side-effect only — its value is discarded. The expression's value still comes from the try body or the catch handler. The try-expression pairs naturally with the `?` operator. Use `try` to enter Result-land and `?` to propagate within it: ```harn fn fetch_json(url) { const body = try { harness.net.get(url) } const text = unwrap(body)? const data = try { json_parse(text) } return data } ``` When `catch` or `finally` follows `try`, the form is the handled expression described above; only the bare `try { body }` form wraps in `Result`. If the bare `try` body already returns a `Result`, that result is returned unchanged instead of being nested as `Result.Ok(Result.Ok(...))`. ## Runtime shape validation errors When a function parameter has a structural type annotation (a shape like `{name: string, age: int}`), Harn validates the argument at runtime. If the argument is missing a required field or a field has the wrong type, a clear error is produced: ```harn,ignore fn process(user: {name: string, age: int}) { harness.stdio.log("${user.name} is ${user.age}") } process({name: "Alice"}) // Error: parameter 'user': missing field 'age' (int) process({name: "Alice", age: "old"}) // Error: parameter 'user': field 'age' expected int, got string ``` Shape validation works with both plain dicts and struct instances. Extra fields beyond those listed in the shape are allowed (width subtyping). This catches a common class of bugs where a dict is passed with missing or mistyped fields, giving you precise feedback about exactly which field is wrong. ## Result type The built-in `Result` enum provides an alternative to try/catch for representing success and failure as values. A `Result` is either `Ok(value)` or `Err(error)`. Statically, `Result` is generic: `Result`. ```harn const ok = Ok(42) const err = Err("something failed") const typed_ok: Result = ok const typed_err: Result = err harness.stdio.log(ok) // Result.Ok(42) harness.stdio.log(err) // Result.Err(something failed) ``` The shorthand constructors `Ok(value)` and `Err(value)` are equivalent to `Result.Ok(value)` and `Result.Err(value)`. ### Result helper functions | Function | Description | |---|---| | `is_ok(r)` | Returns `true` if `r` is `Result.Ok` | | `is_err(r)` | Returns `true` if `r` is `Result.Err` | | `unwrap(r)` | Returns the `Ok` value, throws if `r` is `Err` | | `unwrap_or(r, default)` | Returns the `Ok` value, or `default` if `r` is `Err` | | `unwrap_err(r)` | Returns the `Err` value, throws if `r` is `Ok` | ```harn const r = Ok(42) harness.stdio.log(is_ok(r)) // true harness.stdio.log(is_err(r)) // false harness.stdio.log(unwrap(r)) // 42 harness.stdio.log(unwrap_or(Err("x"), "default")) // default ``` ### Pattern matching on result Result values can be destructured with `match`: ```harn fn fetch_data(url) { // ... returns Ok(data) or Err(message) } match fetch_data("/api/users") { Result.Ok(data) -> { harness.stdio.log("Got ${len(data)} users") } Result.Err(err) -> { harness.stdio.log("Failed: ${err}") } } ``` ### The `?` operator The postfix `?` operator provides concise error propagation. Applied to a `Result` value, it unwraps `Ok` and returns the value, or immediately returns the `Err` from the enclosing function. ```harn fn divide(a, b) { if b == 0 { return Err("division by zero") } return Ok(a / b) } fn compute(x) { const result = divide(x, 2)? // unwraps Ok, or returns Err early return Ok(result + 10) } const r1 = compute(20) // Result.Ok(20) const r2 = compute(0) // Result.Err(division by zero) ``` The `?` operator has the same precedence as `.`, `[]`, and `()`, so it chains naturally: ```harn fn fetch_and_parse(url) { const response = harness.net.get(url)? const data = json_parse(response)? return Ok(data) } ``` Applying `?` to a non-Result value produces a runtime type error. ### Result vs. try/catch Use `Result` and `?` when errors are expected outcomes that callers should handle (validation failures, missing data, parse errors). Use `try`/`catch` for unexpected errors or when you want to recover from failures in-place without propagating them through return values. The two patterns can be combined: ```harn fn transform(data) { return data } fn parse_json_result(input) { const parsed = try { json_parse(input) } if is_err(parsed) { return Err("parse error: ${unwrap_err(parsed).message}") } return parsed } fn process(raw) { const data = parse_json_result(raw)? // propagate Err if parse fails return Ok(transform(data)) } ``` ## Stack traces When a runtime error occurs, Harn displays a stack trace showing the call chain that led to the error. The trace includes file location, source context, and the sequence of function calls. ```text error: division by zero --> example.harn:3:14 | 3 | let x = a / b | ^ = note: called from compute at example.harn:8 = note: called from pipeline at example.harn:12 ``` The error format shows: - **Error message**: what went wrong - **Source location**: file, line, and column where the error occurred - **Source context**: the relevant source line with a caret (`^`) pointing to the exact position - **Call chain**: each function in the call stack, from innermost to outermost, with file and line numbers Stack traces are captured at the point of the error, before try/catch unwinding, so the full call chain is preserved even when errors are caught at a higher level. ## Combining patterns ```harn retry 3 { try { const result = harness.llm.call(prompt, system) const parsed = json_parse(result.text) return parsed } catch (e) { harness.stdio.log("Attempt failed: ${e}") throw e // re-throw to trigger retry } } ``` --- ## Read next - [Language basics](https://harnlang.com/language-basics.md) - [Diagnostic codes catalog](https://harnlang.com/diagnostics.md) --- # Diagnostic codes > Every diagnostic emitted by harn check , harn lint , and harn fmt carries a stable HARN-- code. Codes are dispatchable: agents, IDEs, and the hosted error pages all... Website: https://harnlang.com/diagnostics.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. --- Every diagnostic emitted by `harn check`, `harn lint`, and `harn fmt` carries a stable `HARN--` code. Codes are dispatchable: agents, IDEs, and the hosted error pages all read the same `apiStability: stable` contract, so cross-tooling integrations never have to regex on prose. Look up a single code interactively: ```sh harn explain HARN-TYP-014 harn explain HARN-TYP-014 --json ``` The structured JSON sidecar that drives this page is committed at [`docs/diagnostics-catalog.json`](https://github.com/burin-labs/harn/blob/main/docs/diagnostics-catalog.json) — its `schemaVersion: 1` shape is the contract consumed by downstream tooling (an IDE host's diagnostic panel, a cloud platform's hosted error pages). Regenerate locally with `make sync-diagnostics-catalog`. Prose-style tour of common shape and nilable diagnostics: [Reading shape diagnostics](./reading-shape-diagnostics.md). ## Repair safety classes Repairs are tagged with a six-level safety class so `harn fix --apply --safety ` and IDE auto-apply policies can dispatch without inspecting individual edits: | Class | Meaning | |---|---| | `format-only` | Whitespace, trivia, or canonical layout only. Always safe to auto-apply. | | `behavior-preserving` | Intended not to change observable runtime behavior. | | `scope-local` | Confined to the current local scope or file; blast radius does not cross a public surface. | | `surface-changing` | Touches a signature, export, or call-site surface other files can observe. | | `capability-changing` | Required capabilities or sandbox profile may change. | | `needs-human` | Planning hint only — propose, never auto-apply. | ## Categories | Category | Title | Codes | |---|---|---:| | [`TYP`](#typ--type-checker) | Type checker | 29 | | [`PAR`](#par--parser--lexer) | Parser / lexer | 6 | | [`NAM`](#nam--naming-and-resolution) | Naming and resolution | 12 | | [`CAP`](#cap--capabilities) | Capabilities | 8 | | [`LLM`](#llm--llm-calls) | LLM calls | 4 | | [`ORC`](#orc--orchestration-constructs) | Orchestration constructs | 12 | | [`STD`](#std--stdlib-usage) | Stdlib usage | 5 | | [`PRM`](#prm--prompt-templates) | Prompt templates | 7 | | [`MOD`](#mod--modules-and-exports) | Modules and exports | 7 | | [`RMD`](#rmd--reminder-lifecycle) | Reminder lifecycle | 8 | | [`SUS`](#sus--suspend--resume-lifecycle) | Suspend / resume lifecycle | 13 | | [`LNT`](#lnt--lint-rules) | Lint rules | 76 | | [`FMT`](#fmt--formatter) | Formatter | 3 | | [`IMP`](#imp--import-resolution) | Import resolution | 3 | | [`OWN`](#own--ownership-and-mutability) | Ownership and mutability | 4 | | [`RCV`](#rcv--error-recovery) | Error recovery | 3 | | [`MAT`](#mat--match-exhaustiveness) | Match exhaustiveness | 3 | | [`POL`](#pol--runtime-policies) | Runtime policies | 2 | | [`MET`](#met--compile-time-meta-restrictions) | Compile-time meta restrictions | 1 | | [`CST`](#cst--const-eval-sandbox) | Const-eval sandbox | 4 | | [`CMP`](#cmp--bytecode-compilation) | Bytecode compilation | 1 | ## TYP — Type checker Harn's static type checker rejects programs whose types do not unify. Type errors block compilation — Harn refuses to run a program until they are fixed. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-TYP-001`](#harn-typ-001) | expected and actual types are incompatible | `casts/insert-explicit-conversion` | `scope-local` | | [`HARN-TYP-002`](#harn-typ-002) | binary operator is not defined for the operand types | — | — | | [`HARN-TYP-003`](#harn-typ-003) | string concatenation should be rewritten as interpolation | `style/string-interpolation` | `behavior-preserving` | | [`HARN-TYP-004`](#harn-typ-004) | returned expression does not match the declared return type | `casts/insert-explicit-conversion` | `scope-local` | | [`HARN-TYP-005`](#harn-typ-005) | assigned value does not match the target type | `casts/insert-explicit-conversion` | `scope-local` | | [`HARN-TYP-006`](#harn-typ-006) | argument value does not match the parameter type | `casts/insert-explicit-conversion` | `scope-local` | | [`HARN-TYP-007`](#harn-typ-007) | initializer does not match the declared variable type | `casts/insert-explicit-conversion` | `scope-local` | | [`HARN-TYP-008`](#harn-typ-008) | closure return expression does not match its declared type | `casts/insert-explicit-conversion` | `scope-local` | | [`HARN-TYP-009`](#harn-typ-009) | field value does not match its declared type | `casts/insert-explicit-conversion` | `scope-local` | | [`HARN-TYP-010`](#harn-typ-010) | method receiver or result type is incompatible | `casts/insert-explicit-conversion` | `scope-local` | | [`HARN-TYP-011`](#harn-typ-011) | callable does not accept type arguments | — | — | | [`HARN-TYP-012`](#harn-typ-012) | type argument does not satisfy the generic parameter | — | — | | [`HARN-TYP-013`](#harn-typ-013) | generic call has the wrong number of type arguments | — | — | | [`HARN-TYP-014`](#harn-typ-014) | declaration has the wrong number of type parameters | — | — | | [`HARN-TYP-015`](#harn-typ-015) | type argument does not satisfy a where-clause constraint | — | — | | [`HARN-TYP-016`](#harn-typ-016) | expression must be iterable | — | — | | [`HARN-TYP-017`](#harn-typ-017) | subscript index type is invalid | `casts/insert-explicit-conversion` | `scope-local` | | [`HARN-TYP-018`](#harn-typ-018) | expression must be callable | — | — | | [`HARN-TYP-019`](#harn-typ-019) | cast cannot be proven valid | `casts/remove-unchecked` | `scope-local` | | [`HARN-TYP-020`](#harn-typ-020) | type name cannot be resolved | `imports/fix-path` | `scope-local` | | [`HARN-TYP-021`](#harn-typ-021) | variant type is used in an invalid position | — | — | | [`HARN-TYP-022`](#harn-typ-022) | struct literal is invalid | — | — | | [`HARN-TYP-023`](#harn-typ-023) | enum construction is invalid | — | — | | [`HARN-TYP-024`](#harn-typ-024) | pattern binding is invalid for the expected type | — | — | | [`HARN-TYP-025`](#harn-typ-025) | optional access is invalid for the receiver type | — | — | | [`HARN-TYP-026`](#harn-typ-026) | thrown value type is not covered by the callable's declared throws set | — | — | | [`HARN-TYP-027`](#harn-typ-027) | constant tuple index is outside the fixed arity | — | — | | [`HARN-TYP-028`](#harn-typ-028) | declared parameter has no type annotation | `types/annotate-parameter` | `surface-changing` | | [`HARN-TYP-029`](#harn-typ-029) | type predicate contract is invalid | — | — | ## PAR — Parser / lexer The lexer or parser raises these before type checking begins. Harn cannot build an AST from the source until the offending token sequence is repaired. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-PAR-001`](#harn-par-001) | parser found an unexpected token | — | — | | [`HARN-PAR-002`](#harn-par-002) | parser reached end of file while expecting syntax | — | — | | [`HARN-PAR-003`](#harn-par-003) | lexer found an unexpected character | — | — | | [`HARN-PAR-004`](#harn-par-004) | string literal is unterminated | — | — | | [`HARN-PAR-005`](#harn-par-005) | block comment is unterminated | — | — | | [`HARN-PAR-006`](#harn-par-006) | integer literal is out of range for int (i64) | — | — | ## NAM — Naming and resolution Name resolution failed: the identifier, field, or attribute referenced does not match anything in the visible scope. Harn cannot proceed without a binding. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-NAM-001`](#harn-nam-001) | variable name cannot be resolved | `bindings/rename-to-closest` | `scope-local` | | [`HARN-NAM-002`](#harn-nam-002) | function name cannot be resolved | `bindings/rename-to-closest` | `scope-local` | | [`HARN-NAM-003`](#harn-nam-003) | attribute name is not recognized | — | — | | [`HARN-NAM-004`](#harn-nam-004) | field name does not exist on the target type | `bindings/rename-to-closest` | `scope-local` | | [`HARN-NAM-005`](#harn-nam-005) | method name does not exist on the receiver type | `bindings/rename-to-closest` | `scope-local` | | [`HARN-NAM-006`](#harn-nam-006) | argument name is duplicated | — | — | | [`HARN-NAM-008`](#harn-nam-008) | builtin name cannot be resolved | `bindings/rename-to-closest` | `scope-local` | | [`HARN-NAM-009`](#harn-nam-009) | function call targets a deprecated declaration | `stdlib/migrate-renamed` | `scope-local` | | [`HARN-NAM-010`](#harn-nam-010) | declaration reference cannot be resolved | `bindings/rename-to-closest` | `scope-local` | | [`HARN-NAM-011`](#harn-nam-011) | attribute is attached to an unsupported declaration | — | — | | [`HARN-NAM-012`](#harn-nam-012) | attribute argument is invalid | — | — | | [`HARN-NAM-101`](#harn-nam-101) | `fn main` must take an explicit `harness: Harness` parameter | `bindings/thread-harness-needs-param` | `surface-changing` | ## CAP — Capabilities A host capability call (file I/O, network, HITL approval, tool host, etc.) failed static validation. Capabilities are the trust boundary between Harn scripts and the embedding host, so checks are strict by design. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-CAP-001`](#harn-cap-001) | capability payload is invalid | — | — | | [`HARN-CAP-004`](#harn-cap-004) | capability result must be checked | `errors/check-or-rescue` | `scope-local` | | [`HARN-CAP-005`](#harn-cap-005) | host capability operation is not declared | — | — | | [`HARN-CAP-006`](#harn-cap-006) | host capability call must use a static operation name | — | — | | [`HARN-CAP-007`](#harn-cap-007) | tool host capability binding is invalid | `manual/review-capability-binding` | `needs-human` | | [`HARN-CAP-008`](#harn-cap-008) | declared host capability operation is not served | — | — | | [`HARN-CAP-201`](#harn-cap-201) | harness capability denied by active sandbox profile | — | — | | [`HARN-CAP-301`](#harn-cap-301) | child agent effect set exceeds the parent's declared effects | `policy/narrow-child-effects` | `surface-changing` | ## LLM — LLM calls A `harness.llm.call(...)` invocation violates the schema Harn enforces. Schema-validated, provider-portable LLM calls are a load-bearing Harn contract; drift in the options table is rejected at check time. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-LLM-003`](#harn-llm-003) | LLM call is missing schema validation | `llm/add-schema` | `surface-changing` | | [`HARN-LLM-004`](#harn-llm-004) | LLM schema option is invalid | — | — | | [`HARN-LLM-005`](#harn-llm-005) | prompt branches on provider identity instead of capability flags | `llm/use-capability-flag` | `capability-changing` | | [`HARN-LLM-006`](#harn-llm-006) | provider, model, and requested options form a known-unsafe composition | — | — | ## ORC — Orchestration constructs An orchestration construct — agent / workflow / pipeline / tool definition, or a call to an orchestration builtin — is shaped in a way the orchestrator cannot accept. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-ORC-001`](#harn-orc-001) | orchestration construct has invalid arity | — | — | | [`HARN-ORC-002`](#harn-orc-002) | orchestration construct argument has invalid type | — | — | | [`HARN-ORC-003`](#harn-orc-003) | agent declaration is invalid | — | — | | [`HARN-ORC-004`](#harn-orc-004) | workflow declaration is invalid | — | — | | [`HARN-ORC-005`](#harn-orc-005) | tool declaration is invalid | — | — | | [`HARN-ORC-006`](#harn-orc-006) | pipeline declaration is invalid | — | — | | [`HARN-ORC-007`](#harn-orc-007) | select construct is invalid | — | — | | [`HARN-ORC-008`](#harn-orc-008) | statement cannot be reached | `control-flow/remove-dead` | `behavior-preserving` | | [`HARN-ORC-009`](#harn-orc-009) | Flow invariant attribute set is invalid | — | — | | [`HARN-ORC-010`](#harn-orc-010) | execution target path cannot be found | — | — | | [`HARN-ORC-011`](#harn-orc-011) | a self-deadlock acquire would block forever | — | — | | [`HARN-ORC-012`](#harn-orc-012) | a wait-for graph cycle would block forever | — | — | ## STD — Stdlib usage A stdlib symbol is used in a way Harn does not support, or has been renamed/removed and the call site still references the old surface. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-STD-001`](#harn-std-001) | stdlib symbol has been renamed or deprecated | `stdlib/migrate-renamed` | `scope-local` | | [`HARN-STD-002`](#harn-std-002) | stdlib call is invalid | — | — | | [`HARN-STD-003`](#harn-std-003) | builtin call has invalid arity | — | — | | [`HARN-STD-101`](#harn-std-101) | public stdlib function is missing declared metadata | `doc/add-stdlib-metadata` | `behavior-preserving` | | [`HARN-STD-102`](#harn-std-102) | public stdlib function is missing an explicit return type | — | — | ## PRM — Prompt templates A prompt template (`.harn.prompt` / `.prompt`) failed validation, either because its front matter is missing required fields or because the body references slots the schema does not declare. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-PRM-001`](#harn-prm-001) | prompt template cannot be parsed | — | — | | [`HARN-PRM-002`](#harn-prm-002) | prompt template has too many capability-aware branches | `manual/needs-human` | `needs-human` | | [`HARN-PRM-003`](#harn-prm-003) | prompt construction risks direct injection | `prompts/escape-injection` | `scope-local` | | [`HARN-PRM-004`](#harn-prm-004) | prompt template branches on provider identity | `llm/use-capability-flag` | `capability-changing` | | [`HARN-PRM-005`](#harn-prm-005) | prompt references a tool outside the declared surface | `prompts/add-tool-to-surface` | `surface-changing` | | [`HARN-PRM-006`](#harn-prm-006) | prompt references a deferred tool without tool search | `prompts/add-tool-to-surface` | `surface-changing` | | [`HARN-PRM-007`](#harn-prm-007) | prompt or template target cannot be found | — | — | ## MOD — Modules and exports A module-level import declaration cannot be satisfied or has been authored in a shape Harn rejects. Module boundaries are checked before the body is type-checked. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-MOD-001`](#harn-mod-001) | module import cannot be resolved | `imports/fix-path` | `scope-local` | | [`HARN-MOD-002`](#harn-mod-002) | module import is unused | `imports/remove-unused` | `behavior-preserving` | | [`HARN-MOD-003`](#harn-mod-003) | module imports are not in canonical order | `imports/reorder` | `format-only` | | [`HARN-MOD-004`](#harn-mod-004) | module export is invalid | — | — | | [`HARN-MOD-005`](#harn-mod-005) | module imports expose colliding names | — | — | | [`HARN-MOD-006`](#harn-mod-006) | module re-exports conflict | — | — | | [`HARN-MOD-007`](#harn-mod-007) | imported module failed to compile | — | — | ## RMD — Reminder lifecycle Reminder lifecycle errors are raised by `session/remind` and friends when the payload, tags, or scheduling do not match the documented contract. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-RMD-001`](#harn-rmd-001) | reminder lifecycle option key is not recognized | — | — | | [`HARN-RMD-002`](#harn-rmd-002) | reminder payload shape is invalid | — | — | | [`HARN-RMD-003`](#harn-rmd-003) | retired provider-specific reminder role-hint diagnostic | — | — | | [`HARN-RMD-004`](#harn-rmd-004) | discardable reminder has no TTL | — | — | | [`HARN-RMD-005`](#harn-rmd-005) | reminder propagate value is not recognized | — | — | | [`HARN-RMD-006`](#harn-rmd-006) | reminder provider returned a malformed reminder spec | — | — | | [`HARN-RMD-007`](#harn-rmd-007) | too many reminder providers are enabled | — | — | | [`HARN-RMD-008`](#harn-rmd-008) | hook event does not support reminder effects | — | — | ## SUS — Suspend / resume lifecycle Suspend / resume lifecycle errors are raised when a worker is suspended, resumed, or queried outside the lifecycle states the operation supports. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-SUS-001`](#harn-sus-001) | suspend_agent target worker is not running | — | — | | [`HARN-SUS-002`](#harn-sus-002) | ResumeConditions validation failed | — | — | | [`HARN-SUS-003`](#harn-sus-003) | resume_agent target worker is not suspended | — | — | | [`HARN-SUS-004`](#harn-sus-004) | resume snapshot cannot be loaded or used | — | — | | [`HARN-SUS-005`](#harn-sus-005) | agent_await_resumption was invoked outside agent_loop structural handling | — | — | | [`HARN-SUS-006`](#harn-sus-006) | concurrent resume changed the worker before resume could complete | — | — | | [`HARN-SUS-007`](#harn-sus-007) | ResumeConditions trigger could not be registered | — | — | | [`HARN-SUS-008`](#harn-sus-008) | resume timeout action is unsupported | — | — | | [`HARN-SUS-009`](#harn-sus-009) | resume input failed agent_loop input validation | — | — | | [`HARN-SUS-010`](#harn-sus-010) | closed suspended worker cannot be resumed | — | — | | [`HARN-SUS-011`](#harn-sus-011) | replay resume input hash diverges from journaled suspension | — | — | | [`HARN-SUS-012`](#harn-sus-012) | replay drain decision prompt hash diverges from journaled receipt | — | — | | [`HARN-SUS-013`](#harn-sus-013) | lifecycle receipt signed timestamp failed verification | — | — | ## LNT — Lint rules Lints are not hard errors. The code compiles, but Harn flags the pattern as likely-incorrect, unidiomatic, or risky in a production agent. Most lints can be auto-fixed. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-LNT-001`](#harn-lnt-001) | renamed stdlib symbol lint | `stdlib/migrate-renamed` | `scope-local` | | [`HARN-LNT-002`](#harn-lnt-002) | cyclomatic complexity lint | — | — | | [`HARN-LNT-003`](#harn-lnt-003) | naming convention lint | `style/rename-to-convention` | `surface-changing` | | [`HARN-LNT-004`](#harn-lnt-004) | eager collection conversion lint | `collections/prefer-lazy` | `scope-local` | | [`HARN-LNT-005`](#harn-lnt-005) | redundant clone lint | `clones/remove-redundant` | `behavior-preserving` | | [`HARN-LNT-006`](#harn-lnt-006) | long-running workflow cleanup lint | `manual/needs-human` | `needs-human` | | [`HARN-LNT-007`](#harn-lnt-007) | MCP tool annotations lint | `manual/needs-human` | `needs-human` | | [`HARN-LNT-008`](#harn-lnt-008) | PR open without secret scan lint | — | — | | [`HARN-LNT-009`](#harn-lnt-009) | shadow variable lint | `bindings/rename-shadow` | `scope-local` | | [`HARN-LNT-010`](#harn-lnt-010) | persona hook target lint | — | — | | [`HARN-LNT-011`](#harn-lnt-011) | dead code after return lint | `control-flow/remove-dead` | `behavior-preserving` | | [`HARN-LNT-012`](#harn-lnt-012) | let then return lint | `control-flow/flatten` | `behavior-preserving` | | [`HARN-LNT-013`](#harn-lnt-013) | unhandled approval result lint | `errors/check-or-rescue` | `scope-local` | | [`HARN-LNT-014`](#harn-lnt-014) | unused variable lint | `bindings/rename-unused` | `behavior-preserving` | | [`HARN-LNT-015`](#harn-lnt-015) | unused pattern binding lint | `bindings/rename-unused` | `behavior-preserving` | | [`HARN-LNT-016`](#harn-lnt-016) | unused parameter lint | `bindings/rename-unused` | `behavior-preserving` | | [`HARN-LNT-017`](#harn-lnt-017) | unused import lint | `imports/remove-unused` | `behavior-preserving` | | [`HARN-LNT-018`](#harn-lnt-018) | mutable never reassigned lint | `bindings/make-immutable` | `behavior-preserving` | | [`HARN-LNT-019`](#harn-lnt-019) | unused function lint | `declarations/remove-unused` | `surface-changing` | | [`HARN-LNT-020`](#harn-lnt-020) | unused type lint | `declarations/remove-unused` | `surface-changing` | | [`HARN-LNT-021`](#harn-lnt-021) | persona body must call steps lint | — | — | | [`HARN-LNT-022`](#harn-lnt-022) | undefined function lint | — | — | | [`HARN-LNT-023`](#harn-lnt-023) | pipeline return type lint | — | — | | [`HARN-LNT-024`](#harn-lnt-024) | missing harndoc lint | `doc/add-harndoc` | `behavior-preserving` | | [`HARN-LNT-025`](#harn-lnt-025) | assert outside test lint | — | — | | [`HARN-LNT-026`](#harn-lnt-026) | prompt injection risk lint | `prompts/escape-injection` | `scope-local` | | [`HARN-LNT-027`](#harn-lnt-027) | connector effect policy lint | — | — | | [`HARN-LNT-028`](#harn-lnt-028) | unnecessary cast lint | `casts/remove-redundant` | `behavior-preserving` | | [`HARN-LNT-029`](#harn-lnt-029) | untyped dict access lint | `types/validate-boundary-value` | `scope-local` | | [`HARN-LNT-030`](#harn-lnt-030) | constant logical operand lint | `expressions/simplify` | `behavior-preserving` | | [`HARN-LNT-031`](#harn-lnt-031) | pointless comparison lint | `expressions/simplify` | `behavior-preserving` | | [`HARN-LNT-032`](#harn-lnt-032) | comparison to bool lint | `expressions/simplify` | `behavior-preserving` | | [`HARN-LNT-033`](#harn-lnt-033) | invalid binary operator literal lint | — | — | | [`HARN-LNT-034`](#harn-lnt-034) | redundant nil ternary lint | `expressions/simplify` | `behavior-preserving` | | [`HARN-LNT-035`](#harn-lnt-035) | empty block lint | `blocks/remove-empty` | `scope-local` | | [`HARN-LNT-036`](#harn-lnt-036) | unnecessary else return lint | `control-flow/flatten` | `behavior-preserving` | | [`HARN-LNT-037`](#harn-lnt-037) | duplicate match arm lint | `match/remove-duplicate-arm` | `behavior-preserving` | | [`HARN-LNT-038`](#harn-lnt-038) | require in test lint | — | — | | [`HARN-LNT-039`](#harn-lnt-039) | break outside loop lint | — | — | | [`HARN-LNT-040`](#harn-lnt-040) | template parse lint | — | — | | [`HARN-LNT-041`](#harn-lnt-041) | blank line between items lint | `format/reformat` | `format-only` | | [`HARN-LNT-042`](#harn-lnt-042) | trailing comma lint | `format/reformat` | `format-only` | | [`HARN-LNT-043`](#harn-lnt-043) | unnecessary parentheses lint | `format/reformat` | `format-only` | | [`HARN-LNT-044`](#harn-lnt-044) | template variant explosion lint | `manual/needs-human` | `needs-human` | | [`HARN-LNT-045`](#harn-lnt-045) | require file header lint | `format/reformat` | `format-only` | | [`HARN-LNT-046`](#harn-lnt-046) | template provider identity branch lint | `llm/use-capability-flag` | `capability-changing` | | [`HARN-LNT-047`](#harn-lnt-047) | import order lint | `imports/reorder` | `format-only` | | [`HARN-LNT-048`](#harn-lnt-048) | prefer optional shorthand lint | `expressions/simplify` | `behavior-preserving` | | [`HARN-LNT-049`](#harn-lnt-049) | legacy doc comment lint | `doc/migrate-comment-style` | `format-only` | | [`HARN-LNT-050`](#harn-lnt-050) | removed LLM options lint | `llm/migrate-removed-option` | `scope-local` | | [`HARN-LNT-051`](#harn-lnt-051) | unnecessary safe navigation lint | `expressions/simplify` | `behavior-preserving` | | [`HARN-LNT-052`](#harn-lnt-052) | ambient clock builtin replaced by `harness.clock.*` | `bindings/thread-harness-clock` | `scope-local` | | [`HARN-LNT-053`](#harn-lnt-053) | ambient stdio builtin replaced by `harness.stdio.*` | `bindings/thread-harness` | `scope-local` | | [`HARN-LNT-054`](#harn-lnt-054) | ambient fs builtin replaced by `harness.fs.*` | `bindings/thread-harness-fs` | `scope-local` | | [`HARN-LNT-055`](#harn-lnt-055) | ambient env builtin replaced by `harness.env.*` | `bindings/thread-harness-env` | `scope-local` | | [`HARN-LNT-056`](#harn-lnt-056) | ambient random builtin replaced by `harness.random.*` | `bindings/thread-harness-random` | `scope-local` | | [`HARN-LNT-057`](#harn-lnt-057) | ambient net builtin replaced by `harness.net.*` | `bindings/thread-harness-net` | `scope-local` | | [`HARN-LNT-058`](#harn-lnt-058) | if / while / guard condition is statically known to always succeed or always fail | — | — | | [`HARN-LNT-059`](#harn-lnt-059) | project rule-engine or native lint rule | — | — | | [`HARN-LNT-060`](#harn-lnt-060) | inline options dict bypasses the typed option constructors | `types/add-shape-annotation` | `surface-changing` | | [`HARN-LNT-061`](#harn-lnt-061) | nil coalesce fallback has no effect | `expressions/simplify` | `behavior-preserving` | | [`HARN-LNT-062`](#harn-lnt-062) | nil coalesce fallback is unreachable | — | — | | [`HARN-LNT-063`](#harn-lnt-063) | non-null assertion `!` on an already-non-nil value | `expressions/simplify` | `behavior-preserving` | | [`HARN-LNT-064`](#harn-lnt-064) | a mutable variable captured from an enclosing scope is reassigned inside a `parallel`/`spawn` body, so concurrent branches share one cell and race | — | — | | [`HARN-LNT-065`](#harn-lnt-065) | nil coalesce fallback repeats the left identifier | `expressions/simplify` | `behavior-preserving` | | [`HARN-LNT-066`](#harn-lnt-066) | the result of a pure collection method is discarded, so the call has no effect on the receiver | — | — | | [`HARN-LNT-068`](#harn-lnt-068) | prompt template names a filter the engine does not implement | — | — | | [`HARN-LNT-069`](#harn-lnt-069) | helper accepts root Harness but uses only narrow capability handles | `bindings/attenuate-harness` | `surface-changing` | | [`HARN-LNT-070`](#harn-lnt-070) | public API has too many same-typed positional parameters | — | — | | [`HARN-LNT-071`](#harn-lnt-071) | global builtin has moved to a Harness capability method | `bindings/thread-harness-method` | `scope-local` | | [`HARN-LNT-072`](#harn-lnt-072) | call names a builtin whose declared exposure keeps Harn source from naming it | — | — | | [`HARN-LNT-073`](#harn-lnt-073) | parameter carrying a narrow capability handle is not named for that capability | `bindings/name-capability-parameter` | `surface-changing` | | [`HARN-LNT-074`](#harn-lnt-074) | explicitly unused private pipeline input can be removed | `bindings/remove-unused-pipeline-input` | `surface-changing` | | [`HARN-LNT-075`](#harn-lnt-075) | tool handler returns a freeform dict, so its outcome must be inferred from key names instead of declared by its type | — | — | | [`HARN-LNT-076`](#harn-lnt-076) | tool handler reaches the privileged host wire | — | — | | [`HARN-LNT-077`](#harn-lnt-077) | record literal copies fields one by one from a value that `pick` can select | `records/pick-fields` | `behavior-preserving` | ## FMT — Formatter The formatter could not produce a canonical layout — either the input contains a construct it does not know how to render, or a layout rule was violated in a way auto-fix cannot resolve. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-FMT-001`](#harn-fmt-001) | formatter could not parse the source | — | — | | [`HARN-FMT-002`](#harn-fmt-002) | source is not in canonical format | `format/reformat` | `format-only` | | [`HARN-FMT-003`](#harn-fmt-003) | formatter normalized trailing comma layout | `format/reformat` | `format-only` | ## IMP — Import resolution Import resolution failed at a deeper layer than `MOD` — the file, symbol, or package referenced in an import declaration could not be located, parsed, or exposed. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-IMP-001`](#harn-imp-001) | import target cannot be resolved | `imports/fix-path` | `scope-local` | | [`HARN-IMP-002`](#harn-imp-002) | imported symbol does not exist | — | — | | [`HARN-IMP-003`](#harn-imp-003) | import graph contains a cycle | — | — | ## OWN — Ownership and mutability Harn's binding-and-mutability discipline rejects this usage. `let` bindings may not be reassigned; `mut` bindings should actually be reassigned somewhere. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-OWN-001`](#harn-own-001) | immutable binding is reassigned | `bindings/make-mutable` | `scope-local` | | [`HARN-OWN-002`](#harn-own-002) | mutable binding is never reassigned | `bindings/make-immutable` | `behavior-preserving` | | [`HARN-OWN-003`](#harn-own-003) | owned value escapes its valid scope | — | — | | [`HARN-OWN-004`](#harn-own-004) | unvalidated boundary value is used directly | — | — | ## RCV — Error recovery A recovery construct (`try`, `rescue`) is in an invalid position or shaped in a way Harn's error-recovery rules cannot accept. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-RCV-001`](#harn-rcv-001) | rescue construct is outside a function body | `errors/wrap-in-fn` | `surface-changing` | | [`HARN-RCV-002`](#harn-rcv-002) | try construct is outside a function body | `errors/wrap-in-fn` | `surface-changing` | | [`HARN-RCV-003`](#harn-rcv-003) | rescue construct is invalid | — | — | ## MAT — Match exhaustiveness A `match` expression is incomplete, ambiguous, or otherwise invalid. Harn requires arms to cover every variant of the scrutinee type — partial matches must opt in with an explicit catch-all. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-MAT-001`](#harn-mat-001) | match expression is not exhaustive | `match/add-missing-arms` | `scope-local` | | [`HARN-MAT-002`](#harn-mat-002) | match expression contains a duplicate arm | `match/remove-duplicate-arm` | `behavior-preserving` | | [`HARN-MAT-003`](#harn-mat-003) | match pattern is invalid | — | — | ## POL — Runtime policies A runtime policy (pool backpressure, scheduling, quotas) rejected the attempt. Policies are configurable, but defaults are tuned for safety over throughput. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-POL-001`](#harn-pol-001) | pool backpressure rejected a submit | — | — | | [`HARN-POL-002`](#harn-pol-002) | fail-fast pool has no immediate capacity | — | — | ## MET — Compile-time meta restrictions A `const` binding's right-hand side must be a pure expression evaluable at compile time under the const-eval sandbox. These codes flag constructs the sandbox rejects. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-MET-001`](#harn-met-001) | expression is not permitted in a const initializer | — | — | ## CST — Const-eval sandbox The bounded const-eval sandbox enforces step, recursion, and capability limits on every `const` initializer so a hostile or accidental expression cannot stall the compiler. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-CST-001`](#harn-cst-001) | const initializer exceeded the step budget | — | — | | [`HARN-CST-002`](#harn-cst-002) | const initializer exceeded the recursion depth budget | — | — | | [`HARN-CST-003`](#harn-cst-003) | const initializer attempted a sandboxed capability | — | — | | [`HARN-CST-004`](#harn-cst-004) | const initializer raised a runtime error during evaluation | — | — | ## CMP — Bytecode compilation The bytecode compiler rejected a program that parsed and type-checked. These are structural / codegen errors the type checker does not model — `harn check` runs the compile pass too, so anything that would stop `harn run` is reported up front. | Code | Summary | Repair | Safety | |---|---|---|---| | [`HARN-CMP-001`](#harn-cmp-001) | the program failed to compile to bytecode | — | — | ## Code reference ### `HARN-TYP-001` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` expected and actual types are incompatible - **Repair:** `casts/insert-explicit-conversion`  ·  **Safety:** `scope-local` - Insert an explicit conversion or correct the operand type - **See also:** [`HARN-TYP-005`](#harn-typ-005), [`HARN-TYP-006`](#harn-typ-006), [`HARN-TYP-004`](#harn-typ-004), [`HARN-TYP-007`](#harn-typ-007), [`HARN-TYP-009`](#harn-typ-009) #### What it means Harn's type checker compared the inferred ("actual") type of an expression against the type the surrounding context expects, and the two did not unify. This is the most common type error — it covers any mismatch that doesn't fall into one of the more specific TYP codes (assignment, argument, return, etc.). #### How to fix - Adjust the expression so its inferred type matches the surrounding context. - Widen the declared type at the binding / parameter / return position to accept the actual type. - Convert the value explicitly (`as`, a stdlib coercion, etc.) when a safe conversion exists. - If the mismatch is between a concrete type and an optional, use `?`-chaining or supply a default with `??`. ### `HARN-TYP-002` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` binary operator is not defined for the operand types ### `HARN-TYP-003` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` string concatenation should be rewritten as interpolation - **Repair:** `style/string-interpolation`  ·  **Safety:** `behavior-preserving` - Rewrite string concatenation as an interpolation literal ### `HARN-TYP-004` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` returned expression does not match the declared return type - **Repair:** `casts/insert-explicit-conversion`  ·  **Safety:** `scope-local` - Insert an explicit conversion or correct the operand type - **See also:** [`HARN-TYP-001`](#harn-typ-001), [`HARN-TYP-008`](#harn-typ-008) ### `HARN-TYP-005` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` assigned value does not match the target type - **Repair:** `casts/insert-explicit-conversion`  ·  **Safety:** `scope-local` - Insert an explicit conversion or correct the operand type - **See also:** [`HARN-TYP-001`](#harn-typ-001), [`HARN-TYP-007`](#harn-typ-007) ### `HARN-TYP-006` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` argument value does not match the parameter type - **Repair:** `casts/insert-explicit-conversion`  ·  **Safety:** `scope-local` - Insert an explicit conversion or correct the operand type - **See also:** [`HARN-TYP-001`](#harn-typ-001), [`HARN-TYP-012`](#harn-typ-012) ### `HARN-TYP-007` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` initializer does not match the declared variable type - **Repair:** `casts/insert-explicit-conversion`  ·  **Safety:** `scope-local` - Insert an explicit conversion or correct the operand type - **See also:** [`HARN-TYP-001`](#harn-typ-001), [`HARN-TYP-005`](#harn-typ-005) ### `HARN-TYP-008` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` closure return expression does not match its declared type - **Repair:** `casts/insert-explicit-conversion`  ·  **Safety:** `scope-local` - Insert an explicit conversion or correct the operand type - **See also:** [`HARN-TYP-004`](#harn-typ-004) ### `HARN-TYP-009` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` field value does not match its declared type - **Repair:** `casts/insert-explicit-conversion`  ·  **Safety:** `scope-local` - Insert an explicit conversion or correct the operand type - **See also:** [`HARN-TYP-001`](#harn-typ-001), [`HARN-TYP-022`](#harn-typ-022) ### `HARN-TYP-010` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` method receiver or result type is incompatible - **Repair:** `casts/insert-explicit-conversion`  ·  **Safety:** `scope-local` - Insert an explicit conversion or correct the operand type - **See also:** [`HARN-TYP-001`](#harn-typ-001), [`HARN-TYP-018`](#harn-typ-018) ### `HARN-TYP-011` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` callable does not accept type arguments - **See also:** [`HARN-TYP-012`](#harn-typ-012), [`HARN-TYP-013`](#harn-typ-013) unsupported) ### `HARN-TYP-012` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` type argument does not satisfy the generic parameter - **See also:** [`HARN-TYP-013`](#harn-typ-013), [`HARN-TYP-015`](#harn-typ-015) mismatch) ### `HARN-TYP-013` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` generic call has the wrong number of type arguments - **See also:** [`HARN-TYP-012`](#harn-typ-012), [`HARN-TYP-014`](#harn-typ-014) ### `HARN-TYP-014` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` declaration has the wrong number of type parameters - **See also:** [`HARN-TYP-013`](#harn-typ-013) #### What it means A generic declaration (function, type alias, or struct) was written with a different number of type parameters than the use-site supplies. Harn refuses to silently fill in missing parameters or discard extra ones — the count must match exactly. #### Example ```harn,ignore fn pair(a: T, b: T) -> [T; 2] { [a, b] } // HARN-TYP-014: pair takes 1 type parameter, not 2 const xs = pair::(1, "two") ``` #### How to fix - Supply exactly as many type arguments as the declaration declares, or omit the explicit list and let Harn infer them. - If the declaration itself is wrong, edit the `` list on the declaration to match the arity the callers expect. - For type aliases and structs, the same rule applies: `Map` needs two arguments, not one and not three. ### `HARN-TYP-015` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` type argument does not satisfy a where-clause constraint - **See also:** [`HARN-TYP-012`](#harn-typ-012) ### `HARN-TYP-016` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` expression must be iterable ### `HARN-TYP-017` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` subscript index type is invalid - **Repair:** `casts/insert-explicit-conversion`  ·  **Safety:** `scope-local` - Insert an explicit conversion or correct the operand type ### `HARN-TYP-018` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` expression must be callable ### `HARN-TYP-019` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` cast cannot be proven valid - **Repair:** `casts/remove-unchecked`  ·  **Safety:** `scope-local` - Remove the unchecked cast or guard it with a type test ### `HARN-TYP-020` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` type name cannot be resolved - **Repair:** `imports/fix-path`  ·  **Safety:** `scope-local` - Replace the import path with a resolvable target ### `HARN-TYP-021` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` variant type is used in an invalid position ### `HARN-TYP-022` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` struct literal is invalid ### `HARN-TYP-023` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` enum construction is invalid ### `HARN-TYP-024` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` pattern binding is invalid for the expected type ### `HARN-TYP-025` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` optional access is invalid for the receiver type ### `HARN-TYP-026` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` thrown value type is not covered by the callable's declared throws set #### What it means A function, tool, pipeline, or `fn` closure declared a typed exception channel with a `throws E` (or `throws E1 | E2`) clause, but the callable can surface a thrown value whose type is not a member of `E`. Harn checks every `throw` site — and every value propagated out of a `try`/`catch` — against the declared set, so the declared channel is an exhaustive description of what the callable may throw. The clause is opt-in: a callable with no `throws` clause is not throw-checked and never raises this error. Once a clause is present, though, it must account for every escaping error. #### Catch-exhaustiveness An error handled inside the callable does not count against its `throws` set. The check mirrors the runtime: - A typed `catch (e: E)` handles a thrown error only when its type is `E` (the VM matches thrown *enum* errors by name and rethrows the rest), so it subtracts `E` from what escapes. - An untyped `catch` is a catch-all and absorbs every error the body can throw. - A `throw` in a `catch` or `finally` body always escapes and is checked against the declared set. So a `try`/`catch` whose handler does not cover an error the body can throw makes that error part of the callable's thrown set — and it must then be declared (or handled) or this error fires. #### How to fix - Add the missing type to the `throws` clause, e.g. widen `throws NotFound` to `throws NotFound | ParseError`. - Handle the error inside the callable with a `catch` that covers its type, so it no longer escapes. - Convert the thrown value to a declared type before it leaves the callable. - Remove the `throws` clause entirely if the callable should not constrain what it throws (this reverts to the historical unconstrained behavior). ### `HARN-TYP-027` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` constant tuple index is outside the fixed arity A `tuple` has a statically known number of positions. A constant subscript must name one of those positions, including Harn's negative-index spelling (`-1` is the final position). Use an in-bounds index, destructure the tuple, or widen it to a `list` when the collection is intentionally variable-length. ### `HARN-TYP-028` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` declared parameter has no type annotation - **Repair:** `types/annotate-parameter`  ·  **Safety:** `surface-changing` - Annotate the parameter with the inferred type, or `unknown` and narrow it at the dynamic boundary A parameter with no type annotation is unchecked in both directions. The body may reach for any member on it, and every caller may pass any value. Nothing recovers the type later, so the mistake shows up at run time as a failed member access instead of at check time with a code and a repair. The rule covers named declarations: `fn`, `pub fn`, `gen fn`, `pipeline`, `tool`, methods in an `impl` block, and signatures in an `interface` block. A default value does not exempt a parameter, because the default constrains only the call that omits the argument. Closure and lambda parameters are not covered. The checker types those from the position the literal appears in, so they are inferred rather than implicit. #### Fix it Annotate the parameter: ```harn fn greet(user: {name: string}) -> string { return "hi ${user.name}" } ``` When a value really is unconstrained, say so: ```harn fn passthrough(value: T) -> T { return value } ``` Use a generic when input and output have the same shape. Use `unknown` at a genuine dynamic boundary, then narrow or validate it before use. #### Migrate a codebase `harn fix --apply --code HARN-TYP-028 ` infers each parameter's type from how the body uses it and from the arguments at every call site in the module graph, writes the annotation, and reports how many parameters it could not prove anything about. Those fall back to `unknown` for a human to refine. For an unattended runtime-version migration of code written before this rule, use `harn fix --apply --safety behavior-preserving --preserve-implicit-any --json `. That compatibility mode writes only explicit `any`, preserving the former unchecked call contract. Its census fails if any eligible parameter is unresolved or receives a narrower annotation. It does not replace the surface-changing authoring repair above. ### `HARN-TYP-029` **Category:** `TYP` (Type checker)  ·  **API stability:** `stable` type predicate contract is invalid A type predicate tells callers how a boolean result narrows one argument. Harn checks the function body before trusting that claim. This error means the declaration does not prove its contract. Common causes include: - The predicate names a missing, untyped, or rest parameter. - The narrower type is not a subtype of the parameter type. - The body does not end with one return condition. - The true branch does not prove the narrower type. - A two-sided predicate claims too much about the false branch. - The predicate targets a generic type parameter. #### Fix it Use a two-sided predicate when true and false both give exact type facts: ```harn fn is_text(value: unknown) -> value is string { return type_of(value) == "string" } ``` Add `implies` when only a true result proves the type: ```harn fn is_nonempty_text(value: unknown) -> implies value is string { return type_of(value) == "string" && len(value) > 0 } ``` A false result in the second example may still mean an empty string, so Harn does not narrow the false branch. ### `HARN-PAR-001` **Category:** `PAR` (Parser / lexer)  ·  **API stability:** `stable` parser found an unexpected token #### How to fix - Re-read the source around the highlighted span and restore the missing token(s). - If the surrounding construct is a multi-line expression, check brace / bracket balance. ### `HARN-PAR-002` **Category:** `PAR` (Parser / lexer)  ·  **API stability:** `stable` parser reached end of file while expecting syntax #### How to fix - Re-read the source around the highlighted span and restore the missing token(s). - If the surrounding construct is a multi-line expression, check brace / bracket balance. ### `HARN-PAR-003` **Category:** `PAR` (Parser / lexer)  ·  **API stability:** `stable` lexer found an unexpected character #### How to fix - Re-read the source around the highlighted span and restore the missing token(s). - If the surrounding construct is a multi-line expression, check brace / bracket balance. ### `HARN-PAR-004` **Category:** `PAR` (Parser / lexer)  ·  **API stability:** `stable` string literal is unterminated #### How to fix - Re-read the source around the highlighted span and restore the missing token(s). - If the surrounding construct is a multi-line expression, check brace / bracket balance. ### `HARN-PAR-005` **Category:** `PAR` (Parser / lexer)  ·  **API stability:** `stable` block comment is unterminated comment) #### How to fix - Re-read the source around the highlighted span and restore the missing token(s). - If the surrounding construct is a multi-line expression, check brace / bracket balance. ### `HARN-PAR-006` **Category:** `PAR` (Parser / lexer)  ·  **API stability:** `stable` integer literal is out of range for int (i64) An integer literal must fit in a 64-bit signed integer (`int`), i.e. be in the range `-9223372036854775808 ..= 9223372036854775807`. Harn does not silently widen an out-of-range integer literal to a float, because that would lose both the exact value (distinct literals collapse onto the same `float`) and the `int` type. #### How to fix - If you meant a floating-point value, write it with a decimal point or exponent so it lexes as a `float` (e.g. `9223372036854775808.0`). - If you need the most negative `int`, build it by arithmetic — the sign is not part of the literal, so `9223372036854775808` overflows on its own: `-9223372036854775807 - 1`. - Otherwise the value genuinely does not fit in `int`; rework the computation to stay within range. ### `HARN-NAM-001` **Category:** `NAM` (Naming and resolution)  ·  **API stability:** `stable` variable name cannot be resolved - **Repair:** `bindings/rename-to-closest`  ·  **Safety:** `scope-local` - Rename to the closest in-scope identifier - **See also:** [`HARN-NAM-002`](#harn-nam-002), [`HARN-NAM-010`](#harn-nam-010) #### How to fix - Define the missing name, import it, or fix the typo. - Confirm the symbol is exported by the module you're importing from. ### `HARN-NAM-002` **Category:** `NAM` (Naming and resolution)  ·  **API stability:** `stable` function name cannot be resolved - **Repair:** `bindings/rename-to-closest`  ·  **Safety:** `scope-local` - Rename to the closest in-scope identifier - **See also:** [`HARN-NAM-008`](#harn-nam-008), [`HARN-NAM-010`](#harn-nam-010) #### How to fix - Define the missing name, import it, or fix the typo. - Confirm the symbol is exported by the module you're importing from. ### `HARN-NAM-003` **Category:** `NAM` (Naming and resolution)  ·  **API stability:** `stable` attribute name is not recognized - **See also:** [`HARN-NAM-012`](#harn-nam-012), [`HARN-NAM-011`](#harn-nam-011) #### How to fix - Define the missing name, import it, or fix the typo. - Confirm the symbol is exported by the module you're importing from. ### `HARN-NAM-004` **Category:** `NAM` (Naming and resolution)  ·  **API stability:** `stable` field name does not exist on the target type - **Repair:** `bindings/rename-to-closest`  ·  **Safety:** `scope-local` - Rename to the closest in-scope identifier - **See also:** [`HARN-NAM-005`](#harn-nam-005), [`HARN-TYP-022`](#harn-typ-022) #### How to fix - Define the missing name, import it, or fix the typo. - Confirm the symbol is exported by the module you're importing from. ### `HARN-NAM-005` **Category:** `NAM` (Naming and resolution)  ·  **API stability:** `stable` method name does not exist on the receiver type - **Repair:** `bindings/rename-to-closest`  ·  **Safety:** `scope-local` - Rename to the closest in-scope identifier - **See also:** [`HARN-NAM-004`](#harn-nam-004), [`HARN-TYP-018`](#harn-typ-018) Raised when `receiver.method()` names a method the receiver's statically known type does not have — either an interface-constraint method that no bound declares, or a method that does not exist on a concrete builtin (`string`, `list`, `set`, `int`, `float`, `bool`) or `struct` receiver. #### How to fix - Fix the typo — the message suggests the closest available method. - Call a method that exists on the type (see "available methods" in the help), or add it to the type's `impl` block. - If the receiver is genuinely dynamic, narrow or annotate it so the intended type — and its methods — are in scope. ### `HARN-NAM-006` **Category:** `NAM` (Naming and resolution)  ·  **API stability:** `stable` argument name is duplicated #### How to fix - Define the missing name, import it, or fix the typo. - Confirm the symbol is exported by the module you're importing from. ### `HARN-NAM-008` **Category:** `NAM` (Naming and resolution)  ·  **API stability:** `stable` builtin name cannot be resolved - **Repair:** `bindings/rename-to-closest`  ·  **Safety:** `scope-local` - Rename to the closest in-scope identifier #### How to fix - Define the missing name, import it, or fix the typo. - Confirm the symbol is exported by the module you're importing from. ### `HARN-NAM-009` **Category:** `NAM` (Naming and resolution)  ·  **API stability:** `stable` function call targets a deprecated declaration - **Repair:** `stdlib/migrate-renamed`  ·  **Safety:** `scope-local` - Rename the call to the renamed stdlib symbol #### How to fix - Define the missing name, import it, or fix the typo. - Confirm the symbol is exported by the module you're importing from. ### `HARN-NAM-010` **Category:** `NAM` (Naming and resolution)  ·  **API stability:** `stable` declaration reference cannot be resolved - **Repair:** `bindings/rename-to-closest`  ·  **Safety:** `scope-local` - Rename to the closest in-scope identifier #### How to fix - Define the missing name, import it, or fix the typo. - Confirm the symbol is exported by the module you're importing from. ### `HARN-NAM-011` **Category:** `NAM` (Naming and resolution)  ·  **API stability:** `stable` attribute is attached to an unsupported declaration - **See also:** [`HARN-NAM-003`](#harn-nam-003), [`HARN-NAM-012`](#harn-nam-012) #### How to fix - Define the missing name, import it, or fix the typo. - Confirm the symbol is exported by the module you're importing from. ### `HARN-NAM-012` **Category:** `NAM` (Naming and resolution)  ·  **API stability:** `stable` attribute argument is invalid - **See also:** [`HARN-NAM-003`](#harn-nam-003), [`HARN-NAM-011`](#harn-nam-011) #### How to fix - Define the missing name, import it, or fix the typo. - Confirm the symbol is exported by the module you're importing from. ### `HARN-NAM-101` **Category:** `NAM` (Naming and resolution)  ·  **API stability:** `stable` `fn main` must take an explicit `harness: Harness` parameter - **Repair:** `bindings/thread-harness-needs-param`  ·  **Safety:** `surface-changing` - Add a `harness: Harness` parameter where the stdio capability handle is required and update local callers #### What it means When a Harn program declares a top-level `fn main`, the runtime auto-invokes it with the script's `Harness` capability handle. The convention is therefore strict: the entrypoint must be exactly `fn main(harness: Harness) { ... }` — or `fn main(_harness: Harness) { ... }` when the handle is intentionally unused. It takes one parameter, named `harness` or `_harness`, typed `Harness`, no defaults, no rest. Any other shape (zero parameters, a renamed parameter, a missing or non-`Harness` type annotation, extra parameters, or a default value) fails this check before bytecode is emitted, so the runtime never tries to bind a mismatched signature. The `Harness` value gives the script typed access to its capability sub-handles via field access (`harness.stdio`, `harness.term`, `harness.clock`, `harness.fs`, `harness.env`, `harness.random`, `harness.net`, `harness.process`, `harness.channels`, `harness.system`, `harness.llm`). Threading the handle through `main` replaces ambient stdio, terminal, clock, filesystem, environment, randomness, network, process, crypto, system, and LLM catalog globals. #### How to fix Rewrite the entrypoint with the canonical signature: ```harn fn main(harness: Harness) { harness.stdio.println("Hello, world!") } ``` If you do not need any capabilities — for example, a script that only does pure computation — prefix the parameter with `_` to mark it deliberately unused: ```harn fn main(_harness: Harness) { // pure logic … } ``` If the function does not need to be the entrypoint, rename it (e.g. `helper`, `run_once`) so the convention does not apply. ### `HARN-CAP-001` **Category:** `CAP` (Capabilities)  ·  **API stability:** `stable` capability payload is invalid #### How to fix - Match the capability signature documented in the Harn capability spec. - If approval / receipt handling is required, wire it through `human_approval` or the equivalent before calling the capability. ### `HARN-CAP-004` **Category:** `CAP` (Capabilities)  ·  **API stability:** `stable` capability result must be checked - **Repair:** `errors/check-or-rescue`  ·  **Safety:** `scope-local` - Check the result or wrap the call in a `rescue` block - **See also:** [`HARN-RCV-001`](#harn-rcv-001), [`HARN-RCV-002`](#harn-rcv-002) #### How to fix - Match the capability signature documented in the Harn capability spec. - If approval / receipt handling is required, wire it through `human_approval` or the equivalent before calling the capability. ### `HARN-CAP-005` **Category:** `CAP` (Capabilities)  ·  **API stability:** `stable` host capability operation is not declared - **See also:** [`HARN-CAP-006`](#harn-cap-006) #### How to fix - Match the capability signature documented in the Harn capability spec. - If approval / receipt handling is required, wire it through `human_approval` or the equivalent before calling the capability. ### `HARN-CAP-006` **Category:** `CAP` (Capabilities)  ·  **API stability:** `stable` host capability call must use a static operation name name required) #### How to fix - Match the capability signature documented in the Harn capability spec. - If approval / receipt handling is required, wire it through `human_approval` or the equivalent before calling the capability. ### `HARN-CAP-007` **Category:** `CAP` (Capabilities)  ·  **API stability:** `stable` tool host capability binding is invalid - **Repair:** `manual/review-capability-binding`  ·  **Safety:** `needs-human` - Review the capability binding; the fix is not mechanical #### How to fix - Match the capability signature documented in the Harn capability spec. - If approval / receipt handling is required, wire it through `human_approval` or the equivalent before calling the capability. ### `HARN-CAP-008` **Category:** `CAP` (Capabilities)  ·  **API stability:** `stable` declared host capability operation is not served Harn found a declared host operation that the target host does not serve. `harn check` reads the file named by `host_served_capabilities_path`. ACP checks the operations advertised by the connected host when a prompt starts. Add the operation to the host, remove the declaration, or list its exact name in `runtime_installed_host_operations` if its handler is added at runtime. ### `HARN-CAP-201` **Category:** `CAP` (Capabilities)  ·  **API stability:** `stable` harness capability denied by active sandbox profile - **See also:** [`HARN-CAP-001`](#harn-cap-001) #### What it means A `harness.fs.*`, `harness.env.*`, `harness.random.*`, `harness.net.*`, `harness.system.*`, or `harness.llm.*` method was rejected by the active sandbox profile. Examples: - `harness.fs.write_text("/etc/passwd", ...)` from a script whose `workspace_roots` only include `./build/` — the path is outside the permitted set. - `harness.net.get("https://example.com")` from a script whose egress allowlist does not include `example.com`. - Any `harness.*` capability under `SandboxProfile::OsHardened` when the required platform mechanism is unavailable. The runtime raises the rejection as a typed `tool_rejected` error so the harness method surface stays narrow — every script-visible capability tightens the same way at the same boundary, instead of each ambient builtin growing its own bespoke deny diagnostic. When the requested operation can be evaluated against the profile ahead of time (literal path or URL argument, well-known method on a sub-handle), `harn check` and `harn lint` surface the same diagnostic at static-check time so callers don't have to actually execute the script to discover the denial. #### How to fix - Widen the active sandbox profile via `CapabilityPolicy::workspace_roots` or the egress allowlist if the request is legitimate. The relevant policy lives in `~/.config/harn/policy.toml` or the per-script `CapabilityPolicy` overlay. - Use `harn graph --json` to inspect which capabilities your script actually needs, then narrow the call site to those. - For tests, switch from `Harness::real()` to `Harness::mock()` / `Harness::null()` so the call is recorded without touching the host. ### `HARN-CAP-301` **Category:** `CAP` (Capabilities)  ·  **API stability:** `stable` child agent effect set exceeds the parent's declared effects - **Repair:** `policy/narrow-child-effects`  ·  **Safety:** `surface-changing` - Narrow the child agent's effects to a subset of the parent's, or widen the parent's declared effects - **See also:** [`HARN-CAP-001`](#harn-cap-001), [`HARN-CAP-007`](#harn-cap-007) #### What it means A spawned child agent requests typed side-effects (`harness.net.*`, `harness.fs.*`, `llm_call`, tool dispatch, ...) that are not part of the parent agent's declared effect set. The dispatcher (and `harn check`'s static analyzer) enforce that a child's effect set must be a subset of the parent's so an over-delegated child can never escape its parent's trust boundary. Specifically: at least one effect on the child handoff is not covered by any effect declared on the parent. Coverage is structural: - **Kind** must match by family (`stdio`, `fs`, `net`, `llm`, `tool`, `hostcall`, `persona`, `spawn`). - **Scope** must be at least as permissive on the parent (`read`/`observe` ≤ `write` ≤ `mutate`). - **Resource**, when declared on the parent, must match the child's exactly. A parent with no `resource` covers any child resource of the same kind/scope family. This diagnostic surfaces from two paths and they are intentionally identical: - **Static**: `harn check` derives parent and child effect sets via the same capability analysis that backs `harn graph --json` and emits `HARN-CAP-301` when a child statically out-grants its parent. - **Runtime**: at spawn time the dispatcher folds the parent's `current_execution_policy()` into an effect set, compares it against the child's computed effects, and refuses the spawn with a typed `EffectInheritanceViolation` deny event. The event payload carries the same `HARN-CAP-301` code and `policy/narrow-child-effects` repair id. #### How to fix The repair id is `policy/narrow-child-effects`. Two shapes are typically valid: 1. **Narrow the child** — remove the over-granted calls from the child pipeline body, or guard them behind a capability the parent already declares. This is the safe default; the child stops asking for what it does not need. 2. **Widen the parent** — declare the missing effect on the parent pipeline (e.g. add a real `harness.net.get(...)` call in the parent's entrypoint, or extend the parent's `policy.capabilities` map). Only apply this when widening the parent's trust surface is itself an intentional design change — it touches a public surface. Both shapes are marked `safety: surface-changing`, so `harn fix --apply --safety surface-changing` will dispatch the chosen repair. #### Stability The matched `EffectInheritanceViolation` runtime payload's `_type` discriminator (`effect_inheritance_violation`) is part of the stable contract. ### `HARN-LLM-003` **Category:** `LLM` (LLM calls)  ·  **API stability:** `stable` LLM call is missing schema validation - **Repair:** `llm/add-schema`  ·  **Safety:** `surface-changing` - Add a typed output schema to the LLM call - **See also:** [`HARN-LLM-004`](#harn-llm-004) #### How to fix - Pass a `schema:` option that validates the model output, with `schema_retries:` as appropriate. - Drop or rename deprecated options to the names listed in the LLM call quickref. - Pick capability flags (`tool_calling: true`, etc.) instead of branching on `provider:` identity. ### `HARN-LLM-004` **Category:** `LLM` (LLM calls)  ·  **API stability:** `stable` LLM schema option is invalid - **See also:** [`HARN-LLM-003`](#harn-llm-003) #### How to fix - Pass a `schema:` option that validates the model output, with `schema_retries:` as appropriate. - Drop or rename deprecated options to the names listed in the LLM call quickref. - Pick capability flags (`tool_calling: true`, etc.) instead of branching on `provider:` identity. ### `HARN-LLM-005` **Category:** `LLM` (LLM calls)  ·  **API stability:** `stable` prompt branches on provider identity instead of capability flags - **Repair:** `llm/use-capability-flag`  ·  **Safety:** `capability-changing` - Branch on a capability flag instead of provider identity - **See also:** [`HARN-PRM-004`](#harn-prm-004) #### How to fix - Pass a `schema:` option that validates the model output, with `schema_retries:` as appropriate. - Drop or rename deprecated options to the names listed in the LLM call quickref. - Pick capability flags (`tool_calling: true`, etc.) instead of branching on `provider:` identity. ### `HARN-LLM-006` **Category:** `LLM` (LLM calls)  ·  **API stability:** `stable` provider, model, and requested options form a known-unsafe composition #### How to fix - Remove a portable generation option that the selected route does not support, or choose a compatible route. - For `cache` or `prompt_cache_ttl`, declare prompt-cache support and selectable TTL values for the custom route, or remove the request. - Put a provider-native control below `provider_options.` instead of spelling it as a portable top-level option. - Omit `tool_format` to use the catalog default, or select the catalog-recommended format. - Choose a provider/model route whose declared tool-calling channel supports the requested format. - For a deliberate probe, add a non-empty `tool_format_override_reason`; agent loops record the override event, and provider-call records expose the effective format and native tool count. Dynamic values remain under the runtime guard. Custom generation routes remain open-world, but custom cache controls require authored capability facts because Harn must select a provider-specific lowering. This diagnostic only rejects a literal composition the registry can already prove unsafe. ### `HARN-ORC-001` **Category:** `ORC` (Orchestration constructs)  ·  **API stability:** `stable` orchestration construct has invalid arity #### What it means An orchestration construct — agent / workflow / pipeline / tool definition, or a `select` block — does not satisfy the structural rules Harn enforces. These constructs carry runtime semantics that depend on a small set of well-formed shapes. #### How to fix - Re-read the orchestration construct's spec section and align the arity / type / structure. ### `HARN-ORC-002` **Category:** `ORC` (Orchestration constructs)  ·  **API stability:** `stable` orchestration construct argument has invalid type #### What it means An orchestration construct — agent / workflow / pipeline / tool definition, or a `select` block — does not satisfy the structural rules Harn enforces. These constructs carry runtime semantics that depend on a small set of well-formed shapes. #### How to fix - Re-read the orchestration construct's spec section and align the arity / type / structure. ### `HARN-ORC-003` **Category:** `ORC` (Orchestration constructs)  ·  **API stability:** `stable` agent declaration is invalid #### What it means An orchestration construct — agent / workflow / pipeline / tool definition, or a `select` block — does not satisfy the structural rules Harn enforces. These constructs carry runtime semantics that depend on a small set of well-formed shapes. #### How to fix - Re-read the orchestration construct's spec section and align the arity / type / structure. ### `HARN-ORC-004` **Category:** `ORC` (Orchestration constructs)  ·  **API stability:** `stable` workflow declaration is invalid #### What it means An orchestration construct — agent / workflow / pipeline / tool definition, or a `select` block — does not satisfy the structural rules Harn enforces. These constructs carry runtime semantics that depend on a small set of well-formed shapes. #### How to fix - Re-read the orchestration construct's spec section and align the arity / type / structure. ### `HARN-ORC-005` **Category:** `ORC` (Orchestration constructs)  ·  **API stability:** `stable` tool declaration is invalid #### What it means An orchestration construct — agent / workflow / pipeline / tool definition, or a `select` block — does not satisfy the structural rules Harn enforces. These constructs carry runtime semantics that depend on a small set of well-formed shapes. #### How to fix - Re-read the orchestration construct's spec section and align the arity / type / structure. ### `HARN-ORC-006` **Category:** `ORC` (Orchestration constructs)  ·  **API stability:** `stable` pipeline declaration is invalid #### What it means An orchestration construct — agent / workflow / pipeline / tool definition, or a `select` block — does not satisfy the structural rules Harn enforces. These constructs carry runtime semantics that depend on a small set of well-formed shapes. #### How to fix - Re-read the orchestration construct's spec section and align the arity / type / structure. ### `HARN-ORC-007` **Category:** `ORC` (Orchestration constructs)  ·  **API stability:** `stable` select construct is invalid #### What it means An orchestration construct — agent / workflow / pipeline / tool definition, or a `select` block — does not satisfy the structural rules Harn enforces. These constructs carry runtime semantics that depend on a small set of well-formed shapes. #### How to fix - Re-read the orchestration construct's spec section and align the arity / type / structure. ### `HARN-ORC-008` **Category:** `ORC` (Orchestration constructs)  ·  **API stability:** `stable` statement cannot be reached - **Repair:** `control-flow/remove-dead`  ·  **Safety:** `behavior-preserving` - Remove the unreachable code #### What it means An orchestration construct — agent / workflow / pipeline / tool definition, or a `select` block — does not satisfy the structural rules Harn enforces. These constructs carry runtime semantics that depend on a small set of well-formed shapes. #### How to fix - Re-read the orchestration construct's spec section and align the arity / type / structure. ### `HARN-ORC-009` **Category:** `ORC` (Orchestration constructs)  ·  **API stability:** `stable` Flow invariant attribute set is invalid invalid) #### What it means An orchestration construct — agent / workflow / pipeline / tool definition, or a `select` block — does not satisfy the structural rules Harn enforces. These constructs carry runtime semantics that depend on a small set of well-formed shapes. #### How to fix - Re-read the orchestration construct's spec section and align the arity / type / structure. ### `HARN-ORC-010` **Category:** `ORC` (Orchestration constructs)  ·  **API stability:** `stable` execution target path cannot be found #### What it means An orchestration construct — agent / workflow / pipeline / tool definition, or a `select` block — does not satisfy the structural rules Harn enforces. These constructs carry runtime semantics that depend on a small set of well-formed shapes. #### How to fix - Re-read the orchestration construct's spec section and align the arity / type / structure. ### `HARN-ORC-011` **Category:** `ORC` (Orchestration constructs)  ·  **API stability:** `stable` a self-deadlock acquire would block forever #### What it means The VM detected an acquire that can never succeed and would otherwise block forever, so it surfaced an error instead of hanging. Two deterministic, provably-unresolvable cases are caught: - **Re-entrant mutex.** A lexical `mutex { ... }` block acquires a non-reentrant, capacity-1 lock. Entering a second `mutex` block on the same key while the first is still held — directly or through a called function — can never be granted, because the sole permit holder is the task that is now waiting on it. - **Self-join.** A task `await`s its own join handle. The handle only resolves when the task finishes, so waiting on it from inside the task itself can never complete. Harn's static checks (like Rust's borrow checker) prevent data races but not deadlocks; this runtime guard is the analogue of the Go runtime's "all goroutines are asleep — deadlock!" detection for the cases that are provable with no false positives. #### How to fix - Do not nest `mutex { ... }` blocks that resolve to the same lock, and avoid calling a function that opens a `mutex` block from inside another one. Restructure so the lock is released before it is acquired again, or hold a single lock around the whole critical section. - Never `await` the handle of the current task. Await it from the task that spawned it instead. This error is non-retryable: it indicates a structural concurrency bug, not a transient failure. ### `HARN-ORC-012` **Category:** `ORC` (Orchestration constructs)  ·  **API stability:** `stable` a wait-for graph cycle would block forever #### What it means The VM detected that every active task in the current execution tree is waiting, and the outstanding channel operations cannot match a sender with a receiver. Without this guard the run would block forever. This check only fires when the runtime can prove the wait is closed-world: running tasks, sleeping tasks, time-bounded selects, and `deadline { ... }` scopes keep the guard from reporting a deadlock. #### How to fix Make sure every blocking `receive` has a task that can still send to that channel, and every blocking `send` on a full channel has a task that can still receive from it. If the wait is intentionally optional, use `try_receive`, `select timeout`, `harness.runtime.channel_select(..., timeout_ms)`, or wrap the operation in a `deadline { ... }` block. This error is non-retryable: it indicates a structural concurrency bug, not a transient failure. ### `HARN-STD-001` **Category:** `STD` (Stdlib usage)  ·  **API stability:** `stable` stdlib symbol has been renamed or deprecated - **Repair:** `stdlib/migrate-renamed`  ·  **Safety:** `scope-local` - Rename the call to the renamed stdlib symbol #### What it means A stdlib symbol is being used in a way Harn does not support, or has been renamed / deprecated since the script was written. The stdlib is the live source of truth for what's available. #### How to fix - Switch to the supported / renamed stdlib API listed in the diagnostic help text. ### `HARN-STD-002` **Category:** `STD` (Stdlib usage)  ·  **API stability:** `stable` stdlib call is invalid #### What it means A stdlib symbol is being used in a way Harn does not support, or has been renamed / deprecated since the script was written. The stdlib is the live source of truth for what's available. #### How to fix - Switch to the supported / renamed stdlib API listed in the diagnostic help text. ### `HARN-STD-003` **Category:** `STD` (Stdlib usage)  ·  **API stability:** `stable` builtin call has invalid arity #### What it means A stdlib symbol is being used in a way Harn does not support, or has been renamed / deprecated since the script was written. The stdlib is the live source of truth for what's available. #### How to fix - Switch to the supported / renamed stdlib API listed in the diagnostic help text. ### `HARN-STD-101` **Category:** `STD` (Stdlib usage)  ·  **API stability:** `stable` public stdlib function is missing declared metadata - **Repair:** `doc/add-stdlib-metadata`  ·  **Safety:** `behavior-preserving` - Add `@effects` and `@errors` fields to the stdlib function's doc block #### What it means Every public stdlib function ships with a prose summary plus two machine-meaningful metadata fields above its `pub fn` declaration so that `harn graph --json`, the LSP hover, generated docs, and downstream agents can read a single source of truth for the function's runtime contract. The required fields are: | Field | Purpose | |------------|---------------------------------------------------------------------------------------------------| | `@effects` | Capabilities the function may touch (e.g. `fs.read`, `stdio.write`, `llm.call`). `[]` means pure. | | `@errors` | Error variants the function may surface. `[]` means infallible. | Two more fields are recognized but optional: | Field | Purpose | |------------------|--------------------------------------------------------------------------------| | `@api_stability` | Stability promise (`experimental`, `internal`, `deprecated`). Absent ⇒ stable. | | `@example` | Hand-written usage example. Absent ⇒ tooling derives one from the signature. | Only write an `@example` when it shows something the type signature cannot (non-obvious argument shapes, a multi-step idiom). LSP hover and `harn graph --json` synthesize a signature-derived example otherwise. The lint warns when a `pub fn` declared inside an embedded stdlib module (`crates/harn-stdlib/src/stdlib/**/*.harn`) is missing a required field from the `/** ... */` HarnDoc block immediately above it. #### How to fix Add the missing fields to the function's HarnDoc block: ```harn,ignore /** * Render the project README. * * @effects: [fs.read, fs.write] * @errors: [FileNotFound, PermissionDenied] */ pub fn render_readme(path: string) -> Result { ... } ``` The lint considers `@effects: []` and `@errors: []` valid declarations — they explicitly assert "no effects" and "infallible" respectively, which is what agents need to read out of the graph. ### `HARN-STD-102` **Category:** `STD` (Stdlib usage)  ·  **API stability:** `stable` public stdlib function is missing an explicit return type #### What it means Every public function exported from Harn's embedded standard library is a contract boundary. The function's return type is part of that contract: callers, generated docs, LSP hovers, schema export, and downstream agents should be able to depend on the producer shape without rediscovering it from implementation details. This lint warns when a `pub fn` declared inside `crates/harn-stdlib/src/stdlib/**/*.harn` omits an explicit `-> Type` annotation. Private helpers remain inferable. #### How to fix Declare the narrowest honest return type in the function signature: ```harn,ignore pub fn read_json(path: string) -> Result { ... } ``` Use named closed records for finite object shapes, `Result` for fallible operations, and typed maps such as `dict` for true open-key maps. Do not silence the lint with `any` or open `dict` unless the value is genuinely opaque and the caller must narrow it before use. ### `HARN-PRM-001` **Category:** `PRM` (Prompt templates)  ·  **API stability:** `stable` prompt template cannot be parsed - **See also:** [`HARN-PRM-007`](#harn-prm-007) #### What it means A prompt template (`.harn.prompt` / `.prompt`) failed validation, either because the template body is malformed or because it references the model surface in a way that violates Harn's prompt safety rules. #### How to fix - Fix the template syntax (`{{ }}`, `{% %}` are the only structural delimiters). - Replace identity-based branching with capability flags from the LLM call options. ### `HARN-PRM-002` **Category:** `PRM` (Prompt templates)  ·  **API stability:** `stable` prompt template has too many capability-aware branches - **Repair:** `manual/needs-human`  ·  **Safety:** `needs-human` - Plan a human-led change; auto-apply is not safe here - **See also:** [`HARN-LNT-044`](#harn-lnt-044) #### What it means A prompt template (`.harn.prompt` / `.prompt`) failed validation, either because the template body is malformed or because it references the model surface in a way that violates Harn's prompt safety rules. #### How to fix - Fix the template syntax (`{{ }}`, `{% %}` are the only structural delimiters). - Replace identity-based branching with capability flags from the LLM call options. ### `HARN-PRM-003` **Category:** `PRM` (Prompt templates)  ·  **API stability:** `stable` prompt construction risks direct injection - **Repair:** `prompts/escape-injection`  ·  **Safety:** `scope-local` - Pass the untrusted input through a structured placeholder - **See also:** [`HARN-LNT-026`](#harn-lnt-026) #### What it means A prompt template (`.harn.prompt` / `.prompt`) failed validation, either because the template body is malformed or because it references the model surface in a way that violates Harn's prompt safety rules. #### How to fix - Fix the template syntax (`{{ }}`, `{% %}` are the only structural delimiters). - Replace identity-based branching with capability flags from the LLM call options. ### `HARN-PRM-004` **Category:** `PRM` (Prompt templates)  ·  **API stability:** `stable` prompt template branches on provider identity - **Repair:** `llm/use-capability-flag`  ·  **Safety:** `capability-changing` - Branch on a capability flag instead of provider identity - **See also:** [`HARN-LLM-005`](#harn-llm-005), [`HARN-LNT-046`](#harn-lnt-046) branch) #### What it means A prompt template (`.harn.prompt` / `.prompt`) failed validation, either because the template body is malformed or because it references the model surface in a way that violates Harn's prompt safety rules. #### How to fix - Fix the template syntax (`{{ }}`, `{% %}` are the only structural delimiters). - Replace identity-based branching with capability flags from the LLM call options. ### `HARN-PRM-005` **Category:** `PRM` (Prompt templates)  ·  **API stability:** `stable` prompt references a tool outside the declared surface - **Repair:** `prompts/add-tool-to-surface`  ·  **Safety:** `surface-changing` - Add the referenced tool to the declared tool surface #### What it means A prompt template (`.harn.prompt` / `.prompt`) failed validation, either because the template body is malformed or because it references the model surface in a way that violates Harn's prompt safety rules. #### How to fix - Fix the template syntax (`{{ }}`, `{% %}` are the only structural delimiters). - Replace identity-based branching with capability flags from the LLM call options. ### `HARN-PRM-006` **Category:** `PRM` (Prompt templates)  ·  **API stability:** `stable` prompt references a deferred tool without tool search - **Repair:** `prompts/add-tool-to-surface`  ·  **Safety:** `surface-changing` - Add the referenced tool to the declared tool surface deferred reference) #### What it means A prompt template (`.harn.prompt` / `.prompt`) failed validation, either because the template body is malformed or because it references the model surface in a way that violates Harn's prompt safety rules. #### How to fix - Fix the template syntax (`{{ }}`, `{% %}` are the only structural delimiters). - Replace identity-based branching with capability flags from the LLM call options. ### `HARN-PRM-007` **Category:** `PRM` (Prompt templates)  ·  **API stability:** `stable` prompt or template target cannot be found #### What it means A prompt template (`.harn.prompt` / `.prompt`) failed validation, either because the template body is malformed or because it references the model surface in a way that violates Harn's prompt safety rules. #### How to fix - Fix the template syntax (`{{ }}`, `{% %}` are the only structural delimiters). - Replace identity-based branching with capability flags from the LLM call options. ### `HARN-MOD-001` **Category:** `MOD` (Modules and exports)  ·  **API stability:** `stable` module import cannot be resolved - **Repair:** `imports/fix-path`  ·  **Safety:** `scope-local` - Replace the import path with a resolvable target - **See also:** [`HARN-IMP-001`](#harn-imp-001), [`HARN-IMP-002`](#harn-imp-002) #### What it means A module-level import declaration cannot be satisfied or has been authored in a non-canonical form. The module graph must resolve cleanly before any further checks. #### How to fix - Confirm the import path resolves on disk and the imported symbol is exported. - Run `harn lint --fix` to auto-sort / dedupe import groups. ### `HARN-MOD-002` **Category:** `MOD` (Modules and exports)  ·  **API stability:** `stable` module import is unused - **Repair:** `imports/remove-unused`  ·  **Safety:** `behavior-preserving` - Remove the unused import - **See also:** [`HARN-LNT-017`](#harn-lnt-017) #### What it means A module-level import declaration cannot be satisfied or has been authored in a non-canonical form. The module graph must resolve cleanly before any further checks. #### How to fix - Confirm the import path resolves on disk and the imported symbol is exported. - Run `harn lint --fix` to auto-sort / dedupe import groups. ### `HARN-MOD-003` **Category:** `MOD` (Modules and exports)  ·  **API stability:** `stable` module imports are not in canonical order - **Repair:** `imports/reorder`  ·  **Safety:** `format-only` - Reorder imports into canonical grouping #### What it means A module-level import declaration cannot be satisfied or has been authored in a non-canonical form. The module graph must resolve cleanly before any further checks. #### How to fix - Confirm the import path resolves on disk and the imported symbol is exported. - Run `harn lint --fix` to auto-sort / dedupe import groups. ### `HARN-MOD-004` **Category:** `MOD` (Modules and exports)  ·  **API stability:** `stable` module export is invalid #### What it means A module-level import declaration cannot be satisfied or has been authored in a non-canonical form. The module graph must resolve cleanly before any further checks. #### How to fix - Confirm the import path resolves on disk and the imported symbol is exported. - Run `harn lint --fix` to auto-sort / dedupe import groups. ### `HARN-MOD-005` **Category:** `MOD` (Modules and exports)  ·  **API stability:** `stable` module imports expose colliding names #### What it means A module-level import declaration cannot be satisfied or has been authored in a non-canonical form. The module graph must resolve cleanly before any further checks. #### How to fix - Confirm the import path resolves on disk and the imported symbol is exported. - Run `harn lint --fix` to auto-sort / dedupe import groups. ### `HARN-MOD-006` **Category:** `MOD` (Modules and exports)  ·  **API stability:** `stable` module re-exports conflict #### What it means A module-level import declaration cannot be satisfied or has been authored in a non-canonical form. The module graph must resolve cleanly before any further checks. #### How to fix - Confirm the import path resolves on disk and the imported symbol is exported. - Run `harn lint --fix` to auto-sort / dedupe import groups. ### `HARN-MOD-007` **Category:** `MOD` (Modules and exports)  ·  **API stability:** `stable` imported module failed to compile #### What it means An `import` in this file resolves to a module that could not itself be lexed or parsed. Because the target never produced an AST, none of its symbols exist, so callers would otherwise see every imported name reported as "undefined" at their own call sites — pointing you at the wrong file. This diagnostic instead names the broken module and surfaces its real lex/parse error. #### How to fix - Open the imported module named in the message and fix the lex/parse error reported there (the message includes its failing line and column). - Re-run `harn check` on the imported module directly to confirm it compiles, then re-check this consumer. ### `HARN-RMD-001` **Category:** `RMD` (Reminder lifecycle)  ·  **API stability:** `stable` reminder lifecycle option key is not recognized - **See also:** [`HARN-RMD-002`](#harn-rmd-002), [`HARN-RMD-005`](#harn-rmd-005) Reminder lifecycle option tables reject unknown keys so reminder shape stays stable across transcript transforms, hooks, and bridge integrations. Use only the documented `transcript.inject_reminder` keys: `body`, `tags`, `dedupe_key`, `ttl_turns`, `preserve_on_compact`, `propagate`, and `role_hint`. For `transcript.clear_reminders`, use at least one selector from `id`, `tag`, or `dedupe_key`. ### `HARN-RMD-002` **Category:** `RMD` (Reminder lifecycle)  ·  **API stability:** `stable` reminder payload shape is invalid - **See also:** [`HARN-RMD-001`](#harn-rmd-001), [`HARN-RMD-005`](#harn-rmd-005) The reminder payload shape is invalid. `session/remind` expects a typed reminder object, not a user-message payload. Provide a non-empty `body`, use string `tags`, a positive integer `ttl_turns` when present, boolean `preserve_on_compact`, and one of the documented `propagate` and `role_hint` values. Put host-specific extension fields under `_meta`. ### `HARN-RMD-003` **Category:** `RMD` (Reminder lifecycle)  ·  **API stability:** `stable` retired provider-specific reminder role-hint diagnostic This diagnostic is retired and retained only so stored diagnostics and tooling can continue to resolve its stable code. Directives now use one provider-neutral model-facing envelope and `role_hint` no longer selects a provider-specific slot. Use `authority` to express `contract`, `corrective`, or `advisory` precedence. ### `HARN-RMD-004` **Category:** `RMD` (Reminder lifecycle)  ·  **API stability:** `stable` discardable reminder has no TTL A reminder literal sets `preserve_on_compact: false` while leaving `ttl_turns` unset or `nil`. That reminder can live forever during normal turns, but it is allowed to disappear at the next transcript compaction. Set a finite `ttl_turns` for short-lived nudges, or set `preserve_on_compact: true` when the reminder must survive compaction. ### `HARN-RMD-005` **Category:** `RMD` (Reminder lifecycle)  ·  **API stability:** `stable` reminder propagate value is not recognized - **See also:** [`HARN-RMD-001`](#harn-rmd-001), [`HARN-RMD-002`](#harn-rmd-002) A reminder specified an unsupported `propagate` value. Reminder propagation must be one of `all`, `session`, or `none`. Use `all` for reminders that should follow child agents, `session` for the current session only, and `none` for reminders that should never propagate. Example fix: ```harn transcript.inject_reminder(transcript(), { body: "Check the task ledger before continuing.", propagate: "session", }) ``` ### `HARN-RMD-006` **Category:** `RMD` (Reminder lifecycle)  ·  **API stability:** `stable` reminder provider returned a malformed reminder spec - **See also:** [`HARN-RMD-002`](#harn-rmd-002) A reminder provider closure returned a value that could not be parsed as a `ReminderSpec`. Provider closures may return `nil`, a reminder spec, an effect such as `{reminder: {...}}`, or a list of those effects. Return a dict with a non-empty `body` and only supported reminder fields. Example fix: ```harn register_reminder_provider({ id: "custom", subscribes_to: ["session_idle"], evaluate: { _ctx -> return { reminder: {body: "Re-check current session state.", ttl_turns: 1}, } }, }) ``` ### `HARN-RMD-007` **Category:** `RMD` (Reminder lifecycle)  ·  **API stability:** `stable` too many reminder providers are enabled - **See also:** [`HARN-RMD-004`](#harn-rmd-004) An `agent_loop` enables more than eight distinct reminder providers. Many providers can inject overlapping ambient context and increase prompt size. Disable providers that are not useful for the loop, or split the loop into smaller stages with different reminder settings. Example fix: ```harn agent_loop(task, nil, { reminders: {providers: ["token_pressure", "idle_nudge"]}, }) ``` ### `HARN-RMD-008` **Category:** `RMD` (Reminder lifecycle)  ·  **API stability:** `stable` hook event does not support reminder effects - **See also:** [`HARN-RMD-006`](#harn-rmd-006) A hook handler returned a reminder effect from a lifecycle event that cannot inject reminders. Worker lifecycle events are observational and must not mutate the active transcript with reminder effects. Move the reminder to a session, tool, step, or persona hook that runs at a turn-boundary mutation point. Example fix: ```harn fn main(harness: Harness) { harness.agent.register_session_hook( "post_turn", { _hook_harness, _event -> return { reminder: {body: "Review worker progress before continuing."}, } }) } ``` ### `HARN-SUS-001` **Category:** `SUS` (Suspend / resume lifecycle)  ·  **API stability:** `stable` suspend_agent target worker is not running - **See also:** [`HARN-SUS-003`](#harn-sus-003), [`HARN-SUS-010`](#harn-sus-010) `suspend_agent(...)` can only create a new cooperative suspension for a running worker. A worker that is awaiting input, completed, failed, cancelled, or interrupted no longer has a running turn boundary where a suspend checkpoint can be installed. Use the worker summary status to branch before suspending. For terminal workers, spawn a fresh worker instead. Calling `suspend_agent(...)` again on an already suspended worker remains an idempotent summary read. ### `HARN-SUS-002` **Category:** `SUS` (Suspend / resume lifecycle)  ·  **API stability:** `stable` ResumeConditions validation failed - **See also:** [`HARN-SUS-007`](#harn-sus-007), [`HARN-SUS-008`](#harn-sus-008) `ResumeConditions` did not match the supported shape for a suspended agent. The object may contain `trigger`, `timeout`, and `on_event`. Trigger entries must parse as trigger specs, timeout entries need a positive `duration_minutes`, and event entries must be valid runtime event topics. Validate untrusted condition tables with `parse_resume_conditions(...)` or `agent_await_resumption(...)` before passing them to `suspend_agent(...)`. ### `HARN-SUS-003` **Category:** `SUS` (Suspend / resume lifecycle)  ·  **API stability:** `stable` resume_agent target worker is not suspended - **See also:** [`HARN-SUS-001`](#harn-sus-001), [`HARN-SUS-006`](#harn-sus-006) `resume_agent(...)` was called on a live worker that is not currently in the `suspended` state. Warm resume is only meaningful for workers with an active suspension envelope. Check the worker summary before resuming. Use `send_input(...)` for workers that are awaiting input, inspect terminal summaries directly, or load a snapshot path when you only need to restore persisted state. ### `HARN-SUS-004` **Category:** `SUS` (Suspend / resume lifecycle)  ·  **API stability:** `stable` resume snapshot cannot be loaded or used - **See also:** [`HARN-SUS-003`](#harn-sus-003) `resume_agent(...)` could not load the requested snapshot, or the snapshot was not usable as a worker state. The path may be missing, stale, unreadable, or from an incompatible worker-state format. Use the `snapshot_path` from the latest worker summary and keep the worker state directory available across process restarts. If the snapshot belongs to an older incompatible runtime, spawn a replacement worker instead of resuming. ### `HARN-SUS-005` **Category:** `SUS` (Suspend / resume lifecycle)  ·  **API stability:** `stable` agent_await_resumption was invoked outside agent_loop structural handling - **See also:** [`HARN-SUS-002`](#harn-sus-002) The model-facing `agent_await_resumption` tool is structural. `agent_loop` intercepts that tool call, records audit metadata, and turns it into a suspend checkpoint. Invoking the tool handler directly bypasses that control flow, so Harn rejects it. Call `agent_await_resumption(reason, conditions?)` only to build or normalize the request shape, or let an `agent_loop` turn handle the lifecycle tool call. ### `HARN-SUS-006` **Category:** `SUS` (Suspend / resume lifecycle)  ·  **API stability:** `stable` concurrent resume changed the worker before resume could complete - **See also:** [`HARN-SUS-003`](#harn-sus-003), [`HARN-SUS-010`](#harn-sus-010) A resume operation started while the worker looked suspended, but another operator, trigger, or timeout changed the worker state before the resume could finish. Harn rejects the later resume so callers do not accidentally treat a race as a successful wake-up. Refresh the worker summary and retry only if the worker is still suspended. For trigger-driven resumes, ensure only one trigger or timeout path remains armed for the suspension. ### `HARN-SUS-007` **Category:** `SUS` (Suspend / resume lifecycle)  ·  **API stability:** `stable` ResumeConditions trigger could not be registered - **See also:** [`HARN-SUS-002`](#harn-sus-002), [`HARN-SUS-008`](#harn-sus-008) The `conditions.trigger` entry was valid enough to request an auto-resume binding, but Harn could not register or resolve the trigger in the live dispatcher registry. Check the trigger kind, event matchers, and registry availability. Prefer normalizing the full `ResumeConditions` table before suspension so shape errors surface as `HARN-SUS-002`. ### `HARN-SUS-008` **Category:** `SUS` (Suspend / resume lifecycle)  ·  **API stability:** `stable` resume timeout action is unsupported - **See also:** [`HARN-SUS-002`](#harn-sus-002), [`HARN-SUS-007`](#harn-sus-007) An auto-resume timeout used an `on_timeout` action Harn does not implement. Supported actions are `resume_with_summary`, `resume_with_input`, and `fail`. Use `parse_resume_conditions(...)` to normalize timeout settings before suspending, or update the timeout table to one of the supported actions. ### `HARN-SUS-009` **Category:** `SUS` (Suspend / resume lifecycle)  ·  **API stability:** `stable` resume input failed agent_loop input validation - **See also:** [`HARN-SUS-003`](#harn-sus-003) `resume_agent(...)` received resume input that could not be converted into a non-empty task prompt for the resumed agent loop. Pass `nil` when resuming without new input, or pass a non-empty string or value whose string form is suitable as the next task prompt. ### `HARN-SUS-010` **Category:** `SUS` (Suspend / resume lifecycle)  ·  **API stability:** `stable` closed suspended worker cannot be resumed - **See also:** [`HARN-SUS-003`](#harn-sus-003), [`HARN-SUS-006`](#harn-sus-006) The target worker was closed or cancelled after being suspended. Closing a worker removes the suspension envelope and rejects future resume attempts against that live worker handle. Treat the worker as terminal. Spawn a new worker, or restore an explicit snapshot path only when you intentionally want to inspect persisted state. ### `HARN-SUS-011` **Category:** `SUS` (Suspend / resume lifecycle)  ·  **API stability:** `stable` replay resume input hash diverges from journaled suspension The replay runtime computed a different resume-input fingerprint than the one captured in the original run's [`ResumptionReceipt`]. Lifecycle replay must be deterministic: the second run must feed the suspended worker the exact same payload it received the first time, because the worker's post-resume trajectory was hashed into the journaled state. Common causes: - A pipeline that used to depend on wall-clock time or another non-deterministic input now produces a different resume payload on replay. Capture the payload itself (or use `harness.testing.clock_set(...)`) so the replayed runtime can reproduce the original value. - The journal entry was edited after the original run completed. The signed timestamp on the receipt detects this — verify the receipt's signature with `verify_lifecycle_receipt_signature(...)` before blaming the runtime. - The settlement agent (drain phase) changed its mind and produced a different resume input. Record a fresh `ResumptionReceipt` in the current run instead of replaying the stale one. If the new payload is intentionally different (eg. you are running a deliberate counterfactual, not a determinism replay), open the trace as a fresh recording rather than feeding it back through the replay oracle. ### `HARN-SUS-012` **Category:** `SUS` (Suspend / resume lifecycle)  ·  **API stability:** `stable` replay drain decision prompt hash diverges from journaled receipt The replay runtime tried to memoize a settlement-agent drain decision against a [`DrainDecisionReceipt`] whose `prompt_hash` does not match the candidate prompt the second run computed. Drain decisions are journaled so that replay can skip the settlement agent's LLM call — but if the prompt drifts, the recorded `action` no longer reflects what the agent would actually decide. Common causes: - The pipeline's settlement-agent prompt template or its inputs (the unsettled-state snapshot, the budget summary, the worker list) changed between record and replay. Either re-record the trace or roll the template back to its recorded form. - The drain item set itself changed shape (eg. a new suspended subagent appeared on replay). Fix the upstream determinism gap first, then the drain receipt will line up. The recorded receipt is still safe to inspect via `harness.agent.lifecycle_receipts_snapshot()`, and its signed timestamp tells you when the original decision was minted. Treat the mismatch as a signal that the replay is no longer a determinism check. ### `HARN-SUS-013` **Category:** `SUS` (Suspend / resume lifecycle)  ·  **API stability:** `stable` lifecycle receipt signed timestamp failed verification A [`SuspensionReceipt`], [`ResumptionReceipt`], or [`DrainDecisionReceipt`] carries a `SignedLifecycleTimestamp` whose HMAC signature does not match the receipt's identifying fields under the per-process signing salt. The signature binds `(kind, at_ms, subject_id, initiator_id)`, so any of the following will trip this diagnostic: - The receipt was minted by a *different* `harn` process and is now being verified after a restart. Per-process salts are intentional — cross-process verification requires migrating to the longer-lived `provenance` chain (see `build_signed_receipt`). - The journal entry was edited after the fact (eg. someone hand-tweaked `at_ms` or `initiator_id` in the on-disk record). The replay oracle rejects the tampered receipt before relying on it. - The signing algorithm or key id changed across a Harn upgrade. The current algorithm is `hmac-sha256` with key id `local-session`. If the original process is gone but you still need to read the receipts, treat the on-disk payloads as advisory only — the unsigned fields (`handle`, `reason`, `input_hash`, `action`) are still informational, but `replay_resume_input` / `replay_drain_decision` will refuse to memoize against an unverified receipt. ### `HARN-LNT-001` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` renamed stdlib symbol lint - **Repair:** `stdlib/migrate-renamed`  ·  **Safety:** `scope-local` - Rename the call to the renamed stdlib symbol - **See also:** [`HARN-STD-001`](#harn-std-001) #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-002` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` cyclomatic complexity lint #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-003` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` naming convention lint - **Repair:** `style/rename-to-convention`  ·  **Safety:** `surface-changing` - Rename to match the casing convention for this kind #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-004` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` eager collection conversion lint - **Repair:** `collections/prefer-lazy`  ·  **Safety:** `scope-local` - Replace the eager collection step with a lazy variant conversion) #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-005` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` redundant clone lint - **Repair:** `clones/remove-redundant`  ·  **Safety:** `behavior-preserving` - Remove the redundant clone #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-006` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` long-running workflow cleanup lint - **Repair:** `manual/needs-human`  ·  **Safety:** `needs-human` - Plan a human-led change; auto-apply is not safe here cleanup) #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-007` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` MCP tool annotations lint - **Repair:** `manual/needs-human`  ·  **Safety:** `needs-human` - Plan a human-led change; auto-apply is not safe here #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-008` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` PR open without secret scan lint scan) #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-009` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` shadow variable lint - **Repair:** `bindings/rename-shadow`  ·  **Safety:** `scope-local` - Rename the shadowing binding to a distinct name #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-010` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` persona hook target lint #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-011` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` dead code after return lint - **Repair:** `control-flow/remove-dead`  ·  **Safety:** `behavior-preserving` - Remove the unreachable code #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-012` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` let then return lint - **Repair:** `control-flow/flatten`  ·  **Safety:** `behavior-preserving` - Flatten the unnecessary control flow construct #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-013` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` unhandled approval result lint - **Repair:** `errors/check-or-rescue`  ·  **Safety:** `scope-local` - Check the result or wrap the call in a `rescue` block result) #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-014` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` unused variable lint - **Repair:** `bindings/rename-unused`  ·  **Safety:** `behavior-preserving` - Mark an unused binding without changing callable arity #### How to fix - Use the `_` discard binding for side-effect-only local expressions, for example `let _ = cleanup()`. - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-015` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` unused pattern binding lint - **Repair:** `bindings/rename-unused`  ·  **Safety:** `behavior-preserving` - Mark an unused binding without changing callable arity #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-016` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` unused parameter lint - **Repair:** `bindings/rename-unused`  ·  **Safety:** `behavior-preserving` - Mark an unused binding without changing callable arity #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Functions, closures, public or extended pipelines, and fixture- or table-bound tests keep positional arity and prefix the unused parameter with `_`. - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-017` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` unused import lint - **Repair:** `imports/remove-unused`  ·  **Safety:** `behavior-preserving` - Remove the unused import - **See also:** [`HARN-MOD-002`](#harn-mod-002) #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-018` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` mutable never reassigned lint - **Repair:** `bindings/make-immutable`  ·  **Safety:** `behavior-preserving` - Declare the never-reassigned binding with `const` instead of `let` - **See also:** [`HARN-OWN-002`](#harn-own-002) #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-019` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` unused function lint - **Repair:** `declarations/remove-unused`  ·  **Safety:** `surface-changing` - Remove the unused declaration #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-020` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` unused type lint - **Repair:** `declarations/remove-unused`  ·  **Safety:** `surface-changing` - Remove the unused declaration #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-021` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` persona body must call steps lint steps) #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-022` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` undefined function lint #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-023` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` pipeline return type lint #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-024` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` missing harndoc lint - **Repair:** `doc/add-harndoc`  ·  **Safety:** `behavior-preserving` - Add a `///` doc comment describing this declaration #### What it means A public function has no `/** */` HarnDoc block above its declaration. This rule is **opt-in**: it only runs when the nearest `harn.toml` sets ```toml [lint] require_docstrings = true ``` Out of the box, public functions in user scripts and pipelines need no doc comments. Embedded stdlib sources always enforce docstrings (the HARN-STD-101 metadata lint relies on the doc block existing). #### How to fix - Add a `/** ... */` block with a one-line prose summary above the declaration. No structured tags are required; LSP hover derives a usage example from the type signature. - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Or leave `require_docstrings` unset if the project doesn't want the requirement. ### `HARN-LNT-025` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` assert outside test lint #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-026` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` prompt injection risk lint - **Repair:** `prompts/escape-injection`  ·  **Safety:** `scope-local` - Pass the untrusted input through a structured placeholder - **See also:** [`HARN-PRM-003`](#harn-prm-003) #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-027` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` connector effect policy lint #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-028` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` unnecessary cast lint - **Repair:** `casts/remove-redundant`  ·  **Safety:** `behavior-preserving` - Remove the redundant cast #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-029` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` untyped dict access lint - **Repair:** `types/validate-boundary-value`  ·  **Safety:** `scope-local` - Validate the parsed value with schema_expect() or schema_check() before reading it #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-030` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` constant logical operand lint - **Repair:** `expressions/simplify`  ·  **Safety:** `behavior-preserving` - Simplify the expression to its canonical form #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-031` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` pointless comparison lint - **Repair:** `expressions/simplify`  ·  **Safety:** `behavior-preserving` - Simplify the expression to its canonical form #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. #### NaN caveat A self-comparison such as `x == x` or `x != x` is **not** constant when `x` is a NaN float: `x == x` is `false` and `x != x` is `true` for NaN (and the same holds element-wise for lists/dicts containing a NaN). The lint therefore only offers an auto-fix when the operand provably cannot be NaN (a non-float literal, or a list/dict built only from such literals). When the operand could be a float the lint still warns — a self-comparison is usually a typo — but leaves the code untouched and points you at `is_nan(...)`, the idiomatic NaN test. ### `HARN-LNT-032` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` comparison to bool lint - **Repair:** `expressions/simplify`  ·  **Safety:** `behavior-preserving` - Simplify the expression to its canonical form #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-033` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` invalid binary operator literal lint #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-034` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` redundant nil ternary lint - **Repair:** `expressions/simplify`  ·  **Safety:** `behavior-preserving` - Simplify the expression to its canonical form #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-035` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` empty block lint - **Repair:** `blocks/remove-empty`  ·  **Safety:** `scope-local` - Remove the empty block or fill in an explicit body #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-036` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` unnecessary else return lint - **Repair:** `control-flow/flatten`  ·  **Safety:** `behavior-preserving` - Flatten the unnecessary control flow construct #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-037` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` duplicate match arm lint - **Repair:** `match/remove-duplicate-arm`  ·  **Safety:** `behavior-preserving` - Remove the duplicated match arm - **See also:** [`HARN-MAT-002`](#harn-mat-002) #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-038` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` require in test lint #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-039` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` break outside loop lint #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-040` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` template parse lint #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-041` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` blank line between items lint - **Repair:** `format/reformat`  ·  **Safety:** `format-only` - Apply canonical formatting #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-042` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` trailing comma lint - **Repair:** `format/reformat`  ·  **Safety:** `format-only` - Apply canonical formatting #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-043` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` unnecessary parentheses lint - **Repair:** `format/reformat`  ·  **Safety:** `format-only` - Apply canonical formatting #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-044` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` template variant explosion lint - **Repair:** `manual/needs-human`  ·  **Safety:** `needs-human` - Plan a human-led change; auto-apply is not safe here - **See also:** [`HARN-PRM-002`](#harn-prm-002) explosion) #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-045` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` require file header lint - **Repair:** `format/reformat`  ·  **Safety:** `format-only` - Apply canonical formatting #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-046` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` template provider identity branch lint - **Repair:** `llm/use-capability-flag`  ·  **Safety:** `capability-changing` - Branch on a capability flag instead of provider identity - **See also:** [`HARN-PRM-004`](#harn-prm-004) identity branch) #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-047` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` import order lint - **Repair:** `imports/reorder`  ·  **Safety:** `format-only` - Reorder imports into canonical grouping #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-048` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` prefer optional shorthand lint - **Repair:** `expressions/simplify`  ·  **Safety:** `behavior-preserving` - Simplify the expression to its canonical form shorthand) #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-049` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` legacy doc comment lint - **Repair:** `doc/migrate-comment-style`  ·  **Safety:** `format-only` - Migrate the legacy comment to canonical doc syntax #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-050` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` removed LLM options lint - **Repair:** `llm/migrate-removed-option`  ·  **Safety:** `scope-local` - Replace the removed option with its supported equivalent #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-051` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` unnecessary safe navigation lint - **Repair:** `expressions/simplify`  ·  **Safety:** `behavior-preserving` - Simplify the expression to its canonical form navigation) #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`). - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-052` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` ambient clock builtin replaced by `harness.clock.*` - **Repair:** `bindings/thread-harness-clock`  ·  **Safety:** `scope-local` - Replace the ambient clock builtin with the corresponding `harness.clock.*` method - **See also:** [`HARN-NAM-101`](#harn-nam-101), [`HARN-LNT-001`](#harn-lnt-001) #### What it means The lint fires on any call to `now_ms`, `monotonic_ms`, `sleep_ms`, `timestamp`, or `elapsed`. These were ambient clock-capability builtins in the pre-`Harness` runtime. Time access now routes through the `harness.clock.*` sub-handle so capability requirements appear in the type system instead of being hidden in the stdlib surface. The legacy effectful globals are removed. This lint supplies an actionable migration repair before the checker reports the removed symbol. #### How to fix - Run `harn fix --apply --safety surface-changing` over the file. Calls inside an existing Harness boundary are rewritten in place; otherwise the fixer threads an explicit Harness parameter through local callers. - Run lint again. `capability-attenuation` suggests replacing an unnecessarily broad helper parameter with the narrow nominal handle it actually uses. ### `HARN-LNT-053` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` ambient stdio builtin replaced by `harness.stdio.*` - **Repair:** `bindings/thread-harness`  ·  **Safety:** `scope-local` - Thread the existing `harness` binding through local helper calls and replace the ambient stdio builtin with `harness.stdio.*` - **See also:** [`HARN-NAM-101`](#harn-nam-101), [`HARN-LNT-001`](#harn-lnt-001) #### What it means The lint fires on calls to `print`, `println`, `eprint`, `eprintln`, `read_line`, and `prompt_user`. These were ambient stdio-capability builtins in the pre-`Harness` runtime. Stdio access now routes through the `harness.stdio.*` sub-handle so capability requirements are visible in the type system. This lint is emitted during auto-repair planning so existing call sites can be migrated before the removed builtin produces an unknown-name diagnostic. New code should use `harness.stdio.print`, `harness.stdio.println`, `harness.stdio.eprint`, `harness.stdio.eprintln`, `harness.stdio.read_line`, or `harness.stdio.prompt`. #### How to fix - Run `harn fix --apply --safety surface-changing` over the file. Calls inside an existing Harness boundary are rewritten in place; otherwise the fixer threads an explicit Harness parameter through local callers. - Run lint again. `capability-attenuation` suggests replacing an unnecessarily broad helper parameter with `HarnessStdio`. ### `HARN-LNT-054` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` ambient fs builtin replaced by `harness.fs.*` - **Repair:** `bindings/thread-harness-fs`  ·  **Safety:** `scope-local` - Replace the ambient fs builtin with the corresponding `harness.fs.*` method - **See also:** [`HARN-NAM-101`](#harn-nam-101), [`HARN-LNT-001`](#harn-lnt-001) #### What it means The lint fires on any call to `read_file`, `write_file`, `file_exists`, `delete_file`, `append_file`, `append_file_locked`, `list_dir`, `mkdir`, `copy_file`, `temp_dir`, `mkdtemp`, `stat`, `move_file`, `read_lines`, `walk_dir`, `glob`, or `find_text`. These were ambient fs-capability builtins in the pre-`Harness` runtime. Filesystem access now routes through the `harness.fs.*` sub-handle so capability requirements appear in the type system instead of being hidden in the stdlib surface. The legacy effectful globals are removed. This lint supplies an actionable migration repair before the checker reports the removed symbol. #### How to fix - Run `harn fix --apply --safety surface-changing` over the file. Calls inside an existing Harness boundary are rewritten in place; otherwise the fixer threads an explicit Harness parameter through local callers. - Run lint again. `capability-attenuation` suggests replacing an unnecessarily broad helper parameter with `HarnessFs`. ### `HARN-LNT-055` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` ambient env builtin replaced by `harness.env.*` - **Repair:** `bindings/thread-harness-env`  ·  **Safety:** `scope-local` - Replace the ambient env builtin with the corresponding `harness.env.*` method - **See also:** [`HARN-NAM-101`](#harn-nam-101), [`HARN-LNT-001`](#harn-lnt-001) #### What it means The lint fires on calls to the ambient `env` and `env_or` builtins. Environment access now routes through the `harness.env.*` sub-handle so capability requirements appear in the type system instead of being hidden in the stdlib surface. The legacy effectful globals are removed. This lint supplies an actionable migration repair before the checker reports the removed symbol. #### How to fix - Run `harn fix --apply --safety surface-changing` over the file. Calls inside an existing Harness boundary are rewritten in place; otherwise the fixer threads an explicit Harness parameter through local callers. - Run lint again. `capability-attenuation` suggests replacing an unnecessarily broad helper parameter with `HarnessEnv`. ### `HARN-LNT-056` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` ambient random builtin replaced by `harness.random.*` - **Repair:** `bindings/thread-harness-random`  ·  **Safety:** `scope-local` - Replace the ambient random builtin with the corresponding `harness.random.*` method - **See also:** [`HARN-NAM-101`](#harn-nam-101), [`HARN-LNT-001`](#harn-lnt-001) #### What it means The lint fires on calls to the ambient `random`, `random_int`, `random_choice`, and `random_shuffle` builtins. Randomness now routes through the `harness.random.*` sub-handle so capability requirements appear in the type system instead of being hidden in the stdlib surface. The legacy effectful globals are removed. Use the matching `harness.random.*` method (`random` → `harness.random.f64`, `random_int` → `harness.random.range`, etc.). Seeded streams via an explicit `Rng` handle remain available through the `Rng.*` surface for tests that need deterministic output. #### How to fix - Run `harn fix --apply --safety surface-changing` over the file. Calls inside an existing Harness boundary are rewritten in place; otherwise the fixer threads an explicit Harness parameter through local callers. - Run lint again. `capability-attenuation` suggests replacing an unnecessarily broad helper parameter with `HarnessRandom`. ### `HARN-LNT-057` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` ambient net builtin replaced by `harness.net.*` - **Repair:** `bindings/thread-harness-net`  ·  **Safety:** `scope-local` - Replace the ambient net builtin with the corresponding `harness.net.*` method - **See also:** [`HARN-NAM-101`](#harn-nam-101), [`HARN-LNT-001`](#harn-lnt-001) #### What it means The lint recognizes removed ambient network calls and supplies a migration repair. Requests, downloads, streams, sessions, SSE, WebSockets, and servers all route through the `HarnessNet` interface so capability requirements appear in the type system instead of being hidden in a global surface. Pure response constructors and event encoders remain ordinary globals. The legacy effectful globals are not a compatibility surface: ordinary source must use the corresponding `harness.net.*` method. The lint exists so old source gets an actionable repair before the checker reports the removed symbol. #### How to fix - Run `harn fix --apply --safety surface-changing` over the file. Calls inside an existing Harness boundary are rewritten in place; otherwise the fixer threads an explicit Harness parameter through local callers. - Run lint again. `capability-attenuation` suggests replacing an unnecessarily broad helper parameter with `HarnessNet`. ### `HARN-LNT-058` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` if / while / guard condition is statically known to always succeed or always fail #### What it means The condition of an `if`, `while`, or `guard` is statically known — one of its two branches is unreachable. The lint fires for two patterns: ##### 1. Constant-evaluable conditions The condition reduces to a known boolean using only literal operands and short-circuit / negation rules. Examples: - `if true { … }`, `if false { … }` — direct booleans. - `if nil { … }`, `if 0 { … }`, `if "" { … }` — falsy literals. - `if (true || some_call()) { … }` — short-circuits to `true`. - `if (some_call() && false) { … }` — short-circuits to `false`. - `if !!true { … }` — chains of negations on a constant. Side-effecting subexpressions inside a vacuous compound (`some_call()` above) still execute, but their result is dead — usually a mistake left behind by a partial refactor. ##### 2. Statically-determined `schema_is` / `is_type` The variable's static type already proves whether the predicate matches: - **Always true** — `x`'s static type is a subtype of the schema, so the truthy branch narrows to nothing new and the falsy branch is dead. Example: `x: int` and `schema_is(x, int)`, or `x: {a: int, b: string}` and `schema_is(x, {b: string})` (width subtyping). - **Always false** — `x`'s static type and the schema are disjoint. The truthy branch is dead. Example: `x: int` and `schema_is(x, string)`. The check is deliberately conservative: it skips when `x: unknown` or `x: any` (the open-world top types — `schema_is` is informative there), and it requires shape fields to match optionality before reporting "always true" (an optional field in `x` can be absent at runtime, so the predicate may still legitimately fail). The same shape is used by [`no-unnecessary-condition` in typescript-eslint][tse] and the [`unnecessary-invariant` rule in Flow][flow]. [tse]: https://typescript-eslint.io/rules/no-unnecessary-condition/ [flow]: https://flow.org/en/docs/linting/rule-reference/#toc-unnecessary-invariant #### How to fix - If the check is genuinely dead code, remove the surrounding `if` / `while` / `guard` and inline (or delete) the live branch. - If you intended a *narrower* schema, change `S` to the narrower shape (e.g. a tagged variant, not the parent type). - If the variable is meant to come from an untrusted boundary, type it as `unknown` (or `any`) and validate at the boundary; the lint will then correctly stay silent. ### `HARN-LNT-059` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` project rule-engine or native lint rule #### What it means A project-supplied rule matched here. This is not a built-in lint: it is a structural rule (a `*.toml` pattern from the project's `[rules] ruleDirs`) run through the linter via the rule engine (`harn-rules`), a `.lint.harn` script rule loaded from `ruleDirs`, or a trusted native rule library loaded from `[rules] nativeRuleDirs`. The message, severity, and any suggested fix come from the matched rule. The diagnostic's reported rule id is the project rule's id, so you filter it with `disable_rules` (or `[lint]` config) by that id, exactly like a built-in. #### Why it fires The project declared one or more rule directories: ```toml [rules] ruleDirs = ["rules"] nativeRuleDirs = ["native-rules"] ``` and a rule in one of them matched this code. Declarative rules pair a structural `pattern` with a `message`; script rules return findings from `lint(source)`; native rules emit diagnostics through the `harn_lint::native` ABI. Rules that carry a fix surface it as a machine-applicable lint fix. #### How to fix - Address the issue the rule describes (see the rule's `message`). - If the rule carries a fix, `harn lint --fix` applies it. Declarative codemod fixes can also be applied by `harn codemod`. - To silence it, disable the rule by its id, or remove/adjust the rule in your `ruleDirs` / `nativeRuleDirs`. #### See also - The rule engine: `harn scan`, `harn codemod`, and the `harn-rules` skill. - Project rule discovery: `[rules] ruleDirs` and `nativeRuleDirs` in `harn.toml`. ### `HARN-LNT-060` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` inline options dict bypasses the typed option constructors - **Repair:** `types/add-shape-annotation`  ·  **Safety:** `surface-changing` - Annotate the dict with a concrete shape type - **See also:** [`HARN-LNT-050`](#harn-lnt-050), [`HARN-LNT-029`](#harn-lnt-029) #### What it means `agent_loop` and `workflow_execute` accept large option surfaces. Passing an inline dict literal at the call site skips the typed structural aliases (`AgentSpec` from `std/agent/options`, `StageSpec` and friends from `std/workflow/options`), so option typos and wrongly-typed values are only discovered at runtime — or silently ignored. The dict still executes exactly as before; this lint is a deprecation-level nudge toward the single documented path. #### How to fix - Bind the options to an annotated `let` first: `let opts: AgentSpec = {...}` then `agent_loop(task, system, opts)`. - Or build the options through a typed constructor: `agent_preset(kind, {...})` / `agent_options({...})` from `std/agent/options`, or `workflow_stage_spec({...})` from `std/workflow/options`. - Suppress the lint only for throwaway probes and fixtures where the inline dict is the point. ### `HARN-LNT-061` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` nil coalesce fallback has no effect - **Repair:** `expressions/simplify`  ·  **Safety:** `behavior-preserving` - Simplify the expression to its canonical form #### What it means The fallback side of a nil-coalescing expression is the literal `nil`: ```harn,ignore const value = task?.flag ?? nil ``` That expression is equivalent to `task?.flag`. If the left side is present, its value is returned; if it is absent, `?? nil` returns `nil`, which is already the left side's absent value. #### How to fix Remove the fallback: ```harn const value = task?.flag ``` Use a real default only when the surrounding code needs a non-nil value. The same rule applies to `false` used as the exact positive condition of an assertion: ```harn,ignore assert(task?.ready ?? false) ``` `assert` accepts any value and applies Harn truthiness. Both `nil` and `false` fail, so the fallback cannot change the result. Assert the value directly: ```harn assert(task?.ready) ``` Keep boolean fallbacks used outside this exact position. In particular, a negation or a `?? true` fallback can change how a missing value behaves. ### `HARN-LNT-062` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` nil coalesce fallback is unreachable #### What it means The left side of a `??` expression has a statically non-nil type, so the fallback branch can never run. This often happens after a typed conversion or producer already returns a concrete value: ```harn fn parse_number(raw: string) -> int { return 0 } const raw: string? = nil const value = parse_number(raw ?? "0") ?? 0 ``` The outer fallback is redundant when `parse_number(...)` has a non-nil result type. #### How to fix Remove the unreachable fallback: ```harn fn parse_number(raw: string) -> int { return 0 } const raw: string? = nil const value = parse_number(raw ?? "0") ``` If the fallback really is needed, change the left expression or annotation so its type accurately includes `nil`. ### `HARN-LNT-063` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` non-null assertion `!` on an already-non-nil value - **Repair:** `expressions/simplify`  ·  **Safety:** `behavior-preserving` - Simplify the expression to its canonical form A non-null assertion (`expr!`) was applied to a value whose type is already non-nil, so the assertion does nothing and can be removed. #### How to fix - Apply the lint's auto-fix where one is offered (`harn lint --fix`) to drop the trailing `!`. - Suppress the lint with an attribute only when the surrounding code is intentionally non-idiomatic. ### `HARN-LNT-064` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` a mutable variable captured from an enclosing scope is reassigned inside a `parallel`/`spawn` body, so concurrent branches share one cell and race A variable declared in an enclosing scope is reassigned inside a `parallel` or `spawn` body. Harn closures capture by reference, and `parallel`/`spawn` bodies are lowered into closures whose captured cells are shared — by `Arc` — with every concurrent branch. Reassigning such a variable therefore mutates a single shared cell from many branches at once. This is memory-safe (the cell is a mutex), but the writes race: branches interleave at every `await` point (an `llm_call`, a host call), so a read-modify-write such as `total = total + x` silently loses updates, and under a multi-threaded runtime it is a genuine data race in the logical sense. #### How to fix - Prefer returning a value from each branch and combining the results after the fan-out, which is deterministic and lock-free: ```harn const parts = parallel each items { item -> compute(item) } const total = fold(parts, 0, { acc, part -> acc + part }) ``` - If a shared mutable accumulator is genuinely required, guard it explicitly and accept that ordering is nondeterministic. - Reassigning a variable that is declared *inside* the branch body is fine and never flagged — only captures from an enclosing scope trip this lint. ### `HARN-LNT-065` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` nil coalesce fallback repeats the left identifier - **Repair:** `expressions/simplify`  ·  **Safety:** `behavior-preserving` - Simplify the expression to its canonical form #### What it means Both sides of a nil-coalescing expression are the same identifier: ```harn,ignore const value = task ?? task ``` For a plain identifier, this is equivalent to `task`. If `task` is present, the left side wins; if it is `nil`, the fallback evaluates to the same `nil` value. #### How to fix Remove the fallback: ```harn const value = task ``` If a real recovery path is needed, replace the right side with an actual default or explicitly handle `nil` before using the value. ### `HARN-LNT-066` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` the result of a pure collection method is discarded, so the call has no effect on the receiver A `list` / `dict` / `set` / `string` method was called as a statement and its result thrown away, so the call has no effect at all. Every method on Harn's built-in collections is **pure**. They are persistent, copy-on-write values: `appending` clones the receiver, adds an item, and returns a *new* list. It never modifies the receiver. ```harn const l = [] l.appending(1) // error[HARN-LNT-066]: no effect — `l` is still [] l.appending(2) // error[HARN-LNT-066] // l == [] ``` The method name reads as a value-producing operation, but its result still has to be consumed. Without this diagnostic, discarding it would typecheck, run without error, and quietly build an empty list. #### How to fix Assign the result back. Because the binding's value changes, it must be `let` rather than `const` (see the binding rule in the language spec — `const` means this binding's value never changes): ```harn let l = [] l = l.appending(1) l = l.appending(2) // l == [1, 2] ``` When building a collection in a loop, the same shape applies: ```harn let out = [] for item in items { out = out.appending(transform(item)) } ``` To call a method purely for a side effect inside it — which only a closure argument can produce — the result is not discarded in the same sense, and this lint does not fire. Prefer a `for` loop over `map` when you want effects. #### When it does not fire - The tail expression of a value-producing block (a closure body, `match` arm, `if`/`else` branch, `try`, or `block { … }`), where the value *is* the block's result rather than a discarded statement. - A call taking a closure argument (`items.map({ item -> … })`), which may perform effects and so is not provably inert. - A receiver rooted at `harness`, whose methods exist for their effects. - A method name the file also declares in an `impl` block, which may be a user-defined method with effects of its own. ### `HARN-LNT-068` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` prompt template names a filter the engine does not implement A `.harn.prompt` or `.prompt` expression names a filter the template engine does not implement. Filter names are valid identifiers, so this is a lint error rather than a template parse error. ```harn-prompt,ignore Hello {{ name | uppr }} ``` #### How to fix Replace the name with one of the built-in filters. Near misses include a machine-applicable suggestion, so `harn lint --fix` can repair `uppr` to `upper`. ```harn-prompt Hello {{ name | upper }} ``` ### `HARN-LNT-069` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` helper accepts root Harness but uses only narrow capability handles - **Repair:** `bindings/attenuate-harness`  ·  **Safety:** `surface-changing` - Replace the root Harness parameter with the single capability the helper uses An ordinary function takes the root `Harness`, but every use in its body reads only one or two capabilities off it. Root authority belongs at entrypoints and orchestration boundaries. A reusable helper should ask for what it actually uses, so a reader can tell from the signature what the helper can touch. #### How to fix When the helper uses one capability, change the parameter to the `Harness*` type the diagnostic names and pass that sub-handle at each call site: ```harn fn load_manifest(fs: HarnessFs, path: string) -> string { return fs.read_text(path) } fn main(harness: Harness) { harness.stdio.println(load_manifest(harness.fs, "harn.toml")) } ``` When it uses two, take them as one record rather than two parameters, so each call site names what it grants: ```harn fn refresh_index(io: {fs: HarnessFs, tools: HarnessTools}, path: string) { io.tools.invoke("index", {source: io.fs.read_text(path)}) } fn main(harness: Harness) { refresh_index({fs: harness.fs, tools: harness.tools}, "src/index.md") } ``` A record keeps each grant named at the call site. Two positional handles are easy to swap by accident, and the swap still type-checks if the shapes are similar. `harn fix --apply --safety surface-changing` performs both rewrites. It changes the parameter, updates every use inside the helper, then narrows the argument at the call sites it can see (`harness` becomes `harness.fs`, or becomes `{fs: harness.fs, tools: harness.tools}`). It reuses the existing parameter name so the new binding cannot shadow anything else in scope, which is why a narrowed parameter can come out of this repair still called `harness`. `HARN-LNT-073` reports that and renames it, so running `harn fix` again finishes the job. #### When to keep root `Harness` Keep it when the function genuinely coordinates several capabilities or hands authority onward. Runtime entrypoints keep it too, because the host calls those signatures directly: `main`, jobs, trigger handlers, registered callbacks, and the standard connector exports. The lint reads connector exceptions from the connector ABI registry, and stays quiet whenever authority escapes the function or the surrounding code cannot prove that narrowing is safe. ### `HARN-LNT-070` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` public API has too many same-typed positional parameters A public function takes four or more positional parameters of the same type. A caller who swaps two of them still type-checks, and nothing at the call site says which value is which. #### How to fix Take the group as one record instead, so the call site names each value: ```harn pub fn draw_box(rect: {x: int, y: int, width: int, height: int}) { // ... } draw_box({x: 0, y: 0, width: 80, height: 24}) ``` This is guidance about a public API's readability, not a limit on how many parameters a function may have. It does not fire for private helpers, or for signatures where the types already tell the values apart. Parameters with defaults do count, since callers still pass them positionally. A rest parameter does not. ### `HARN-LNT-071` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` global builtin has moved to a Harness capability method - **Repair:** `bindings/thread-harness-method`  ·  **Safety:** `scope-local` - Replace the ambient runtime builtin with its typed `harness.*` method and thread authority through local callers This builtin was called as a global, but it performs an effect, so it now lives on a `Harness` capability instead. Reaching it through the harness is what makes a script's effects readable from its signatures. #### How to fix Call the method on the handle the diagnostic names, and pass that handle into the helper that needs it: ```harn fn report(stdio: HarnessStdio, message: string) { stdio.println(message) } ``` Some globals that returned a single value are now a field on a structured snapshot. For example, `platform()` becomes `harness.system.platform().os`, and `username()` becomes `harness.system.identity().username`. Keep the root `Harness` at entrypoints and at boundaries that genuinely coordinate several capabilities. Elsewhere, pass the narrowest handle that covers what the function does. `harn fix --apply --safety surface-changing` rewrites these calls for you, including calls inside `${...}` string interpolation, and adds the parameter to the local callers that need to supply the handle. ### `HARN-LNT-072` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` call names a builtin whose declared exposure keeps Harn source from naming it Every builtin declares an *exposure* saying which surface may reach it. Two of those values are closed to scripts: - `privileged_wire` — a trusted embedder primitive. Only artifacts stamped with privileged provenance may call it; user modules cannot name or re-export it. - `runtime_internal` — a compiler or runtime implementation detail that is never source-visible. The VM still registers those builtins, because the runtime and the host bridge call them. That is why this reads as a lint rather than an unknown name: the name exists, it is simply not yours to call, and the typechecker will reject the program. A `Harness` capability method also reports here when it is called as a bare global — the method is real, but it is reached through its handle. #### How to fix Call the surface the diagnostic names. For a capability method, thread the handle: ```harn fn summarize(obs: HarnessObs, message: string) { obs.log_info(message) } ``` For `host_call`, there is no single replacement, because it was a generic dispatcher rather than one operation. Almost every target it dispatched to has a declared capability method, so the rewrite is per-namespace: ```harn host_call("ast.outline", {path: p}) // before harness.ast.outline(p) // after ``` When the operation name is a string literal, the diagnostic resolves it for you and names the destination method — including across spelling differences, so `host_call("prmonitor.run_commands", ...)` reports `harness.pr_monitor.run_commands`. A computed operation name cannot be resolved, so those calls get the generic route; look the target up with `harn contracts builtins`. An operation that no capability declares is one your host provides. Reach it through the callable root installed with `register_callable_host_operation`, as `.`. Argument shapes do not carry over, and the diagnostic deliberately does not guess them. `host_call` packed its arguments into one dict keyed by the host's names, while a typed method takes the parameters its own signature declares — a mapping that happens to compile is not necessarily the one you meant. If a script genuinely needs a privileged wire, it has to run as a privileged artifact; calling it from ordinary source will not work regardless of how the call is spelled. ### `HARN-LNT-073` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` parameter carrying a narrow capability handle is not named for that capability - **Repair:** `bindings/name-capability-parameter`  ·  **Safety:** `surface-changing` - Rename the capability parameter and its references after the capability it carries A parameter is typed as a narrow capability handle — `HarnessNet`, `HarnessFs`, `HarnessStdio`, and so on — but is named something else, most often `harness`. The type says the function holds one capability; the name says it holds root authority. Readers and call sites believe the name. ```harn pub fn ack(harness: HarnessNet, url: string) { return harness.http_post(url, {}) } ``` Nothing here is unsound, but `harness.http_post(...)` reads like a root handle with a surprising method on it. The narrowing that `HARN-LNT-069` asks for is only legible once the name carries it too. #### How to fix Name the parameter after the capability's field on `Harness`, which is the same name a call site already uses to produce it: ```harn pub fn ack(net: HarnessNet, url: string) { return net.http_post(url, {}) } fn main(harness: Harness) { ack(harness.net, "https://example.invalid/ack") } ``` Harn arguments are positional, so a parameter rename moves no call site. `harn fix --apply --safety surface-changing` performs it, rewriting the parameter and every reference to it inside the function. This also finishes what `HARN-LNT-069` starts. That repair narrows the type but reuses the existing parameter name, so its output can still read `harness: HarnessNet`; this lint then renames it. #### When the lint stays quiet The rename must be provably safe from the function alone, so the lint reports nothing when: - the capability's name is already bound in the function — as another parameter, or anywhere in the body — because renaming onto it would capture that binding; - a nested function or closure rebinds either name, because its inner references belong to a different binding; - the parameter is a rest parameter, or the type is root `Harness`, which is correctly named `harness`. A dict key that happens to share the parameter's name is a record field, not a reference, so `{harness: harness}` becomes `{harness: net}` and the record's shape is unchanged. ### `HARN-LNT-074` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` explicitly unused private pipeline input can be removed - **Repair:** `bindings/remove-unused-pipeline-input`  ·  **Safety:** `surface-changing` - Remove an explicitly unused test pipeline input #### What it means An underscore-prefixed input on a private, unextended pipeline is unused and has no default, rest, or caller-owned contract. Unattributed host and operational pipelines and bare `@test` declarations qualify. Unattributed `test_*` pipelines and other attributes retain their runner, fixture, and table-owned inputs. #### How to fix Review external host callers, then apply the surface-changing fix explicitly to remove the input. Keep and type a named input when the pipeline uses the value. ### `HARN-LNT-075` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` tool handler returns a freeform dict, so its outcome must be inferred from key names instead of declared by its type A tool handler's return value is what tells the runtime whether the operation succeeded. When that value is a plain dict, nothing in it says so. The runtime has to guess from key names, and every reader of the result has to guess the same way. That guess cannot be finished. A dict carrying a `status` key may be declaring a failure or merely reporting progress, and no set of key names separates the two, because the value carries no type saying which it is. A handler is equally free to return `{failed: true}` or `{error_code: 7}`, which no convention covers, and those read as success. This is not hypothetical. A handler returning `{ok: false}` had its refusal rendered to display text before anything classified it, and every dict-shaped refusal was reported a success until `harn#7884` fixed the reader. #### How to fix Return a typed struct. The type declares the outcome, so no reader has to infer it: ```harn struct ApplyOutcome { ok: bool, message: string, } fn apply_handler(args: dict) -> ApplyOutcome { if args.blocked { return ApplyOutcome{ok: false, message: "the rewrite was refused"} } return ApplyOutcome{ok: true, message: "applied"} } ``` When the handler's result is text the model should read, return the handler result envelope, which renders its `text` verbatim and carries structured data beside it: ```harn fn search_handler(args: dict) -> dict { return { schema: "harn.agent_tool_handler_result.v1", text: "3 matches", data: {matches: 3}, } } ``` #### Severity This reports as a warning while in-tree handlers migrate. It becomes an error once no untyped handler result remains, at which point outcome classification stops being a heuristic over key names. ### `HARN-LNT-076` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` tool handler reaches the privileged host wire `host_call(...)` belongs to the trusted entry boundary selected by an embedding host. A tool handler runs later under model or client control, where that wire is not a stable capability: its bridge can be absent even when the same call worked while assembling the pipeline. Read the host-owned value before registering the handler and pass it through a closure or an explicit typed capability. At runtime, reaching `host_call` from a handler raises an error that names the unavailable operation; it never falls through to a standalone default that can be mistaken for an empty host answer. ### `HARN-LNT-077` **Category:** `LNT` (Lint rules)  ·  **API stability:** `stable` record literal copies fields one by one from a value that `pick` can select - **Repair:** `records/pick-fields`  ·  **Safety:** `behavior-preserving` - Replace the field-by-field record copy with `pick` #### What it means A record literal repeats every field name twice to copy fields from one value: ```harn,ignore const ctx = {env: harness.env, fs: harness.fs, tools: harness.tools} ``` `pick` does the same job in one call and keeps each field's type: ```harn,ignore const ctx = pick(harness, ["env", "fs", "tools"]) ``` The rule fires only when the rewrite can't change behavior: the literal has two or more entries, every entry copies a field of the same name from one value, and each field is one the checker knows is present. That covers the root `Harness`, a parameter or binding with a record type, and a struct value. A dictionary key or an optional field stays as it is, because a missing key gives `nil` in the literal but is left out by `pick`. #### How to fix Replace the literal with `pick` and the field names in the same order: ```harn fn main(harness: Harness) { const ctx = pick(harness, ["env", "fs"]) harness.stdio.println(ctx.fs.exists(ctx.env.get_or("APP_CONFIG", "."))) } ``` `harn lint --fix` and `harn fix --apply` make this change automatically. ### `HARN-FMT-001` **Category:** `FMT` (Formatter)  ·  **API stability:** `stable` formatter could not parse the source #### What it means The formatter could not produce a canonical layout for this source — either because parsing failed first, or because the existing layout drifts from the canonical form. #### How to fix - Run `harn fmt` to bring the file to canonical form, or fix the parse error blocking the formatter. ### `HARN-FMT-002` **Category:** `FMT` (Formatter)  ·  **API stability:** `stable` source is not in canonical format - **Repair:** `format/reformat`  ·  **Safety:** `format-only` - Apply canonical formatting #### What it means The formatter could not produce a canonical layout for this source — either because parsing failed first, or because the existing layout drifts from the canonical form. #### How to fix - Run `harn fmt` to bring the file to canonical form, or fix the parse error blocking the formatter. ### `HARN-FMT-003` **Category:** `FMT` (Formatter)  ·  **API stability:** `stable` formatter normalized trailing comma layout - **Repair:** `format/reformat`  ·  **Safety:** `format-only` - Apply canonical formatting #### What it means The formatter could not produce a canonical layout for this source — either because parsing failed first, or because the existing layout drifts from the canonical form. #### How to fix - Run `harn fmt` to bring the file to canonical form, or fix the parse error blocking the formatter. ### `HARN-IMP-001` **Category:** `IMP` (Import resolution)  ·  **API stability:** `stable` import target cannot be resolved - **Repair:** `imports/fix-path`  ·  **Safety:** `scope-local` - Replace the import path with a resolvable target - **See also:** [`HARN-MOD-001`](#harn-mod-001), [`HARN-IMP-002`](#harn-imp-002) #### What it means Import resolution failed at a deeper layer than `MOD` — the file, symbol, or module graph cannot be constructed. Compilation cannot proceed. #### How to fix - Add the missing module or symbol, or update the import path. - Break import cycles by extracting the shared definitions into a third module. ### `HARN-IMP-002` **Category:** `IMP` (Import resolution)  ·  **API stability:** `stable` imported symbol does not exist #### What it means A selective import named a symbol the target module does not make available. Two cases trigger this: - The symbol genuinely does not exist in the target module, or import resolution failed at a deeper layer than `MOD` (the file or module graph cannot be constructed). - The symbol **exists but is not exported**: it is a non-`pub` function in a module that marks something else `pub`. A module that marks at least one function `pub` opts into explicit exports, so its non-`pub` functions are private and cannot be imported by name — the same rule wildcard imports already follow, and the same rule TypeScript, Rust, and Go enforce. #### How to fix - Add the missing module or symbol, or update the import path. - If the symbol is meant to be part of the module's API, mark it `pub`. - To exercise a private helper from a test, co-locate the test (a `pipeline` or `fn` in the same file sees module-private functions directly), rather than importing the private name. - Break import cycles by extracting the shared definitions into a third module. ### `HARN-IMP-003` **Category:** `IMP` (Import resolution)  ·  **API stability:** `stable` import graph contains a cycle - **See also:** [`HARN-IMP-001`](#harn-imp-001) #### What it means Import resolution failed at a deeper layer than `MOD` — the file, symbol, or module graph cannot be constructed. Compilation cannot proceed. #### How to fix - Add the missing module or symbol, or update the import path. - Break import cycles by extracting the shared definitions into a third module. ### `HARN-OWN-001` **Category:** `OWN` (Ownership and mutability)  ·  **API stability:** `stable` immutable binding is reassigned - **Repair:** `bindings/make-mutable`  ·  **Safety:** `scope-local` - Declare the binding with `let` so it can be reassigned - **See also:** [`HARN-OWN-002`](#harn-own-002) #### How to fix - Declare the binding with `let` (mutable) instead of `const` (immutable) if it really needs to be reassigned. - Restructure so owned values do not escape their scope. ### `HARN-OWN-002` **Category:** `OWN` (Ownership and mutability)  ·  **API stability:** `stable` mutable binding is never reassigned - **Repair:** `bindings/make-immutable`  ·  **Safety:** `behavior-preserving` - Declare the never-reassigned binding with `const` instead of `let` - **See also:** [`HARN-LNT-018`](#harn-lnt-018) #### How to fix - Declare the binding with `const` (immutable) instead of `let` (mutable) since it is never reassigned. - Restructure so owned values do not escape their scope. ### `HARN-OWN-003` **Category:** `OWN` (Ownership and mutability)  ·  **API stability:** `stable` owned value escapes its valid scope #### What it means A binding annotated with `owned` carries sole ownership of a drop-able resource (a file, channel, MCP session, transcript writer, sync permit). The compiler emits an implicit `defer { drop(x) }` at the binding's enclosing block so the resource closes deterministically when control leaves the scope. Returning the binding by name — or otherwise transferring it out of the scope without declaring the transfer in the type — defeats that contract: the auto- drop never fires, and the resource leaks until the runtime garbage-collects the handle (which, for OS resources, may be never). ```harn fn open_log() -> channel { const ch: owned = channel("log", 64) return ch // HARN-OWN-003: `ch` escapes; auto-drop is bypassed } ``` #### How to fix - **Transfer ownership explicitly** by declaring the function's return type as `owned`. The caller then receives an owned binding and is responsible for dropping it (or transferring it on again): ```harn fn open_log() -> owned { const ch: owned = channel("log", 64) return ch // OK — ownership flows to the caller } ``` - **Confine the value to a narrower scope** with `block { ... }`. Owned values declared inside the block drop before the enclosing function continues: ```harn fn write_log(msg: string) -> nil { block { const ch: owned = channel("log", 64) send(ch, msg) } // `ch` drops here, before `write_log` continues nil } ``` - **Drop the value explicitly** with `drop(x)` if you need to release earlier than the enclosing block would close it. ### `HARN-OWN-004` **Category:** `OWN` (Ownership and mutability)  ·  **API stability:** `stable` unvalidated boundary value is used directly #### How to fix - Validate values returned by boundary APIs such as `json_parse`, `llm_call`, and `llm_completion` before accessing fields or indexes. - Prefer a typed result schema at the call site when the boundary supports it, or pass the value through `schema_expect()` / guard it with `schema_is()` before property or subscript access. - A type annotation on the binding also validates: since harn#6252 a declared type is checked where it is written, exactly as a declared parameter type is checked where it is passed. `const doc: {name: string} = json_parse(text)` rejects a payload whose `name` is not a string. Constructing a struct with annotated fields (harn#6268) checks those fields the same way. - Choose between them by the report you want on failure. A binding assertion names the binding and the declared type; `schema_expect()` names the field that failed and why. For a payload from outside the program, the second is usually worth the extra line. ### `HARN-RCV-001` **Category:** `RCV` (Error recovery)  ·  **API stability:** `stable` rescue construct is outside a function body - **Repair:** `errors/wrap-in-fn`  ·  **Safety:** `surface-changing` - Move the construct inside a function body - **See also:** [`HARN-RCV-002`](#harn-rcv-002), [`HARN-RCV-003`](#harn-rcv-003) #### What it means A recovery construct (`try`, `rescue`) is in an invalid position or is shaped in a way Harn's structured-error handling does not support. #### How to fix - Move the construct inside a function body, or wrap it in one. - Use `try` / `rescue` only around expressions that can produce structured errors. ### `HARN-RCV-002` **Category:** `RCV` (Error recovery)  ·  **API stability:** `stable` try construct is outside a function body - **Repair:** `errors/wrap-in-fn`  ·  **Safety:** `surface-changing` - Move the construct inside a function body - **See also:** [`HARN-RCV-001`](#harn-rcv-001) #### What it means A recovery construct (`try`, `rescue`) is in an invalid position or is shaped in a way Harn's structured-error handling does not support. #### How to fix - Move the construct inside a function body, or wrap it in one. - Use `try` / `rescue` only around expressions that can produce structured errors. ### `HARN-RCV-003` **Category:** `RCV` (Error recovery)  ·  **API stability:** `stable` rescue construct is invalid #### What it means A recovery construct (`try`, `rescue`) is in an invalid position or is shaped in a way Harn's structured-error handling does not support. #### How to fix - Move the construct inside a function body, or wrap it in one. - Use `try` / `rescue` only around expressions that can produce structured errors. ### `HARN-MAT-001` **Category:** `MAT` (Match exhaustiveness)  ·  **API stability:** `stable` match expression is not exhaustive - **Repair:** `match/add-missing-arms`  ·  **Safety:** `scope-local` - Add arms covering the missing variants - **See also:** [`HARN-MAT-003`](#harn-mat-003), [`HARN-MAT-002`](#harn-mat-002) #### What it means A `match` expression is incomplete, ambiguous, or otherwise invalid. Harn enforces exhaustive matching to keep agent decision logic auditable. #### How to fix - Add arms covering the remaining variants, or use a wildcard `_` arm as the explicit fallback. - Remove duplicate arms — each variant should appear at most once. ### `HARN-MAT-002` **Category:** `MAT` (Match exhaustiveness)  ·  **API stability:** `stable` match expression contains a duplicate arm - **Repair:** `match/remove-duplicate-arm`  ·  **Safety:** `behavior-preserving` - Remove the duplicated match arm - **See also:** [`HARN-MAT-001`](#harn-mat-001), [`HARN-LNT-037`](#harn-lnt-037) #### What it means A `match` expression is incomplete, ambiguous, or otherwise invalid. Harn enforces exhaustive matching to keep agent decision logic auditable. #### How to fix - Add arms covering the remaining variants, or use a wildcard `_` arm as the explicit fallback. - Remove duplicate arms — each variant should appear at most once. ### `HARN-MAT-003` **Category:** `MAT` (Match exhaustiveness)  ·  **API stability:** `stable` match pattern is invalid #### What it means A `match` expression is incomplete, ambiguous, or otherwise invalid. Harn enforces exhaustive matching to keep agent decision logic auditable. #### How to fix - Add arms covering the remaining variants, or use a wildcard `_` arm as the explicit fallback. - Remove duplicate arms — each variant should appear at most once. ### `HARN-POL-001` **Category:** `POL` (Runtime policies)  ·  **API stability:** `stable` pool backpressure rejected a submit `pool.submit(...)` attempted to enqueue work into a bounded pool whose `backpressure` policy was already full and configured with `on_full: "fail_submitter"`. Increase the queue depth, choose a different `on_full` policy, wait for existing task handles to drain, or catch the error and retry from your own orchestration policy. ### `HARN-POL-002` **Category:** `POL` (Runtime policies)  ·  **API stability:** `stable` fail-fast pool has no immediate capacity `pool.submit(...)` targeted a pool configured with `Backpressure().fail_fast`, but all worker slots were already occupied. Fail-fast pools do not queue work. Catch the error if rejection is expected, raise `max_concurrent`, or use `Backpressure().queue(...)` when callers should wait, drop, or retain a bounded queue instead. ### `HARN-MET-001` **Category:** `MET` (Compile-time meta restrictions)  ·  **API stability:** `stable` expression is not permitted in a const initializer #### What it means A `const` binding's right-hand side must be a pure expression that can be folded at compile time by the bounded const-evaluator. The expression referenced something the evaluator does not permit: a call into a non-const function, a property access on a host object such as `harness`, a runtime construct (spawn / parallel / select / try / yield / emit / await), a loop, an assignment, or any builtin outside the curated const-friendly allowlist. Rejected: ```harn,ignore const Z = harness.clock.now() // host capability const W = spawn { 1 } // runtime construct const Q = some_user_fn() // user function call ``` Accepted: ```harn const X: int = 5 + 3 const Y: string = format("hello-{}", X) const NS: list = [1, 2, X] const COUNT: int = len([1, 2, 3]) // reads to silence the unused-variable lint in the example const _ = [X, Y, NS, COUNT] ``` #### How to fix - Move the side-effecting computation into a regular `let` binding inside a pipeline or function body. - If the value really is a compile-time constant, restructure it as arithmetic, string concatenation, literal collections, or reads of earlier `const` bindings. #### Stability Adding pure builtins to the const-eval allowlist is backwards-compatible — newly permitted expressions stop being rejected. ### `HARN-CST-001` **Category:** `CST` (Const-eval sandbox)  ·  **API stability:** `stable` const initializer exceeded the step budget #### What it means The bounded compile-time evaluator counts every reduction step it performs while folding a `const` initializer. When the running count exceeds `MAX_STEPS` (default 100,000), evaluation is aborted with this diagnostic. The cap is enforced on every step, not amortized, so a hostile or accidental quadratic expression cannot stall the compiler. ```harn,ignore // Rejected (would expand far beyond the step budget): const HUGE = sum_to(1000000) ``` #### How to fix - Pre-compute the value off-line and embed a literal. - Reduce the expression to a smaller closed form. - If the work genuinely belongs at runtime, switch from `const` to `let`. #### Stability The default budget may grow over time; tightening it would require a deprecation cycle. ### `HARN-CST-002` **Category:** `CST` (Const-eval sandbox)  ·  **API stability:** `stable` const initializer exceeded the recursion depth budget #### What it means The compile-time evaluator tracks how deeply it has recursed into nested expressions or conditionals. Exceeding `MAX_DEPTH` (default 256) aborts evaluation with this diagnostic. The cap protects the compiler thread's stack and prevents pathological deeply-nested literals from creating an unbounded stack frame chain. #### How to fix - Flatten deeply-nested literals (e.g. a 1,000-level nested ternary). - Break the constant into intermediate `const` bindings. #### Stability The default budget may grow over time; tightening it would require a deprecation cycle. ### `HARN-CST-003` **Category:** `CST` (Const-eval sandbox)  ·  **API stability:** `stable` const initializer attempted a sandboxed capability #### What it means The compile-time evaluator denies any expression that would touch the filesystem, network, environment, the current process, host bindings (`harness.*`), the orchestration runtime, or any other ambient side effect. Even a syntactically reachable call into one of those surfaces is rejected before evaluation runs — the const-eval sandbox refuses to mediate I/O or non-determinism. ```harn,ignore // Rejected: const X = harness.fs.read_text("/etc/passwd") const Y = harness.env.get("HOME") const Z = spawn { ... } const W = harness.clock.now() ``` #### How to fix - Move the impure expression into a `let` binding inside a pipeline. - Replace I/O with a literal value or with a pure transformation of already-folded constants. #### Stability The denylist is enforced by allowlist (only listed pure builtins are accepted), so newly added stdlib functions stay sandboxed by default. ### `HARN-CST-004` **Category:** `CST` (Const-eval sandbox)  ·  **API stability:** `stable` const initializer raised a runtime error during evaluation #### What it means The expression was syntactically eligible for compile-time evaluation, but the evaluator hit a value-level error during folding: integer overflow on a literal arithmetic, division by zero, indexing past the end of a literal list, an undefined identifier the const-eval environment cannot resolve, or a type mismatch on a binary operator. ```harn,ignore // Rejected at compile time: const ZERO = 1 / 0 const OOB = [1, 2, 3][9] const BAD = "a" + 1 ``` #### How to fix - Inspect the offending operand and supply a valid value. - If the expression depends on a value that is only known at runtime, use `let` instead of `const`. ### `HARN-CMP-001` **Category:** `CMP` (Bytecode compilation)  ·  **API stability:** `stable` the program failed to compile to bytecode The program parsed and type-checked, but the bytecode compiler rejected it. These are structural or codegen errors the type checker does not model yet — for example an unsupported nested list/dict pattern in a `match` arm, a `break` or `continue` outside a loop, `try*` outside a function, or a malformed string interpolation hole. `harn check` runs this compilation pass (discarding the bytecode) so that any error which would stop `harn run` is reported up front, rather than only when the program is executed. #### How to fix - Read the message: it names the specific construct the compiler could not lower and usually how to rewrite it. - For nested `match` patterns, bind the element with an identifier and match it in a nested `match`. - For `break`/`continue`, ensure they appear inside a loop. --- ## Read next - [Error handling](https://harnlang.com/error-handling.md) - [Reading shape diagnostics](https://harnlang.com/reading-shape-diagnostics.md) --- # Reading shape diagnostics > Harn's typechecker and runtime aim to fail loudly at the closest point to the bug. Most authoring mistakes around shapes, structs, schemas, and nilable values surface as one of... Website: https://harnlang.com/reading-shape-diagnostics.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's typechecker and runtime aim to fail loudly at the closest point to the bug. Most authoring mistakes around shapes, structs, schemas, and nilable values surface as one of the diagnostic shapes documented here. > Looking for the full list of `HARN--` codes, their repair ids, > and safety classes? See the generated [diagnostic codes > catalog](./diagnostics.md) (or the JSON sidecar at > [`docs/diagnostics-catalog.json`](https://github.com/burin-labs/harn/blob/main/docs/diagnostics-catalog.json)). > This page stays focused on the most common shape and nilable patterns. When you hit one of these, the message tells you the *contract* (what the type declares), the *observed value* (what arrived), and the *fix* (`?.`, `assert_shape`, `if x != nil`, etc.) directly. You should rarely need to dig into the source to interpret one. ## Missing field on a shape annotation ```harn,ignore type User = {name: string, email: string, age: int} const u: User = {name: "Ada", email: "ada@x", age: 36} harness.stdio.log(u.emial) ``` ```text error: field `emial` does not exist on shape `{name: string, email: string, age: int}` — did you mean `email`? | 5 | harness.stdio.log(u.emial) = help: available fields: name, email, age ``` The diagnostic includes: * **The expected shape** — useful when the alias resolves to an inline shape. * **A `did you mean` suggestion** when a field name is one or two edits away. * **The full set of available fields** so you don't have to chase the type alias back to its definition. The same diagnostic fires for struct fields: ```text error: field `emial` does not exist on struct `User` — did you mean `email`? = help: available fields: name, email ``` ## Property access on a nil value A value whose static type is exactly `nil` cannot have fields read off it. The error explicitly tells you the type is nil and points at the two canonical fixes — optional access and the nil-guard: ```text error: cannot access property `foo` on `nil`; the value is statically known to be nil here = help: use the optional access operator `?.foo`, or narrow the value with a `!= nil` guard before reading fields ``` ## Property access on a nilable type A `T?` (i.e. `T | nil`) value may be nil at runtime. Direct field access produces the same fix-suggestion as the nil case: ```text error: cannot access property `name` on nilable type `{name: string, email: string}?`; the value may be nil at runtime = help: use the optional access operator `?.name`, or narrow the value with a `!= nil` guard to drop the nil arm ``` The canonical guard pattern, which lets the typechecker narrow the inner shape inside the `if` body, is: ```harn const data = r.data // r.data: T | nil if data != nil { harness.stdio.log(data.name) // data: T here } ``` ## Property access on an `unknown` value `unknown` is the safe top type — its whole point is to force a narrowing step before fields can be read. The diagnostic surfaces as a warning so gradual code keeps running while still telling the author what to do: ```text warning: property access `.verdict` on an `unknown` value will fail at runtime if the value is not a shape with that field = help: narrow with `is_a`/`type_of`, validate with `assert_shape`, or annotate with a shape type before accessing fields ``` The `schema_is(value, Shape)` form participates in flow narrowing — inside the truthy branch, `value` narrows to `Shape` and field access is strict-checked against it. See [Schema as type](./migrations/schema-as-type.md). ## Loose dict literals stay lenient `let d = {a: 1, b: 2}` is inferred as a structural shape, but historically this idiom also covers "I want a dynamic dict." Harn keeps it lenient: a missing field on a literal-inferred shape returns nil at runtime instead of erroring. To opt into strict checking, annotate the binding or thread it through a typed function parameter: ```harn // Lenient — d.missing returns nil const d = {a: 1, b: 2} // Strict — d.missing is a typecheck error type Counts = {a: int, b: int} const d: Counts = {a: 1, b: 2} ``` The same opt-in applies to `let x = nil` widening loops; annotating with `let x: T? = nil` enables the strict diagnostics. Use the unannotated form for genuinely dynamic loops, and the annotated form when you want the typechecker to enforce the invariant. ## Stdlib helper option errors Stdlib collection helpers such as `pick_keys`, `filter_nil`, and `omit` declare typed signatures (`dict`, typed option shapes like `PickKeysOptions = {drop_nil?: bool}`). The typechecker catches misuse statically against the declared contract: ```text error: function `pick_keys` parameter `d`: expected dict, found string ``` Every closed-record parameter rejects unknown keys in a direct record literal. The record type owns this rule, so it applies to user functions and builtins without relying on names such as `options` or `config`: ```text error: argument 3 `options`: unknown field `dropnil` in closed record; expected one of `drop_nil` — did you mean `drop_nil`? ``` An existing inferred value may carry extra fields because the callee can read only the fields in its contract. Use an open record when a direct literal may also contain fields that the callee does not name. See [Open records in the language specification](./spec/language/19-type-annotations.md#open-records). When a value flows in dynamically (e.g. via `unknown` or a boundary source), the runtime parameter guard catches it with the parameter name intact: ```text Type error: parameter 'd': expected dict or struct, got string ``` Either way the failure points at the helper boundary, not several frames down inside the helper body. ## Runtime shape validation A runtime `assert_shape` (used by typed-parameter checks at `fn` and `pipeline` boundaries) reports the missing field, the closest match, and the keys actually present: ```text Type error: parameter 'u': missing field 'age' (int) — available fields: name, email, agee ``` The "available fields: …" tail is especially useful when an external boundary (LLM output, JSON parse, host bridge call) silently returned a near-miss of the expected shape — reading the actual keys often diagnoses the bug without rerunning under a debugger. The wording matches the static typechecker so authors see the same phrasing whether the failure surfaces at `harn check` time or runtime shape validation. --- ## Read next - [Diagnostic codes catalog](https://harnlang.com/diagnostics.md) - [Pick fields from a record](https://harnlang.com/pick.md) --- # Pick fields from a record > pick(source, keys) builds a new record from the fields you name. It's a global builtin, so you don't import it. Website: https://harnlang.com/pick.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. --- `pick(source, keys)` builds a new record from the fields you name. It's a global builtin, so you don't import it. ```harn type Context = {env: HarnessEnv, fs: HarnessFs} fn config_exists(ctx: Context) -> bool { const path = ctx.env.get_or("APP_CONFIG", "harn.toml") return ctx.fs.exists(path) } fn main(harness: Harness) { const ctx = pick(harness, ["env", "fs"]) harness.stdio.println(config_exists(ctx)) } ``` `ctx` has the type `{env: HarnessEnv, fs: HarnessFs}`. Each handle keeps its grants, so `config_exists` can call methods on it. ## Arguments | Argument | Accepts | |---|---| | `source` | A record, a dictionary, a struct value, or the root `Harness` | | `keys` | A list of strings. Each string names one top-level field | ## What pick returns at runtime - Every picked value is kept as-is, including `nil`, `false`, `0`, and `""`. - A key the source doesn't have is skipped. - A repeated key produces one field. - An empty list returns `{}`. - Keys don't reach into nested records. `"a.b"` looks for a field named `a.b`. - The source isn't changed. Editing a nested record in the result leaves the source alone. Capability handles stay shared with the source. - Any other kind of source, or a key that isn't a string, is a runtime error. ```harn fn main(harness: Harness) { const source = {name: "Ada", age: 37, absent: nil} const picked = pick(source, ["name", "absent", "name"]) assert_eq(picked, {name: "Ada", absent: nil}) assert_eq(pick(source, []), {}) } ``` ## What the checker knows about the result The result has the type of the fields you picked. What the checker can promise depends on how you write `keys`. | `keys` | Result type | |---|---| | A list literal such as `["name", "age"]` | Those fields, with their original types. Optional fields stay optional | | A `const` that holds a list literal, or an alias of that `const` | The same as the literal | | A `const` string inside a list literal | The field that string names | | A list only known at runtime | Every field it could name, all optional | A runtime list can be empty, so the checker can't promise that any field is present. When the list has the type `list<"name" | "age">`, only those two fields appear in the result, and both are optional. ```harn type Person = {name: string, age?: int} type Partial = {name?: string, age?: int} fn some_fields(person: Person, keys: list) -> Partial { return pick(person, keys) } fn main(harness: Harness) { const fields = ["name"] const result: {name: string} = pick({name: "Ada", age: 37}, fields) assert_eq(result.name, "Ada") } ``` The checker reports two mistakes. A literal key that the source type doesn't have: ```text pick: unknown field `fss` in `Harness` ``` And reading a field you didn't pick, such as `ctx.tools` after `pick(harness, ["env", "fs"])`: ```text field `tools` does not exist on shape `{fs: HarnessFs, env: HarnessEnv}` ``` ### Source types | Source type | Result | |---|---| | A record such as `{name: string, age?: int}` | The picked fields, with their types | | A dictionary such as `dict` | Optional fields of type `int`, because the dictionary may not hold them | | An open record such as `{name: string, ...dict}` | Declared fields keep their types. Other keys are optional `int` | | A type alias or a generic struct | Resolved first, then picked like a record | | A union such as `{kind: "text", value: string} \| {kind: "number", value: int}` | Picked branch by branch, so `kind` and `value` stay linked. A literal key must exist in every branch | | An intersection such as `{name: string} & {age: int}` | The combined fields, then picked like a record | A function or a local value named `pick` shadows the builtin. Passing `pick` as a value instead of calling it gives the return type `dict`. ## Pipe form Use `_` for the source: ```harn fn main(harness: Harness) { const ctx = harness |> pick(_, ["env", "fs"]) assert_eq(type_of(ctx.fs), type_of(harness.fs)) } ``` ## Automatic repairs The `prefer-pick` lint (`HARN-LNT-077`) recognizes copies of two or more fields from the same source when all fields are known to exist. `harn lint --fix` or `harn fix --apply --code HARN-LNT-077` replaces those copies with `pick`. Optional fields and literals with comments stay unchanged. ## Related helpers - [`pick_keys(data, keys, {drop_nil: true})`](modules.md#stdcollections) picks and then drops `nil` values. It returns a dictionary. - [`omit` and `merge`](modules.md#stdjson) remove or combine fields. - `std/json.pick` is gone. See [Migrating to 0.10](migrations/v0.10.md#stdjsonpick-is-now-the-global-pick). --- ## Read next - [Reading shape diagnostics](https://harnlang.com/reading-shape-diagnostics.md) - [Modules and imports](https://harnlang.com/modules.md) --- # Modules and imports > Harn supports splitting code across files using import and top-level fn declarations. Website: https://harnlang.com/modules.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 supports splitting code across files using `import` and top-level `fn` declarations. ## Importing files ```harn,ignore import "lib/helpers.harn" ``` The extension is optional — these are equivalent: ```harn,ignore import "lib/helpers.harn" import "lib/helpers" ``` Import paths are resolved relative to the current file's directory. If `main.harn` imports `"lib/helpers"`, it looks for `lib/helpers.harn` next to `main.harn`. ## Writing a library file Library files contain top-level `fn` declarations: ```harn // lib/math.harn fn double(x) { return x * 2 } fn clamp(value, low, high) { if value < low { return low } if value > high { return high } return value } ``` When imported, these functions become available in the importing file's scope. ## Using imported functions ```harn,ignore import "lib/math" pipeline default(harness: Harness, task) { harness.stdio.log(double(21)) // 42 harness.stdio.log(clamp(150, 0, 100)) // 100 } ``` ## Importing pipelines Imported files can also contain pipelines, which are registered globally by name: ```harn // lib/analysis.harn pipeline analyze(harness: Harness, task) { harness.stdio.log("Analyzing: ${task}") } ``` ```harn,ignore import "lib/analysis" pipeline default(harness: Harness, task) { // the "analyze" pipeline is now registered and available } ``` ## What needs an import Most Harn builtins — `println`, `log`, `read_file`, `write_file`, `harness.llm.call`, `agent_loop`, `http_get`, `parallel`, `workflow_*`, `transcript_*`, `mcp_*`, and the rest of the runtime surface — are registered globally and require **no import statement**. You can call them directly from top-level code or inside any pipeline. `import "std/..."` is only needed for the Harn-written helper modules described below (`std/text`, `std/json`, `std/math`, `std/collections`, `std/changelog`, `std/ansi`, `std/table`, `std/diff`, `std/path`, `std/fs`, `std/os`, `std/slug`, `std/edit`, `std/identity`, `std/disclosure`, `std/artifact/web`, `std/ui_resource`, `std/cache`, `std/llm/envelope`, `std/llm/handlers`, `std/llm/budget`, `std/llm/prompts`, `std/vision`, `std/context`, `std/agent_state`, `std/agents`, `std/agent/user`, `std/agent/fact`, `std/agent/probe`, `std/agent/scratchpad`, `std/runtime`, `std/command`, `std/gha`, `std/tui`, `std/git`, `std/review`, `std/experiments`, `std/project`, `std/memory`, `std/prompt_library`, `std/monitors`, `std/postgres/query`, `std/sqlite`, `std/net_policy`, `std/oauth/providers`, `std/triage`, `std/worktree`, `std/checkpoint`, `std/personas/prelude`, `std/personas/bulletins`, `std/connectors/http`, `std/connectors/shared`, and provider-specific `std/connectors/...` modules). These add layered utilities on top of the core builtins; the core builtins themselves are always available. ## Standard library modules Harn includes built-in modules that are compiled into the interpreter. Import them with the `std/` prefix: ```harn,ignore import "std/agent_state" import "std/agents" import "std/agent/user" import { retry_predicate_with_backoff } from "std/async" import "std/cache" import "std/changelog" import "std/collections" import "std/connectors/http" import "std/connectors/shared" import "std/context" import "std/disclosure" import "std/edit" import "std/identity" import "std/artifact/web" import "std/ui_resource" import "std/experiments" import "std/git" import "std/json" import "std/llm/budget" import "std/llm/envelope" import "std/llm/prompts" import "std/math" import "std/monitors" import "std/net_policy" import "std/path" import "std/personas/bulletins" import "std/personas/prelude" import "std/prompt_library" import "std/review" import "std/slug" import "std/text" import "std/triage" import "std/tui" import "std/vision" ``` `std/oauth/providers` exports a `custom` helper, which also exists in `std/experiments`. Use selective imports when combining those modules: ```harn import { provider_catalog } from "std/oauth/providers" ``` ### std/changelog Pure typed changelog transformations for release harnesses: | Function | Description | |---|---| | `changelog_parse_fragment(filename, body, categories)` | Validate and parse one `..md` fragment | | `changelog_order_fragments(fragments, categories)` | Order fragments by category, natural ID, and filename without integer overflow | | `changelog_assemble_fragments(fragments, categories)` | Normalize fragment bodies and render deterministic `###` category sections | | `changelog_parse_sections(text)` | Parse exact `##` sections outside fenced code blocks with UTF-8 byte offsets | | `changelog_find_section(text, heading)` | Return one named section or a typed missing/duplicate-heading failure | | `changelog_merge_unreleased(text, assembled, categories)` | Merge assembled content into `## Unreleased` while preserving authored content and newline style | The module performs no filesystem, Git, process, versioning, or publication work. Callers retain those policies and pass their category definitions explicitly. ### std/async Polling and retry helpers for closure-shaped conditions: | Function | Description | |---|---| | `wait_for(timeout_ms, interval_ms, predicate)` | Poll `predicate()` until it returns a truthy value or the timeout expires | | `retry_until(max_attempts, predicate)` | Retry `predicate()` without delay until it returns a truthy value or attempts are exhausted | | `retry_predicate_with_backoff(max_attempts, base_ms, predicate)` | Retry `predicate()` with exponential backoff between attempts | | `circuit_call(name, closure)` | Run `closure()` only while the named circuit breaker allows calls, recording success or failure | ### std/abort Cooperative abort across concurrent branches: run everything and record every outcome like `parallel settle`, but let a branch that reaches a doomed verdict stop the siblings that have not started. See [Cooperative abort](./concurrency.md#cooperative-abort-stdabort) for the full semantics and its limits — it is cooperative, so a branch blocked inside one long call is not interrupted. | Function | Description | |---|---| | `abort_token(options?)` | Mint a cooperative-abort token that crosses into child tasks as a plain value | | `abort_requested(token)` | Whether an abort has been requested — call at your branch's own safe checkpoints | | `abort_reason(token)` | The `AbortReason` recorded when the token was tripped, else `nil` | | `request_abort(token, reason)` | Trip the token, first writer wins; `true` when this call recorded the reason | | `settle_with_abort(items, body, options?)` | Settle over `items` while honouring the token, with optional `max_failures` / `abort_on` policies | | `decisive_error(outcome)` | The first failure that was not an abandonment — the cause to report | ### std/connectors/shared Connector package helpers for common provider plumbing: | Function | Description | |---|---| | `verify_hmac_signature(body, signature, secret, algorithm?, options?)` | Constant-time check for bare or `sha256=` signatures; legacy `sha1=` requires `options.allow_legacy_sha1` | | `verify_jwt(token, jwks_url, options?)` | Verify a compact JWT against a JWKS URL, or `options.inline_jwks`, returning `{ok, claims, error}` | | `oauth2_token_refresh(client_id, client_secret, refresh_token, token_url, options?)` | Refresh an OAuth2 access token with form-encoded `grant_type=refresh_token` | | `rate_limit_token_bucket(state?, config?, now_ms?)` | Pure token-bucket transition for package-local quota decisions | | `paginate_cursor(initial_url, fetch_fn, cursor_path, options?)` | Collect cursor-paginated pages from a package-supplied fetch closure | | `connector_lifecycle(provider, state_key, reset_keys?)` | Bind `state`, `init`, `activate`, and `shutdown` operations to provider-owned runtime keys; each takes `HarnessRuntime`, with context or bindings as its second argument. Duplicate binding paths fail before state changes. | The four `connector_http_*` helpers remain compatibility re-exports from this module. New code should import their single implementation from `std/connectors/http`. ### std/connectors/http HTTP transport policy for connector packages: | Function | Description | |---|---| | `connector_http_request(clock, net, method, url, options?)` | Capability-attenuated, non-throwing request wrapper with normalized retry, idempotency, error categories, and bounded failure envelopes | | `connector_http_json(clock, net, method, url, options?)` | `connector_http_request` plus response JSON parsing; invalid JSON returns `error.category == "invalid_json"` | | `connector_http_header(headers_or_response, name)` | Case-insensitive header lookup for response envelopes or raw header dicts | | `connector_http_rate_limit(clock, headers_or_response)` | Extract `Retry-After`, `RateLimit-*`, and `X-RateLimit-*` metadata, including `retry_after_ms` when parseable | ### std/oauth/providers Static OAuth provider records and factory helpers for auth orchestration: | Function | Description | |---|---| | `provider_names()` | Return the ten named provider keys | | `provider(name, overrides?)` | Return one provider record with optional endpoint/scope overrides | | `provider_catalog(overrides?)` | Return all ten provider records, with optional per-provider overrides | | `providers(overrides?)` | Return a namespace containing the ten records plus `github_enterprise` and `custom` factories | | `github_enterprise(base_url, overrides?)` | Build a GitHub Enterprise Server record from an instance web base URL | | `custom(config, overrides?)` | Build a provider record for enterprise or niche OAuth providers | ### std/triage Normalize connector-derived inbox items into host-renderable dashboard cards: | Function | Description | |---|---| | `triage_normalize(input, options?)` | Convert a TriggerEvent or provider payload into `harn.triage_event.v1`, preserving provider raw payload separately | | `triage_dedupe_key(provider, source_kind, source_url, source_id?)` | Build a stable dedupe key from provider-neutral source provenance | | `triage_dedupe_events(events)` | Drop duplicate triage events by stable dedupe key | | `triage_emit(input, options?)` | Validate and append a triage event to the EventLog, returning an emit receipt | | `triage_start_my_day(inputs, options?)` | Build a deduped Start My Day feed and optionally emit each event | ### std/monitors Monitor waits for external state with deterministic replay records: | Function | Description | |---|---| | `wait_for(options)` | Poll a source until `condition(state)` is truthy or timeout expires; push-capable sources can wake early from trigger inbox events | See [Monitor stdlib](./stdlib/monitors.md) for the source shape and result record. ### std/text Text processing utilities for LLM output and code analysis: | Function | Description | |---|---| | `int_to_string(value)` | Convert an integer-compatible value to a decimal string | | `float_to_string(value)` | Convert a float-compatible value to a string | | `parse_int_or(value, fallback)` | Parse an integer, returning `fallback` on failure | | `parse_float_or(value, fallback)` | Parse a float, returning `fallback` on failure | | `extract_paths(text)` | Extract file paths from text, filtering comments and validating extensions | | `parse_cells(response)` | Parse fenced code blocks from LLM output. Returns `[{type, lang, code}]` | | `filter_test_cells(cells, target_file?)` | Filter cells to keep code blocks and write_file calls | | `truncate_head_tail(text, n)` | Keep first/last n lines with omission marker | | `truncate_text(text, max_chars?, marker?)` | Keep the first `max_chars` characters and append a deterministic truncation marker | | `truncate_middle(text, max_chars?, marker?)` | Keep both ends of a long string with an omission marker in the middle | | `single_line_or(value, fallback?)` | Collapse whitespace into one line, returning `fallback` for blank input | | `prefix_lines(text, prefix?)` | Prefix every line in a text block | | `indent(text, spaces?)` | Prefix every line with a fixed number of spaces | | `detect_compile_error(output)` | Check for compile error patterns (SyntaxError, etc.) | | `has_got_want(output)` | Check for got/want test failure patterns | | `format_test_errors(output)` | Extract error-relevant lines (max 20) | These helpers are intentionally small because they sit under higher-level reporting modules. For example, harnesses can normalize untrusted command output before placing it into prompt context: ```harn import { single_line_or, truncate_middle } from "std/text" const long_output = "..." const label = single_line_or(" cargo\n test\t-p harn-vm ", "command") const summary = truncate_middle(long_output, 2000) ``` ### std/edit Pure helpers for agent-authored text patches: | Function | Description | |---|---| | `edit_apply_node(harness.ast, params)` | AST-precise replace via a Tree-Sitter query. Splices `replacement` in for each matched node, preserving leading indentation and trailing trivia; validates the post-edit source by re-parsing. Routes through staged-fs when `session_id` is supplied. See [Edit stdlib](./stdlib/edit.md). | | `edit_insert_at_anchor(harness.ast, params)` | AST-precise insert relative to a unique anchor node. `position` picks `before`/`after`/`first_child`/`last_child`; content is re-indented to the inferred target depth and validated by re-parsing. Routes through staged-fs when `session_id` is supplied. See [Edit stdlib](./stdlib/edit.md). | | `edit_safe_text_patch(harness.fs, harness.random, params)` | Multi-hunk text patch with staged-fs collision rejection: reads the file through the overlay, hash-checks `expected_hash`, applies each `{old_text, new_text}` hunk through the same matcher as `edit_apply_old_new_patch`, and commits all-or-nothing through `harness.fs.safe_text_patch`. Returns `result ∈ {applied, no_op, stale_base, hunk_conflict}` plus per-call telemetry. See [Edit stdlib](./stdlib/edit.md). | | `edit_fast_apply(harness.fs, harness.random, harness.ast, harness.llm, params)` / `fast_apply(path, intent, options?)` | Merge-model-assisted full-file apply. Reads the target file, calls the configured `merge` model role for complete updated bytes, validates and previews through `edit_dry_run`, then commits through hash-guarded `edit_safe_text_patch`. See [Edit stdlib](./stdlib/edit.md#edit_fast_apply--merge-model-assisted-full-file-apply). | | `edit_dry_run(params)` | Render a multi-op edit plan (`apply_node`, `insert_at_anchor`, `safe_text_patch`, `rename_symbol`) as a per-file unified diff bundle without touching disk. Plan ops share a transient staged-fs session, so cumulative edits collapse to one diff per file. See [Edit stdlib](./stdlib/edit.md#edit_dry_run--preview-a-multi-op-plan). | | `edit_apply_old_new_patch(text, old_text, new_text, options?)` | Apply one anchored old/new patch with exact, line-normalized, and structural matching; returns hashes, match kind, line span, changed regions, errors, warnings, and provenance | | `edit_splice_lines(text, start_line, end_line_exclusive, new_text, options?)` | Replace a half-open 0-based line range and return the same patch metadata shape | | `edit_changed_regions(before, after)` | Return deterministic line-level changed-region metadata for one contiguous diff | | `edit_validate_changed_regions(before, after, expected_regions, options?)` | Verify that all changes fall inside expected 0-based line regions | | `edit_strip_line_number_prefixes(text)` | Remove leading `N` line-number prefixes when at least 60% of non-empty lines carry them; useful as preprocessing for `old_text` pasted from a numbered file read | | `edit_explain_whitespace_difference(needle, matched)` | Diagnose the dominant whitespace cause (tabs vs spaces, base indent, blank lines) when a fuzzy match was needed | | `edit_check_lazy_truncation(old_content, new_content, options?)` | Detect whole-file rewrites that shrank a file below `min_keep_pct` (35%) of its original line count while still containing lazy placeholders | Default guardrails reject empty anchors, no-op edits, whitespace-only edits, lazy omission placeholders (including `// TODO: implement`, `// ... rest`, `# ...`, `pass # ...`, `/* ... */`, and "unchanged" / "omitted for brevity" phrases), ambiguous matches, and excessive patch growth. Structural matching is conservative by default: the needle must contain at least three non-blank lines and both the first and last anchor lines must carry a distinctive 4+ character alphanumeric token. Callers can relax this with `structural_require_anchored_lines: "either" | "none"`, `structural_min_nonblank_lines: N`, or `structural_anchor_chars: N`. Pass `strip_line_numbers: true` to apply `edit_strip_line_number_prefixes` to `old_text` before matching. Successful line/structural matches surface a `whitespace_explanation` field describing the dominant difference between the needle and the matched span. ### std/artifact/web Safe helpers for small generated HTML/CSS/JS artifacts: | Function | Description | |---|---| | `web_artifact_extract(html)` | Extract `