nodwyn.

nodwyn Scheme — language reference

nodwyn embeds a small Scheme for orchestration only. Scheme code decides what to do; every effect — reading a file, running a command, calling a model — is a Go primitive, and every primitive application crosses the guard. Scheme never touches the network or the filesystem on its own.

This document is the contract for the language (ADR 7). The interpreter implements what is written here and nothing else; the conformance suite has a snippet for every form and builtin below, and a builtin that is not documented here fails the suite.

§10 lists what is deliberately absent.

The language itself (§§1-8 and §10) is implemented by Brote, the Scheme interpreter nodwyn imports as github.com/fclaude/brote/scheme (a module of its own since 2026-10-22, ADR 7). Brote's docs/scheme.md is the source of truth for those sections, which are kept here so that this file reads whole; where the two differ, Brote's wins for the language. What is nodwyn's own is the host primitives (§5), the guard (§7) and the agent loop (§9).


1. Lexical syntax

Token Notes
; … end of line comment
#| … |# block comment; may span lines; |# ends it (nesting is not supported). Unterminated is incomplete input
( ) list delimiters; [ ] are not accepted
#( ) a vector: #(1 2 "x")
'x `x ,x ,@x (quote x) (quasiquote x) (unquote x) (unquote-splicing x)
"…" string; escapes \" \\ \n \t \r, \xHH (one byte), \uHHHH and \UHHHHHHHH (one code point); may span lines
"""…""" raw string: no escapes, so the text is exactly what the file holds. See §1.1
42 -7 +3 integer (int64)
3.14 1e3 -0.5 .5 float (float64)
#t #f booleans
. dotted pair inside a list: (a . b)
anything else a symbol; case-sensitive; +, -, ..., 1+ are symbols

A token is parsed as an integer if strconv.ParseInt accepts it in base 10, else as a float if strconv.ParseFloat accepts it and it is made only of digits, signs, a point and an exponent, else it is a symbol: inf, nan and 1_000 are symbols, and an integer too large for 64 bits reads as a float.

Unbalanced input is reported as incomplete, which the REPL uses to keep reading lines.

1.1 Raw strings

""" begins a raw string and the next """ ends it. Nothing between them is an escape, so quotes and backslashes are themselves:

(define pattern """\d+\.\d+""")

When the opening delimiter is followed immediately by a newline the block is read as indented text: that first newline is dropped, the indentation shared by every non-blank line and by the closing delimiter's line is removed, and the newline before the closing delimiter is dropped. A block therefore reads at the indentation it is written at and produces text with none:

(define instructions
  """
  Prefer table-driven tests.
  Run `make accept` before saying a change is done.
  """)

is exactly "Prefer table-driven tests.\nRunmake acceptbefore saying a change is done.". Lines indented further than the margin keep the difference, and blank lines stay blank.

Anything else is verbatim: """a "b" c""" is a "b" c.

Note that """ always starts a raw string, so an empty string written immediately before another string ("""x") is read as a raw string rather than as "" followed by "x". Separate them, as ordinary formatting already does.

2. Data types

Type Written as Predicate Notes
empty list () null? must be quoted in code: '()
boolean #t #f boolean? only #f is false; everything else is true
integer 42 integer? int64; wraps on overflow like Go
float 2.5 1.0 float? float64; always printed with a . or exponent
string "hi" string? immutable UTF-8; lengths and indices count runes
symbol foo symbol? interned by name
pair / list (1 2 3) (a . b) pair? list? lists are chains of pairs ending in (); no mutation
vector #(1 2 "x") vector? indexed array; vector-set! mutates; no mutation of lists
procedure #<procedure f> #<primitive car> procedure? closures and primitives are both procedures
handle #<cred:class:id> handle? opaque credential handle (P3). Cannot be converted to a string by any builtin
void #<void> — result of define, set!, display, …; the REPL does not print it

number? is true for integers and floats.

3. Evaluation

  • A symbol evaluates to its binding; an unbound symbol is an error.
  • Numbers, strings, booleans and void are self-evaluating.
  • () unquoted is an error ("empty application").
  • A list whose head is one of the special form names below is that form. These names are reserved: binding one as a variable does not change how the form is parsed (this includes with-output-to-string).
  • Any other list is an application: the head and every argument are evaluated left to right, then the procedure is applied.
  • Proper tail calls. The last expression of a lambda body, begin, let/let* body, cond clause, and/or, and either branch of if is in tail position. A loop written as a tail-recursive function runs in constant stack space.
  • Nesting is bounded. Evaluations nested inside each other (calls not in tail position, recursion through a primitive such as map) are limited to 10,000; past that the evaluation raises eval: recursion too deep: more than 10000 nested evaluations, an ordinary error guard can catch. The reader refuses a datum nested deeper than that, json-emit refuses a value nested deeper, equal? treats values nested deeper as equal only when they are the same object, and the printer writes ... past that depth, so a vector that holds itself (vector-set!) prints, compares and emits without end. load refuses a file that is already being loaded. Without these bounds a runaway recursion overflowed Go's stack, which ends the whole process.

Special forms

Form Meaning
(define name expr) bind name in the current environment
(define (name param … [. rest]) body …) shorthand for a named lambda
(lambda (param … [. rest]) body …) (lambda args body …) closure; rest/args receive a list
(if test then [else]) missing else yields void
(let ((v e) …) body …) bind in parallel
(let name ((v e) …) body …) named let: name is bound to the loop procedure inside the body
(let* ((v e) …) body …) bind sequentially
(letrec ((v e) …) body …) mutual bindings: every initializer may reference the other names (a closure resolves them at call time)
(case key [(datum …) body …] … [(else body …)]) first clause whose datum list contains a member equal? to the key wins; no clause → void
(do ((v init [step]) …) (test [e …]) cmd …) loop: check test; on false evaluate the cmds, then the steps (in parallel), rebind, repeat. Value is the test clause's last e (void if none). A Go loop — constant stack
(when test e …) e … only if test is true; yields void otherwise
(unless test e …) e … only if test is false; yields void otherwise
(guard (var clause …) body …) evaluate body …; if it raises a Scheme error, bind var to the condition — an alist ((message . "text") (irritants …)) — and try the clauses like cond ((test body …), (test => proc), (else …)). The first true clause's value is the value of guard; no match re-raises. A guard denial and a cancellation are never catchable
(with-output-to-string e …) evaluate e … with display/write/newline captured; return the captured text (a minimal string port)
(begin e …) evaluate in order, yield the last
(set! name expr) assign an existing binding; error if unbound
(quote d) the datum itself
(quasiquote d) like quote, but (unquote e) evaluates e and (unquote-splicing e) splices a list. One level only: nested quasiquote is not supported
(cond (test body …) … [(test => proc)] [(else body …)]) first true clause wins; => passes the test value to proc; no clause → void
(and e …) short-circuit; (and) is #t
(or e …) short-circuit; (or) is #f

define inside a lambda body binds in that call's environment (internal definitions). When define names a fresh anonymous closure, the closure takes that name for printing and error messages.

4. Errors

(error message irritant …) raises an error whose text is message followed by the written irritants. Runtime failures (unbound variable, wrong argument count, wrong type, division by zero, a denied primitive) raise the same kind of error with a message of the form who: what.

Errors may be caught with guard (§3):

(guard (e ((string-contains? (assoc-ref e 'message) "boom") 'caught)
          (else (assoc-ref e 'irritants)))
  (error "boom" 1 2))            ; caught

An error not caught by any clause unwinds to the host, which reports it (the REPL prints it, the agent loop feeds it back to the model as the tool result).

A primitive the guard denies raises <name>: denied: <reason> (fs-read: denied: …). Denials and the turn's own cancellation (an abort, the turn's deadline) pass through guard untouched: a program cannot hide that it was refused, and an abort always unwinds. A primitive's own timeout while the turn goes on (a judge's ten seconds, an MCP call's two minutes) is an ordinary error guard catches. So do the agent loop's stops (§9): a spent budget, the tool-call limit, a progress stop and a log that can no longer be written end the turn whatever the strategy's guard clauses say.

5. Builtins

Signatures use … for variadic and [x] for optional. Every builtin is a primitive in the table (§7) and can be listed with (primitives).

Numbers

(+ n …) (* n …) sum / product; (+) is 0, (*) is 1
(- n m …) subtraction; (- n) negates
(/ n m …) division; (/ n) inverts. Two integers divide exactly when the result is integral, otherwise yield a float. Division by zero is an error
(= a b …) (< a b …) (> a b …) (<= a b …) (>= a b …) numeric comparison chains; mixed int/float compare by value
(quotient n m) (remainder n m) (modulo n m) integer division family; integers only. remainder takes the sign of n, modulo the sign of m
(abs n) (min n …) (max n …)
(floor x) (ceiling x) (round x) (truncate x) integers pass through; floats stay floats ((floor 2.5) → 2.0). round rounds half to even
(exact x) (inexact x) float→int (truncates toward zero) / int→float
(expt b e) (sqrt x) expt of two integers with e ≥ 0 is an integer (an error when it does not fit 64 bits, unlike + and *, which wrap); everything else is a float
(number? x) (integer? x) (float? x)
(zero? n) (positive? n) (negative? n) (even? n) (odd? n) even?/odd? need integers
(number->string n) (string->number s) string->number returns #f when s is not a number

Booleans and equivalence

(not x) #t only for #f
(boolean? x)
(eq? a b) (eqv? a b) identity for pairs, vectors, procedures and handles; value for everything else, including strings. eqv? is an alias
(equal? a b) structural equality

Pairs and lists

(cons a d) (car p) (cdr p)
(caar p) (cadr p) (cdar p) (cddr p) (caddr p) (cdddr p) compositions
(list x …) (list? x) (pair? x) (null? x) list? is true only for proper lists
(length l) (append l …) (reverse l)
(list-ref l i) (list-tail l i) 0-based; out of range is an error
(iota n) (0 1 … n-1); n is at most 1,000,000 (more is an error: the list is built without a chance to abort)
(memq x l) (member x l) tail starting at the first match (eq? / equal?), else #f
(assq k al) (assoc k al) first pair whose car matches (eq? / equal?), else #f
(map f l …) n-ary; stops at the shortest list
(for-each f l …) for effect; yields void
(filter pred l)
(fold f init l) left fold: (f acc x)
(sort l less?) stable; less? is a Scheme or primitive procedure
(apply f arg … l) last argument must be a list

Vectors

(vector x …) (vector? x) build / predicate
(vector-length v)
(vector-ref v i) 0-based; out of range is an error
(vector-set! v i x) mutate element i; yields void
(vector->list v) (list->vector l) convert between vectors and lists

equal? compares vectors elementwise; eq?/eqv? compare vectors by identity.

Strings

(string? x) (string-length s) length in runes
(string-append s …)
(substring s start [end]) rune indices; end defaults to the length; out of range is an error
(string=? a b …) (string<? a b …)
(string-upcase s) (string-downcase s) (string-trim s) string-trim strips Unicode whitespace at both ends
(string-split s sep) sep is a literal separator, not a regex; empty sep is an error
(string-join l sep)
(string-contains? s sub) (string-prefix? prefix s) (string-suffix? suffix s)
(string-replace s old new) replace every occurrence
(string->symbol s) (symbol->string sym) (symbol? x)
(format fmt arg …) ~a display, ~s write, ~% newline, ~~ tilde; too few or too many args is an error

There is no character type; one-character strings serve that purpose.

Output

(display x) human form: strings without quotes
(write x) machine form: strings quoted and escaped, readable back
(newline)
(with-output-to-string e …) a special form (reserved like the others): capture display/write/newline from e … and return the text

Output goes to the interpreter's output writer, which the host provides (the REPL uses stdout; the agent loop captures it). All three yield void.

Control

(procedure? x)
(apply f arg … l) see lists
(void) the void value
(error message irritant …) raise; see §4

JSON

(json-parse s) JSON text → value
(json-emit v) value → compact JSON text

Mapping, in both directions:

JSON Scheme
object association list with string keys, in document order: (("a" . 1) ("b" . 2))
array list
string / number / true / false string / integer-or-float / #t / #f
null the symbol null

json-emit treats a proper list whose elements are all pairs with string cars as an object and any other proper list as an array. Consequently '() emits [], there is no way to emit {}, and parsed JSON does not always come back as it was: an array of arrays that each start with a string ([["a","b"]]) parses to a list that emits as an object ({"a":["b"]}). An MCP tool's arguments are written by its schema instead: an argument the schema calls an array is emitted as one, and one it calls an object as an object, {} when empty, so a server gets them as the model wrote them. Procedures, handles, vectors, void, symbols other than null, and a NaN or infinite float cannot be emitted.

This mapping is also how the agent loop turns a model's tool-call arguments into Scheme values (ADR 3).

Host primitives (registered by the session, not by nodwyn repl)

(fs-read path [start [count]]) (fs-write path content) (fs-edit path old new [replace_all]) (fs-edit path "" "" #f edits) (fs-list path) repository-pinned file access; writes and edits are irreversible (confirm, diff preview). fs-read returns the whole file (at most 1 MiB; a larger one must be read in ranges), or with start (a 1-based line) and count (default 200) a header [lines A-B of N] and those lines exactly as in the file; a ranged read is streamed, so file size does not limit it, and stops at a whole line within window.max_result_tokens × 3 − 256 bytes (the cap as the window is sized now, which grows with the model's context), so the window never cuts it; a single line longer than that comes back cut, under a [line N of M, cut at B bytes] header. In the lean profile (§9.8) fs-read takes a fourth parameter, symbol ((fs-read path 0 0 "Class.method")), a definition outline lists, and returns that definition's lines under its header instead of a range. fs-edit replaces exactly one occurrence of old, or with replace_all (#t) every one; when old is not found but matches one place with whitespace ignored, the error names those lines and quotes them exactly; in a lean session (prim.Options.Lean; a full session's tool, schema and answers are the ones they were) an old that matches nowhere as written but holds \n, \t or \" sequences, and matches exactly once with one level of escaping undone (\\ too), is applied so, new unescaped the same way, and the answer ends with old held literal \n sequences; they were matched as real newlines (naming the sequences it held); and an old that matches nowhere otherwise gets the five lines around the line of the file that shares most words with old's first line (at least two words and half of old's, at a resemblance of 0.5 or more; of equal lines the one whose next lines resemble old's more, then the first), numbered, or "there is no close match", and no error names a tool; a file that holds credential-shaped text is matched as the model reads it, each credential a [REDACTED <class>] placeholder (ADR 43): the placeholder in old matches whatever the file holds there, and in new, with the span's other placeholders in the same order, keeps it (leave them out of new to drop it; any other placeholders in new are an error, so a credential is never copied or moved); a match that starts or ends inside a placeholder is refused, an old that equals the file's own text still matches, quoted lines are as the model reads them, and an old with credential-shaped text is told to write the placeholder instead. Several changes to one file go in one call (ADR 38): edits is a list of objects, alists with string keys as json-parse makes them, such as '((("old" . "a") ("new" . "b")) (("old" . "c") ("new" . "d") ("replace_all" . #t))), given instead of old and new; a model's call names only path and edits, which reaches Scheme as the second form, old and new empty and replace_all #f. The edits apply in order, each to the text the one before left; nothing is written unless all apply, and a failure names the edit (edit 2 of 3 failed, so nothing was written: …). The call asks once, with one diff from the file to the text all of them leave, keeps one previous version and writes once. Giving both forms, or neither, is an error that says so. A written edit answers with the lines it changed as the file now reads them, numbered, with two lines around each (edited PATH, now N lines (+D); the changed lines now read: then LINE<tab>text rows, with (+A -R), the lines the edit added and removed, in the lean profile, where an edit that swaps a line leaves no change in the count; a changed run longer than 40 lines shows its ends, and one answer shows at most 120 lines). In a lean session, when an edit without replace_all replaced at least two non-blank lines or 40 non-space characters, not all of them import lines (import, from … import) or decorators, the answer ends with the replaced text also occurs at: PATH:LINE, …; check whether the same change applies there for the other places, one to three, where those lines occur, ignoring whitespace, in the source files git tracks (git ls-files): other files and other places in the edited file, never the edit's own new text. The search starts with the edited file and its folder, skips hidden folders, build, dist, vendor, node_modules, binary files and files over 1 MiB, and every file the model's own read would be refused (the credential denylist, a checker of the user's), reads at most 5,000 files in 400 ms, and says nothing when it finds more than three, runs out, or the repository is no git work tree; an edit in a list gets a line of its own, labelled edit N:. An old that occurs more than once is refused with the line each occurrence begins on, and one that matches for its first lines and then differs says where it stops (both in every profile). fs-write answers ok; in the lean profile it says how many lines it wrote and how many the file had, and warns when most of an existing file went. A file over 16 MiB is refused by fs-edit (and by fs-write, since no version of it can be kept) and shows no diff in a write's confirm; a diff of more than 2,500 changed lines, or of files together over 6,000 lines, is summarised as too large to show
(index-grep path pattern [glob [context]]) P10. Search a repository file or directory with a regular expression (RE2 syntax, as in Go and ripgrep; not command-line options). The directory is walked in Go (hidden entries and symbolic links skipped, no ignore files), glob narrows the candidates (*.py, tests/**/*.py, *.{go,md}; docs/memory.md has the rules), and each is read through the guarded fs-read and matched in Go. Returns matches (path, line, text, and with context the lines before and after: 0 to 5, more is capped), files searched and skipped, and a truncated flag, set when the matches reach 100 or the output cap (MaxOutput, 256 KiB). More than 3000 candidate files is an error asking for a narrower path or a glob (each read's guard decision is logged, so the limit also bounds the log); in the lean profile (§9.8) a tree that large is searched for source and configuration files alone, and the answer's note says so; the whole query has 30 seconds; protected, hidden, binary and oversized files and symlinks are skipped. Needs no program on PATH
(fs-find path glob) file names under a repository directory whose path matches glob: sorted, relative to the root, at most 500, with their count and a truncated flag. Names only, listed as index-grep lists candidates, with the same glob rules; hidden and credential-looking names are left out
(index-search query scope [limit [path]]) ADR 50, 51. Search by words, best match first (BM25, words split as code names them). scope is "repo" (chunks of the repository's files, from the project's index; path narrows it to a directory or file; values tainted external:file), "conversation" (this task's events, from the log, shown under their turns) or "earlier" (the project's other tasks, only when the person turned it on for the project; otherwise an error saying where), whose values are tainted external:log; any other scope is an error that says so. Returns the best limit results (8 unless given, at most 20); no match is a sentence saying so, not an error. What is indexed, how it is kept and what a result shows are in memory.md
(index-update [path]) ADR 50. Brings the repository's index up to date, for at most 60 seconds a call (the next call goes on), and says what it holds and what is left; with path, a directory or file of the repository that is indexed whole from then on. Not irreversible: it writes only nodwyn's data directory, and is offered only where the session has one. See memory.md
(recall seq [start [count [task]]]) ADR 50, 51. One event of this session's conversation as the log recorded it (redacted), by its seq (the N of [elided seq=N], or one index-search gave), its lines from start (1 unless given), count of them; a turn's first message comes with its card. With task, an earlier task's id as index-search names it, under the same leave as that search and only for a task of this project. Only the kinds index-search ranks; any other seq is an error saying which it reads. It shows what was, not what is: a file is read again with fs-read. See memory.md
(git-status) (git-diff [path [staged [against]]]) (git-head) only when the root holds .git: the working tree's status (branch, changed, staged and untracked files, short format), and the uncommitted changes as a unified diff, the working tree against the index or with staged (#t) the index against the last commit; path narrows it; against, a commit or branch name (never anything that starts like an option), compares with it instead of the last commit, so what was committed since counts too (--end-of-options goes before it). (git-head), not offered as a tool, is the commit HEAD names as a full hash, #f before the first commit: a strategy measures a turn's change against the commit it began on, so a commit moves nothing out of it (a strategy may use this to retain the turn's changes after a commit). Read-only, so no approval: git runs with fixed arguments and everything that would run another program switched off (external diff drivers, text conversion, the file system monitor, the pager), and a diff leaves out the contents of the files fs-read refuses. The status names every file, credential files included: a name is no secret, and the evidence loop reads it to know what changed. Untracked files are not in a diff
(git-content-state) Hidden host primitive in a Git worktree: opaque SHA-256 fingerprint of sorted tracked and non-ignored untracked file paths, bytes, executable bits and link targets, independent of HEAD and staging. Credential-denied files are omitted; links are not followed. Listing/read failures, non-files, a listing over 16 MiB or contents over 256 MiB return an error, never a valid state. The repository validation wrapper certifies only successful checks of unchanged known contents
(scheme code) run a small Scheme program that calls the other tools as procedures (positional parameters, in the order each tool's schema lists them), to read, search, edit or run several things in one call and keep only what matters of the results. The program runs with the model's origin, so every tool it calls goes through the guard, the repository pin, the denylist and the approvals as a call of its own would; it is read as data and run in a frame that holds only the tools the model is offered (but scheme) and the language's pure builtins, with no register-tool, no hidden primitive and no procedure a strategy defined. A tool that fails raises an error a guard can handle. Within 3 minutes; a result over 24 KiB is cut. Registered in the lean profile only (§9.8), and offered to the control model when lean-tools names it; experimental
(outline path) the shape of a source file without its text: classes, functions, methods and module-level variables, nested under what holds them, each with the lines it spans, its signature and the first line of its documentation. Go is read with go/parser in process; Python by the interpreter's own ast module (a fixed script that parses and never runs the file, python3 -I); other languages by universal-ctags when installed, else by line patterns. At most 250 definitions. Registered in the lean profile only (§9.8), and offered to the control model when lean-tools names it; experimental
(tests-for path) the test files that exercise a source file and how to run them: the test files (test or tests folders, test_*, *_test.*, *.test.*, *.spec.*) that mention the most of the file's definitions, the rarest names counting most, each with the command that runs it where the layout says (python tests/runtests.py <label> in a Django tree, pytest <path>, go test ./<dir>/). It ranks by names and does not trace imports. Registered in the lean profile only (§9.8), and offered to the control model when lean-tools names it; experimental
(shell-exec cmd args) run a program with an argument list in the repository root, scrubbed environment (TMPDIR is the scratch directory); irreversible; returns (("status" . n) ("stdout" . s) ("stderr" . s)). Each stream is cut at 256 KiB by keeping its first and last 128 KiB with a line between them that counts what was left out, since a long run's failure is at its end. In a lean session the description is short and generic (the program and its arguments, bash -c for shell syntax, the three fields, the view, the time limit), and on the model's own call (not a program's of the scheme tool, a strategy's or the person's), a stream of more than 8 KiB or 150 lines is instead returned as a view, so that a long output is neither poured into the context nor trimmed with | tail by the model: its first 20 lines, a marker line where the others are, and its last 50 lines, the whole at most 6 KiB (two views share it) and a line cut at 300 characters with …; the marker says which lines are missing and where the whole stream is, [... lines 21-310 of 330 not shown; full output is saved at $TMPDIR/out/7.stdout: sed -n '21,120p' $TMPDIR/out/7.stdout shows a range, grep -n PATTERN $TMPDIR/out/7.stdout searches it ...] (7.stderr for the error stream; a file that stopped at 8 MiB says so, with the last line it holds). The files are <scratch>/out/N.stdout and N.stderr, N being 1, 2, 3 … for the session, shared by the two streams of a call and taken only by a call that needs a file, made exclusively when the stream passes the thresholds and written complete up to 8 MiB; reading them through shell-exec (sed -n, grep -n, with $TMPDIR as the shell or the arguments write it) asks no question, the scratch directory not being outside the repository. The view is under what the window cuts (window.max_result_tokens, 3,072 tokens by default, 9 KiB), so the model reads it whole. A command line that reads a file or limits its own output (cat, head, tail, nl, sed -n, anywhere in it) is not shown as a view, and neither is any output when the session has no scratch directory: they keep the 256 KiB cut above. A full session's stream is cut at 256 KiB alone. A command still running after 10 minutes is stopped and returns what it printed, with status the text timed out after 10m0s in place of a number; in a lean session the model's own call is instead left running (ADR 32, amended 2026-10-17): status is still running after 10m0s, stdout and stderr are the view of what it printed by then (empty for a pipe into tail), and the note names the files its output goes on into, $TMPDIR/out/N.stdout and N.stderr (made now if the call had none, the command's whole output up to 8 MiB), and N.exit, where its exit status (a number, 128 plus the signal's for one a signal ended) is written when it ends. What it left in its process group is stopped then, as for any command; the session stops a command still running when it closes, before its scratch directory goes, and a command at the limit of a session that is closing is stopped as before. A bash -c command line runs with pipefail, so a pipeline's status is that of its last command that failed (make test \| tail -n 40 reports the tests' failure; a pipeline into head can end in 141 when head stops reading early, and the result then carries a note saying so; it carries one too when a command that reads a file exits 0 and prints nothing, as a range past the end of the file does). Whatever the program leaves running in its process group (a server started with &) is stopped when it ends: SIGTERM, then SIGKILL after 2 seconds; a program that keeps running goes to shell-start
(shell-start cmd args) start a program that keeps running (a server, a watcher, a program to talk to) in the repository root, in the background: no shell, the same scrubbed environment and TMPDIR as shell-exec, its own process group. Irreversible, and checked like shell-exec (its cmd and args are the command line). Returns text: p1: running (the command line) and its first output (as soon as there is some, waiting at most a second). At most 4 run at once; every one is stopped when the session closes. A bash -c command line runs with pipefail, as shell-exec's does
(shell-read id [wait]) what process id printed since the last read (stdout and stderr together), after a first line with its state: running or exited N. With wait (0 to 600 seconds) it first waits for new output or an exit. The latest 1 MiB of output is kept, and a read says how much was dropped before it, or how much is left past the output cap. Read-only, so no approval
(shell-send id text) type text into the process's input (end a line with \n), and return what it printed next (as soon as there is output, waiting at most a second). At most 16 KiB a call; a program that does not read its input fails the call after 5 seconds, and an abort ends the wait at once. Irreversible, with the reason that what it does depends on the program: it asks every time; text is a payload the credential checks read
(shell-stop id) stop the process (SIGTERM to its group, SIGKILL after 2 seconds) and return its last output and exit status. Needs no approval: it ends only what this session started
(term-open cmd args [cols rows]) Experimental and temporary (ADR 48); only with [experimental] terminal = true. Run a program on a pseudo-terminal in the repository root (100 by 30 unless cols (20 to 250) and rows (5 to 100) say otherwise), with shell-start's scrubbed environment, TERM=xterm-256color, a UTF-8 locale when there is none and a TMUX_TMPDIR of the session's own, so full-screen programs (tmux, neovim, less) work. Irreversible, with a confirm that says it is experimental; cmd and args are checked like shell-start's. Nothing typed into it afterwards is checked. Returns the screen (see term-screen) once the program has been quiet for 300 ms. At most 3 are open; all are hung up on when the session closes
(term-type id text) type text into a terminal, a newline pressing Enter; typed literally, so a word like Escape is letters. At most 16 KiB. Returns the screen once the program has been quiet for 150 ms, or after 2 seconds. No approval: it is not checked at all
(term-keys id keys) press the keys of a list of strings in order, each a single character, a name (Enter Escape Tab BTab Space BSpace Delete Insert Up Down Left Right Home End PageUp PageDown F1 to F12, not case-sensitive) or C-x or M-x (Control or Alt with a letter). A key that is none of these is an error and none of the list is pressed. At most 200 keys. Returns the screen as term-type does. No approval
(term-screen id [offset]) the terminal's screen as text: a header (t1: running (nvim main.go) 100x30, cursor row 7 col 12, full-screen, or exited N), then each row with its number and \| (not part of the screen), trailing spaces cut and three or more blank rows in a row shown as one 2-19\| (blank). With offset (a number of lines), the view scrolled back that many lines through the history of a program that does not use the alternate screen (5000 lines kept), as a scroll wheel would; a full-screen program keeps no history and refuses it. Readable after the program has exited, until term-close. Read-only, so no approval
(term-wait id [seconds [until]]) wait, then return the screen as term-screen does and, on a last line, why it returned: [matched after 2.1s] when a line of the screen matches the regular expression until (Go syntax, multi-line; the rows without their numbers; it returns at once if one already does), [quiet after 0.5s] when with no until the program has printed nothing for half a second, [exited after …], or [timed out after …] after seconds (1 to 60, 10 when left out). Read-only, so no approval
(term-close id) hang up on the terminal's program (SIGHUP to its process group, SIGKILL after 2 seconds), return its last screen and free the id. A tmux server the program started keeps running, in the session's own TMUX_TMPDIR, until the session ends, so a later term-open can run tmux attach. No approval: it ends only what this session opened
(scratch-dir) the session's scratch directory (a session always has one; nodwyn repl and eval have none of the session's primitives, and load is their only host primitive). It is a private directory (mode 0700) in the system's temporary directory, outside the repository and outside nodwyn's configuration and data directories, so a program run with it as TMPDIR makes its temporary files there without being refused (until 2026-10-16 it was in the data directory, ADR 28); the file primitives take absolute paths inside it, or paths starting with $TMPDIR/, which the interpreter rewrites to it in every path and command argument before the guard; writes there need no approval, and commands get it as TMPDIR. It is removed when the session closes, a resumed session gets a new, empty one, and directories a crashed session left, in the temporary directory or in the data directory, are swept once untouched for a week (a session touches its own at every turn; only this user's own, named for a scratch directory, go). The default system prompt tells the model about it by $TMPDIR, not by its path, so the prompt is the same across sessions.
(name-task title) the model's name for the task, from its first request (ADR 39, amended 2026-10-23): the tool the model is offered in a session the daemon opened (core.Options.Titles), and a procedure a strategy may call. The interactive system prompt asks for it in the first reply, next to its first step (name-task-rule, said only where the tool is offered). The host collapses the title's whitespace, drops the quotes and the full stop around it, cuts it to 120 characters and records it as session.title with actor model; it answers that the task has a name already, and records nothing, when the log holds any session.title (a person's, a workflow's, an earlier call's, or a name a person took back), and refuses an empty title. A model that never calls it leaves the task read by its first message. The lean profile does not offer it
(project-notes) (project-notes-save text) this repository's notes kept across sessions (how tests run, where things are, runbooks), "" when there are none; and replacing them. They live in nodwyn's data directory, keyed by the repository's path, never in the repository. A save is capped at 8 KiB and redacted, and its text is a payload the guard checks, so content that looks like a credential needs a person's approval. The default system prompt shows the notes under a heading that calls them leads to check, not instructions. project-notes-save is also a tool the model is offered, and the working rules ask it to keep there what a later session would need and could not quickly find
(git-land message) offered only in a session given a git worktree of its own (ADR 34, amended 2026-10-05): lands the session's work on its base branch in one step. It commits every change with message (needed when there is one; the files fs-read refuses are left out and named, taken out of the index when already staged, and nothing is committed if one stays there), signed as the repository's git configuration says with the person's agent (SSH_AUTH_SOCK, GNUPGHOME, the person's GIT_SSH_COMMAND and the like pass to git; when they have set none, ssh runs with BatchMode; every prompt is off, so a passphrase prompt fails at once); rebases the session's own commits, those after the worktree's base commit or after where the branch last met the base, onto the newest base (its upstream, fetched first, when it has one); refuses a change whose outgoing diff adds credential-shaped text (a line it removes, or context it only edits beside, is not scanned); pushes to the base's upstream by fast-forward only; and moves the local base branch: its ref when no checkout has it, a fast-forward merge in a clean checkout that has it, nothing (said) in one with changes. A conflict stops the rebase, naming the files; a call after the model resolved them goes on. A core.hooksPath inside the worktree is refused, since the model could have written those hooks. Irreversible: it asks, as any such call does, unless the task's "Don't ask" is on, which answers it (ADR 34, amended 2026-10-23). Returns what it did, a line each
(worktree) #f, or the git worktree made for this session when the person started it in one (ADR 34), as (("repository" . P) ("branch" . "nodwyn/1968c13a") ("base" . "main") ("base-commit" . C)): the session's root is then that worktree, at P/.nodwyn/worktrees/1968c13a, and base is "" when the repository's HEAD was detached. No primitive makes or moves a worktree: the host makes it before the session loads, when a client asks with worktree.open
(project-instructions) the repository's AGENTS.md (or agents.md), its maintainers' instructions for coding agents, "" when it has none. Read as a regular file inside the root after links are followed and past the credential denylist under both names (a link to a key elsewhere reads as no file), capped at 32 KiB with a line that says the file goes on, and redacted, since it rides in every request (ADR 36)
(http-request cred url method position headers body) P3. Send a request with the credential handle injected at position (header:NAME, header:NAME:SCHEME such as header:Authorization:Bearer, query:NAME, body). Egress: the guard checks the binding table for (class, host, method, position) first. Credential injection requires HTTPS except on loopback unless the user enabled policy.allow_insecure_http; the model cannot enable it. Not offered to the model (a handle cannot come from JSON); reach it through a user-registered tool. Returns (("status" . n) ("body" . s)), the body read up to 1 MiB
(policy-read name) User-only, hidden from the model catalog. Read configured bindings or repository approvals, returning content, revision and existence; bounded to 64 KiB and rejects recognized secrets. No arbitrary path.
(policy-write name revision content) User-only, hidden from the model catalog. Validate TOML and atomically replace that fixed policy after a one-time diff confirmation and revision check. Binding changes reload before egress; approval-file changes need a fresh nodwyn trust review before new/resumed sessions load them.
(credential-register class) P3. Masked input through the front-end, stored in the keychain, returns #<cred:class:id>; a class that already has a static key gets the new value instead of a second credential. User origin only: refused to the model and to the loop's strategy; never a model tool
(credential class) The handle of a key already in the keychain, or #f if there is none (or no keychain is open). An OAuth2 credential (ADR 24; added with nodwyn credential add --oauth2) is a handle like any other: its access token is refreshed, when due, just before it is injected. This is how Scheme reaches a keyed service — http-request to a model API, say — without asking for the key again. The judge needs none: its primitives carry its server's key themselves. Looking a handle up grants nothing: every use is checked against the binding table. Refused to model-originated code
(judge-yes-no state instructions) Ask the judge a yes/no question about a text; returns the probability of yes (0..1). Egress to the judge's server (ADR 46): Jev's System One API (a noul), or OpenAI's Decisions API (a predicate) for a judge on an OpenAI server, whose answer is read into the same values. Go injects the server's stored key, and the guard judges the call as carrying its class, so that server's binding decides it. Everything sent (state, instructions, levels, options) is redacted (L1) before it leaves, and every argument is a payload; logged as jev.req with the call's origin. A Decisions refusal is an error
(judge-score state instructions levels) Rate a text on an ordered rubric of 2–10 level strings, low to high; returns (("score" . 0..1) ("raw" . n) ("levels" . k) ("confidence" . c))
(judge-choice state instructions options) Pick one of the options (an alist name → description); returns (("choice" . name) ("confidence" . c) ("probabilities" . ((name . p) …)))
judge-yes-no, judge-score, judge-choice The three ask the task's judge (ADR 47): [judge]'s, or the one the task was opened with or moved to, read on every call, so a call after a switch goes to the new judge with its key, and one with no judge is an error. They are registered when a judge could be asked at all (a keychain open, judge.content not none, a judge provider in config.toml), and are the model's tools only at judge.content = "full" and when the task starts with a judge whose key is stored, because the model chooses the text it sends (ADR 20, amended); below that they are hidden, for the plan gate and your Scheme. A tool you register may call them at any level, with the model's origin: which judge-backed tools exist is yours to decide (ADR 20 §3)
(judge-available?) Whether the task has a judge now whose key is stored: what the plan gate's (plan-judge?) asks. Hidden, no egress; registered with the judge's primitives (ADR 47)
(doc-export source target) W5. Convert a Markdown (.md) or LaTeX (.tex) file to a Word document (.docx), OpenDocument text (.odt) or HTML page (.html) with pandoc (2.15 or later, from PATH); the target's extension picks the format. source is a source (read: pinned, denylisted, a read-only mount may supply it) and target a path (written): irreversible, so it asks with a preview (source → target, formats, whether it creates or replaces), a standing write approval covers it as it covers fs-write, and the target's previous version is kept. pandoc runs twice with fixed arguments, no shell, the scrubbed environment and shell-exec's timeout: --sandbox --from=F --to=json reads the source on stdin and nothing else (an \input, an include or a remote image reads nothing); Go then keeps, for .docx and .odt, only the images that are files in the workspace and not credential-shaped, by absolute path, and turns any other into its description, and for .html drops images linked from elsewhere; raw content (OpenXML, HTML, LaTeX written into the source) becomes literal code in every format; --from=json --to=T --standalone --output=- writes the result, which Go writes to target. Nothing the model writes becomes a pandoc option. No PDF: a TeX engine reads and runs what a document asks, so a LaTeX build stays shell-exec (latexmk). The first run's tree is read into memory, so it may be at most 8 MiB (a source of several hundred KB): a larger one is refused, "pandoc wrote more than 8 MiB". Returns what it wrote, the images it left out and pandoc's warnings
(google-doc-upload path [title]) W5. Send a .docx, .odt, .html or .txt file (at most 5 MiB, Drive's limit for one upload) to Google Drive as a new Google Doc, in one multipart upload asking for application/vnd.google-apps.document; returns (("id" . ID) ("url" . "https://docs.google.com/document/d/ID/edit") ("title" . T)). title defaults to the file's name without its extension. Go uses the stored credential of the class docs.google_credential names (default google, an OAuth2 credential from nodwyn credential add --oauth2 google, whose narrow drive.file scope suffices), and the guard judges the call as carrying that class to the upload endpoint's host (www.googleapis.com) by POST at header:Authorization:Bearer: unbound is denied before anything is asked. Egress and irreversible: how a call is handled is the person's setting docs.google_upload. With ask every call is a security confirm naming the file, its size and the destination (the confirm also names the class and scopes), which no standing answer, --yolo, --judge or unattended run answers yes. With allow no call asks. With deny every call is refused and later sessions are not offered the tool. While it is unset, the first call asks the person which and saves the answer (setting.change). The file's bytes never become a Scheme value, so the handler scans them itself before sending (L1, extract.AllText: a text file as it is; every XML part of a .docx or .odt, runs joined; an embedded file is refused) and refuses a known secret shape whose class is not bound for that host. The token is injected through the session, so an OAuth2 token is refreshed first and cred.refresh logged; each upload is recorded as doc.upload (file, size, hash, destination, document id; never content or token). Registered only when the session starts with such a credential stored and the setting is not deny. path is a source; title is a payload

MCP servers you approve in the console (ADR 23) add one primitive per tool you turned on, named server__tool (model APIs refuse /; the console shows server/tool). Each is egress to that server's host, with the server's key, if it has one, bound to it, and a tool you did not declare as reading only confirms every call. Every argument is a payload: a literal key in it is refused unless its class is bound for that host (L1), and content from outside may go there because you approved the server, key or not (L2). Results are tagged external:mcp. They are offered to the model like the tools above.

A contained server's tools (ADR 23 W3b) send to its container, mcp:NAME, which may pass the arguments on to the hosts of its network list and nowhere else. A literal key in them, or a value derived from a credential, is refused unless its class is bound for every one of those hosts; with no network, always. The call carries no credential: the server's key went in when it started, and its binding is checked again before each call. The confirm and the tool's description name the contained server and what it can reach.

Provenance (L2)

Every value carries provenance tags: where it came from, not what it contains. A host primitive tags its result (fs-read, fs-list, fs-find, index-grep, git-status, git-diff, doc-export and policy-read mint external:file; shell-exec and the background-process primitives mint external:shell; http-request, the judge-* primitives and google-doc-upload mint external:http; MCP tools mint external:mcp; session-list, session-events, recall and index-search over the conversation and earlier tasks mint external:log, and index-search over the repository and index-update external:file), a credential handle carries cred:<class>, and the tags of a call's arguments flow into its result.

(taint-of x) the tags as a list of strings, sorted; () for a clean value
(tainted? x) true if the value carries any tag
(wipe x) the same value with its tags removed. User-originated Scheme only — the guard denies either from model-originated code and from an agent-turn strategy (§9), which handles every tool result — and every use is logged

Propagation happens at the primitive dispatch. A list keeps each element's tags on the element, and a list built without a primitive (by map, by quasiquote, or holding what vector-set! put in a vector) may carry none of its own; so a string a primitive makes from a list or a vector (string-join, json-emit) takes the tags of everything in it, as do the output with-output-to-string captures and the message guard hands on from an error that quoted a tagged value. A primitive whose result measures its arguments rather than being made of them carries nothing: length, string-length, vector-length, every predicate (null?, string?, even?, …), the comparisons (=, <, string=?, string-contains?, …), not, and taint-of itself.

What provenance is for: the guard refuses to send content that came from outside to a destination no binding authorises, and refuses a command line built from tool output without a confirm. What it is not: a boundary against the user, who may wipe, and it does not survive a value being rebuilt a character at a time through the opaque primitives above. The real egress lines are the binding table, fail-closed defaults and ingress redaction (SPEC Risk 1).

Meta

(primitives) list of primitive names, sorted
(primitive-info name) alist: doc, params, variadic, egress, irreversible, host, origin
(register-tool name proc meta) add a primitive backed by a Scheme procedure; see §7
(load path) evaluate a file's forms in the global environment; a relative path resolves against the interpreter's base directory (the repository root in a session). A host primitive: it reads the filesystem and is subject to the guard
(handle? x)

6. Prelude

Brote's scheme/prelude/*.scm is embedded in the binary and loaded into the global environment before anything else. It defines, in Scheme:

(assoc-ref al key [default]) cdr of (assoc key al), or default (void if absent)
(identity x)
(compose f g) (lambda args (f (apply g args)))
(string-empty? s)

After the prelude, nodwyn loads the current ./.nodwyn/init.scm only after external user trust (ADR 14, amended by ADR 45). nodwyn trust reviews and accepts the init/allow revision; a clone cannot grant its own startup authority.

7. Primitives and the guard

The primitive table maps a name to a Go handler plus metadata. It is the only surface through which Scheme reaches the host (SPEC invariant 4).

Every primitive declares:

Field Meaning
doc one line, shown to the model as the tool description
params ordered list of (name type doc); type is one of string integer number boolean list alist vector any. A Go primitive may mark trailing parameters optional (ADR 29): a call may stop before any of them, and the handler sees fewer arguments; one skipped before a later one that is given gets its type's empty value. A variadic primitive cannot also have optional parameters. A Go primitive's list parameter whose elements are objects may describe their fields (Items, ADR 38): the tool schema gives it an items object schema, and the guard reads a field's role as it reads a parameter's
variadic extra arguments allowed after the declared ones
egress may send bytes off-machine
irreversible cannot be undone by the harness
host touches the OS (filesystem, processes, network)
origin builtin or scheme
hidden never offered to the model as a tool (load, register-check, the loop's primitives)
audited its guard decision is logged even when it is an allow of a pure builtin
preview Go-only, for irreversible host primitives: renders what the call would do (a diff) for the confirm prompt

Parameter roles (path, source, dest, payload, credential, and for commands and keyed requests argv, method and position) tell the guard what an argument is; they are declared by host primitives. A path is what the primitive acts on, and writes when it is irreversible; a source is a file it only reads, whatever else it does (doc-export's document, the file google-doc-upload sends). Both are pinned and checked against the credential-file denylist, but a read-only mount may supply a source even to an irreversible primitive. A write is an irreversible primitive with a path and something to put there: a payload, or a source. A field of a list parameter's elements may carry a role too (ADR 38): each new in fs-edit's edits is a payload, so the secret scan and the taint check see every replacement, and not the old text it replaces, as they see a single edit's new.

Application of a primitive — whether from the model, the prelude, a .scm file or the REPL — goes through exactly one Go function, which:

  1. checks the argument count against params/variadic;
  2. asks the guard (when one is installed) for a decision; a denial raises denied: <reason>;
  3. calls the handler.

register-tool adds a primitive whose handler applies a Scheme procedure:

(register-tool "go-test"
  (lambda (pkg) (shell-exec "go" (list "test" "-race" pkg)))
  '((doc . "Run the Go tests for a package pattern")
    (params . (("pkg" "string" "package pattern, e.g. ./internal/log")))
    (irreversible . #f)))

Rules: the name must be new (builtins cannot be shadowed, and a tool cannot be re-registered — the tool list must be stable for the whole session, ADR 9); once the session has fixed its tool list, register-tool is an error wherever it is called from. doc and params are required; egress, irreversible and variadic default to #f. What a provider or the first call would reject is refused where the tool is registered: a name outside 1 to 64 letters, digits, - and _, a parameter listed twice, and a procedure whose arguments do not fit params (a variadic tool needs a rest parameter). A registered tool is indistinguishable from a builtin at the dispatch site, and its own primitive calls are guarded in turn.

register-check (ADR 20) adds a guard checker written in Scheme:

(register-check "no-external-llms" "confirm"
  (lambda (prim-name args)
    (or (equal? prim-name "shell-exec")
        (equal? prim-name "http-request"))))
  • name is the checker name that appears in guard.decision events.
  • verdict is fixed to "deny" or "confirm" at registration time. It can never be "allow" — the guard merges decisions strictest-wins, so a user checker can only tighten, never relax.
  • predicate is a procedure (prim-name args). When it returns a true value (anything but #f, as everywhere in Scheme) the checker returns its fixed verdict; otherwise it has no opinion (allow). A predicate that raises an error fails closed as a denial.

The builtin is hidden (never offered to the model as a tool) and callable only from user-originated Scheme — the model cannot add or bypass checkers at runtime. Checkers registered this way run after all built-in Go checkers in the pipeline.

Read-only tools may run concurrently, but Scheme checker predicates are evaluated one at a time across all the session's registered checkers. They share the interpreter's bindings, so a predicate's state changes cannot race another predicate. Their strictest-wins and fail-closed rules still apply.

Loading a .scm file can grow the primitive table and the global environment. It cannot add special forms or change evaluation: eval and apply are two fixed Go functions, and the test suite checks, after loading arbitrary code, that the primitive table grew by the registered tools alone and that define-syntax is still a plain binding.

8. Embedding API (Go)

in := scheme.New(scheme.WithOutput(w))       // prelude loaded
v, err := in.EvalString(ctx, "(+ 1 2)")      // last value of the source
v, err = in.Apply(ctx, proc, args)           // call a procedure
err = in.Register(&scheme.Prim{...})         // add a host primitive
in.SetGate(g)                                // install the guard
snap := in.Global().Snapshot()               // copy the global bindings
in.Global().Restore(snap)                    // put them back, unbinding names bound since

ctx cancellation is checked at every application; a cancelled evaluation returns the context's error. ctx also says who started the evaluation (scheme.WithOrigin: the user, the model or the loop's strategy); a context that does not say is the strategy's, never the user's, so an embedder gives the user's powers (wipe, register-check, policy-write) only where it says so.

9. The agent loop

A turn is a Scheme procedure. The session records the user's message and calls (turn-start request) and then (nodwyn-turn request) with the recorded (redacted) text; nodwyn-turn runs the plan gate when the turn must plan and then (agent-turn request) (§9.5). turn-start resets what the default habits keep for one turn (the stop after a compaction that wrote nothing, the stalled count, the request second-look reads, whether the turn has edited code), so a project that replaces nodwyn-turn keeps those resets; one that redefines turn-start calls the definition it replaces, as the habit files do. The defaults are installed before .nodwyn/init.scm loads, so a project can redefine either. Stripped of /compact, /optimize, the plan gate, ask-model's retry of cut replies and the evidence loop for unattended turns (all below, and in internal/loop/strategy.scm), the default agent-turn is this loop:

(define (agent-turn request)
  (let loop ((reply (model-turn)))
    (let ((calls (assoc-ref reply 'tool-calls '())))
      (unless (null? calls)
        (run-calls calls)
        (maybe-compact request reply)
        (loop (model-turn))))))
(model-turn) (model-turn name) (model-turn name options) Ask a model once: the control model ("control"), or the configured model [models.NAME] (manual, "Choosing models"). options is an alist; '((tools . #f)) offers the model no tools, as the planner's request does, and '((tools "fs-read" "fs-list")) only the named ones (an unknown name is an error that lists the tools), as a sub-agent's requests do. Both are recorded (llm.req no_tools, offered_tools) so replay rebuilds the request. Any other value but #t is an error: '((tools #f)), a list holding #f, is not a silent yes. '((headroom . #t)) lets the request run past the window's high water, up to its ceiling ((window-limits)), before older context is trimmed, as compaction's note request does; llm.req records that trim line as high_water. Returns an alist: content, thinking, done (why it stopped), tool-calls, each call an alist of id, name and args (the model's JSON text; a call whose arguments the model wrapped in a JSON string, the whole object or a list or object parameter, arrives unwrapped and the log notes it, ADR 29), and prompt-tokens and output-tokens, the server's counts for this request (0 when it gives none), cached-tokens, the prompt tokens the provider read from its cache (#f when it does not say), reasoning-tokens, the part of output-tokens spent thinking (0 when the server does not say), and cost, the request's cost in USD as the provider reported it or as the model's configured prices estimate it (#f when neither). The window is built from the log, so the model sees what the strategy's earlier calls recorded; each request is recorded, with the model it went to, for nodwyn replay --window. An attached image in the window goes only to a model that takes images at the host its confirm named; any other request carries a line saying why it does not, recorded as llm.req withheld_images (ADR 22, amended 2026-10-01).
(run-tools calls) Run calls, each through the guard, in order, but for a batch of two or more different calls that are all read-only tools (fs-read, fs-list, fs-find, index-grep, outline, git-status, git-diff, git-head), which run concurrently, at most 8 at once. A command, a write, shell-read, index-search, an MCP tool, a tool a project defines, scheme or a repeated call in the batch makes the whole batch run in order, and so does a batch of one call, which a strategy that runs its calls one at a time with run-tools always passes. Either way it returns an alist per call, in the order of calls, whichever finished first: id, name, ok, value (the text the model reads, carrying the tool's provenance), error, denied. A concurrent batch reserves each call's tool-call budget in order before any starts, so a limit that falls in the middle of the batch runs the calls before it and answers the rest as not executed; the progress guard sees the calls in order once they have all finished, and when it stops the turn at one, the calls after it have already run and are answered as they ended. A call that fails stops none of the others; a call that needs a confirmation asks while the others run.
(skip-tools calls reason [refused]) Answer calls without running them; the model reads Not executed: reason. With #t as the third argument the calls were refused as the model's mistake and count as failed calls: against the tool-call budget and to the progress guard, as a run call that failed does, so a model that keeps sending what is refused is stopped (shell.scm does this for command lines). Without it the skip is the strategy's own bookkeeping and counts for neither.
(add-message text) Add text to the conversation, logged as harness.msg. The model reads it as a user message at the next model-turn; the current task stays the user's. Refused while calls from the latest model-turn are unanswered, since a call must be followed by its result.
(fold-result result text) Show a tool result as text in later windows, such as an outline or a line saying what it was, once its details stopped mattering; result is the tool.result event's seq or the id of the call it answers. Recorded as result.fold, so replay rebuilds the same window (recipe window-v9); the result itself stays in the log for audit and recall. Folding an old result changes the prompt from that point on, so the server re-reads what follows it: fold in batches, or results near the end
(narrow-context system [agent [options]]) Start a fresh context, like Emacs's narrowing: until fold-context, widen or the user's next message, models see system as their system prompt and only what comes after this point. Follow it with add-message to hand over the task. This is how a model gets a task and a plan without the conversation that led to them. Contexts nest. Calls from the latest model-turn that are still unanswered wait for this context to fold; its own model-turns start with none pending. agent labels the context in the log. options is an alist; '((lasting . #t)) makes the narrowing outlast the turn, as compaction's does (§9.2): if it is still open at the user's next message, the conversation starts there from then on, under the session's system prompt, and what came before stays in the log only. Recorded as context.narrow lasting (recipe window-v11). Any other value but #t or #f is an error.
(fold-context) End the innermost narrowed context and fold it away: later windows skip everything since its narrow-context and see the enclosing context again; the log keeps it all. The calls that waited for it are the latest model-turn's again. Refused while calls are unanswered.
(widen) End every narrowed context. Models see the whole conversation again, including what happened while it was narrowed, from where the latest lasting narrowing of an earlier turn started it. A new user message widens too, except that a lasting narrowing still open becomes where the conversation starts. Refused while a context holds calls waiting for an answer: fold it.
(answer-call call text) Answer one of the model's calls with text, as a successful result, without running the tool: how a tool the strategy handles itself answers. Counts against the tool-call budget.
(session-system-prompt) The system prompt the session started with (§9.1), fixed for its life: what a narrowed context passes to narrow-context to keep the model's standing instructions.
(session-requests) The user's messages the session holds, oldest first, as the log recorded them (redacted); the running turn's request is the last. A session opened here holds every one; a resumed one holds the end of its log (ADR 49 §10), which has at least the latest three and the latest that is not a /compact. Read from the log, so it holds across a resume: how /compact finds the request it hands over (§9.2).
(request-context) What the latest request answers and carries beside its words, as the log recorded it (redacted): an alist of reply, the agent's last answer before it (its last reply that said something and called no tool; "" when there is none), and attachments, the names of the files attached since the request before it. How the plan gate's explorer and planner, in contexts of their own, see what a reply answers (§9.5).
(model-info) (model-info name) What is known about a model before asking it: the control model, or the configured model [models.NAME]. An alist: name, model (the provider's id), provider, num-ctx (its context length, or what its provider publishes; 0 when neither says), max-output (the reply cap its requests carry: its max_output, else the longest reply its provider publishes at most a quarter of its context, else window.output_reserve, at most a quarter of a context too small for the window and that reply), and temperature and seed (its sampling settings, #f when the server decides) and labels (the words its config entry gives it, a list of strings). An unknown name is an error that lists the configured ones.
(model-names [label]) The names model-turn and model-info accept: "control" and each configured [models.NAME], sorted. With a label, only the models that carry it: (model-names "fast"). A model's labels are its entry's, else those of another entry for the same model, as its efforts are; the default model goes by its [models.NAME] entry's.
(tool-names) The names of the tools the model is offered, in the order it is offered them: the names model-turn's tools option and sub-agent's tools list accept. Empty until the tool list is fixed, once the session's Scheme has loaded (ADR 9).
(window-limits) The window's sizes, in tokens as the window estimates them: an alist of high-water (past it older context is trimmed), low-water (what trimming goes down to), ceiling (the most a request with headroom may carry: the control model's context less the reply cap, or the high water when nothing is known above it), max-result-tokens (a longer tool result is shortened) and output-reserve (the default reply cap). The water marks grow with a large context unless config.toml sets them, and shrink to fit a small one whether set or not (manual, "Choosing models"). Compaction's default threshold is read from it (§9.2).
(context-tokens) (context-tokens result) The size of the context the next model-turn would send, in the units of (window-limits), before any trimming. Unlike a reply's prompt-tokens it counts as the window does, whatever the server's tokenizer, and includes the tool results added since the last request. Compaction measures it (§9.2). With a tool result (its seq, or the id of the call it answers, as for fold-result), only the part from that result to the end: what a server with a prompt cache reads again when the result is folded, since the prompt before it is unchanged. #f when the window does not show the result (a narrowed context or a compaction left it out). hygiene.scm weighs its folds with it (§9.6).

When nobody reviews the work as it happens — (unattended?) (nodwyn run's --yolo, a run's auto_approve, --judge, --unattended) or a supervised run's turn (plan-required?) — the default agent-turn ends a turn on evidence, not on the model's word (evidence-loop): a reply that stops before anything changed, or with a change and no test run after it, gets finish-no-change-message or finish-untested-message, at most finish-reminders (2, and 0 in the lean profile, §9.8) times; one whose last test or check run after the last edit exited non-zero gets finish-failing-message once until the next edit, and may answer that the tests fail on the unchanged code too and end. The loop reads the tests a run reports failing (failure-names: Go's --- FAIL: and FAIL<tab>package, pytest's FAILED, unittest's FAIL: and ERROR:) and keeps those of the runs it knows are on the unchanged code through the edits after: any run on $TMPDIR/base, and a full test run (not -run, -k or a node id) before the turn's first edit, in a turn that began on the last commit. A run that fails only such tests, none of which the request, the plan or a file the turn wrote names (failures-known?), is not told: its result says they failed on the unchanged code (failure-known-hint) and the reminder is not due. When only some are such, the reminder names them (finish-failing-known-message: say so in your answer, do not fix them) and the others (ADR 32, amended 2026-10-16). An exit that says no test ran is no failure: not-run-statuses (126 and 127, a program not found or not executable) and, for a pytest command, pytest-not-run-statuses (4 and 5, a path it could not find, no tests collected). A run on the copy of the last commit ($TMPDIR/base, base-run?) tests nothing of the change; one that fails after the change's run failed is the comparison done, and the failing run is not told. After finish-no-change-message a turn that has run a test or check may end unchanged: the code may already do what the request asks, and the message says to show it with a check (ADR 32, amended 2026-10-10). In sandbox mode ((sandbox?), ADR 55) no turn is told that nothing changed: the whole machine is the workspace, and a command may change a file anywhere on it that neither git nor the file tools see. When such a turn first ends with a change and none of those reminders is due, the finish review (finish-review-points) gathers what the change lacks and says it in one message, (finish-review-message points): one point as it is, several numbered, closed by finish-review-close (asked once; run the check again after any change; the next reply without tool calls ends the turn). It is made once a turn, and the turn is accepted after it: an answer ends it, and the reminders above are not asked again (ADR 32, amended 2026-10-15). In a run nobody reads as it goes it is made at most finish-review-limit (2, and 0 in the lean profile) times in all, counted in finish-reviews over the session's turns; a turn after the second ends on its report. A turn whose answer a person reads ((reviewed?)) is not held to it (ADR 32, amended 2026-10-16). Its points: files new since the turn began that git does not track ((finish-new-files-message files), below); a plan's steps without their evidence (§9.5, plan-language-unmet-message; a test step may name a scenario or fixture file, a path through a scenarios, testdata or fixtures folder, which is met by that file, one the repository has or one the turn wrote whose name starts with the step's path, and by a go test run of the folder above it: plan-language-test-present?); the parts of the change the check or test run that exited 0 since the last edit and runs the most of what changed (broadest-passing: the one that names the most of the changed files' directories, ./... and the whole-check words naming all of them, one not narrowed to some tests before one that is, the newest of those that tie; not the last passing run, which was the exfiltration suite alone on 855e90e7) does not test, run again with each part taken out in turn (check-without) and naming the parts it still passes without, printing the same (finish-untested-parts-message): a check that cannot fail for a part does not test it, and one that prints something else without it does, whatever its exit status. The review asks for a test only a few times: it names at most finish-ask-limit (3) of those parts, the largest first, and of the plan's test steps whose test is missing or passes on the unchanged code, and of the dropped conditions, says how many more there were, and does not name an item again in a later review of the run (finish-ask, finish-asked); the parts message asks for a case only for a part that implements what the request asks for, and says a flag, a rename, a refactor or a skip gets no test (ADR 32, amended 2026-10-16). And a command the final report cites in backquotes, one the turn ran (shell-commands reads each command line as the shell does) and has not run since the last edit (stale-citations, finish-stale-citations-message). Runs on $TMPDIR/base never go stale, and once the repository's whole check (whole-check-words: make validate, make check, make test, …) has passed since the last edit, a cited check command stands on it. When the latest run since the last edit passed, so the model's own check did not notice, the review also names what the change removed (change-removals): definitions HEAD had in a changed file that its package no longer defines and the request does not name (finish-removed-definitions-message: keep it and have its callers go through it, unless removing it is the task), and lines of existing tests that stated an expectation, a case of a table or an assertion, removed or rewritten (finish-removed-expectations-message: restore each the request does not change, or quote the words that change it; not the lines whose answer to the question at the edit was read and supported, and, as the review's first point, the lines it was not: finish-unsupported-edits-message, below), and the existing tests the turn edited that fail when their files are as HEAD had them (original-tests, finish-original-tests-message: the tests are named; either the change is wrong, so change the code, appending to what a test pins rather than rewording it; or the request names the new result for that input or case, so the assertion changes and the answer says which sentence requires it; or the request contradicts the test's setup, a fixture, so change the setup and not its assertions, and say which sentence requires it; any language: the check the turn passed is run again with the edited test files as HEAD had them, and the tests are named from its output; the log says when no passing run runs the edited files), the tests of a run that failed that the last commit passes (regression-failures, finish-regressed-tests-message: the same command with every changed file as HEAD had it; your change breaks them, so change the code, or say which sentence of the request requires it), and the parts of a condition a rewritten Go function no longer checks (change-dropped-conditions, finish-dropped-conditions-message: add a test case where it is false if the task drops it, else put it back). And it names the lines the change adds to documents and Go and Scheme comments that state what nothing in the turn showed (change-claims, finish-unshown-claims-message): a sentence reporting a run (passed, warnings, found nothing, executions, a fuzz or benchmark figure) that no run of the command it names, exited 0 in the turn, backs with its figures among its last lines; an amendment date the file did not have that no command printed and the request does not give; three or more lines a document's edit inserted that stand word for word elsewhere in it; and entries a hand edit added to a generated file (its head says Code generated or Generated by, or names a "source" address, or a trusted .nodwyn/init.scm lists it in generated-files as (path command) rows) with no command after the edit that made or fetched it. The loop keeps the turn's commands for it (shown-entry: each command line, exit status and the first shown-head and last shown-tail characters it printed, at most shown-max of them; the file tools and the tools that only look are left out, since their answers show the model's own text) and the files it edited by hand with the commands run since (hand-edits-add). A command is a test run when its command line contains one of test-command-words (pytest, go test, cargo test, npm test, make test, make validate, …; a project may redefine the list), whether shell-exec ran it or shell-start did and shell-read or shell-stop saw it exit after the last edit (background-call: the shell-start call itself, never one rebuilt from the shell's output, whose taint the guard would refuse when check-without runs it again), or a tool the repository registers to run a command did (tool-commands: validate is make validate, go-test {pkg} go test , run-make {target} make ; a project may add rows): tool-call makes the shell-exec call that runs its command from the call's own arguments, and the tool's text gives its status ("exit N", or a background run's "pN: exited N"); a tool's whole check (validate) runs for minutes, so it is not the check check-without takes apart, though its pass stands for the whole check's. A run whose status may be another command's (masked-run?: a pipe without pipefail, an ||, a $? in the line) passed, for a plan's evidence and marks, only when its output shows tests passing and none failing, and a run on $TMPDIR/base never did (run-entry); a Go test step needs its test's --- PASS or its package's ok in the output (ADR 32, amended 2026-10-14). After unrunnable-after (6) test commands in a row fail to run, the model gets (finish-unrunnable-message n) once: finish and report the change as unverified rather than spend the budget on a broken environment. A run that shows tests ran here does not count, and ends the count (tests-ran?: a test it names failed, a test or package passed, or a package says FAIL with a time of its own, not [build failed]), and neither does a failing run after an edit that took part of the model's own change out (shrinking-edit?: the file's changes against HEAD got smaller, measured from the turn's first command on), which is a revert experiment, expected to fail, and leaves the count where it was (ADR 32, amended 2026-10-16, the original tests). In sandbox mode, the first time a turn of the session ends on the model's word (a reply without tool calls, with none of these messages due), default-agent-turn says sandbox-finish-message and goes on as the evidence loop, so the turn ends at the model's next answer unless a reminder is due: the model is asked to check its result as someone who did not watch it work would, and to replace what only makes its checks pass with a real solution or remove it and say what does not work. It is said once in the session (sandbox-finish-said), in either profile and under lean-quiet too, never after a stop or to a turn that is stopping (turn-stopping), and the log has a note beginning "sandbox finish check:" (ADR 55, amended 2026-10-19). When a person reads the turn's answer before anything else happens, (reviewed?) (a chat's turn, or a supervised run's turn that a run checkpoint follows; not an unattended run, nor a run's last turn), a reply that changed nothing gets finish-no-change-reviewed-message instead: make the change, unless the request asks for no change yet, such as a proposal to review first. After it the turn may end unchanged. Where git-status exists, it names every changed file, credential files included (a name is no secret; git-diff leaves their contents out), so a file changed through the shell counts. The scratch directory is not the repository: a file the model writes there (as $TMPDIR/… or by its path) is not a change, and a command that runs one of those files — its check script — is a test run. A check fails until the work is done, so its failing runs never count toward unrunnable-after. In a turn nobody reads ((unattended?)), a test or check run that passed after the turn changed something has the loop call (keep-passing): the host keeps the repository's changes as they are. If a hard limit or the progress guard then stops the turn, which no strategy can catch, the core puts the files back to that state, saves what they held after it, and says so in the log and in the turn's end (ADR 18, amended 2026-10-06). A turn that changed nothing keeps nothing, and a chat, a person's own stop or a soft limit put nothing back. Where git-status exists, git says whether anything changed, edits made through the shell included: a tracked file's status differs from the start of the turn, or the file tools wrote a file git now lists as new or listed already; files a run leaves behind are no change. Files new since the turn began that git does not track are a point of the finish review, (finish-new-files-message files): whatever is left in the repository is in the change. Files the turn's plan names, by path or by name (plan-names?), or whose name starts with a plan step's path (a scenario file: test/exfil/scenarios/019 for 019-key-before-private-key.scm), are left out of it: the plan asked for them. A shell run of make validate or go run ./scripts/validate (validate-commands) that ran to an exit counts, for a repository whose .nodwyn/init.scm defines validation-state and validated-state (nodwyn's does, to remind a turn that has not validated since its last change), as its validate tool's run: validated-run sets validated-state as the tool does, and either is a test run, the whole check, and no check for check-without to take apart. The untracked files a validate run, or a tool of tool-commands, leaves (files-made: new in git-status since the round began, not written by the file tools) are the run's output, not the change. The validate result names them (validate-left-note: delete them unless they are part of the change; that needs no new run), and the loop keeps the repository as validate left it: when the only thing that moved since is that some of those files are gone, validate still stands and validate-settle sets validated-state again, so the repository's reminder is not said for a deletion. A file still there when the finish review is made is named in it as one whose deletion needs no new run (ADR 32, amended 2026-10-16).

The loop also asks about an expectation of an existing test as the edit is made, not only at the finish. After a round whose calls wrote a test file (test-file-path?: x_test.go, test_x.py, x.test.js, a file under tests/) not yet asked about in the turn, it sets the change against HEAD with change-removals and, for the lines of that file, adds (edited-expectations-message lines) before the next request (test-edit-nudge, which returns (files . lines)): which sentence of the request requires the new expectation, for that input or case, answered in a form, one line for each row, the row and then a sentence copied word for word from the request that names the input or case the row tests; if none does, none, restore the line and change the code (appending to the phrase a test pins, not rewording it). The expectation lines are a case of a table, an assertion that carries one, a literal assigned to want, expect, expected or a name that starts with one (wantErr, expected_msg), and in Go a strings or bytes check (Contains, ContainsAny, HasPrefix, HasSuffix, EqualFold) of a string literal that is not a case line; a line whose string literals all stand on one line the change adds (a case moved from a map to a direct assertion) is not removed. The edit is never refused. The lines are HEAD's, so a test the turn added, or a case it adds to a table, is not asked about, and a file is asked about once in a turn.

The answer is read, not weighed (edit-answers, edit-support). The rows the question named are kept for the turn, each in the state asked, firm or ok. After every round whose reply has words, the rows still changed (the model may have restored one: then it is answered) are set against the reply with (edit-support lines request reply): a quote is a run of three words or more of the reply that stands in the request in the same order, case, punctuation and white space aside, and it supports a row when it shares a distinctive token with the row's string literals, its input and the message it expects, and with the line that replaces it (a word of three letters or more, or a number from 2 up, lightly stemmed, that is no stop word and no part of the name of a function the row calls; the words of its identifiers when its literals give none). A row whose replacement cuts words out of the phrase it expects, or puts words inside it, needs more: a quote with most of the words cut or all of the words put, so that appending to a pinned phrase is supported by the sentence that asks for the addition, and cutting or splitting it is not. A supported row is ok; a row with no token to judge it by is left to the review's other point. A reply that quotes the request and supports no more of a row gets one firmer message for it (edited-expectations-firm-message: what the request does not mention, the default of restoring the old expectation and changing the code, and that the row is kept only by quoting the sentence that names it), and its state becomes firm. A reply that quotes nothing is no answer yet, and the reply that ends the turn is read for the rows it supports and says nothing more. At the finish, the rows still unsupported are the review's first point, with the original tests that fail (finish-unsupported-edits-message). Nothing is reverted or refused: the model may keep an edit, against the evidence (ADR 32, amended 2026-10-16).

The finish review used to ask for the lines of the change that no test ran (finish-uncovered-lines-message, with coverage-extensions, branch-words and coverage-run?) and no longer does: on commit-bench 6e18b7ba (dsl22) the model spent 62 of its 167 calls and 1.12M of its 2.38M cost-eq running coverage and writing tests for branches of its own defensive code. source-extensions and source-path? remain, for the directories a change touches (changed-dirs, which broadest-passing uses).

Both loops also count, in stall-check, the rounds in a row whose reply thought at length (stall-reasoning-tokens, 10,000, or more reasoning tokens, as ask-model noted in last-reasoning) and whose calls wrote nothing; at stall-rounds (3; 0 turns it off) the model gets stall-message, to write down the design it reached and make the edit, and the count starts again. Each turn starts it at 0.

Both loops, the chat's and evidence-loop, count the rounds in a row whose calls only look, at the repository or the conversation: every call names one of look-tools (fs-read, fs-list, fs-find, index-grep, read-definition, git-status, git-diff, index-search, recall; a project adds the tools of its own that only look). After reading-reminder-after (12; a table, §9.7; never under lean-no-read-reminder, the lean profile's) such rounds the model gets (reading-reminder-message n), or (reading-reminder-reviewed-message n) when (reviewed?): its thinking may not come back to it, so write down what it found now, as a plan with plan-write, or, when a person reads the answer and the request asks a question or needs their decision, as the answer. A turn that has a plan ((plan-recorded?)) is told instead to make the change for the plan's next unmarked step, and to write the plan again only if it must change, since a step it rewords starts unmarked. Any other call starts the count again, and so does the reminder; 0 turns it off.

(unattended?) true when nobody approves this session's calls: it answers ordinary confirms itself (nodwyn run --yolo, a run's auto_approve), the judge answers them (--judge), or nobody is there at all (nodwyn run --unattended). [approval] (ADR 35) does not make it true: a chat whose commands the judge answers still has a person to ask
(sandbox?) true in sandbox mode (nodwyn run --sandbox container, ADR 55): the machine is the session's disposable sandbox, its tools reach any path but nodwyn's own directories and credential files, and nobody approves anything ((unattended?) is true too). The lean prompt says so (§9.8), the default loop does not tell a turn that no file changed, since a command may change any file on the machine, and asks the model once to check its result as someone who did not watch it work would (sandbox-finish-message, §9)

A reply's done is "length" when the model reached the output cap (the model's reply cap: its max_output, else the longest reply its provider publishes (at most a quarter of its context), else window.output_reserve, at most a quarter of a context too small for the window and that reply); the core notes it in the log. What to do is the strategy's: (ask-model …) is model-turn, asked again with output-cut-message when the reply was cut with no tool call to answer, at most output-cut-retries (2) times. A plain (ask-model), the one that offers every tool, is also asked again, once (empty-reply-retries), with empty-reply-message, when the reply has no words and no tool call: such a reply would end the turn with nothing to read. The default loop, the planner and sub-agent use it.

A strategy talks to the model through tools: a tool's description is an instruction, its result and a skip reason are feedback. add-message is for when there is no call to answer, such as a model that stopped with work left.

Routing work to another model is narrow-context, add-message and (model-turn name) in a loop: the other model gets a context the strategy built, and its tool calls run like any model's. Narrowing and routing are events in the log, so replay rebuilds every request whichever model it went to.

These primitives are hidden from the model. The ones that act on the conversation (model-turn, run-tools, skip-tools, answer-call, add-message, log-note, fold-result, record-plan, plan-steps, record-step, record-compaction, narrow-context, fold-context, widen) are refused outside a turn and from model-originated code; the ones that only describe the session (model-info, model-names, window-limits, session-system-prompt, session-requests, context-tokens, unattended?, sandbox?, reviewed?, plan-required?, plan-recorded?, run-plan, unfinished-plan, session-list, session-events) answer at any time (session-system-prompt and context-tokens once the session has started), which is how compact.scm reads (window-limits) as it loads. The strategy runs with harness origin, so it cannot strip provenance or change policy (wipe, register-check and policy-write are user-only); the tools it runs execute with model origin, as if the model had called them directly. Whatever the strategy does:

  • it runs or skips only calls the model made, each once, with the arguments the model sent;
  • every call gets exactly one result: calls left unanswered are skipped when the strategy asks the model again or when the turn ends;
  • budgets, the tool-call limit and the progress guard (the same call four times in a row, a denied call retried three times, ten failures in a row, one tool failing on one file in six of the last sixteen responses, a background command still running read thirty times in a row) end the turn with an error no guard clause can catch (§4), and so does a failure to write the log, which is the source of truth. In sandbox mode the same call four times in a row does not end the turn: the fourth result, and every fourth after it, tells the model so after the tool's output (a fold-result of it keeps that note after its text; the value the strategy reads is the tool's own), and the log has a note beginning "progress warning:" (ADR 55, amended 2026-10-19).

9.1 Prompts

Every prompt nodwyn sends is Scheme. The defaults live in internal/loop/prompts.scm, which loads with the default strategy, before .nodwyn/init.scm; a project redefines any of them there, usually with raw strings (§1.1):

(system-prompt repo) the control model's system prompt: (lean-system-prompt repo) when lean-prompt is true (the lean profile, §9.8; about 90 tokens, and it says nobody approves or answers the calls), else (unattended-system-prompt repo) when (unattended?), else (interactive-system-prompt repo). Unattended, it is how to work — understand the request, set up a check that runs, make the whole change, validate by running it, report honestly — instead of announcing each call to an approver. Interactive, the answer says what changed, how it was checked and what was not, and names the choices made when the user went ahead without answering them; a question is answered, and a request for something never built is told so, with what exists, what is missing and what the model would build, before it builds anything
plan-explore-system, (plan-explore-task request), (plan-draft-with-findings request findings) the explorer that looks before a plan is drafted (§9.5): the code at the place the request describes first, every place the change must reach, the request's own examples and formats copied exactly, the options it finds without choosing one, the tests involved, and the documents and what the repository's instructions for coding agents (AGENTS.md) ask a change to include; and the draft request that carries what it found
finish-no-change-message, finish-no-change-reviewed-message, finish-untested-message, finish-failing-message, (finish-unrunnable-message n), (finish-new-files-message files), (finish-untested-parts-message check parts omitted), (finish-stale-citations-message spans), (finish-removed-definitions-message defs), (finish-removed-expectations-message lines), (finish-original-tests-message failures), (finish-regressed-tests-message names), (finish-dropped-conditions-message dropped), (finish-unshown-claims-message claims), (finish-unsupported-edits-message rows original), (finish-review-message points), finish-review-close, (edited-expectations-message lines), (edited-expectations-firm-message verdicts) what the evidence loop tells the model (§9); from finish-new-files-message to finish-unsupported-edits-message, points of the finish review, and the one message that carries them; edited-expectations-message is said as a test file is edited, once for each file, and edited-expectations-firm-message once for a row whose answer quoted the request and did not support it
sandbox-finish-message what the default loop tells the model once in a sandbox session, the first time a turn ends on its word: check the result as someone who did not watch you work would (§9)

| plan-explore-system, (plan-explore-task request), (plan-draft-with-findings request findings) | the explorer that looks before a plan is drafted (§9.5): the code at the place the request describes first, every place the change must reach (each cited as path:line), the request's own examples and formats copied exactly, the options it finds without choosing one, the tests involved, and the documents and what the repository's instructions for coding agents (AGENTS.md) ask a change to include; and the draft request that carries what it found | | finish-no-change-message, finish-no-change-reviewed-message, finish-untested-message, finish-failing-message, (finish-unrunnable-message n), (finish-new-files-message files), (finish-untested-parts-message check parts omitted), (finish-stale-citations-message spans), (finish-removed-definitions-message defs), (finish-removed-expectations-message lines), (finish-original-tests-message failures), (finish-regressed-tests-message names), (finish-dropped-conditions-message dropped), (finish-unshown-claims-message claims), (finish-unsupported-edits-message rows original), (finish-review-message points), finish-review-close | what the evidence loop tells the model (§9); from finish-new-files-message on, points of the finish review, and the one message that carries them | | (reading-reminder-message n), (reading-reminder-reviewed-message n) | what the default loop tells a model whose last n rounds only looked (§9) | | (plan-findings-message findings) | what the model doing the work is told of the explorer's findings once a plan is recorded (§9.5): leads found without running anything, and any fix in them a guess to test; plan-hand-over adds the lines of the files they cite (plan-excerpts, below) | | task-header plan-header | the headers under which the window shows the user's request, at the head of their message, and its plan, as a message of its own after the calls it came with are answered; neither is ever trimmed away. The system prompt stays the same, so a new request or a plan keeps the server's cached prefix (window recipe v15); before v15 the system prompt carried both | | (run-turn-prompt task complete-line turn max-turns) | the message a supervised run sends for each turn; complete-line is the marker the run looks for. It asks for the plan's next unmarked step and says the plan carries over to the next turn (§9.5); under lean-plan-hidden it says nothing of a plan | | (plan-system-prompt repo) | the planner's system prompt | | (plan-shape) | the shape every plan is asked for, built from plan-kinds (§9.5): each step starts with its kind and a short name for what it does, then — and the detail the console folds away; a step that says all it should in its name needs no dash; the last is the Check:; a plan with no test or no doc to change says so, and why, in a step (Test: none — only docs change); a step carries only what the request states, behaviour it does not state goes on an unnumbered line after the steps, Assumptions: ..., checked against the existing tests before a step relies on it, and for each rule the request states the cases are listed by clause and, for a range, an overlap or an edge, by position (inside, crossing the start, crossing the end, equal, empty). With the plan language (planspec.scm) the planner writes forms, and (assumes "behaviour" "what it was checked against") is that line, written out after the steps as one unnumbered line (ADR 32, amended 2026-10-16) | | (plan-draft-prompt request) | the planner's request for a plan, in the shape (plan-shape) gives | | (plan-revise-prompt request plan score) | the request for a better plan after the judge scored it score (0..1), with the shape again: the planner revises in a context of its own | | plan-needed-question | the judge's yes/no question: does this request need a plan? A plan is a plan of changes: a question, a review, a comparison, an explanation or a proposal asks for an answer and needs none, however much reading the answer takes; a reply to the model's question or a correction carries on the request before it | | (plan-request-with-earlier earlier request) | a chat's latest request after the person's messages before it, earlier, as the gate's judge, explorer and planner read it (plan-chat-request) | | (plan-request-after-reply reply), (plan-request-attachments files) | what the explorer and the planner read beside that, never the judge (plan-model-request): the agent's last answer before it, and the names of the files the person attached, with the instruction to say what they cannot tell rather than guess | | compact-summary-prompt, (compact-handover-prompt request note) | compaction's request for a handover note and the start of the compacted context (§9.2) | | (optimizer-system-prompt), (optimizer-task history focus) | the optimizer's system prompt and task (§9.4) | | plan-score-rubric | the judge's instruction for scoring a plan: it fulfils the request, and its steps have the shape the planner is asked for | | plan-score-levels | the rubric's 2–10 levels, worst first. A plan whose tests do not say what they check and why they fail today, or that leaves out the docs, does not reach the top one | | working-rules | how to work, added to both system prompts (§9.6): shell command lines, programs that keep running or may hang (shell-start), the project's environment, reading with the file tools, calls that do not depend on each other in one reply, a plan as a first guess, the check loop, following the request's conclusion, widening in general form, whole test modules (and code a browser runs checked by the project's browser checks; a build or a syntax check is not a check that it works), comparing failures with the unchanged code only when tests fail, on a copy of the last commit and never with git stash, finding regressions file by file, keeping what a later session would need in the project notes (project-notes-save) | | (git-rule) | in both system prompts after working-rules, only where git-diff is offered: look at the change with git-status and git-diff before finishing; it holds nothing it does not use | | (worktree-section) | in both system prompts after (git-rule), only in a session given a git worktree of its own ((worktree) is not #f, ADR 34): the folder is a worktree made for the session, on its branch, from its base, of the repository at its path; other sessions may work in other worktrees of it at the same time, so work only here and leave other branches alone; the changes reach the repository only through the branch, which the answer names; before landing it says to run validate first, only where the session has a tool of that name (nodwyn's own repository does, most do not). It also says never to use git stash there, since every worktree shares one stash, and to compare with the unchanged code on a copy of the last commit (git archive HEAD \| tar -x -C "$TMPDIR/base") instead. "" in any other session, so their prompts are unchanged (ADR 32, amended 2026-10-03) | | (project-instructions-section) | in both system prompts after the workspace's folders and before the project notes, and at the end of the lean one, after its fixed sentences (§9.8; ADR 32, amended 2026-10-18): the repository's AGENTS.md under a heading that says to follow it where it applies and the request where they differ, or "" without one. Read when the session starts, so an edit to the file shows in the next session (ADR 36) |

(define base-system-prompt system-prompt)      ; keep the default to extend it
(define (system-prompt repo)
  (string-append (base-system-prompt repo) "\n"
    """
    Work unattended: nobody approves your calls.
    Run the tests of every module you change before you finish.
    """))

nodwyn reads the system prompt and the two headers once, after init.scm has run: they are then fixed for the session like the tool list (ADR 9), and recorded with the window's recipe so a replay rebuilds requests that used a project's own. A system prompt that is not a procedure returning a non-empty string, or a header that is not a non-empty string, stops the session from starting. The others are ordinary Scheme, read when the plan gate (§9.5) uses them, so a (reload) changes them from the next turn.

9.2 Compaction

A long turn fills the context with tool results the model no longer needs. nodwyn elides the oldest ones past [window] high_water on its own; compaction goes further, as user code over the primitives above, in internal/loop/compact.scm. When the context passes compact-threshold, measured as the window measures it ((context-tokens), not a reply's prompt-tokens, which a server counts its own way and which lacks the latest tool results), maybe-compact asks the model for a handover note (the task and its example as given, what it found with the evidence for each finding, what it changed, how to test, what is unsure or left, and its check script), then narrows the context to the session's system prompt, the request and that note, and gives the turn's plan back after the note, under plan-header with the steps marked so far (compact-plan), since the window shows a plan where it was recorded, which the narrowing leaves behind. The narrowing lasts (narrow-context with '((lasting . #t)), labelled compaction): the rest of the turn goes on from the note, and so do later turns, each under the session's system prompt with its own request, rather than from the whole conversation again. The repository keeps the model's changes, and the log keeps the whole conversation, as context.narrow with lasting and the note before it, so replay and audit see everything the note replaced. The note request may use the room between the window's high water and its ceiling, so a round that jumps past the high water is still compacted rather than trimmed; a context no bigger than the window has no such room.

The user compacts whenever they like, whatever the context's size, with a message; it works in every client, like /optimize (§9.4):

/compact                     compact now
/compact the failing tests   any words after it are what the note must keep

The default agent-turn runs compact-turn for it instead of the loop. It hands over the user's latest request before it, not counting other /compacts (session-requests), so a note written between turns keeps the task that was being worked on; the words after /compact are added to the note request with compact-focus-prompt. The turn ends once the note is handed over: the model is not asked to go on, so it does not start on what the note says is left, and the user's next message goes on from the note. A session with no request before the /compact has nothing to compact, and the model is not asked. Each outcome is a note in the log, which the clients show.

Every compaction, whatever set it off and whether it worked, is recorded as context.compact (record-compaction): the trigger (auto, command for /compact, requested for compact-next-round), whether it compacted, the context's size before and after in the window's estimated tokens, the threshold an automatic one passed, the focus, and the handover note's reply (its llm.resp seq, its length in tokens, and whether it stopped at the output limit). The clients show it as one line, such as compacted (past ~58,983 tokens): context ~59,210 → ~4,100 tokens, and one that gave up as a warning.

A strategy asks for compaction at the next round, whatever the size, with (compact-next-round [focus]): maybe-compact runs it where the loop next calls it, in this turn or the next, and the request is used once.

compact-threshold context tokens, as (context-tokens) counts them, past which the context is compacted; #f never compacts on its own (/compact and compact-next-round still do). The default, 'high-water, is just under the window's high water as it is now, a twenty-fifth below it ((window-limits): 58983 at the default 61440), so only work that really runs long compacts (ADR 32) and a switch to a model with another window moves it; (compact-limit) gives it in tokens. A compaction that gives up raises it by a quarter until one works
compact-attempts replies to the note request that may be tool calls (they are skipped), or notes cut off at the output limit (asked for again, shorter, with compact-note-cut-message), before compaction gives up or keeps the cut note; after giving up it waits for the context to grow another quarter of the threshold
compact-stop-unchanged #t (the default): the second automatic compaction of a turn with no fs-write or fs-edit asked for since the first stops the turn instead (compact-stop), and a turn that is stopping is not compacted again. The model first gets a write round: compact-write-message tells it to make the code changes its note and plan name, tests and docs after, and report; it is offered compact-write-tools (fs-edit, fs-write, read-definition, go-build: those of them the session has) for at most compact-write-rounds (6) requests, then asked for its report without tools (turn-stop-report), and that answer, or one it gives sooner, ends the turn. With compact-write-rounds at 0, or neither fs-edit nor fs-write in the session, it is asked for the report at once with compact-stop-message. #f compacts as often as the context fills
turn-stopping (strategy.scm) #f, or the message that stopped the turn: while it is set ask-model offers only turn-stop-tools for turn-stop-rounds more requests, then no tools after saying turn-stop-report, skips a call to any tool it did not offer (a reply whose every call it skipped spends its round and is asked again), and the finish checks ask nothing more; each turn starts with it #f and no tools left
compact-summary-prompt, compact-note-cut-message the request for the note, at most 600 words with an edit decided and not made first, and the request for a shorter one when it was cut off (prompts, §9.1). The note leaves the turn's plan out, since it comes back after the note, and says a file holds something only when the work wrote it there or read it there
(compact-plan) the turn's plan as the compacted context gets it back after the note (compact-plan-message), or #f when the turn recorded none (plan-steps): the plan as recorded, with the latest mark of each step, read with unfinished-plan
(compact-plan-message plan marks) that message: plan-header, the plan, its marks (Step 3 skipped: why) under "Marked before the conversation was cleared:", and a line saying the harness keeps the plan and no file holds it (prompts, §9.1)
(compact-focus-prompt focus) what the note must keep, as the user put it after /compact or a strategy gave compact-next-round, added after compact-summary-prompt
(compact-handover-prompt request note) what the compacted context starts with: the request, and the note with a request to make the edit it names as decided and not made, then re-run its reproduction and re-check its findings before building on them
(maybe-compact request reply) compact if a strategy asked for it with compact-next-round, or if the context the next model-turn would send passed the threshold; call it once the reply's calls are answered (reply itself is not read)
(compact-context request [focus [trigger]]) compact now, handing over request and the turn's plan (compact-plan); #t if the turn continues in the narrowed context. trigger (auto, command, or requested, the default) is what the record says set it off
(compact-next-round [focus]) compact at the next maybe-compact, whatever the size; compact-requested holds the request (#f when there is none)
(compact-request? request) (compact-turn request) whether a message is a /compact, and what the default agent-turn does with one
(define compact-threshold 40000)    ; in .nodwyn/init.scm

A strategy that redefines agent-turn calls maybe-compact itself, where the default does: after running the calls, before the next model-turn; it keeps /compact by calling (compact-turn request) when (compact-request? request). Compacting rewrites the start of the context, so the server re-reads the new, short prompt once instead of reusing its cache (ADR 9); a late threshold compacts rarely and keeps the prefix stable in between. A note that the model will not write (it keeps calling tools, or writes nothing) leaves the context as it was, and the model is told to go on.

9.3 Sub-agents

A sub-agent is a model loop in a context of its own: it gets a system prompt and a task, works with the tools it is given, and answers; the context then folds away, so whoever asked sees only the answer (ADR 27). They are user code, in internal/loop/agents.scm:

(sub-agent system task tools [name [options]]) run task in a narrowed context under system; tools lists the tool names it may use (its model is offered only those, and other calls are skipped; '() offers none), is a procedure that takes a call and says whether it may run (its model is then offered every tool), or is #f for all; name (or #f) labels it in the log (default: the agent tool being answered, else sub-agent); options is an alist, '((model . "NAME")) asking the configured model NAME instead of the control model and '((rounds . N)) giving it N rounds instead of sub-agent-max-rounds (a caller that wants fewer says so here rather than setting the global, which a stop inside the sub-agent left set); returns its final answer, after fold-context. An error on the way folds its context before it is raised again, unchanged, so a caller that catches it goes on in its own context
(delegate-models), the delegate tool the configured models this task's model may hand work to, chosen when the task opened (none by default; never "control"; hidden primitive). When there are any, a delegate tool is offered: model and task, run as a sub-agent on that model, offered every tool but delegate (tool-names). A helper that fails (its provider down, out of quota, refusing the request) answers the call with its error, so the task's model goes on without it. Its description is fixed with the tool list, so a call with an empty task lists the models, with each one's id, context length and current labels; the model chooses by labels, not names. A model outside the set is refused whatever name is asked for. No exposed model, no tool
sub-agent-max-rounds replies with tool calls a sub-agent may make before it must answer (40); its last calls are then skipped and it is asked for its answer (sub-agent-wrap-up-message), so its work is not lost. It is offered the tools it had, so a caching server reads only the request's end again, and a call it makes is skipped (sub-agent-text-only-reason) and it is asked once more with none (ADR 32, amended 2026-10-14)
sub-agent-repeat-limit identical calls (same tool and arguments) a sub-agent may run in a row (2). The core's progress stop, at four in a row, ends the whole turn and cannot be caught, so a sub-agent stops short of it: the next identical call is skipped with sub-agent-repeat-reason, and if it repeats again it is asked for its answer as an agent out of rounds is, which it returns with sub-agent-stopped-note. Its caller's work goes on
sub-agent-seen #f, or a list the calls a sub-agent runs are added to with their results, newest first, as (call . result): how a caller learns what a sub-agent read, whose context folds away when it answers. plan-explore sets it to '() for the explorer, reads the explorer's fs-reads from it and sets it back to #f; the plan gate clears it at the start of every turn
(define-agent-tool name doc params handler) register a tool the model calls like any other, answered by (handler call args), args parsed from the model's JSON; define it in .nodwyn/init.scm
(run-calls calls) run a reply's calls: agent tools through their handlers and answer-call, the rest with run-tools (a batch with an agent tool in it goes one call at a time, so it does not run concurrently); returns one result alist per call. The default agent-turn uses it
;; .nodwyn/init.scm: a reviewer the model can consult, reading but not writing
(define-agent-tool "ask-reviewer"
  "Ask a reviewer who can read the repository whether a change is finished."
  '(("change" "string" "what you changed and why, and how you tested it"))
  (lambda (call args)
    (sub-agent "You are a strict code reviewer. Read what you need, then reply VERDICT: done, or what is missing."
               (assoc-ref args "change")
               '("fs-read" "fs-list" "index-grep"))))

Every sub-agent leaves its whole transcript in the session log: its context.narrow names the agent and lists the model's calls it answers, then come its task, requests, replies, tool calls and guard decisions, up to its context.fold; the calls it answered follow as tool.result. Folding hides the span from later windows, never from the log, so an agent's work can be read, replayed (nodwyn replay --window) and learned from on its own.

A strategy can also run one itself, such as a review when the model stops: (sub-agent reviewer-system (string-append issue diff tests) '()). A sub-agent always asks the control model (whichever model its caller was routed to); a strategy that wants another writes its own loop with (model-turn name). Each fold costs the server one re-read of the caller's context: the prefix it had cached was the sub-agent's (ADR 9).

9.4 History, reload and the optimizer

A strategy can read this repository's past sessions and change how nodwyn works here, while the session runs:

(session-list [n]) this repository's sessions, newest first (all, or the latest n); each an alist with id, first-message, last-event, current, in-flight and truncated. Other repositories' sessions are not listed
(session-events id [kinds]) one of those sessions as its log events, optionally only the kinds listed ('("user.msg" "tool.result")); each an alist with seq, ts, kind and payload, the event's JSON redacted and parsed as json-parse does. The values carry the external:log taint, like a tool's output
(reload) run the currently trusted revision of .nodwyn/init.scm again, with the caller's origin; changed or revoked project trust refuses before evaluating any code. An explicit Go-host Options.InitFile remains host-authorised. The strategy, compaction settings, agent-tool handlers and anything else read when used apply from the next turn; a tool keeps its schema and takes a changed body. What a session is fixed with (ADR 9) does not change: new or changed tools, guard checks (register-check) and prompts are returned as notes and apply from the next session or a resume. The file runs over what it found the first time: each global the first run changed is put back first, so a wrapper's (define base-x x) captures the default again, not its own earlier wrapper; globals a turn set are kept. A reload that fails puts every global back as it was

The optimizer (internal/loop/optimize.scm) is user code over these. The user starts it with a message; the default agent-turn runs it instead of the loop:

/optimize                 every earlier session of this repository
/optimize 5               the latest five
/optimize ID ID ...       the sessions named (ids from session-list)
/optimize 5 test runs     any other words are a focus

It digests the sessions (requests, answers, the calls made, failures and refusals, judges' verdicts, sub-agents, notes) within optimize-session-chars per session and optimize-total-chars in all, and hands them to a sub-agent labelled optimizer. That agent may read the repository and write only under .nodwyn/, a protected path, so every write asks the user and shows the diff; a session nobody approves (nodwyn run's --yolo or --judge, an unattended run) refuses those writes, so /optimize needs a person at the prompt. A changed project init is kept pending review with nodwyn trust; the new code has not run. Once trusted, reload or resume to use it. For an explicitly authorised init, reload still applies immediately; an ordinary reload failure restores the old file. The model is told which outcome occurred. Its prompts are (optimizer-system-prompt) and (optimizer-task history focus), in prompts.scm with the others. A strategy that redefines agent-turn keeps /optimize by calling (optimize-turn request) when (optimize-request? request).

9.5 The plan gate

The plan gate is user code too, internal/loop/plan.scm. The host runs nodwyn-turn for every user message, not agent-turn directly:

(define (nodwyn-turn request)
  (when (plan-required?) (plan-gate request))   ; a supervised run's turns
  (agent-turn request))

and the default agent-turn calls (plan-gate request) before its loop, so an interactive turn is gated too. plan-gate does nothing once the turn has a plan, so a strategy may call it as well; a strategy that redefines agent-turn still plans in a run. A project that plans its own way redefines nodwyn-turn (a name specific to nodwyn, so a project's own turn, a counter say, cannot shadow it): the example above is the whole of the default, since the per-turn resets live in turn-start (§9). With a judge (§5: [judge] on a provider, its primitives registered and its server's key stored), it asks plan-needed-question about the request, in a chat's later turn after the person's messages before it (plan-chat-request); a request that needs a plan gets one drafted by the control model in a narrowed context of its own (§9), which also reads the agent's last answer and the names of the files attached (plan-model-request), offered no tools and folded away afterwards, then scored against the request and revised until it scores plan-accept-score or plan-max-revisions run out. The outcome is recorded as gate.plan, and the window shows the plan to the model for the rest of the turn. Without a judge there is no gate. If the judge's yes/no question fails, the model plans anyway, unscored ("degraded"); a judge the guard refuses (an unbound key) ends the turn, as any denial does.

A run's plan is the run's, not the turn's. Its first turn drafts the plan; each later turn carries it (plan-carry): it records the same plan again, decision carried, with the steps the run's turns have marked, so the steps keep their numbers, plan-step answers with the right next step, and the window shows the marks under the plan. Nothing is explored, drafted or scored for a carried plan. Only when every step is marked and the run goes on does a turn draft a new one, from the request with the previous plan and how each step went (plan-run-request). A model that finds the plan wrong marks the steps that no longer apply skipped, saying why.

A chat's plan is carried the same way past a turn that did not finish. When an error, a limit or the person's Stop ended the turn, the next message ("continue", or a correction) carries its plan with the steps marked so far (unfinished-plan, then plan-carry), so the model goes on from the next unmarked step instead of reading the repository again and writing the plan again. A turn that finished, its answer given, leaves no plan: the next message is a new request, gated as usual.

Every plan, the planner's and the model's own, is asked for in one shape (ADR 32, amended 2026-10-03): numbered steps, each starting with its kind and a short name for what it does, then a dash (—) and the detail after it; a step that says all it should in its name needs no dash. Change: names a file (or function) and what changes in it; Test: the test file and name, the behaviour it checks and why it fails on the unchanged code ("fails today: …"), or that it guards behaviour that already works ("passes today: guards …"); Docs: each file a person or a later session reads that the change makes wrong or incomplete; and the last step, Check:, the command that shows the whole change works. A plan with no test or no doc to change says so in a step, with the reason. The judge scores against the shape, and plan-write turns down a plan without it.

plan-needed-threshold the judge's probability of "needs a plan" at which a request gets one (0.5)
plan-accept-score the normalised score at which a plan is accepted (0.7)
plan-max-revisions revisions after the first draft (3)
plan-empty-retries more times the planner is asked when it answers with no plan, in a fresh context each time and noted in the log (2): an empty reply, tool calls, a line that opens a fenced code block, or more than plan-max-chars (8000) characters (plan-not-a-plan); after the retries the last reply stands. An empty revision keeps the best plan
plan-explore-rounds before the first draft, a read-only sub-agent labelled explorer (fs-read, fs-list, index-grep, index-search, fs-find) looks at the repository for up to this many tool rounds and the planner is told what it found, so the plan is not written blind (12; 16 for the muse-spark, glm, deepseek and gpt-6-luna families, whose rounds hold fewer calls; 8 for qwen, whose explorer used all 16 in most commit-bench runs while the work read again about half of what it read: a table, §9.7); 0 skips it. Once the plan is recorded the model doing the work is told too (plan-hand-over, plan-findings-message), so it does not read the same files again to learn it. Its reads come before the plan is recorded, which the core allows: none is irreversible
plan-excerpts-cap, plan-excerpt-cap, plan-excerpt-before, plan-excerpt-after what the explorer read goes with its findings to the model doing the work, so it does not read it again after the explorer's context folds away (plan-excerpts, over plan-explore-reads, one (path first lines) per fs-read of the explorer). For each file the findings name, in the order they name them: the lines around each place they cite as path:line or path:first-last (plan-excerpt-before lines before, 2, and plan-excerpt-after after, 12; places that touch become one), copied from what the explorer read, as the file has them and under a heading with the lines' numbers; a file named without a line only when what the explorer read of it is at most plan-excerpt-cap characters (3000, also an excerpt's most). All of it is at most plan-excerpts-cap characters (12000, 0 for none): an excerpt that does not fit what is left is left out, and a shorter one after it may still go in. It is information, not a read the model must make; the message says to read more of a file in a range. plan-excerpts-intro is the sentence that introduces it; the explorer is asked to cite path:line (plan-explore-task)
plan-earlier-requests in a chat's later turn, how many of the person's messages before it the gate's judge, explorer and planner read first, so a reply to the model's question or a correction is not read alone (2); 0 reads the request alone. Each is cut at plan-earlier-cap characters (2000). A run's turns each repeat the run's task, and read it alone
(record-compaction facts) record a compaction as context.compact, from an alist: trigger (auto, command or requested), compacted, before (the size (context-tokens) gave before it), threshold and focus. The size after and the note's reply are read when it is called, so call it once the handover is in place, as compact-context does
(record-plan outcome) record the gate's outcome as gate.plan, from an alist: decision (plan, proceed, degraded, required, carried or model), plan, score, revisions, reason, hidden (#t: recorded, and shown to the person, but not to the model; the lean profile's one-line plan, lean-plan-hidden), and marks, the steps of the plan already marked, each an alist of step, state and note as run-plan and unfinished-plan give them; the turn's record-step and the window start from them. Without marks, a plan recorded in a turn that has marked steps of an earlier plan keeps the marks of the steps it keeps word for word, at their new numbers. An error for a step the plan does not have, another state, a step marked twice, or marks without a plan
(plan-required?) true when this turn must plan before it acts, as a supervised run's turns must
(reviewed?) true when a person reads this turn's answer before anything else happens: a chat's turn, and a supervised run's turn that a run checkpoint follows; false in an unattended run, in a run's last turn (no checkpoint follows it) and outside a turn. The evidence loop lets a reviewed turn end unchanged when the request asks for no change yet
(plan-recorded?) true once this turn has recorded a non-empty plan
(keep-passing) keep the repository's uncommitted changes as they are now, as a state a test run passed on, and return how many files were kept; #f when the session has no keeper, an error saying why when it cannot keep (not a git work tree, a file over 8 MiB, more than 64 MiB or 4000 files). What is kept is the files git lists as changed (modified, new, deleted) as the bytes on disk, in the scratch directory, replacing the earlier keep. A file the credential denylist refuses, a path the guard protects (.nodwyn/, .git/, Makefile, scripts/ …), a link and a directory are neither kept nor touched, and the index is not. The evidence loop calls it after a passing run in a turn nobody reads; when a hard limit or the progress guard stops the turn, the core puts back what differs from it, from the files kept or from HEAD, never through a link out of the repository, and refuses if HEAD moved. What it replaces is saved under the scratch directory's kept/discarded
(check-without cmd args) run a check again with each part of the uncommitted change taken out in turn, and put each part back after its run: a hunk of a changed code file, or a whole new one; documents, tests, comment-only hunks, deleted files, hunks that only delete lines (their added lines, if any, comments or blank) and what keep-passing leaves alone (credential files, protected paths, links) are not parts, and every file ends with the bytes the change left whatever happens. At most 8 parts, run within 5 minutes in all, so a slow check cannot take the turn's wall time: a part not reached by then is not listed. The check runs once with the whole change first, and it is an error when it does not exit 0 then; that run's output is what each run without a part is compared with (that run counts within the 5 minutes). Returns one alist per part: path, line (its first line now; 0 for a whole file), added, removed, kind (hunk or new file), status, the check's exit status without the part, and same, whether it printed what it printed with the whole change, with durations, (cached), clock times, hex addresses and numbers of four digits or more masked; or error. Hidden from the model; registered in a git work tree. Its cmd and args are judged by the guard as a command's are. The evidence loop calls it before an unattended turn ends on a passing check: a part the check still passes without, printing the same, is one it does not test (ADR 32, amended 2026-10-11 and 2026-10-16: a check ending in ; echo EXIT=$? exits 0 without every part; 2026-10-15: a deletion put back is no part a check can be extended to test)
(read-data text) every datum in text as data, unevaluated: the forms, strings, symbols and numbers it holds; an error names where text stops being readable. Pure; hidden from the model. planspec.scm reads a plan written as forms with it (ADR 32, amended 2026-10-16)
(request-names text) the names a request gives, in order, each once: spans in backquotes without a space, and words that read as names (a hyphen, an underscore or a dot inside, or a capital after the first letter: session-id, cache_key, readResponses); not links, issue numbers or versions. Pure; hidden. A plan as data must hold each (planspec.scm)
(plan-evidence names paths) a plan set against the uncommitted change as git shows it: an alist of missing, the names the change does not add (found whole and in any case in the added lines and new files, so session-id is not found in X-Session-Id), and unchanged, the paths it does not change. Hidden; registered in a git work tree. The evidence loop checks a plan's steps with it before an unattended turn ends (planspec.scm)
(test-before path cmd args) run a test on the code as it was before the change: a copy of HEAD in the scratch directory (plan-base) with the working tree's version of the file at path put in, and the change's other test files and test support (a fake, a fixture), the command run there within 3 minutes; its exit status, or an error when it could not run. Hidden; registered in a git work tree; its command is judged by the guard as a command's is. The evidence loop runs a plan's test steps that say they fail today with it: one that passes there does not show the change (planspec.scm, ADR 32, amended 2026-10-16)
(head-has-name name) whether a file of HEAD holds name whole and in any case (session-id is not in X-Session-Id). Hidden; registered in a git work tree. planspec.scm puts a request's name the plan left out and the code already has into the plan's context instead of asking the planner again, and asks the change only for names HEAD lacks
(docs-mentioning names [packages]) the repository's documents (Markdown, LaTeX, reStructuredText, AsciiDoc, as HEAD has them) that hold one of names whole, and the ADRs whose file name has a word starting with one of packages (0025-ingress-redaction.md for redact), most names first, at most 4, as a list of (path . "the names it holds"); a name in more than 8 documents, or shorter than 4 characters, is left out. Hidden; registered in a git work tree. Before an unattended turn ends, when the change edited no document, planspec.scm says which documents describe a function or package the plan's change steps name
(git-intend paths) mark the files of paths that are new (untracked, inside the repository, not ignored) as intended for the next commit (git add -N), so tools that list the tracked files see them; returns the paths it marked. Nothing is staged. Hidden; registered in a git work tree. planspec.scm marks the new files the plan names as the model writes them: the book's catalogue generator lists files with git ls-files
(change-removals request) what the uncommitted change takes away that its own check cannot show, as an alist: definitions, the top-level definitions (Go functions, methods as Type.Method, types, variables and constants; Python functions and classes; Scheme defines) HEAD has in a changed non-test file that no file of the same language in its directory defines now, at most 6, each an alist of path, name, kind and line (at HEAD), leaving out a name request gives whole or in words that spell it (workspace-kind for WorkspaceKind; a method by its type unless the type is a plain word); and expectations, the lines of existing tests (test files by name or folder, in Go, Python, JavaScript and TypeScript, Ruby, Rust, Java, Kotlin, C#, PHP and others: x_test.go, test_x.py, tests.py, x.test.js, x.spec.ts, anything under test, tests or __tests__) that state an expectation (a case of a table, a line ending in a comma that holds a literal; an assertion that carries it: testify's assert./require., Python's assert, expect(, assert_eq!, assertEquals(; a literal assigned to want, expect, expected or a name that starts with one, such as wantErr or expected_msg; or, in Go, a strings or bytes check (Contains, ContainsAny, HasPrefix, HasSuffix, EqualFold) of a string literal) and that the change removes and adds back nowhere in its test files, white space and trailing comments aside, or whose string literals all stand on one line it adds (a case moved into another shape), at most 8, each an alist of path, line (at HEAD), text and new (the line the change puts in its place: of the lines its change block adds, the one that shares most of its words with this one, if half of them; each used once; "" for none). Hidden; registered in a git work tree. The finish review names them when the latest run since the last edit passed, and the test-edit question reads them as a test file is edited (ADR 32, amended 2026-10-15, 2026-10-16 and 2026-10-16)
(edit-support lines request reply) the reply to the question "which sentence of the request requires the new expectation?" read against the request, for lines (the expectations of change-removals, with their text and new): a list with an alist for each line, in order, of path, line, text, status, quote, quoted, unmentioned, cut and put. The reply quotes the request when a run of three words or more of it stands in the request in the same order, case, punctuation and white space aside (quoted: whether it does so at all). status is "supported" when a quote shares a distinctive token with the line and its replacement (a word of three letters or more, or a number from 2 up, lightly stemmed, that is no stop word and no part of the name of a function the line calls; its identifiers' words when its string literals give none), quote being the best one; "unsupported" when none does; "unchecked" when the line has no token. When the replacement cuts words out of the phrase the line expects, or puts words inside it, a supporting quote also holds most of the words cut or all of the words put (cut, put: the phrases). unmentioned lists the string literals of the line that the request does not contain. Pure; hidden from the model. The evidence loop reads the reply that follows the question with it (ADR 32, amended 2026-10-16)
(original-tests cmd args paths) run a check again with the existing test files the turn edited as the last commit had them, and name the tests that then fail and pass on the unchanged repository. cmd and args are the check as it was run (the evidence loop passes the passing run that runs the edited files). Within 5 minutes in all: once with HEAD's version of each of paths that HEAD has and the working tree edited or deleted, and the test files the turn made taken away; if that run fails, once more with every changed file as HEAD had it, and a test that fails there too is left out. Tests are named from the run's output (pytest, unittest and Django, go test, cargo, jest, vitest, rspec); a run that fails without naming a test, where the unchanged repository passes, is named by its exit status. With an empty paths the check runs as the turn left it and only the second run is made, to name the tests the change itself breaks. Every file is put back to the bytes and the time the turn left, after each run. An alist of failures, each an alist of path (the edited test file the tests belong to, or all of them, comma-separated; empty for an empty paths) and tests (their names, at most 12), and unrun, strings saying why a run could not tell. Hidden; registered in a git work tree. The finish review names the failures (finish-original-tests-message, finish-regressed-tests-message; ADR 32, amended 2026-10-16, the original tests in any language)
(change-claims request runs edits declared) the lines the uncommitted change adds to documents (under docs/, or .md, .tex, .rst, .adoc, .txt) and to Go and Scheme comments that state what nothing in the turn showed, at most 6, each an alist of path, line (now), kind, text and why. Kinds: run, a sentence reporting a run (past tense or "passes with": passed, N or zero warnings, found nothing, executions, a fuzz or benchmark figure; not a caveat such as "not run") when no run of each command it names (make validate, make accept, go test, a fuzz, a benchmark, or any test command) exited 0, or none printed its figures within 5% among its last 6 lines (of a fuzz run's progress lines the last only); date, an amendment date the file did not have that no run printed and request does not give (as 2026-10-15 or 20261015), each date once; repeat, three or more non-blank lines, 120 characters or more, a document's edit inserted that stand word for word elsewhere in it; generated, a file in edits whose head says Code generated or Generated by, or names a "source" address, or that declared lists, with no command after its last edit holding go generate, its generator, or its source's address or host. runs is the turn's commands, each (line status output) (status #f when it did not run to an exit or ran on the copy of the last commit); edits an alist of each path edited by hand and the command lines run after its last edit; declared a list of (path command) rows. Hidden; registered in a git work tree (ADR 32, amended 2026-10-15)
(change-dropped-conditions request) the parts of conditions the uncommitted change stopped checking, at most 3: for each Go function of a changed non-test file whose body changed, the operands of the && and \|\| of its old body, and the conditions of its old guards (an if whose block ends in a return), that neither its new body holds (white space and a leading ! aside) nor the body of a function of its package it calls that the change added or rewrote; error and ok checks, literals, one-letter names and operands with a word request names (four characters or more, not a word every condition uses) left out. A list of alists of path, function (Type.Method for a method), operand and guard (#t for a guard's whole condition). Hidden; registered in a git work tree. The finish review names them when the latest run since the last edit passed (ADR 32, amended 2026-10-15)
(shell-commands cmd args) the simple commands a command line runs, each as its words from the program on, joined by spaces: a shell's -c line read as the shell reads it (a here-document's body, a quoted pattern and a redirection's target are no command; the commands inside a substitution are left out), any other command line as one command; () for a -c line that does not parse (an unclosed quote). Pure; hidden from the model. The evidence loop matches the commands a report cites against the ones the turn ran with it (ADR 32, amended 2026-10-16)
(run-plan) the plan of the run this turn belongs to, as its earlier turns left it, read from the log: ((plan . text) (steps . list) (marks . list) (next . n)), where marks are the latest mark per step, in step order, each ((step . n) (state . s) (note . t)), and next is the first unmarked step, or #f when every step is marked. #f when this turn is not a run's (its user.msg follows no run.turn) or no turn of the run has recorded a plan. Only the run's turns count: a chat turn between leaves its plan alone, a resume keeps it, and a run started afresh has none
(unfinished-plan) the plan a turn that did not finish left to this one, read from the log, in the shape run-plan gives. A turn did not finish when an error, a limit or the person's Stop ended it (its turn.end outcome is error, stopped, hard_budget, spend_limit or canceled), or when nothing ended it; its plan, with every mark made since, goes on through such turns. #f when the latest turn that ended finished (outcome completed, or none recorded, as in an older log) or no turn since has recorded a plan. plan-gate carries it in a chat's turn when a step is unmarked (plan-left)
(turn-plan) the plan this turn recorded, in the shape run-plan gives (plan, steps, marks, next), #f when it has recorded none. A compaction (compact-context) gives the plan back as the message after the note (compact-plan); when that message cannot be made, it records this plan again, with its marks, in the narrowed context, since the window shows only a plan recorded in the context it sends
(plan-carry run) record run's plan, from (run-plan) or (unfinished-plan), as this turn's, decision carried, with its marks
(plan-run-request request run) the planner's request when a run's plan has every step marked: request, the previous plan and how each step went, and what remains to plan in the same shape; request alone when run is #f
(plan-steps [plan]) the steps of the plan recorded in this turn, as strings: its numbered or bulleted lines, in order, or the whole plan when it is prose (one step); '() before a plan is recorded. With plan, a plan's text, the steps of that text, read the same way: plan-write checks a plan's steps before it records it. The console reads a plan's steps the same way
(record-step n state note) record that step n (from 1) of this turn's plan is "done" or "skipped", with a note saying what shows it, as plan.step; a later mark of the same step replaces it, one a carried plan brought included. Returns ((done . k) (of . n) (next . m)): the steps marked so far, carried ones included, the plan's steps, and the first unmarked one, or #f. An error without a plan, for a step the plan does not have, or for another state
plan-write the model's tool for a plan of its own, an agent tool answered by plan-write-answer: {"plan"}, recorded with record-plan, decision model, so the window shows it under plan-header after the call's answer and plan-step marks it. The model's thinking comes back to it only as the server reported it (window recipe v14), often a summary or nothing, and a reply that only calls tools keeps nothing else: without a written plan a design is worked out again at every request. Writing it again revises it: a step it keeps word for word keeps its mark and note, at its new number, and a new or reworded step starts unmarked; in a run it becomes the run's plan, and a turn that does not finish leaves it to the next. The working rules ask for it once the model knows what to change. Its description gives the shape, with an example and the rule on assumptions and cases ((plan-shape)), and it checks it (plan-problems): a plan with a step of no known kind, no Change:, Test: or Docs: step, a last step that is not a Check:, a Test: step that says neither how its test fails on the unchanged code nor that it guards behaviour that already works, or a step of a known kind whose short name — what comes before the first boundary the console folds a step on, the kind included — runs past plan-name-cap (80) characters is turned down, unrecorded, with each problem by step number and what to add. After plan-write-refusals refusals in a turn the next plan is recorded as written, and so is any after it in that turn, answered Plan recorded: and what it still lacks, so a model that cannot get the shape right is not kept from the work
plan-kinds, plan-required-kinds, plan-last-kind, plan-none-kinds, plan-test-reasons, plan-name-cap, plan-name-boundaries, plan-write-refusals the shape plan-write checks and the planner is asked for (ADR 32, amended 2026-10-03): the kinds a step starts with, the word before its first : in any case, each with what a step of it names (Change, Test, Docs, Check); the kinds a plan needs a step of (Change, Test, Docs); the kind of its last step (Check; #f for any); the kinds whose step may say none and why instead (Test, Docs); what a Test: step says of the unchanged code, any of them in any case (fails today, passes today, and the same with on the unchanged code); how many characters a step's short name may take, the kind included, before the console cuts its title (80); and the boundaries the console folds a step on, a dash with a space before it or a sentence end or semicolon with a space after (—, –, --, ., ;); and the refusals in a turn before a plan is recorded as written (2). A project redefines them in .nodwyn/init.scm
(plan-problems steps) what is wrong with a plan's steps, from (plan-steps plan): one message per problem, step by step and then for the plan as a whole, '() when there is none. The messages are Scheme too (plan-kind-problem, plan-test-problem, plan-name-problem, plan-missing-problem, plan-last-problem, plan-refused-message, plan-recorded-message); a project that wants no check defines it to return '()
plan-step the model's tool for those marks, an agent tool (define-agent-tool) answered by plan-step-answer: {"step", "state", "note"}, answered with what was marked and the next unmarked step. Its description says to mark a step as soon as its work is done, in the same reply as the next step's first call — never several together at the end — so whoever follows the task sees the plan's progress move as the work happens (ADR 32, amended 2026-10-04) without a request of its own for each mark (amended 2026-10-16: 69 of 103 marking replies held nothing else), and that a Test: step is done once its test has failed without the change and passes with it, the note naming both runs. The plan header (plan-header) and a run's turn prompt ask the model the same. A mark is the model's word, not a proof: the console shows it as such, and the evidence is the transcript
(repository) the repository's root, for a prompt to name (the planner's system prompt does)
(workspace) the workspace's mounted folders, from the workspace list in the config directory (ADR 22), as (("mounts" (("path" . P) ("access" . "read")) …)). Both default system prompts add a section naming the mounted folders, so a plain repository's prompt is unchanged; mounted folders are reached by absolute path, and a read-only one refuses every write. Before fs-write or fs-edit replaces a file, its previous content is kept in .nodwyn/versions/ and a file.version record names it, unless a git work tree at the root has the file committed and unchanged (ADR 35); a link in the store, which a repository can commit, fails the write instead of being followed (ADR 22, amended 2026-10-19)
(log-note text) add a note to the session log, for the record and the transcript; the model never sees it

A turn that must plan (plan-required?) always drafts one, scored if there is a judge, and fails if the plan is empty. The core keeps one invariant whatever the Scheme does: until a non-empty plan is recorded, nothing irreversible runs, and a turn that records none fails. What it enforces is that a plan was recorded, not who wrote it: the default nodwyn-turn has the control model plan, and a project's own nodwyn-turn may record a plan of its own with record-plan, as trusted project code.

Judged approval. When the judge answers a session's confirms (nodwyn run --judge), the decision is Go's (internal/core/judge.go): it shows the judge the task, the turn's plan and the call, asks judge-choice whether to allow it, and allows it only at judge.approve_threshold (config) or above. Scheme cannot approve anything; it may refuse more:

(approve-refuse? call) optional, defined by a project: asked about a call the judge allowed, with call an alist of task, plan, prim, reasons, path, argv, args and detail; true declines it, and so does an error. Never asked about a call the judge refused

The model marks each of the plan's steps as its work is done, before the next (plan-step), and the console's plan panel shows which are done, which were skipped and which is next. A strategy that does not use run-calls (where agent tools are answered) leaves plan-step unanswered; its own handler may call record-step.

9.6 Working habits

Beside the prompts' working-rules, the default loop carries the habits that held up on a coding benchmark (ADR 32), as user code in internal/loop/, loaded after the files above in this order. Each wraps run-calls (and fold-result, sub-agent, model-turn or nodwyn-turn where it says), so a project's init.scm can wrap them again or redefine them away.

  • shell.scm: a command line sent through shell-exec's cmd/args (a program with its arguments in cmd, shell operators such as | or && in args) is answered, unrun, with the bash -c call that runs it as meant; shell results read as plain text, the exit status then the output, long output keeping its start and its end (shell-head-chars, shell-tail-chars), and, with lean-prompt on, the result's note (exit 141 after a head; a read that printed nothing) on a line of its own after the output, [note] …, which this text left out until 2026-10-17; and (ran-ok? result) counts a test run with failures as a run, so the evidence loop's way out is offered only for commands that never got that far.
  • hygiene.scm: in the main context (not in sub-agents), a test command that never ran a test is folded to one line once a later one does (a command ran tests when it exited 0 or its output says so in a runner's words, hygiene-ran-words: pytest's, Go's ok, --- PASS/--- FAIL and === RUN, mocha's, jest's, node's, cargo's, and a repository validate's summary; a Go package that did not build ran none); a read of a file edited since is folded to its outline after hygiene-stale-age rounds; a read hygiene-age rounds old is folded to its outline; neither folds a read of a file edited in the last hygiene-editing-rounds (4) rounds, the file being worked on. Reads fold in batches of at least hygiene-min-bytes, and only when the fold pays for the prompt cache it costs: a fold makes a caching server read everything after the first folded read again, at the full input price. So a batch folds when it is at least as large as the rest of the window after it ((context-tokens result)), when the provider's cache is not serving the context anyway (it read less than hygiene-cache-share, a quarter, of the last hygiene-cache-requests prompts; a provider that does not say, such as an older Ollama server, counts as serving it), or when the context is past hygiene-pressure (three quarters) of the high water. The log note of each fold says which. A read (hygiene-keep? r) holds, which the plan language says of a file its plan will change and the turn has not edited, is left out of the batch until the context is past hygiene-keep-pressure (three quarters too): below it the held read stays and the others fold only when it pays, past it every due read folds in one sweep. Were hygiene-pressure above it, a held read would lose its hold before the others fold whatever the cache. It wraps sub-agent, to watch the main context only, and model-turn, to read the cache counts of its requests.
  • readdef.scm: the tool read-definition (path, name) reads a function, method or class by name, its whole definition and line range, or the start and members of a long class; a shell script that only reads a file to print it gets a hint naming the file tools, readdef-hints times a session.
  • envhint.scm: a command that fails for a missing module gets a hint to find the project's own environment, envhint-hints times a session (never under lean-no-envhint, the lean profile's).
  • checkloop.scm: the tool second-look (check, saw): when the model's check script does not show the problem a request reports, one read-only helper reads the request alone and another compares that reading with the check, listing what may have been read differently. A history search (git log, git blame) before any code edit gets a hint to use it, second-look-hints times a session.

9.7 Per-model tuning

A round is one reply's calls, and models batch calls into rounds very differently (in the local session logs, gpt-6.1-sol made 2.75 calls a round and glm-5.3-flash 1.22), so a limit counted in rounds is a different amount of work for each. A knob that depends on the model is a number, which holds for every model, or a table: rows of (family . value) and a (default . value) row.

(define plan-explore-rounds
  '(("muse-spark" . 16) ("glm" . 16) ("qwen" . 8) ("deepseek" . 16) ("gpt-6-luna" . 16) (default . 12)))
(define reading-reminder-after '((default . 12)))
(tuned knob) the knob's value for the control model as it is now: a number as it is; from a table, the first row whose family is the front of the model's id, else the default row (#f when there is none). Switching the control model in a chat changes the value from the next use
(model-family-id) the control model's id as its provider spells it, without any vendor/ (z-ai/glm-5.3-flash is glm-5.3-flash), which is what a family is the front of: "qwen" fits qwen3.8-27b and qwen/qwen3.8-27b alike. The model is asked about only when a family row could use it

A narrower family goes before a broader one, since the first row that fits wins. The knobs that are tables are plan-explore-rounds (§9.5) and reading-reminder-after (§9.6); a project that redefines either as a number keeps that number for every model, and 0 still turns it off. The rows shipped come from the logs and say so in their comments (ADR 32, amended 2026-10-05): gpt-6.1-sol and claude-sonnet have none and take the default, and the reminder's table has only a default row. qwen's explorer row is 8 since commit-bench showed it using all 16 rounds in 39 of 48 runs, with the work reading again about half of what it read (amended 2026-10-15).

9.8 The profile: lean and full

The default loop comes in two profiles (ADR 32, amended 2026-10-17). Lean is the loop measured on the 121 clean SWE-bench Verified tasks, and it is what a session nobody approves runs: a system prompt of about 90 tokens (lean-system-prompt: who and where, unattended, one phrase per tool of lean-tools, throwaway files in $TMPDIR or /tmp, one sentence on the work and one on the answer, then the repository's AGENTS.md when it has one, by (project-instructions-section), but not the project notes; in sandbox mode, (sandbox?), it names the folder without calling it a repository, says the machine is a disposable sandbox whose every file is the model's to read, change, install or run, and that relative paths are relative to the folder); the control model offered shell-exec and fs-edit alone (lean-tools); a run's plan the one line plan-minimal-text, with no explorer, planner or judge (plan-minimal), recorded hidden, so the model is shown no plan and the run's turn prompt does not speak of one (lean-plan-hidden); and nothing said to the model while it works or as it ends (lean-quiet): no finish review, no finish reminders, no question on an edited test, no hint on failures the unchanged code has, no reading reminder (lean-no-read-reminder) and no environment hint (lean-no-envhint); a sandbox session's finish check (sandbox-finish-message, §9) is still said. A turn that changed nothing is told so once (lean-empty-reminders), in the words of finish-no-change-lean-message, and a shell-exec call that only reads (grep, sed -n, cat) counts toward the rounds that only look (lean-shell-look). Below the loop the lean session's tools differ too (§5): it registers scheme, outline and tests-for (the control model is offered them only when lean-tools names them), gives fs-read its symbol parameter, and answers an edit with the lines it added and removed, a write with the lines it wrote, and a search of a tree of more than 3000 files with the source files alone; its shell-exec and fs-edit say less and do more (§5), and a lean session nobody approves may write to and read from /tmp as from its scratch directory (prim.Options.Temp), a path outside the repository, $TMPDIR and /tmp then being refused with a message that says nothing ran. Full is the loop before that: the interactive or unattended prompt, every tool as it was, an explorer and a planner for a run, the finish review.

[loop] profile in config.toml chooses: "lean", "full", or "auto", the default. auto is lean when nobody approves the session's calls ((unattended?): --yolo, a run's auto_approve, judge or unattended) and full when a person does, since the lean prompt says nobody is watching and the lean loop was measured only on runs nobody watched. The environment variable NODWYN_PROFILE, when it is not empty, overrides the configuration for the sessions created in that environment (a test, or one run); a value that is not auto, lean or full stops the session from starting. The profile is fixed with the session, as the prompt and the tools are (ADR 9), and a resumed session resolves it again.

The lean profile is what plan.scm, prompts.scm and strategy.scm define. The full profile evaluates internal/loop/full.scm after them, as the host evaluates the defaults and before .nodwyn/init.scm, so a project's init.scm still has the last word under either. It is exactly these definitions:

(define lean-prompt #f)           ; the interactive or unattended prompt; every tool
(define plan-minimal #f)          ; a run's plan goes through the explorer and the planner
(define lean-quiet #f)            ; the finish review, the reminders and the hints
(define finish-reminders 2)       ; computed from lean-quiet when strategy.scm loads, so set again
(define finish-review-limit 2)    ; (0 when lean-quiet is true)
(define lean-empty-reminders 0)   ; (1 when lean-quiet is true)
(define lean-shell-look #f)       ; only the read tools count toward the reading nudge
(define lean-plan-hidden #f)      ; the model is shown the plan it has
(define lean-no-read-reminder #f) ; twelve rounds that only read get the reading reminder
(define lean-no-envhint #f)       ; a missing module gets the environment hint
lean-prompt true: system-prompt is lean-system-prompt and the control model is offered lean-tools (model-turn's tools option, when the strategy gives none). False: the interactive or unattended prompt, every tool
lean-tools the tools the control model is offered under lean-prompt, default ("shell-exec" "fs-edit"); the lean prompt's second sentence names what they do. A project's own tools (register-tool), MCP tools and the other built-ins are not offered under the lean profile unless it adds them: (define lean-tools (append lean-tools '("validate"))). A name no tool has is an error when the first request is made
lean-cli, lean-cli-name when lean-cli is true the lean prompt names three commands of the binary, outline, symbol and tests-for (lean-cli-name, default nodwyn), for the model to run through shell-exec; the same code index as the outline and tests-for tools without a schema in any request. False by default; experimental
plan-minimal, plan-minimal-text a run's turn that must plan records plan-minimal-text as its plan and goes on, unless the run already has one (§9.5)
lean-quiet true: the edit of an existing test is not asked about as it is made (test-edit-nudge), and a failing run's result does not say that the tests it fails fail on the unchanged code too (failure-known-hint). It does not itself bring back finish-reminders, finish-review-limit or lean-empty-reminders, which are numbers: a project that sets lean-quiet to #f sets those three as well
finish-reminders, finish-review-limit, lean-empty-reminders how many times a turn is reminded to change and test, how many finish reviews a run nobody reads as it goes makes in all, and how many times a turn that changed nothing is told so (the larger of this and finish-reminders) (§9)
lean-plan-hidden true: the one-line plan of plan-minimal is recorded with hidden (gate.plan), where it is recorded, carried to a run's later turn or brought back after a compaction, so the window shows the model no plan and no plan header, and run-turn-prompt drops its sentences about the plan's steps
lean-no-read-reminder, lean-no-envhint true: no reading reminder (§9), and no environment hint on a missing module (envhint.scm)
lean-shell-look true: look-call? counts a shell-exec call that reads, searches or lists and writes, runs and changes nothing, so the reading reminder (§9) comes after rounds of grep and sed -n as after rounds of fs-read

A project mixes the two by redefining these names in init.scm, after the profile: (define lean-prompt #t) in a session a person approves gives it the lean prompt and tools; (define lean-tools (append lean-tools '("fs-read"))) adds a tool to the lean loop, and its prompt then says the model can read files; (define plan-minimal #f) brings the explorer and the planner back to a lean run. Under lean-quiet the finish review of a turn a person reads ((reviewed?): a chat's turn) is still made, as in the full profile. What the full profile does not undo, because it is an improvement in both: the finish review reads a run's output in any language and runs the original tests of the test files the turn edited whatever their language (original-tests), tests that a failed run shows failing and the last commit passes (finish-regressed-tests-message), a plan's run matched by what it runs (plan-language-ran?), a task template's fields left out of the names a plan must hold, repo-relative?, the guard's refusal of a path under /tmp saying the scratch directory's own path, and fs-edit's errors that say where an old that occurs twice occurs and where one that almost matches stops.

10. Not in nodwyn Scheme

Deliberately absent.

  • characters and the full string-port API (only with-output-to-string)
  • source positions in runtime errors (reader errors carry positions)
  • define-macro — the prelude does not need it (ADR 7)
  • set-car! / set-cdr! and string mutation
  • nested quasiquote
  • hygienic macros (define-syntax, syntax-rules)
  • call/cc, dynamic-wind
  • an eval primitive — the kernel surface is not exposed to programs
  • exact rationals and bignums

Project authority (ADR 45)

The repository supplies init.scm and allow.toml; their presence is not a grant. Use nodwyn trust --repo PATH and accept its displayed revision before ordinary sessions load them. The user-owned registry is outside the project. The exact checked init bytes run with user origin and the guard still applies; standing approvals can permit actions during startup. Review transitive Scheme and scripts too, since their contents are not part of the two-file revision. A change to either authority file, including creating or removing it, needs review again. See Security boundaries for revocation, worktrees and HTTP choices.