AI Agents

A .nornai 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.

Sidecar File Extensions

An agent sidecar can be named .nornai or .nornagent. The two extensions are accepted interchangeably everywhere — imports, the parser, diagnostics, completion, the agent graph, knowledge indexing, recording and replay, and every CLI command — so nothing about a file's behavior depends on which one you pick.

  • .nornai is the name to use for new sidecars. Starters and the examples in these docs use it.
  • .nornagent remains fully supported. Existing files keep working, and there is no migration to do.
  • A bare .ai extension is not a Norn sidecar. Importing one fails with a message asking you to rename the file to .nornai, in the editor and in the CLI, rather than silently missing the agents.

Both extensions get the same editing support in VS Code and can be mixed in one project.

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.nornai (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.nornai (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.nornai (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.

Giving an Agent Database Access

A database <Alias> … end database block declares an adapter, connection string, and read or full AI access. An agent gets database tools only when its block grants that alias with database <Alias>. The grant offers one tool to execute SQL and another to inspect schemas, tables, and columns. See SQL Queries and Database Tools for the block and import examples.

Prompts in Their Own File

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

agents.nornai (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 "".

Variables in Knowledge Markdown

Domain knowledge lives in ordinary Markdown files. A knowledge <Alias> … end knowledge block names the files and folders that belong to a body of knowledge, and an agent is granted it with knowledge <Alias>.

agents.nornai (excerpt)
knowledge Payments
    source ./knowledge/payments/
    exclude **/archive/**
end knowledge

agent PaymentsExpert
    model Workbench
    knowledge Payments
end agent

That Markdown can carry {{name}} and {{$env.name}} references, resolved from the nearest .nornenv to the Markdown file. Knowledge written once therefore reads correctly against every environment instead of hard-coding one host. Resolution is part of the shared runtime, so a CLI run and a run started in VS Code behave the same way, and there is no new command or flag to learn — the environment is the one the run already selected with --env.

knowledge/payments/refunds.md
# Refund policy

Refunds are issued through {{$env.baseUrl}}/payments/refunds.
Quote the tenant region {{region}} when you escalate.

A document can override that discovery with an import key in a norn:meta block placed directly under its H1 title. The path is relative to the Markdown file and names a .nornenv file. It applies to every section of the document.

knowledge/payments/refunds.md
# Refund policy
<!-- norn:meta
import: "../../environments/team.nornenv"
-->

Refunds are issued through {{$env.baseUrl}}/payments/refunds.
  • With no environment file in scope, references stay exactly as authored. Knowledge that documents placeholder syntax verbatim keeps working untouched.
  • An explicit import is a contract. An import that cannot be read, a variable the environment file does not define, or a missing environment section is reported as an error before the agent provider runs, so a broken reference never costs a model call.
  • A secret can be referenced deliberately with {{name}} or {{$env.name}}. Only values the document actually references are resolved; an unreferenced secret is never added to the text.
  • Topic identity stays authored. Snapshots and the knowledge index store the written text and the import path, never a resolved value, so pointing a document at a different environment file does not re-key its topics. Each invocation resolves its own copy and re-estimates the delivered token count.
  • If one authored topic appears in several files that resolve to different text within the same domain, delivery reports the conflict instead of silently choosing one.

The authoring side of this — the per-file environment selector, inline resolved values, and completion — is described in VS Code Features.

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.nornai"

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.

Chatting With a Declared Agent

Writing a sequence is not always the fastest way to find out whether a system prompt works. In VS Code, every agent block carries a Chat CodeLens that opens a conversation with exactly that declaration — the same model, prompts, contracts, tool grants, and knowledge grants a sequence run would use, resolved against the active environment. Norn: Chat With Agent does the same thing from the Command Palette for the agent under the cursor.

The declaration is validated before anything opens. Norn checks the agent and every sub-agent it can reach, so a bad model alias, an unknown tool, or a missing contract is reported as an error naming the line rather than discovered halfway through a paid turn.

A valid declaration opens in its own editor tab with a chat icon, one tab per agent per file. The tab keeps its own conversation: successful turns become the context for the next message, while a failed or cancelled turn is left out so a broken exchange does not poison what follows. Closing the tab discards that conversation — chat history is a scratchpad, not a stored artifact, and the .nornai file remains the only thing under version control.

The transcript shows the work, not just the reply. Knowledge delivery appears as short notes — what was supplied up front, which topic indexes and domains were offered, what the agent actually loaded, what it asked for twice, and what was refused. MCP tool calls and child-agent invocations each get their own collapsible entry in call order, and a child agent shows its reply along with its own nested activity. Values declared as secrets in .nornenv are masked before they reach the view. Stop cancels a turn in flight and closes the MCP sessions it opened.

This is a VS Code feature. The CLI is unchanged, and the :codex and :claude sessions in the Norn Terminal keep their own separate behavior.

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.nornai"

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.nornai (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 sidecar 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.

Knowledge and database declarations appear as nodes on the same canvas as agents, with arrows to the agents that grant them. They move and scale with the graph when you pan, zoom, or fit it. Select a resource node to see its sources or database adapter and AI access level. Connection strings stay out of the graph.

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, MCP, or database 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.