AI Agents

A .nornagent file declares the models, agents, tools, and boundaries used by your Norn workflows. The agents live in the sidecar; a .norn sequence owns execution order and calls them like any other step. An agent can be given a JSON Schema contract on each side of its boundary, MCP-backed tools, and other agents it is allowed to call. Agent runs are recorded by default so they can be inspected or replayed without another model call.

The same runtime backs the VS Code extension and the CLI, so an editor run and a CI run behave the same way.

Declaring a Model

A model is declared as a block naming every input it needs. Nothing is implied by convention: apiKey points at a .nornenv variable, so the file says exactly which value it requires and the secret itself stays in the environment.

agents.nornagent (excerpt)
model Workbench
    provider openai          # openai, openai_compatible, anthropic, google, or local
    name gpt-5.1
    reasoning medium         # optional; openai only
    apiKey {{$env.OPENAI_API_KEY}}
    # baseUrl {{$env.OPENAI_BASE_URL}}   # optional
end model

provider and name are required; reasoning, apiKey, and baseUrl are optional. Each value is an ordinary Norn template resolved per run, so a model id can be interpolated exactly as a credential can. provider selects the wire format rather than a vendor.

  • openai uses the official OpenAI Responses API.
  • openai_compatible speaks the Chat Completions protocol against a hosted OpenAI-compatible or unknown endpoint. It requires a baseUrl, from its directive or OPENAI_COMPATIBLE_BASE_URL.
  • anthropic and google use each vendor's native API.
  • local speaks Chat Completions against whatever endpoint you point it at, from its baseUrl directive or LOCAL_BASE_URL.

reasoning sets the model's thinking effort and is accepted only for provider openai. It takes one of none, low, medium, high, xhigh, or max; omit it to keep the model's own default. Anthropic and Google reasoning controls are not yet exposed.

If apiKey is omitted, the provider's own environment variable is used (OPENAI_API_KEY, OPENAI_COMPATIBLE_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY or GEMINI_API_KEY). An unresolved reference stops the run before a request is made, naming the variable — a missing credential never spends a model call.

Declaring Agents

An agent names a model and a system prompt. Give it agents and the callee appears to the calling model as an ordinary tool; give it accepts and returns and both sides of that boundary are validated against JSON Schema.

agents.nornagent (excerpt)
agent DomainExpert
    model Workbench
    describe "Call for domain questions and include the ticket context."
    accepts contracts/domain-question.schema.json
    returns contracts/domain-answer.schema.json
    system "Answer only the supplied domain question."
end agent

agent TicketRouter
    model Workbench
    agents DomainExpert
    system "Consult the domain expert when the ticket needs it."
end agent
  • describe is what the calling model reads when deciding whether to delegate.
  • accepts constrains and validates the tool input the calling model generates.
  • returns validates the result before it travels back up the graph.
  • agents lists the agents this one may call, one at a time.

Malformed sub-agent handoffs are returned to the calling model as field-level tool errors so it can correct them up to the configured contract-retry limit.

Giving an Agent Hands

An agent reaches the outside world through MCP servers, declared in the same file the same way — see MCP Tools for the mcp <Alias> … end mcp block. Granting one to an agent takes one of two lines:

agents.nornagent (excerpt)
agent FrontendTester
    model Workbench
    mcp Browser                                    # every tool this server advertises
    tools House.getFixtureUser, House.resetTenant  # only these, from this one
    system file prompts/frontend-tester.md
end agent
  • mcp <Alias> grants the whole server — whatever it advertises, now and later.
  • tools <Alias>.<tool> grants exactly the named tools.

They are deliberately different statements. A granted tool executes when the model asks for it, so the line you write is the permission boundary — static, in git, reviewable — and dropping four characters must never silently widen access from one tool to twenty. Granting a server and then naming one of its tools is a blocking error rather than a quiet merge.

Every granted server is preflighted before the run spends anything: its tools are resolved first, so a missing server or a tool that no longer exists fails without a model call.

Prompts in Their Own File

describe and system take ordinary Norn strings, and both also accept file <path>, resolved relative to the .nornagent file just like an import.

agents.nornagent (excerpt)
agent TicketRouter
    model Workbench
    agents DomainExpert
    system file prompts/ticket-router.md
end agent

The file's text is the prompt verbatim — no escaping, so quotes and backslashes stay as written — and {{...}} references in it resolve exactly as they do inline. A missing or empty prompt file is a parse error on the directive line.

Inline prompts may span lines, opening and closing with the same quote. Use @"..." for quote-heavy prompts, where backslashes are literal and an inner quote is written as a doubled "".

Running an Agent

Import the sidecar and call the agent from an ordinary sequence. The result exposes text, body, toolCalls, model, usage, and ms.

route-ticket.norn
import "./agents.nornagent"

test sequence RouteTicket
    var ticket = run readJson "./ticket.json"
    var verdict = run TicketRouter ticket

    assert verdict.text exists
end sequence

Top-level agent calls are linear and stateless. Sub-agent conversation state is scoped to a single invocation subtree — it is not memory carried between runs.

Long invocations report what they are doing while they do it. Norn surfaces context preparation, model waits, MCP tool calls, knowledge loads, and child-agent invocations as they happen: in the VS Code Results panel as a live parent/child stack under an Agent activity heading, and in an interactive CLI terminal as one elapsed-time line that updates in place. These messages carry only the agent name, the kind of work, and the tool or child-agent name; prompts, arguments, and results never appear in them. The activity is transient. It disappears when the run reaches a terminal state, and the completed trace, recording, replay, JSON output, and reports remain the durable record.

Judged Expectations

Some results cannot be asserted on. A judge statement says what a run's free text was supposed to contain, in your own words, and has an AI agent score it.

review.norn
import "./agents.nornagent"

test sequence ReviewTicket
    var report = run TicketRouter ticket

    judge report with Reviewer expects "A test covers the happy path for POST /orders"
    judge report with Reviewer expects file expectations/api-tests.md
end sequence

judge is one line, like assert — but it is a deliberately distinct keyword, because an assertion is deterministic and free while a judge is a paid, probabilistic model call. It should look like an assertion without pretending to be one.

The scoring rule is fixed and simple: every expectation must be met, and output that exceeds them is not a failure. One unmet expectation fails the statement, which fails the sequence and exits the CLI non-zero.

The judge rules on each expectation independently and quotes the evidence for the ones it marks met. Norn computes the overall result — the model is never asked for it, and is never offered a field to put one in, so a judge cannot trade expectations off against each other. Every ruling carries a one-sentence reason, and a quote that cannot be found in the judged text is flagged.

Expectation Files

A long checklist lives in a file, one expectation per non-empty line. A leading -, *, or 1. marker is stripped and a line beginning with # is a comment, so an ordinary Markdown list works as-is.

expectations/api-tests.md
# Ticket 4182 — expected coverage

- A test covers the happy path for POST /orders
- A test covers a 404 for an unknown order id
- Every test asserts on the response status

Judge Agents

A judge is an ordinary agent that names its own model — nothing about its credentials, redaction, or recording is special. Norn supplies the input contract, output contract, and task framing, so a judge agent may not declare accepts, returns, tools, callable agents, or whole-server MCP grants of its own.

agents.nornagent (excerpt)
agent Reviewer
    model Workbench
    system "You review generated release evidence for a payments team."
end agent

"The judge said no" and "the judge never answered" are reported differently: a provider error, a contract failure, or an unresolved subject is an error on the step, never a set of unmet expectations.

The Agent Graph

Run Norn: Show Agent Graph, or click the CodeLens on any agent block, to draw the graph for a .nornagent file: who calls whom, the accepts / returns contract on each boundary, and how each hop turned out. Click an agent or a boundary for models, timings, usage, prompts, payloads, and contract issues. A second click closes the detail, Escape dismisses it, and its footer button jumps to the declaration or steps replay to that hop.

The toolbar also lists the project's recorded runs, each labelled with the .norn file it came from. Pick one to see how it finished, or press Play run to watch it unfold hop by hop. Playback replays the recording's own events, so nothing re-executes and no model is called; Stop jumps to the end, and This file — current view returns you to the open file.

Recording and Replay

Every sequence run that reaches an agent is recorded under .norn-cache/runs/ by default, with a rolling cap of 20 files. A recording holds the resolved provider request, response, tool transcript, contracts, conversation state, and ordered hop events.

A CLI run of one selected sequence can send that recording somewhere exact instead, with --recording <file.json>. The artifact is masked the same way, is written atomically to the given path, does not consume a cache slot, and is produced even when agents.recording.enabled is false — an explicit request outranks the automatic cache. See CLI for the hosted run contract.

Values declared as secrets in .nornenv are written as stable named placeholders and restored from the selected environment when replay starts; a missing value stops replay before a provider or tool can run.

terminal
norn replay .norn-cache/runs/<recording>.json
norn replay .norn-cache/runs/<recording>.json --json
norn replay .norn-cache/runs/<recording>.json --from CriteriaComparer

Pure replay reproduces the stored trace and exits non-zero for failed hops or contracts. It makes no provider or MCP calls, but recordings containing masked .nornenv secrets still require those named values to be available for placeholder restoration. --from accepts a unique agent name or a canonical path such as TicketRouter[1]/CriteriaComparer[1]; the prefix stays replayed and that hop onward runs live against the current graph.

To step a recording in VS Code, add the artifact to a norn launch configuration.

.vscode/launch.json
{
    "type": "norn",
    "request": "launch",
    "name": "Replay RouteTicket",
    "file": "${workspaceFolder}/route-ticket.norn",
    "sequence": "RouteTicket",
    "recording": "${workspaceFolder}/fixtures/route-ticket-run.json",
    "stopOnEntry": true
}

Agent calls appear as nested frames with their request, response, and contracts available for inspection at each stop.

Limits

The default guardrails are depth 5, 25 total agent invocations, 100 model turns per hop, and a two-rejected-attempt contract cap. Configure them globally, per provider, or on an individual agent — most specific wins.

norn.config.json
{
    "version": 1,
    "agents": {
        "max_tokens": 16000,
        "max_input_tokens": 2000000,
        "max_depth": 5,
        "max_invocations": 25,
        "max_turns": 100,
        "contract_retries": 2,
        "recording": { "enabled": true },
        "providers": {
            "local": { "max_tokens": 4096 }
        }
    }
}

max_tokens is an output ceiling, not prepaid usage: raising it does not spend tokens by itself. Depth, invocation, turn, and retry limits can each permit additional model calls. Set agents.recording.enabled to false to turn off automatic recording.

max_input_tokens is an optional cumulative spend guardrail: it caps the provider-reported input tokens across a whole top-level run, including sub-agents and contract retries. Omit it to record usage without enforcing a ceiling. When the budget is reached the run stops as truncated, keeping the work already done. To keep long tool loops affordable, Norn also bounds what stays in the model's active conversation — large tool results are offloaded to a run-local store the model can read on demand, so re-sending history each turn stays cheap while the full result remains in the recording.