nodwyn.

nodwyn manual

This manual covers how to read the code and how to use nodwyn. If you're starting with the console, the website's guide walks through a first task with screenshots. Read SPEC.md for the architecture, plan.md for what's built and what comes next, and the decision records for why things work the way they do.


Part 1 — Reading the code

The one-paragraph model

A front-end sends the user's text to a core session, which calls the loop: a Scheme procedure, agent-turn, over a few Go primitives. They build a context window from the session log, send it to the control model ([control] in config.toml) and display the redacted answer after generation. When the model asks for a tool, the loop turns the request into a Scheme application (tool-name arg …) and evaluates it in the embedded Scheme interpreter. Every primitive application — from the model, the user, or a .scm file — passes through one Go function, applyPrim, where the guard decides allow / confirm / deny before the host primitive runs. Everything is appended to the log first; the window, the transcript and the front-end's view are all derived from it.

Follow one tool call through the code

Follow these in order to see a message become a model request, a tool call and a recorded result.

Step Where What to notice
1 internal/frontend/plain/chat.go Run reads a line, calls Session.Send, then only renders events until status idle
2 internal/core/session.go Send one goroutine per turn; events.go records and emits; confirm.go handles a prompt plus a wait on Answer
3 internal/loop/strategy.scm agent-turn, prims.go modelTurn, runTools the loop is Scheme; its primitives rebuild the window from EventsSoFar() every request (model.go ask) and give each model call exactly one result (turn.go claim, tools.go runTool)
4 internal/window/window.go Build elides oldest tool results in place so the request prefix never changes
5 internal/llm/ollama.go Chat native /api/chat streaming; thinking separated; tool-call args stay JSON objects
6 internal/loop/tools.go apply Prim.ArgsFromJSON orders named JSON args into positional Scheme values; WithOrigin(ctx, OriginModel)
7 Brote's scheme/prim.go (the module github.com/fclaude/brote) applyPrim the single Scheme→Go exit: arity check, gate.Before, the handler, then the result's taint (taintResult)
8 internal/guard/guard.go Before checkers run in order; strictest verdict wins; a standing approval outranks confirm, never deny; every host-reaching decision is logged
9 internal/guard/checkers.go, allow.go the bootstrap envelope: MetaPolicy, RepoPin, PathDenylist, ArgvTrigger, CommandAllow
10 internal/prim/fs.go, shell.go the host primitives; Meta.Params[].Role is what the guard reads; Preview renders the diff for the prompt
11 internal/log/log.go, payloads.go the source of truth: one JSON line per event, fsync per write, mandatory redaction after the optional Filter hook

Package map

Package Owns Start with
github.com/fclaude/brote/scheme the language, imported from Brote, a module of its own: values, reader, eval/apply, builtins, primitive table docs/scheme.md then Brote's interp.go Eval
internal/guard the one decision point and its checkers guard.go Before
internal/prim host primitives and the diff; the document tools; the judge's primitives and tools prim.go Register, Checkers; docs.go; judge.go
internal/llm the provider seam, the Ollama, OpenAI-compatible and Anthropic clients, what a request cost llm.go Provider; cost.go
internal/window the derived context Build
internal/loop the loop's primitives and default strategy the package comment's reading order, starting at loop.go Turn
internal/core the UI-agnostic session façade construct.go New, session.go Send, confirm.go confirm
internal/budget one turn's budgets and checkpoints budget.go
internal/extract attachments: text by file type, images checked but never read as text extract.go, image.go
internal/memory, internal/retrieval what a task remembers of its history (a card for each turn, an episode for each compaction note), and the words-only ranking index-search uses over the repository, the conversation and earlier tasks (docs/memory.md) memory/card.go; retrieval/index.go
internal/schedule the civil-time rules of scheduled tasks and the words they are said in; it owns no timers or files (ADR 49) schedule.go
internal/watch, internal/workflow what a workflow's source has now (a forge's pull requests, a Gmail search, a command's JSON lines), read through core.Keyed.Authorize so a key goes only where the binding table, the credential's scopes and the transport rule allow, and posted to (a review, a draft reply); a workflow's log: its definition, what it has seen, its events and the answers it holds (ADR 53) watch/watch.go, watch/mail.go; workflow/workflow.go
internal/startup the login item that starts the daemon (a systemd user unit, a launchd agent), written only when asked (ADR 53 §11) startup.go
internal/catalogue what providers publish about their models (context, effort, price), as models.dev collects it (ADR 41) catalogue.go, modelsdev.go
internal/termemu, internal/frontend/termvt the terminal primitives' emulator seam, and the Charm emulator behind it, kept under frontend by the boundary lint (ADR 48) termemu.go; termvt.go
internal/policyfile atomic writes of the policy files the console edits update.go
internal/keychain encrypted credentials, opaque handles and injection; OAuth2 token sets, refresh and acquisition store.go, oauth.go, acquire.go
internal/binding binding loading and authorization lookup binding.go Lookup; Scheme call decoding lives in guard/egress_facts.go
internal/mcp the MCP client, over streamable HTTP or a contained server's stdio; it starts no process itself, and a contained server is run by internal/container client.go, stdio.go; sessions use it from core/mcp.go
internal/container, internal/egress running a contained MCP server with podman or docker; the allowlisting proxy it reaches the network through (cmd/nodwyn-egress) server.go Start, proxy.go
internal/redact, internal/detect/l1 text redaction and credential-shape detection redact.go, l1.go
internal/event what a front-end sees event.go
internal/log the append-only log log.go Append, Replay
internal/config XDG paths, config.toml and allow.toml Default, Validate
internal/frontend/plain line mode and replay chat.go show
internal/frontend/tui the terminal client (docs/tui.md) model.go
internal/frontend/rpc local daemon sessions, turns and events server.go, sessions.go, events.go
internal/frontend/run supervised and unattended runs (docs/runs.md) run.go Driver
internal/frontend/workflows the daemon's workflows and held answers, laid out and acted on by reference, shared by nodwyn workflows, nodwyn outbox and the TUI workflows.go
internal/frontend/web the browser console: its bridge to the daemon and its pages (docs/browser.md) server.go, assets/app.js
cmd/nodwyn flags → core → front-end; nothing else main.go
cmd/nodwyn-schema generates docs/protocol.schema.json main.go
dev/, .nodwyn/ this repository's own tools and standing approvals dev/go.scm
test/exfil the exfiltration scenario suite exfil_test.go, scenarios/
test/browser the console's checks in headless Chromium (make browser; BROWSER=webkit runs some in WebKit) README.md
test/release, test/sessions the release builder's tests, and recorded session logs the replay tests read test_release.py; README.md
scripts/validate what validate runs and checks for a change (ADR 37) main.go
test/fakes/ollama scriptable fake of the model server; serve runs it for the selfcheck ollama.go
test/fakes/jev, mcp, oauth, drive, forge, gmail fakes of the judge, an MCP server (HTTP or stdio), an OAuth2 provider, Drive's upload endpoint, Gitea's and GitHub's pull-request listing and review endpoints, and Gmail's messages and drafts: no test reaches a real service each package's comment
test/fakes/container, chat, home a fake container runtime that is the test binary run again; a fake chat-completions provider; an isolated HOME for packages whose tests build a guard or commit container.go, chat.go, home.go

The invariants and the tests that hold them

Read a test when you want to know what a claim actually means.

Claim (SPEC) Enforced by
The log is the single source of truth nodwyn replay reproduces a transcript from the file alone; window.Build takes only events; log_test.go round-trips and repairs
The interpreter is the only Scheme→Go surface; the guard sits on it applyPrim is the only caller of Prim.Handler; Brote's scheme_test.go TestGateIsTheSingleExit records every crossing including through map/apply/sort
Loading .scm cannot change evaluation Brote's TestKernelFixed: only the primitive table grew, and define-syntax stays a plain binding
Proper tail calls Brote's TestTailCalls: 200k iterations in nine tail positions under an 8 MB stack cap
Every primitive is documented Brote's TestDocCoverage (builtins, against Brote's reference) and TestSessionPrimitivesAreDocumented (every primitive a session registers, builtins included, against docs/scheme.md) fail for any primitive missing
A loop strategy cannot skip the invariants strategy_test.go: fabricated or rewritten calls refused, unanswered calls answered, stops uncatchable, provenance kept
Agent behaviours are user code, not core features userland_test.go: each behaviour is written as a user's init.scm; one that needs Go marks a gap in the core
A handle can never become text Brote's TestHandlePrintsOpaque
The core knows nothing about terminals scripts/lint-boundaries.sh in make lint
No path exists for the canary to leak test/exfil: each scenario runs in a real session (the guard core assembles) and asserts the decision, whether a confirm is a security confirm no standing approval may waive, and greps the canary out of the log, the output and the requests to the model
Request prefix is byte-stable session_test.go TestTurnWithToolCall compares request N's messages with request N+1's prefix

Searching what earlier tasks did

In the console, type words in the sidebar's search box and choose Search conversations for "…" (or press Enter), or choose Search in a task's ⋯ menu. Choose This task and earlier ones in this folder (the default), This task only or Earlier tasks only. Earlier means the other tasks in the same recorded project, including archived tasks and its git worktrees. This is you reading your logs: it works while the model’s earlier-task search is off and leaves that setting unchanged. Paired and read-only consoles can search too, and closed tasks need no resume.

Results show up to 20 matching events, grouped by task and turn, with the turn’s outcome, event times and snippets. Words and identifier parts are ranked through the same index as the model and CLI. Long snippets retain the match; legacy credentials are redacted before clipping. An incomplete read is labelled so an empty partial search cannot claim there were no matches. Submit the same query to refresh it. Its words and scope are kept in the URL, so Back and reload restore the search.

Choose a match to open its recorded conversation. The selected event is highlighted, and its enclosing activity folds open. This initially loads a small section around the match; Load earlier in this turn and Load later in this turn reveal more within the selected turn and run. Historical choices and approval records affect only this read-only view. Back to search results returns to the query; Open task returns to the current conversation, where you can reply or answer live requests. A failed search or history read offers Retry; a failed load of more context retains the loaded history.

Every turn of every task leaves a card: what was asked, how it ended, its plan and the marks made, the files written, the commands run and how they ended, errors, and the start of the answer. When the model searches its conversation with index-search, or a folder's earlier tasks once you let it (the folder's editor on Workspaces, or :earlier on in the TUI; ADR 50), it sees each event it found under its turn's card in one line: how the turn ended, how long it took, what it cost and what it asked. recall of a turn's first message shows the whole card and the seq of every event in the turn (ADR 51). From a terminal, you search all of a repository's tasks the same way, whatever that switch says:

nodwyn --repo ~/src/your-repo memory search flaky pager test
nodwyn --repo ~/src/your-repo memory search --task 20261004T073130Z-40187c24 -n 20 TestPagerOrder
nodwyn --repo ~/src/your-repo memory recall 20261004T073130Z-40187c24 6

search ranks the events by the words they share with yours (an identifier is also found by its parts: "pager" finds TestPagerOrder) and shows them under their turns; recall brings one event back whole, and a turn's first message with what the turn did. The cards and the words are kept beside each log as <id>.mem, made again from the log whenever it does not match, so deleting them is safe; the first search of nodwyn's 73 tasks wrote 10 MB of them in 1.3 seconds, and a search after that takes about 20 milliseconds. Nothing is sent anywhere.

Reading a session log

bin/nodwyn sessions                      # ids, oldest first
bin/nodwyn replay <id>                   # transcript
bin/nodwyn --show-thinking replay <id>   # with the model's thinking (flags go before the command)
jq -c '[.seq,.kind]' ~/.local/share/nodwyn/sessions/<id>.jsonl

Event kinds are listed in internal/log/log.go. Beside each log, <id>.idx is its index (ADR 49 §10): what listing the session and reading a page of its log need, with where every 256th line starts, kept so that neither reads the whole log again when it grows. It is derived from the log and checked against it before use; nothing is only in it. One that does not match its log is built again from the log, so deleting it is always safe, and deleting the session deletes it. Resuming a task reads its index and the end of its log: the session holds the events from where its window, its last few requests and an unfinished plan need them, so a task that has run for months resumes as fast as one that ran today. nodwyn --resume and the TUI still show the whole conversation, read from the log. guard.decision lines carry origin (user, model, or harness for the loop strategy and plan gate), decision, checker, reason and, for confirms, answer and, when recorded, actor: who answered (a daemon client's identity, judge or unattended). An llm.req has elided: the seqs whose content was replaced or omitted in the window. From recipe v16, trimming an assistant's tool-call batch also omits its matching results, so no tool reply is left without its call. A result among the four recent entries protects its whole calling batch. The original events stay in the log; older requests still replay with the recipe they recorded. The model reads them back itself: recall N returns the event at seq N as it was recorded, and index-search with scope "conversation" finds earlier messages, calls and results by their words (ADR 50, memory and retrieval). Restarting and resuming a task uses the new recipe, including repairing tool pairs left orphaned by an earlier trim. A tool result larger than window.max_result_tokens is shown head and tail; from recipe v17 the cut is at line ends and says which lines it left out, as the fs-read that brings them back. The cap follows the window: 3,072 tokens by default, a 48th of the high water once the window grows with a large context (16,128 at a 1M one, at most 16,384), unless you set it. An fs-edit answers with the lines it changed, numbered as the file now reads them. In a lean session (unattended runs) it also says, when the text it replaced (two lines or 40 characters or more, and not only import lines or decorators) occurs in one to three other places of the source git tracks, where, as path:line: the same text is not always the same fault, but a change that belongs in one place often belongs in the others. In a lean session, too, an edit whose text matches nowhere is answered with the lines of the file closest to it when some are close, and one written with literal \n sequences where newlines were meant is made as meant, and says so.

Where each phase lives

Every phase of the v0 plan up to P10 is built except the optional P8 (the book's part "How it was built, and what is next" tells how). This is where to start reading each, where P8 would go, and where contained local MCP servers (W3b) live.

Phase Where it lives
P3 credentials scheme.KHandle; Param.Role = "credential"; internal/keychain; guard.Egress checks the binding table (internal/binding)
P4 L1 internal/detect/l1; guard.TextSecret scans Role: payload/argv text
P5 Jev + plan gate internal/loop/plan.scm; internal/prim/judge.go; log.KindGatePlan; ArgvTrigger is the hard-trigger list
P6 taint Value.Taint, propagated in taintResult; guard.Taint runs before L1
P7 redaction Session.Record sanitizes payloads; the log writer enforces redaction; window.Build derives context from written events
P9 TUI internal/frontend/tui over the daemon's protocol (ADR 30, amended 2026-10-17), no core change
P10 memory summaries in internal/window (summary.go) and log.KindSummary; index-grep (docs/memory.md)
P8 L3 (optional, not started) the residual L1 marks as unknown entropy (l1.Scanner's isUnknownEntropy); a checker after guard.TextSecret
W3b contained local MCP servers internal/container runs a server in podman or docker, internal/egress (cmd/nodwyn-egress) is the allowlisting proxy, core/contained.go connects it to a session; internal/mcp speaks to it over stdio

Part 2 — Using nodwyn

Install

make build                       # bin/nodwyn (and bin/nodwyn-egress) from the working tree
make promote                     # accept + browser checks + selfcheck, then ~/.local/bin/nodwyn-stable (ADR 12/13)
                                 # and nodwyn-egress beside it

nodwyn-stable version is the commit it was built from. It is the webview build where that can be built, so nodwyn-stable app opens a window; on Linux that needs WebKitGTK (the app, "Linux and other systems").

Use nodwyn-stable for real work and bin/nodwyn only to test a change to nodwyn itself. Never run the binary you are editing (ADR 12).

Building requires Go 1.26 or newer (ADR 33); make lint builds its staticcheck from the module, so there is nothing else to install. The selfcheck's model turn goes to a fake model server, so promoting needs no model, key or passphrase. Using nodwyn needs a model to talk to: the [control] table in config.toml (bin/nodwyn paths shows where it lives; everything else has defaults in internal/config/config.go). There is no built-in model; without one, nodwyn prints the table to add. See "Choosing models".

A session

cd ~/src/your-repo
nodwyn-stable                       # pins every path to this directory
nodwyn session 20260921T143846Z-beba937b
repo  /Users/you/src/your-repo
tools doc-export fs-edit fs-find fs-list fs-read fs-write git-diff git-status index-grep index-search
      index-update plan-step plan-write read-definition recall second-look shell-exec shell-read
      shell-send shell-start shell-stop
:h for commands

you> read main.go and explain the startup sequence

Commands at the you> prompt:

:q quit (writes session.end)
:h list these commands
:log [n] last n log events, 1 to 1000 (default 20), from the log, so a resumed session shows the earlier runs too
:tools tools offered to the model
:allow standing command and write approvals in effect
:eval EXPR evaluate Scheme yourself (user origin)
:thinking toggle showing the model's redacted thinking
Ctrl-C abort the running turn; at the prompt, exit

Two messages run a command instead of a turn, in every client (line mode, the TUI, the console and a paired phone):

/compact [what to keep] compact the conversation now, whatever its size: the model writes a handover note for your latest request, and your next message goes on from the note instead of the whole conversation. Words after it are what the note must keep (/compact the failing test names). The log keeps everything
/optimize [n \| ids] [focus] learn from this repository's past sessions and improve .nodwyn/init.scm ("Changing the loop" below)

Sending while a turn runs. A message sent while a turn runs is refused, and the TUI and the console keep your draft; line mode reads no line then. The console and the TUI have two ways to send it anyway. Queue (the console's Queue button, Ctrl+O in the TUI) holds the message and runs it as the next turn when the running one ends, after any queued before it; the conversation shows it as queued until it starts. Steer (Steer, Ctrl+S) stops the running turn, as Stop does, and sends the message next, ahead of the queued ones; a stopped turn keeps its plan, so the message can be a correction. Stop (Stop, Ctrl+C) discards the queue along with the turn. A model chosen while the turn ran is applied before a queued message runs, so the model you chose answers it. Nothing is queued while a file is being attached, a model switch is under way or a run drives the task, and an :eval is never queued. On the wire it is session.send with mode queue or steer (protocol).

Flags go before the command (nodwyn --show-thinking replay ID); a command refuses the ones it does not read, and a command with flags of its own, such as tui or run, takes them after its name. --plain is an old flag that does nothing. The flags are --repo DIR, --resume ID (go on with a task; it keeps its folder, and a --repo beside it must name that folder), --model NAME (a [models.NAME], or a model on [control]'s server), --show-thinking, --no-confirm (every confirm is a no, answered without reading a line, so a script's next line stays its next message — for scripts and CI), --yolo (turns on the task's Don't ask, "Standing approvals" below, which Settings must offer), --no-keychain (leave the daemon's keychain locked) and --keychain-tty (ask for the daemon's keychain passphrase on the terminal even when input is a script). --url, which set a server for one session while line mode ran it itself, is refused, saying where its setting is now: a task in the daemon has config.toml's servers, whichever client opened it.

A task's limits

A task has no limits unless you set some, for that task (ADR 49, amended 2026-10-27): its turns work, and wait for your answers, as long as they need. For the tasks that want a bound, there are five, each set on its own:

Limit Line mode Counts
Tool calls --max-tool-calls N each turn's tool calls
Writes and commands --budget-actions N each turn's irreversible host attempts
Tokens --budget-tokens N each turn's model tokens, each request's prompt and reply, a prompt token read from the provider's cache counted as a tenth
Time --budget-wall 1h30m each turn's wall time, waits for your answers included
Spend --budget-cost USD what the whole task's requests cost, as far as known

Each is one number, and reaching it stops the turn without asking first. In the console they are on the task's panel, under its monitor: Limits says None in one line until you choose Add a limit, pick a kind and type its value (a time as 45m or 1h30m, a spend as 5 or 2.50); each limit set is a chip, which you click to change and whose cross removes it. In line mode the flags set the limits of the task it opens, or, with --resume, replace the ones they name on the task it resumes, 0 removing one. A change applies from the next turn, the conversation says it, and the task keeps its limits when it is resumed. A run keeps the limits it started with until it ends. A paired phone may add a limit or lower one, never raise or remove one. The request and command timeouts and the progress guard, which stops a turn that keeps repeating itself or failing, apply to every task. A [budget] table, or loop.max_tool_calls, that an older config.toml still has loads and sets nothing.

The console's Settings page changes the timeouts (loop.request_timeout, loop.shell_timeout), the window, the model providers and the models and efforts to choose between ("Choosing models" below), the default model's tuning, the judges ("The judge" below) and what a task does without asking ([approval], "Confirms" below), keeping every other line of config.toml as written. It is tabs, each at an address of its own (#/settings/models, #/settings/judge): General (its approvals, and under Advanced the timeouts, the context window and the container runtime), Models, Judge, Keys & devices and This browser. A choice made there waits in a bar at the foot of the page and is saved with the others; only keys, the passphrase, a provider or a model (each its own form) and This browser act at once. Tasks started afterwards get the new values; open ones keep theirs (the browser view, "Settings").

Line mode is one client of several: nodwyn tui is a full-screen terminal client (the TUI), nodwyn app and nodwyn ui show the console in a window or a browser (the app, the browser view), and nodwyn run carries a task to its end with no conversation, a batch tool for scripts and benchmarks (runs). All of them reach tasks through a daemon, so a task open in one is open in the others and runs on its schedule. Line mode, nodwyn tui and nodwyn app use the daemon that is running, or start one in their own process (line mode says which on its error output), and a daemon a process started ends its tasks when that process quits. Quitting line mode closes the task it opened, as it always has, and leaves open one another client had open; quitting the TUI detaches, and :close closes. A message or a scheduled run that another client starts while line mode waits for a line is shown when the next line is read. A script that sets XDG_CONFIG_HOME or XDG_DATA_HOME sets XDG_RUNTIME_DIR too: line mode refuses a daemon that keeps its tasks in another data directory, since that daemon's configuration chose its models and keys. Everywhere else a task is a chat: a request that takes long is a message like any other. The clients lists what each of them can do, and what is still built in one and not another. The console connects again by itself when its connection drops, as a phone's does after a sleep, and reloads the open task from its log; nothing that was on its way is sent again. When nodwyn was upgraded meanwhile, the page says so and offers Reload; it never reloads by itself, since a reload loses the replies being written. nodwyn -h lists every command and flag.

Programs that keep running

shell-exec runs a command to completion, and stops whatever it left running in the background when it ends (SIGTERM, then SIGKILL two seconds later): a server started with & does not outlive the call. In sandbox mode it does: what the command starts in the background keeps running after the call and the session. A bash -c command line runs with pipefail, so make test | tail -n 40 reports the tests' failure rather than tail's success. In a lean session (unattended runs) a long output is not poured into the model's context: when the model runs a command and a stream passes 8 KiB or 150 lines, it reads that stream's first 20 lines, a marker saying which lines are not shown, and its last 50, and the whole stream is saved under the session's scratch directory ($TMPDIR/out/N.stdout, and N.stderr for errors; removed when the session closes) for the model to read a range of with sed -n or search with grep -n, without a question. A command that reads a file (cat, head, tail, nl, sed -n) is shown as it prints, up to 256 KiB, so is a person's own command, and so is a program's of the scheme tool, which computes with the whole of it; a full session's output is cut at 256 KiB alone. A lean session's model also reads the result's note (exit 141 after a head; a read that printed nothing) after the output, as [note] …. A lean session nobody approves may also write to and read from /tmp (the files never reach the repository's diff); a command that names a path outside the repository, $TMPDIR and /tmp is refused, and the model is told nothing of it ran. For a server, a watcher or a program to talk to, the model has shell-start (asks, like shell-exec), shell-read to see new output (optionally waiting up to 60 s), shell-send to type into it (asks every time: typing into a shell is running commands), and shell-stop. At most four run at once, each keeps its latest 1 MiB of output, and all of them are stopped when the session closes. They run in their own process group; if nodwyn itself is killed, they are not cleaned up.

A terminal for the model (experimental)

Experimental and temporary. This was added to find out what a terminal is worth to a model, and it will be removed or rebuilt after that (ADR 48). It may change or disappear in any release, and it is not supported as it is. Read "What it does not do" before turning it on.

shell-start runs a program over pipes, so tmux, neovim, less and the like cannot run in it. With a terminal the model gets a real one, which it reads as text and types into. It is off. To turn it on, add this to config.toml (Settings does not offer it) and start a new task:

[experimental]
terminal = true

The model then has six more tools: term-open runs a program on a pseudo-terminal (a shell, nvim main.go, tmux); term-type types text and term-keys presses keys (Enter, Escape, Up, C-w, F5, ...); term-screen shows the screen, and with an offset the lines that have scrolled off; term-wait waits for a line of the screen to match a pattern, or for the program to go quiet, or exit; term-close hangs up. The screen comes back as text with each row numbered, and the cursor's row and column in the header. A session with it on starts with a note saying it is experimental.

Opening a terminal asks once, like shell-start, and the prompt says it is experimental. At most three are open at once, and all are hung up on when the session closes. tmux the model starts uses a directory of the session's own, never your tmux sessions; the server it leaves running is stopped when the session ends.

What it does not do. Nothing the model types in a terminal is checked. After the one question, it is a shell with your privileges: the credential denylist and the folder pin apply to nodwyn's file tools and not to what a program in the terminal opens (the model can read any file you can, with neovim or cat); files it changes are not versioned or shown with a diff, so git is the only undo; its network is not limited; and a standing approval or the judge does not see what it does. Use it in a folder you can restore, on a task you would give a shell. It shows plain text only: colour, highlighting and the mouse are not visible to the model, and text it types is typed as typed, not pasted. The person sees none of it live: it appears in the transcript as tool calls, with the screens as their results.

Seeing what changed

In a git work tree (a folder with .git), the model also gets git-status and git-diff (optionally a path, and staged for the index against the last commit). They are read-only and need no approval: git runs with fixed arguments, with external diff drivers, text conversion, the file system monitor and the pager switched off. The status names every changed file; a diff leaves out the contents of credential-shaped files. Kept versions are the undo for what git cannot give back: before a write, nodwyn keeps a file's content unless git has it committed and unchanged (nodwyn versions, nodwyn undo).

In the console's Changes tab, click a filename to expand the latest recorded approval preview, with line numbers and addition/deletion counts; long lines wrap rather than scroll. Enter or Space works on the focused filename too. Its label, Approved preview—not proof of a successful write, matters: approval precedes execution; the tool result says whether it succeeded. Older records may have no preview. The inspector reports unavailable, empty or oversized diffs instead of guessing.

Open History and choose Compare with current file to see a read-only comparison from that kept version to today's file (removed lines are from the version; added lines are current). A missing current file shows deletions. Compare again to refresh; Restore is the separate action that writes. Open inspectors survive redraws. No prior version exists for a new file, and committed unchanged Git files rely on Git rather than this store. A write through a link keeps the target's version, and a file over 16 MiB, too large to keep, is not overwritten: the write is refused with that reason.

Several tasks on one repository: worktrees

Tasks in one folder share its files, its git index and its stash: one task's edit shows up in another's test run. In the console, In a worktree beside a git workspace on Home is checked by default, starting a task in a git worktree of its own (ADR 34): <repo>/.nodwyn/worktrees/<id> on a new branch nodwyn/<id>, from the repository's HEAD. Uncheck it to start in the folder itself; this choice is kept per git workspace for its next task, by nodwyn, in every window. In the TUI, the chooser's first row in a repository's top folder is "New task in a new worktree of" it, and nodwyn tui --worktree opens one directly; the title shows the branch (the TUI). The task's tools, commands and .nodwyn/ settings are the worktree's, exactly as if you had checked the branch out there; the notes, MCP servers and session history are the repository's, and the task is listed under it. The folder ignores itself (.nodwyn/worktrees/.gitignore is *), so the repository's git status stays clean. What is uncommitted in the repository is not in the worktree (the conversation says how many files), and nothing untracked is copied; a first build or install there starts from nothing. The task has the repository's mounted folders, from the workspace list. The model is told it is in a worktree, that others may be working beside it, and to compare with the unchanged code on a copy of the last commit rather than with git stash, which every worktree shares.

The work stays on the branch until it lands. Ask the task to land it ("commit and push to main") and it calls git-land once validate has passed: one step that commits what changed with the message the model writes (files the guard keeps from fs-read are left out, even when already staged), rebases the task's own commits onto the newest base (fetching its upstream first), refuses a change that adds credential-shaped text, pushes to the base's upstream by fast-forward only, and fast-forwards your checkout of the base branch when it is clean (a checkout with changes of its own is left alone, and the answer says so). It asks you first, as any call that cannot be undone does, unless Don't ask is on for the task, which answers it too. Commits are signed as your git configuration says, with your own agent; if signing fails it stops and says so rather than commit unsigned, and a prompt nobody can answer (a passphrase) fails at once instead of hanging. A rebase conflict stops it with the files named; the task resolves them and calls it again. SSH uses your GIT_SSH_COMMAND when set, otherwise Git's core.sshCommand for that worktree (including global configuration). Only when neither is configured does it use ssh -o BatchMode=yes. Repository-scoped deploy keys and SSH config selection therefore apply to landing too. You can still merge a branch by hand, for example with git merge nodwyn/<id> in the repository. Closing the task removes the worktree and its branch when nothing would be lost (no change, no untracked or ignored file, no commit its base lacks); otherwise both stay and resuming works in them again, and a removed worktree is made again on resume, on a new branch from where its base branch is then: Review compares with that commit, so what main gained meanwhile is not shown as the task's. The previous versions nodwyn keeps of the task's files are kept with the repository's, in its .nodwyn/versions/, so they never keep a worktree and outlast it. Committing through shell-exec (git commit) still asks, as everywhere.

Reviewing a task's changes. In the console, a task in a worktree has a Review button beside its conversation (the TUI has none, by decision: run git diff in the worktree, whose path the conversation gives, and write comments to the task as a message; ADR 34). It opens in the centre panel; Home's Recently finished also has a Review link beside each worktree task. Review shows every file changed since it branched (committed or not, untracked files too), with its status (added, modified, deleted) and the comparison commit. Unified is the default; Side-by-side aligns the old and new files, with blanks for unmatched lines. Both show old/current-file positions. Long lines wrap within the diff, and the side-by-side view scrolls within the diff on a phone without widening the page. Close review brings back the conversation and its reply draft; it does not close the task. A link, a binary file, a file too large to show or an empty one says so instead of showing nothing; if a branch has more changes than a reply holds, the rest are counted. Review reads the worktree while the task is open, and says to resume a closed one; once opened it is read again when a turn ends, and Refresh reads it now. Failed refreshes keep the previous diff; a late reply does not reopen a review you closed. Hover a current-file line and press + to comment on it, or use Comment on file (including for deletions, whose old-only lines have no comment control); add an overall note if you like. Send review sends all comments together, as one message ordered by file and current-file line, and clears them after acceptance; the task answers them in one turn. Only your comment and the line's number go to the task, not the text of the line. A comment on a line the diff no longer shows is listed under its own heading so it can be read or removed, and one whose line now says something else is marked. Send waits while the task is working, and a refused review keeps its comments and note. Saved comments, the unfinished comment and overall note, the chosen mode, reading position and textarea caret survive redraws and closing/reopening in this tab. They are not stored beyond the page, so reloading loses them. Home's pending-approval Review links still open the conversation's request card, not worktree review. A change of direction is not a comment: close review and write it to the task as a message. Not built yet: --worktree for the plain CLI and nodwyn run, and --worktree-base; nodwyn worktree list and remove; and a list of push destinations in .nodwyn/allow.toml that would let git-land push without asking (ADR 34, amended 2026-10-05).

Naming, archiving and deleting tasks

A task is listed by a short title the model gives it, when nothing has named it yet, until you name it; when the model gives none, by the first sentence of its first message. The model is asked, in the system prompt, to name the task in its first reply, next to its first step, with a tool of its own (name-task): a few words of output in the reply it was making anyway, not a request of its own. Only the tasks the daemon opens have the tool, and an unattended run does not (ADR 39, amended 2026-10-23). In the console, the pencil beside a task's heading renames it, and an empty name takes the title back to the first message; a closed task can be archived, which takes it off the task list until you unarchive or resume it, or deleted, which removes its conversation and log for good and leaves the files it changed. The sidebar lists tasks under their folders, the folder with the latest activity first (the browser view, "Tasks, folders and files"; ADR 39). The names and the archive are records in each session's log, so they last across restarts and every console reads them; nodwyn sessions and the TUI still list first messages.

The console batches streamed text and thinking for the next display frame, keeping the heading and detail panels in place. Tasks kept off screen catch up when shown; replies and questions flush preceding fragments immediately. Reading earlier text keeps your scroll position.

The console folds routine activity (judge requests, focused contexts, instructions to the model, groups of calls and the token line) into one closed Activity row per stretch between replies; click it to see it all. Its summary follows the work without opening and names the active command. A wrapper tool also shows the shell invocation recorded by the guard beside its tool row (arguments may be shortened). The active command leaves the summary when the tool returns; this is not live command output. The row counts a failed call in red ("1 failed"), whose run or group is open inside. A call that was skipped without running (an explorer out of rounds, the rest of a batch after a failure) did not fail: it is a note in the row that says why, one note for the calls skipped in a row for the same reason. Replies, warnings, errors, a turn that stopped short and a worktree that left uncommitted files out stay in view, and a question is asked in its card at the end of the conversation. This folds the display only; /compact [focus] changes the model's context, and the log keeps everything.

The scratch directory

Each session gets a scratch directory outside the repository, a private directory (mode 0700, a name no one can guess) in the system's temporary directory (TMPDIR, else /tmp), for throwaway files: a script that reproduces a bug, notes, temporary output. It is outside nodwyn's configuration and data directories, which the guard protects, so a test suite or a tool a command runs, with the scratch directory as its TMPDIR, makes its temporary files and directories there as it would anywhere, and is not refused them. The default system prompt names it as $TMPDIR, so the prompt is the same in every session, and $TMPDIR means it everywhere: commands run with TMPDIR set to it, and a file primitive's path or a command's argument that starts with $TMPDIR/ (or ${TMPDIR}/) is rewritten to it before the guard sees it. Writes there need no approval. Nothing in it reaches the repository or its diff; a clean close removes it, and a resumed session gets a new, empty one. A session touches it at every turn, and directories untouched for a week, left by sessions that ended without closing (in the temporary directory, and in the data directory, where sessions before 2026-10-16 kept theirs), are swept; only this user's own, named for a scratch directory, are removed. It is the one place outside the repository the tools may use: everything else outside it stays refused, the configuration and data directories included (wherever XDG_CONFIG_HOME and XDG_DATA_HOME put them; and if TMPDIR is itself set inside one, the scratch directory is still the one place there that is open), and a symbolic link out of the scratch directory does not escape it (ADR 28). A system whose temporary directory forbids running programs from it needs a TMPDIR for nodwyn that does not.

Notes kept across sessions

A repository can keep notes from one session to the next: how its tests run, where things are, runbooks. The model keeps them with its project-notes-save tool: before it finishes, it is asked to add what a later session would need and could not quickly find (how the tests run, a setup step, a trap it fell into), and to keep what is still true of the notes it was shown. A strategy can save them too (docs/scheme.md). They live in nodwyn's data directory, keyed by the repository's path (a task in a worktree has its repository's), and never in the repository or its diff. The next session in the same repository shows them in its system prompt, under a heading that calls them the agent's own notes: leads to check, not instructions. A save is capped at 8 KiB and redacted, and content that looks like a credential needs your approval. The console's task view shows them and lets you edit them ("What nodwyn knows here"), through the same guarded save.

The repository's index

The model can search a repository by words as well as by pattern: index-search with scope "repo" ranks chunks of the files git lists (tracked, or untracked and not ignored) from an index nodwyn keeps beside the project's notes, in its data directory (projects/<key>/index), never in the repository. Its first search builds it, reading every file through the same guarded fs-read as any read, so each read is in the task's log as a guard decision, and the build adds an index.update record of what it read, skipped and was refused; later searches read again only the files that changed. Nothing is asked during a build: a file the guard would ask about is left out and named. The index holds words and line numbers, not text, and a worktree shares its repository's. index-update builds it all at once or adds a directory git ignores. For nodwyn itself, 712 files took 1.3 seconds to index and 6.5 MB on disk. Deleting the directory loses nothing; the next search builds it again (ADR 50, memory and retrieval).

Instructions in AGENTS.md

A repository's AGENTS.md (or agents.md) at its root is its maintainers' instructions for coding agents: how to build, test and validate a change there, and the rules of the house. Every system prompt carries it, under a heading that says to follow it where it applies and the request where the two differ (ADR 36): the interactive and unattended ones after the working rules and before the project notes, and the lean profile's at its end, after its fixed sentences (ADR 32, amended 2026-10-18). It is read when a session starts, so an edit shows in the next session; at most 32 KiB of it, redacted. A link that leads outside the repository or to a file on the credential denylist reads as no file. A project that wants it left out redefines (project-instructions-section) in .nodwyn/init.scm.

Confirms

What asks you is [approval] in config.toml, which Settings › General › Approvals changes (ADR 35):

[approval]
writes = "allow"    # the default: writes in the folder go ahead; or "ask"
commands = "judge"  # the default: the judge, then you; "ask": you; "allow": nobody asks
dont_ask = false    # the default: no task offers "Don't ask" ("Standing approvals" below)
  • Writes the repository pin lets through go ahead without asking. Each is logged with its diff and shown in the transcript, and the file's previous content is kept first (git keeps what it has committed and unchanged), so nodwyn undo or the console's Restore takes it back.
  • Commands, and other calls that act such as an MCP tool, that no standing approval covers go to the judge first when one is set up ("The judge" below): if it allows the call, it runs; if it refuses, or there is no judge, you are asked, and the prompt says what it answered.
  • Always you, whatever [approval] says: a write to a protected path, content that looks like a credential, a command built from tool output, a request carrying a credential and an upload.

[approval] is for sessions a person answers. Line mode's --no-confirm answers its prompts no, its --yolo turns on Don't ask, and nodwyn run's flags decide the confirms of a run, below; writes applies to every session. A prompt looks like this (a write, with writes = "ask"):

─── confirm: fs-edit ("internal/x.go" "old…" "new…")
  • fs-edit is irreversible
  • changes internal/x.go
  --- internal/x.go
  +++ internal/x.go
  @@ -12,3 +12,3 @@
  ...
allow? [y/N]

Several changes to one file come as one fs-edit call with a list of edits (ADR 38). It asks once, its diff is the whole file's with every edit applied, and the header shows the call in Scheme's positional form, fs-edit ("x.go" "" "" #f ((("old" . "…") ("new" . "…")) …)). Nothing is written unless every edit applies.

The question lists the answers that prompt offers, the same as the TUI's (a security prompt or a protected path offers only y and N); typing one it does not offer declines.

  • y — once. N or anything else — no; the model is told denied: not confirmed by the user and moves on.
  • a — yes, and a standing approval for this session: for a command line, its prefix (go test, rg, …); for a write, the file's directory (internal/guard/). Not persisted.
  • t — writes only: yes, and approve every further write until this turn ends (with writes = "ask"). The diff of each auto-approved write is logged and shown dimmed as it happens.
  • b — keyed requests only: yes, and save this exact binding (class, host, method, position) to bindings.toml. It is offered only with policy.unbound = "confirm"; by default a keyed request no binding covers is denied outright.
  • Read the reason lines. command line names a path outside the repository and uses the network tool curl are the guard telling you what a y would do. A path anywhere in a bash -c line counts, a redirection's target included; the interpreter of a Python environment (…/envs/NAME/bin/python, .venv/bin/python) that only you or root can change runs without asking, as a program on your PATH does. What the command does with a path counts too: listing a folder anywhere (ls, find without -exec or -delete, which), reading or source-ing what is in such an environment (conda's activation script) and a for loop's list ask nothing; reading a file elsewhere, writing or running something outside, and cd outside still ask. A glob pattern (/tmp/run-*/log) counts by where it can match: its leading folder and every path it matches, links followed.

Unattended runs

What nodwyn does with nobody watching depends on the loop's profile. [loop] profile in config.toml is "auto" unless you set it: the lean loop when nobody approves the session's calls (nodwyn run --yolo, --judge, --unattended, a run's auto_approve), the full loop when you do. "lean" or "full" chooses either way for every session, and the environment variable NODWYN_PROFILE, when it is not empty, overrides the file for the sessions started in it (NODWYN_PROFILE=full nodwyn run ... for one run). Settings does not offer it. The lean loop is the one measured on the 121 clean SWE-bench Verified tasks, and since 2026-10-17 it says even less: a system prompt of about 90 tokens (who and where, that nobody approves or answers the calls, the tools, where throwaway files go, one sentence on the work and one on the answer), the model offered shell-exec and fs-edit alone (the repository's own tools, MCP tools and the other built-ins are not offered unless the project adds them to lean-tools), a run's plan one line, with no explorer or planner, recorded for you and not shown to the model, and nothing said to the model while it works or as it ends: no finish review, no reminders to test or to stop reading, no hints, and a turn that changed nothing told so once. A command the model runs that is still running at shell-exec's ten minutes is left running, its output and then its exit status going to files in the session's scratch directory that the answer names, and stopped when the session ends. On those tasks the loop before that change resolved as many as the same loop with six tools and as Pi, for about a fifth less cost per task (ADR 32, amended 2026-10-17). A chat in a session nobody approves is still reviewed when it ends, since a person reads its answer. The rest of this section describes the full loop. A project's .nodwyn/init.scm can mix the two, switch by switch (docs/scheme.md §9.8).

nodwyn run --yolo answers a run's ordinary confirms with yes, so a benchmark harness or a test rig never stops to ask. (Line mode's --yolo is not this: it turns on the task's Don't ask, "Standing approvals" below, and the task stays one a person answers, whose security confirms ask and whose prompts are a chat's.) In the full loop, with nobody watching, nodwyn's defaults also change (docs/scheme.md §9): the system prompt is how to work rather than what to tell an approver, and a turn ends on evidence — a change, and a test run after it that did not fail — with an honest way out when the tests cannot run at all, and another when the code already does what was asked: a check that shows it. A run that fails because tests fail, or because the model took part of its own change out to see a test fail, is not "tests that cannot run". Before it ends, the check that passed (of those that passed since the last edit, the one that runs the most of what changed) is run again with each part of the change taken out in turn, and the parts it still passes without are named to the model: a check that cannot fail for a part does not test it, and that review is made at most twice in a run. The review asks the model to write a test only a few times: it names at most three of those parts (the largest first), three of the plan's tests that are missing or pass on the unchanged code, and three dropped conditions, says how many more there were, and does not ask about an item again; a part you did not ask for (a flag, a rename, a skip added to make a test pass) gets no test, it is removed or defended with the sentence of your request that requires it. It does not ask for the lines no test ran: that point cost one benchmark run a third of its calls. When an edit removes or rewrites an expectation of a test the repository already had (a case, an assertion, a want string, a phrase the test checks for), the model is asked once for that file, as it edits, which sentence of your request requires the new expectation, and to answer with a line for each, the row and a sentence copied word for word from your request that names its input or case. The loop reads the answer against your request: the quote must be in it, and share a distinctive word with the row (or, for a phrase made shorter or split, say most of the words cut or all of the words put). A row the answer does not support gets one firmer message, with the default of restoring the old expectation, and the finish review lists what is still unsupported as its first point, with the original tests that fail. The edit itself is never refused or reverted. And in a Go project, the test files it edited are run once more as the last commit had them, against the change, and the tests that fail are named to it: either its change is wrong, or your request names the new result and the assertion changes, or your request contradicts the test's setup, which it then changes, not the test's assertions; it says which sentence of the request requires a change to a test. A test that failed on the unchanged code too (in a run on a copy of the last commit, or in the turn's first full run before it edited anything) is said so when it fails again, and is not told to the model as its change's failure. Files that make validate leaves untracked are named in its result, and deleting them does not call for another run. It answers the guard's questions; it does not overrule its refusals. Still denied, exactly as with a human at the prompt:

  • anything outside the repository, and symlinks that leave it;
  • credential-shaped files (.env*, *.pem, id_*, credentials.*, ~/.ssh, …), though not source or documentation named that way: credentials.go and credentials.js are code about credentials;
  • a literal key on a command line, or in a request to an unbound host;
  • content that came from a file or a command heading somewhere no binding names;
  • security confirms — a write whose content looks like a credential, a command line built out of tool output, a write to a protected path (.nodwyn/, .git/, .github/, .gitea/, scripts/, Makefile). Those exist for the case where a human was the control, so yolo refuses them rather than pretending, and so /optimize, which edits .nodwyn/, needs a person at the prompt.

Limits are what stop a runaway turn when nobody is watching, and a task has none unless you give it some, so give the run's task its limits ("A task's limits" above) and the run its --budget-wall and --max-turns, for the job. The console shows an omitted limit as "no limit" on Home; the sidebar and task heading show a turn maximum or time remaining only when one is set. A turn that one of them, or the progress guard, stops is not left one edit past its tests: each time a test run passes after the turn changed something, nodwyn keeps the changes as they are, and on the stop it puts the files back to that state. The files it replaced are saved in the session's scratch directory (kept/discarded), the stop says what it did, and a credential file, a protected path and a link are left alone. A chat and your own Stop put nothing back. Every decision is logged with its verdict and nodwyn replay shows exactly what the run did.

Use it on a throwaway checkout. The guard makes yolo survivable, not safe.

Between turns. A supervised run (nodwyn run) still asks a person before each next turn. nodwyn run --unattended does not ask anything: tool confirms are declined unless a standing approval covers them (or answered as --yolo answers them, if given too), and the run goes on after a turn only if that turn marked a plan step done, stopping otherwise for you to review and nodwyn run resume --unattended SESSION. With --judge instead of --yolo, the judge answers each ordinary confirm: it is shown the task, the plan and the call, and the call runs only if it judges it called for and safe (probability of allow at least judge.approve_threshold in config.toml, 0.8 by default); security confirms are still declined. The decision is Go's: no Scheme can approve what the judge refused, and a project's (approve-refuse? call) can only refuse more. See runs.

With credentials. --yolo on its own cannot unlock the keychain: a passphrase needs a terminal (it is never read from the environment or an argument, ADR 5), so an unattended process with a keychain on disk starts without one and says so. (A keychain without a passphrase opens anywhere, terminal or not; see "The keychain" below for what that gives up.) When the run is supposed to have keys — measuring how the model uses a bound tool, for instance — put the keychain in the daemon and submit runs to it:

nodwyn daemon                      # unlock once, interactively, and leave it
nodwyn run start --repo /path/to/checkout --task task.md   --max-turns 40 --budget-wall 20m --yolo

The daemon holds the keychain and the binding table; every session it opens inherits them, so the run has exactly the credentials you bound and no way to acquire others. --yolo there is a property of that run, not of the daemon: a daemon left running never auto-approves a session someone opens later from the browser. nodwyn run status|stop follow the run. Its limits are yours to give, none unless you do: --max-turns and --budget-wall (the whole run's deadline) bound the run, and its task takes the limits a task takes ("A task's limits" above), each turn's time as --turn-wall; a --budget-cost USD is the whole run's spend, and reaching it ends the run limited. --yolo answers the tool confirms inside each turn; going on to the next turn still waits for a yes at the run checkpoint, from the browser or the terminal client, unless the run is --unattended.

Sandbox mode: a disposable machine

A benchmark such as Terminal-Bench gives the agent a task inside a container that is thrown away afterwards: configure a server in /etc, install a package, start a service the task's tests talk to once the agent is done. The repository pin refuses all of that, and shell-exec stops the service when the command that started it returns. For such a machine, and only there, say so:

nodwyn run --sandbox container --repo /app --task task.md --max-turns 1 --budget-wall 14m

--sandbox container declares that the whole machine is the run's disposable sandbox (ADR 55). Nothing else turns it on: not config.toml, not a repository, not --yolo. In it:

  • The file tools may read and write any path, and a command may name any path. Still refused: nodwyn's own configuration and data directories, the credential directories (~/.ssh, ~/.aws, ~/.gnupg, …) and, for the file tools, credential-shaped files (.env*, *.pem, id_*, …), exactly as everywhere else; a command line that names a path in those directories is refused outright, with a message that nothing of it ran. Secrets in what the model reads are redacted as always, and commands get the same scrubbed environment (PATH, HOME, LANG, … and TMPDIR), not the machine's other variables.
  • A write to the folder's Makefile, scripts/ or .git/ goes ahead; only nodwyn's own folder, .nodwyn/, still needs a person, so it is refused.
  • Nobody is asked anything: the run is --yolo --unattended whether you say so or not, and --judge is refused with it.
  • What a command starts in the background (nohup server &, setsid, a service that daemonises) keeps running when the command returns and when the session ends; the call still returns as soon as the command's own program exits, with what the background process printed in its first second. Its later output is read and dropped while the daemon runs: a server that should outlive the daemon too sends its output to a file. A command still running at shell-exec's limit is the one exception: it is stopped when the session ends, as elsewhere. The scratch directory is kept when the session ends, since such a process has TMPDIR set to it.
  • The folder need not be a git repository, and may be any folder, / and your home directory included, but nodwyn's or the denylist's directories. The lean prompt names the folder, says the machine is a disposable sandbox whose files are the model's to change, and that relative paths are relative to the folder; the loop never says "No file has changed", since a command may change any file on the machine where neither git nor the file tools look. Without git there are no git tools, no kept passing state and no worktree, as in any folder that is not a repository.
  • The first time a turn ends on the model's word, the model is asked once to check its result as someone who did not watch it work would: whether any part only makes the checks it ran pass (values hard-coded to match an expected output, special cases for the inputs it tried, expected results or fallbacks left in place, checks edited or skipped), and to replace such a part with a real solution, or remove it and say in its answer what does not work. The turn then goes on until the model answers again. A turn a budget or the progress guard stops is not asked, and a resumed run may be asked once more. Each time the log has a note beginning "sandbox finish check:".
  • The same call four times in a row does not stop the turn, as it does elsewhere: waiting on a long job with one command is legitimate, and the run's wall-clock and tool-call budgets bound it. The fourth result, and every fourth after it, tells the model it has run that exact call so many times in a row, and the log has a note beginning "progress warning:". The progress guard's other stops are as elsewhere.

run.state records sandbox, and a resume is in sandbox mode only when it says --sandbox container again. The full profile's prompts are not changed: they still tell the model that paths stay inside the repository, so sandbox mode is meant for the lean loop, an unattended run's default.

Never use it on a machine you keep. The guard then protects only nodwyn's own files and your credentials, and anything else on the machine is the model's to change.

Standing approvals

In the console, Don't ask: approve writes and commands under the composer stops the questions for one task: its ordinary writes and commands are approved without asking until you uncheck it or the task closes. Landing a worktree task's work (git-land) counts as one, so it goes ahead too. Protected paths and writes that look like a key still ask, and nothing the guard refuses is allowed. No Scheme can turn it on, a repository's init.scm included, and it is never saved: a resumed task asks again. Line mode's --yolo turns it on as its task opens, and off again when it leaves a task another client has open. Since writes in the folder already go ahead by default ([approval], "Confirms" above), it matters most for commands: while it is on, the judge is not asked.

The switch is not there until you offer it: Settings › General › Approvals › Don't ask in tasks ([approval] dont_ask = true), off by default, with a red warning while it is on. Under the switch, models in this project's own sessions loaded the person's SSH key into an agent, read through the shell a file the guard had refused and rebased the person's checkout, none of it asked; switch it on only in a task you are watching. Choosing Not offered again turns the switch off in every open task.

An "a" (Always under …) on a command approves its program and subcommand for the rest of the task: go test, git diff. A command the model runs through bash -c is offered as the command inside it when there is one, piped into head, tail, wc, sort, uniq or grep or not; a line of several commands, or a program that runs whatever it is given (a shell, python, node, env, xargs, sudo, ssh), offers no "a" and asks each time. An approved prefix covers a bash -c line only when every command in it is approved and nothing in it redirects to a file, substitutes, expands a variable or starts a subshell.

Command prefixes you want to approve ahead of time go in one of two files:

# ~/.config/nodwyn/config.toml
[policy]
allow_commands = ["go test", "go build", "git status", "git diff", "rg"]
# <repo>/.nodwyn/allow.toml   (tracked with the repository)
commands = ["make lint", "make test"]
writes   = ["internal/", "docs/"]     # write prefixes that never prompt, with writes = "ask"

A standing command approval runs without the judge being asked. A repository allow file takes effect only after its current revision is trusted (see “Reviewing project startup” below).

.nodwyn/, .git/, .github/, .gitea/, scripts/ and Makefile always prompt, whatever the list says: they hold code that later runs with your authority (init.scm, git hooks, CI, build scripts). Each write there is a security confirm, so no standing or turn approval waives it and an unattended run refuses it. So does a write whose content looks like a credential (L1): that confirm names the class.

Prefixes match argv tokens and resolved executable identity: "go test" matches go test ./..., not go build or a different program named go. Its arguments must also pass the tool's flag allowlist. For example, go test -race ./... can inherit that approval; go test -exec=program ./..., git diff --output=x, git diff --no-index …, make test SHELL=program and unknown flags ask you with a security confirm when their prefix is approved. Flags choosing another directory or makefile and additional make targets also ask.

No standing approval covers an absolute or repository-escaping path, even inside --flag=VALUE or through a symlink. Parent-directory components (..) also ask, since a preceding symlink can make them escape a seemingly local path. An absolute path to the approved executable also asks; use its approved name or an explicitly approved repository-relative path. The same rules apply inside a statically parsed bash -c line. The judge and unattended approval settings cannot answer these security confirms.

Denies and security confirms still apply; only an ordinary confirm is waived. Every use is logged as allow with reason standing approval: go test. Approve only commands whose code you trust: builds and tests run project code with your operating-system privileges, can write files and can use the network. The guard's file-tool checks do not sandbox an approved process.

The judge

The judge answers typed questions about a task: whether a message needs a plan, how well a plan fits the request, and whether a command may run without asking you. It is a provider's, as a model is (ADR 46): [judge] names a [servers.NAME] whose provider is one of

  • jev, TypeSafe's Jev (its System One API), which answers the judge's questions and has no chat models; or
  • openai at https://api.openai.com/v1 with an API key, whose Decisions API answers them with gpt-6-luna, sent the key the server's models use. A ChatGPT plan's sign-in cannot ask it: the keychain sends a plan's token only to the plan's chat endpoints, so a server signed in with a plan is refused as a judge, in Settings and when a task chooses it (ADR 47).

In the console, Settings › Judge sets a judge up in place: paste Jev's key, or an OpenAI API key, and that is all, since each judge's model and price are known. The key is kept in the keychain and allowed for its host by POST alone; a judge added when there is none becomes the default for new tasks. Each judge set up is listed with its host, model, price and whether it can be asked. [judge] is the default judge; a task chooses its own beside its model (below). By hand:

# ~/.config/nodwyn/config.toml
[servers.jev]
provider = "jev"
url = "https://api.typesafe.ai"
credential = "jev-key"

[judge]
server = "jev"          # or an OpenAI server with an API key

with its key stored and bound as any server's:

nodwyn keychain init                       # once
nodwyn-stable                              # then, at the prompt:
:eval (credential-register "jev-key")    # masked input
# ~/.config/nodwyn/bindings.toml
[[bind]]
class = "jev-key"
dest = "api.typesafe.ai"
positions = ["header:Authorization:Bearer"]
methods = ["POST"]

The judge asks its provider's model (jev-latest, gpt-6-luna), which is all either API answers to, so Settings has no field for it. [judge] model may name another by hand, for the server [judge] names only; one its provider does not answer to fails every question, and Settings › Judge says so with a button that clears it. Without a judge, [judge]'s or the task's own, there is no plan gate, and every command no standing approval covers is put to you.

Each task's judge. A task asks the judge chosen for it, which is [judge]'s unless it was opened with another or moved to one (ADR 47): the New task window and a conversation have a Judge select beside the model and effort, with No judge first, and the TUI has :judge NAME and :judge none. A judge is moved while the task is idle, from its next question; a refused choice keeps the task's judge and its configuration as they were. The log records each successful choice as session.judge, and a resumed task asks the judge it chose. A task opened before any judge was set up, or before the one you want was, can be given it in the same way: no restart is needed (the keychain must have been open when the task opened). One config.toml no longer has leaves it with none, with a note, rather than [judge]'s. A judge that cannot be asked (its key locked, not allowed, or a plan's) does not stop a task: you answer instead, and the picker says why. Only this computer chooses a task's judge, as it chooses its model. A config.toml that still has the [jev] table of earlier versions does not load, and says what replaces it. The thresholds (judge.approve_threshold below, and the plan gate's in Scheme) were set against Jev's probabilities; another judge may put the same command higher or lower.

Before each user message the default strategy's plan gate (internal/loop/plan.scm, docs/scheme.md §9.5) asks the judge whether the message needs a plan: a change in several steps or files does; a question, a review, an explanation or a proposal is answered, not planned, however much reading it takes. A later message in a chat is read after your two before it, so a reply to the model's question or a correction is not judged alone; the model that explores and drafts the plan also reads the agent's last answer and the names of the files you attached, which the judge is not sent. If it needs a plan, the model drafts one, the judge scores it against the ask (up to three revisions), and the plan goes into the task block the model sees. Its thresholds and prompts are Scheme definitions a project may redefine. As the model works it marks the plan's steps done or skipped with the plan-step tool, and the console's plan panel (:plan in the TUI) shows its progress; a mark is the model's word, and the transcript is the evidence. A plan the model writes again keeps the marks of the steps it keeps word for word. Without a plan from the gate, the model writes its own with plan-write, which pins it the same way: its thinking comes back to it only as the server reported it, often a summary or nothing, and a written plan stays. Every plan has one shape: numbered steps that each start with their kind and a short name for what it does, then — and the detail after it (a step that says all it should in its name needs no dash), Change: (a file and what changes in it), Test: (the test, what it checks and why it fails on today's code, "fails today: …"), Docs: (each document the change makes wrong or incomplete) and, last, Check: (the command that shows the whole change works); a plan with no test or no doc to change says why in a step (Test: none — only docs change); behaviour your request does not state goes on an Assumptions: line after the steps, checked against the existing tests, not in a step. The judge scores the gate's plans against it, and plan-write turns down a plan without it, saying what is missing, at most twice in a turn: the third is recorded as written, with what it still lacks. The plan panel shows each step folded to its title, the clause up to its first dash (or the whole of a step that has none, while it fits), with the mark's note behind the fold; clicking a step shows the whole of it and the note. When a turn does not finish (a model error such as a provider's rate limit, a limit, or your Stop), your next message, "continue" or a correction, carries its plan with the steps marked so far, and the model goes on from the next unmarked step; the plan panel says "carried over from an earlier turn". A turn that stops short, at a limit, on an error or at a progress stop, says beside the stop where it had got to: how many of the plan's steps are marked and the next one, the files the file tools wrote in that turn, and the last command it ran with how it ended (the transcript has the rest; files changed through the shell are not listed). A provider that limits requests and says how long to wait (up to a minute) is waited for before the request is sent again, and one that says the account is out of credit is reported at once, since asking again cannot help. A model that waits for a command that is still running is not stopped for reading it again and again, until thirty reads in a row. After a turn that finished, the next message is a new request. A model that only reads and searches for twelve rounds in a row (the count is per model family, set in a table in .nodwyn/init.scm; docs/scheme.md §9.7) is told to write down what it found: its plan, or, in a chat whose request asks a question or needs your decision, its answer. One that already has a plan is told to make the change for its next unmarked step. In a chat a question gets an answer, and a request for something the model finds was never built gets told so, with what it would build, before it builds anything. A plan made with the judge or without one hands the explorer's findings to the model too, so it does not read the same files again, and with them the lines of those files that the findings cite as path:line, copied from what the explorer read (at most plan-excerpts-cap characters in all, 12,000; a file named without a line is carried only when what the explorer read of it is short). They are information for the model, not a read it must make; the system prompt tells it to read a long file by the part it needs, found with index-grep, not whole. The explorer has twelve tool rounds, sixteen for the muse-spark, glm, qwen, deepseek and gpt-6-luna families, which ask for fewer calls in a round; the same table is where a project gives a model another count. Every judge call is a guarded egress: bound credential, L1 redaction of what leaves, a jev.req log line with origin, size, hash and the answer (read into the same shape whichever provider gave it; a Decisions API refusal is no answer). Choose No judge in Settings › Judge to turn it off (judge.content = "none" in config.toml does too, and the console shows it until a level is chosen). Without its server's key stored nothing is sent and the loop runs without a gate.

With [approval] commands = "judge", the default, the judge also answers a command before you are asked ("Confirms" above). It is told that you are at hand and asked when it does not allow a call (a run's judge is told nobody is), that a command that only looks (listing, reading, versions, the date, git status, diff or log) is ordinary work, and so, when the task calls for them, are the project's builds and tests, a script the model wrote for them and writes to the session's scratch directory. It is shown the task after your two messages before it, the turn's plan with the steps marked so far and the next one, the command line, what it and you answered about the same command earlier in the turn, and, for a script the command runs from the session's scratch directory, that the script is the session's own (at judge.content = "full" also the script's text and what the call would send). It is not told that a command is irreversible, which every command is. The command runs if its probability of allow is at least judge.approve_threshold (0.8). The decision is Go's: no Scheme can approve what the judge refused, and a project's (approve-refuse? call) can only refuse more. When it refuses, you are asked, and told its answer.

What the judge used. The judge reports each request's tokens, never its cost, and nodwyn records them in jev.req with the model that answered (jev-1.13.0 for jev-latest; the Decisions API names none). nodwyn prices jev-latest at what typesafe.ai publishes, $0.042 per million input tokens, and gpt-6-luna's questions at what OpenAI's Decisions guide gives, $0.10 per million input tokens, both with nothing for output (prices nodwyn keeps itself: models.dev does not list Jev, and lists gpt-6-luna at its chat prices); give it other prices, in US dollars per million tokens, to estimate the cost instead (one left unset stays the published one):

[judge]
price_input = 0.5
price_output = 2.0

Every client shows each judge request as one line ([judge choice: 318→34 tok · ≈$0.0002] in the terminal; behind Activity in the console). They also show the judge's running total when it has been asked: in the console's side panel ("Judge, this task"), and in the TUI's :budget (this turn and this session). Settings › Judge holds all of it: the judges, the default, what it sees, when it allows a command, how long it waits, and under a fold the default judge's own prices. Judge tokens are not model tokens, so the token budget leaves them out, but a spend limit counts their cost; a judge request with no prices is not counted, and the first turn with one says so.

The model can ask the judge too, with judge-yes-no, judge-score and judge-choice: Go injects the key of the judge's server, its binding decides each call, and what the model sends is redacted the same way. Because the model chooses that text, which may be file contents or command output, the tools are offered only at the level that lets content leave:

# ~/.config/nodwyn/config.toml
[judge]
content = "full"

and only to sessions started after that key was stored. full applies to every project on this computer, proprietary ones included: it is a setting of yours in config.toml, not of a repository (a repository's own files cannot turn it on, and a per-project switch is not built). The plan gate does not need them, and works at the default plan level.

register-check in .nodwyn/init.scm adds your own tightening-only guard checkers (deny or confirm, never allow); see docs/scheme.md §7.

Giving the model tools of its own

After you trust its revision, <repo>/.nodwyn/init.scm is loaded at start (after the prelude). Anything it registers becomes a tool the model can call; nothing it does can bypass the guard. This repository's dev/go.scm is the pattern:

(define (run cmd args)
  (let ((r (shell-exec cmd args)))
    (format "exit ~a~%~a~a" (assoc-ref r "status") (assoc-ref r "stdout") (assoc-ref r "stderr"))))

(register-tool "go-test"
  (lambda (pkg) (run "go" (list "test" "-race" pkg)))
  '((doc . "Run the Go tests for a package pattern such as ./internal/log or ./... .")
    (params . (("pkg" "string" "package pattern")))))

Rules that matter: doc and params are required; declare list parameters rather than variadic ones (the model's tool schema cannot express rest arguments); the inner shell-exec is still irreversible, so pair the tool with a standing approval. The language is in docs/scheme.md; try things in nodwyn repl or with :eval.

Tools from MCP servers

A remote MCP server's tools can be offered too. Add the server in the console, under Connections › Tools (the browser view): its address (HTTPS, or plain HTTP to this computer only), a key if it needs one (kept in the keychain and bound to that host alone), and the folders where it is offered. nodwyn lists the server's tools, you say what each does (reads, or changes things) and which are offered, and it pins that tool list; if the server's list changes, new sessions withhold its tools until you review it again. The servers are kept in mcp.toml in the config directory. The model sees a tool as server__tool, and every call crosses the guard: its arguments are payloads that leave the machine, a tool that changes things asks, and an undeclared one asks every call with no standing answer.

A server on this computer runs only in a container nodwyn controls (W3b, ADR 23); "MCP servers" below says how.

Reviewing project startup

A cloned .nodwyn/allow.toml cannot grant its own approvals. Until reviewed, it and .nodwyn/init.scm are skipped. Run nodwyn trust --repo PATH to inspect the files and their revision, then nodwyn trust --repo PATH --accept REVISION to accept exactly that revision. The record lives in user-owned trusted-projects.json beside config.toml. No Scheme primitive grants it. Changing either file requires another review before start, resume or reload. Review loaded Scheme and invoked scripts too: those dependencies are not hashed. nodwyn trust --repo PATH --revoke withdraws future loading; restart existing tasks to discard their already loaded code and approvals. A managed worktree may use its main project's trust only when both files match. A Go embedder explicitly setting Options.InitFile supplies host authority for that chosen file.

Standing command prefixes match the executable's resolved identity as well as its arguments. A repository's tools/git does not inherit git status; an explicit repository path can be approved separately. Shell wrappers and implicit pipeline filters must resolve to the host tools. Approval does not sandbox what they run.

Changing the loop

A turn is the Scheme procedure agent-turn; redefine it in .nodwyn/init.scm to change how turns go. This one runs a batch of tool calls in order but stops at the first failure, so the model re-plans from the error instead of running calls that assumed success:

(define (run-until-failure calls)
  (unless (null? calls)
    (let ((result (car (run-tools (list (car calls))))))
      (if (assoc-ref result 'ok)
          (run-until-failure (cdr calls))
          (skip-tools (cdr calls) "an earlier call in this batch failed")))))

(define (agent-turn request)
  (let loop ((reply (model-turn)))
    (let ((calls (assoc-ref reply 'tool-calls '())))
      (unless (null? calls)
        (run-until-failure calls)
        (loop (model-turn))))))

run-tools runs a batch in order, except one 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): those run together, up to eight at once, and the answers come back in the order of the calls. A command, a write, an MCP tool or a tool the project defines in the batch makes the whole batch run in order, and so does a strategy like the one above that hands run-tools one call at a time. The tool-call budget is counted for each call before the batch starts, so a limit in its middle runs the calls before it and answers the rest "not executed". The log holds the batch's calls, then its results; the console and the terminal show each result on its call. A read that needs your answer (a path outside the folder) asks while the others run.

A strategy decides which calls run, what the model is told about the ones that don't, when to ask the model again, and — with add-message — what to say when the model stops with work left. It can reach a keyed judge or model API with (credential class) and http-request. It cannot run a call the model did not make, loosen the guard, or keep a turn going past a budget or a progress stop (docs/scheme.md §9). Every prompt is Scheme (§9.1), and so is the plan gate with its thresholds (§9.5). The default loop's lean and full profiles are a handful of definitions that init.scm loads after ("Unattended runs" above, §9.8).

Long work is compacted on its own just under the window's high water: the model writes a handover note and goes on from it, in that turn and the next ones, with the turn's plan and the steps marked so far given back after the note. A turn whose context fills a second time with no file written since the last compaction stops instead of compacting again: the model gets up to six requests with only the edit tools to make the code changes its note and plan name, then reports what is done and what is left, without tools, and that is the turn's answer (compact-stop-unchanged, #f turns it off; compact-write-rounds 0 asks for the report at once). Set (define compact-threshold 40000) to compact earlier, #f to only trim, and send /compact to compact whenever you like (docs/scheme.md §9.2). Each compaction is one line in every client, with what set it off and the context's size before and after (compacted (past ~58,983 tokens): context ~59,210 → ~4,100 tokens, handover note 812 tokens), and one that gave up is a warning. How full the context is shows as you go: the TUI's title (context ~45k of ~61k), its :budget (the estimate beside what the provider counted, and the compactions so far) and the console's Context meter. While the explorer, the planner or a sub-agent asks in a context of its own, they follow that context and name it (context ~52k of ~691k (explorer), Context · explorer), and show the conversation's again once it folds. Those sizes are nodwyn's estimates, the unit the window and compaction use; a provider usually counts a fifth or so fewer.

nodwyn can learn how to work in a repository from its own history: send /optimize (or /optimize 5, or /optimize with session ids, optionally followed by what to focus on) and a sub-agent reads this repository's past sessions and edits .nodwyn/init.scm, asking before every write; the result is kept for you to review with nodwyn trust, then reload or resume (docs/scheme.md §9.4).

internal/core/userland_test.go holds larger examples, each a behaviour written as a user would write it:

  • a plan cycle: the model must submit a plan, a keyed oracle approves it, and the strategy keeps the model on it until every step is done;
  • routing: the control model plans, a rule picks the model that implements, which gets only the task and the plan (narrow-context); if the result diverges, a stronger model gets one retry, and the control model reports.

Credential transport

Credentials in headers, queries and bodies require HTTPS, with HTTP allowed on localhost and literal loopback addresses. For remote HTTP you deliberately accept, set [policy] allow_insecure_http = true in the user's config.toml, or choose Settings › Keys & devices › Credentials over remote HTTP › Allow HTTP. Off is the default. New/resumed tasks take the setting; provider tests and model lists use the daemon's current value; provider setup also honours the saved choice. On HTTP, an observer on the route can read or change the credential and payload. Binding checks, redirect refusal, certificate verification and the fixed ChatGPT credential sinks still apply. The setting does not override separate MCP, OAuth endpoint or Google upload URL validation.

The keychain

Keys live in one encrypted file, keychain in the config directory (nodwyn paths); the model and Scheme only ever hold a handle to one (ADR 5).

nodwyn keychain init                    # asks for a passphrase, twice
nodwyn keychain init --no-passphrase    # or none at all (not recommended)
nodwyn keychain passphrase              # set or change it (asks for the current one)
nodwyn keychain passphrase --remove     # leave it without one (not recommended)

A passphrase is asked for when a daemon starts, at the terminal; the app's console asks for it instead, and so do the TUI and line mode, at their start, for the daemon they use when that daemon's keychain is locked (line mode whose input is a script asks on the terminal only with --keychain-tty). On Home, a locked keychain opens its unlock form and focuses the masked password field. Startup refreshes keep that field and anything already typed in it, rather than moving typing into chat; a failed unlock returns focus there for another attempt. A keychain without a passphrase opens by itself everywhere, including where nobody can type one (an unattended run, a daemon a service manager starts). That is all it buys: the file is still encrypted, but under a key anyone can derive, so it is as safe as its permissions (0600, your user) and no safer. Anyone who can read the file, or a backup of it, can use every key in it, and nodwyn says so when it opens one ("no passphrase"). The console's Settings does the same as these commands, and asks for the current passphrase before changing or removing one: being at the console lets a person use the keys, never read them out of the file.

Keychain readers reject non-regular files, symlinks, files larger than 16 MiB, and unsupported KDF headers before deriving a key. The default Argon2id settings are unchanged; supported bounds and recovery limitations are in Security boundaries. Saves use exclusive random 0600 sibling files, sync the file, rename it, and sync its directory. Local entry and passphrase changes roll back in memory if publication fails; a post-rename sync error reports uncertain durability while retaining the published key. OAuth refresh retains a received token in memory when saving fails, since the provider may already have rotated it, and keeps it across reloads of the file until a save publishes it; the error is still reported.

A daemon that has the keychain open keeps injecting the keys it holds after the passphrase changes elsewhere, but it can no longer add or change a credential or refresh an OAuth2 token: the console shows the keychain as locked, and the new passphrase in its unlock form (or a restart) opens it again.

The keys themselves are managed in the console, under Connections › Credentials (browser.md, "Credentials"): what is stored, where each may go and what uses it; adding a key and allowing it for a host, replacing a rotated one, connecting an OAuth2 account and removing one. From a terminal, nodwyn credential lists them and handles OAuth2 (below), and :eval (credential-register "CLASS") in a session stores a static key at a masked prompt. A class names one credential, whichever way it is added: registering a class that has a static key replaces its value (a rotated key), and every other way of adding one refuses a class that is taken.

OAuth2 credentials

A service that uses OAuth2 (Google Drive and Docs, Microsoft Graph, GitHub, Notion, many MCP servers) gets a credential of kind oauth2: a token set that expires and is refreshed, instead of one value. Register nodwyn as an OAuth client with the provider (a desktop or "installed" app, redirect http://127.0.0.1), then:

nodwyn credential add --oauth2 google --client-id ID --scopes drive.file,documents

or, in the console, Connections › Credentials › Connect an account. Your browser opens the provider's consent page (--no-browser prints its address instead); nodwyn waits five minutes for the redirect on a loopback port it opened for that one request, checks it, exchanges the code (PKCE, so no client secret is needed unless the provider insists: --client-secret asks for it), and stores the tokens in the keychain under the class google (--class names another). The default scopes are each provider's narrowest useful set (drive.file for Google: the files nodwyn created). --oauth2 custom --auth-url URL --token-url URL [--revoke-url URL] covers any other provider, such as an MCP server's authorization server.

Like any key it goes nowhere until a binding names it, and a binding may name the scopes it allows, so a token granted more is refused:

# bindings.toml
[[bind]]
class = "google"
dest = "www.googleapis.com"
positions = ["header:Authorization:Bearer"]
methods = ["GET", "POST"]
scopes = ["https://www.googleapis.com/auth/drive.file"]

Use it as any credential: a model's or an MCP server's credential, or (credential "google") in a tool you register. When the access token has less than a minute left, the keychain refreshes it just before it is sent, only for a use the binding allowed, and the session logs cred.refresh (never a token). A confirm for an OAuth2 credential names its scopes along with its class and destination. If the provider refuses the refresh (you revoked the app, the refresh token expired), the credential is marked and every use is denied with the command that fixes it; nodwyn never opens a browser in the middle of a turn:

nodwyn credential                  # list: kind, scopes, expiry, needs login
nodwyn credential login google     # authorize again
nodwyn credential revoke google    # revoke at the provider, delete here

revoke --local only deletes it here; login and revoke take the credential's id or class. The console's Credentials page does each of these too (Authorize again, Remove…). credential add stores only OAuth2 credentials: a static key is registered in a session, with credential-register, or added in the console. The daemon sees these changes without a restart.

OpenAI models with ChatGPT plan quota

Use the OpenAI OAuth2 profile to authorize eligible model requests against an account's ChatGPT plan quota, without an API key:

nodwyn credential add --oauth2 openai

For console setup, go to Settings › Models › Add a provider, choose OpenAI, then ChatGPT plan. Unlock the keychain, select Continue with ChatGPT, and finish consent in the browser. The form selects the connected account; you can instead choose an existing account, or Sign in again when it needs reauthorization. Save the provider writes its credential class to config.toml and allows GET and POST bearer model requests to api.openai.com. The address and Responses dialect are fixed for this mode. Then add a model from the provider's list, Test it, and choose it as the default for new tasks. No file editing is needed. Switching back to API key requires a new key and keeps the connected account in the keychain.

This is the one place a ChatGPT plan is connected: Connect an account in Connections › Credentials offers no openai. OpenAI registers nodwyn during consent; no client secret or pre-registered client is required. The keychain saves a stable host identifier before sign-in, then verifies the returned ID token's signature, issuer, issued client id, expiry and nonce, and requires the granted plan-use scopes. Tokens stay in the encrypted keychain. OpenAI controls eligibility, the models available to the account, quota and any workspace restrictions; a successful sign-in does not guarantee every model is available. See OpenAI's sign-in flow and models and inference.

For terminal setup, the command prints a binding row and a provider block. Add the row to bindings.toml (or allow the credential for api.openai.com in Connections), and configure the provider and a model available to this account:

# config.toml
[servers.chatgpt]
provider = "openai"
url = "https://api.openai.com/v1"
dialect = "responses"
credential = "openai"

[control]
server = "chatgpt"
model = "MODEL_ID"
# bindings.toml
[[bind]]
class = "openai"
dest = "api.openai.com"
positions = ["header:Authorization:Bearer"]
methods = ["GET", "POST"]

dialect = "responses" is required: plan credentials are sent only as bearer headers to the public GET /v1/models and POST /v1/responses endpoints, even if their binding is broader. Each Responses request asks for store = false and stream = true; nodwyn buffers the reply until its terminal event before showing it, so redaction covers credentials split across deltas. The model list uses the account's visible model slugs. Restart the daemon after editing its model configuration, then choose the model or use Try in Models to check access. There is no automatic fallback to API-key billing when sign-in or quota is refused.

nodwyn credential login openai reuses the saved client and host, and checks that the verified account identity matches before replacing its tokens. Returning sign-in shows OpenAI's account selector; nodwyn sends no ID-token hint in the authorization URL. A refresh is serialized and rotates access and refresh tokens together. A permanent refusal requires another login. Use --class chatgpt-work when adding another account and select that class in its provider block; give each account a different class. nodwyn credential revoke openai revokes the renewable session at the endpoint OpenAI publishes in its discovery document before deleting it here; --local only deletes it here. Manage app access and usage in ChatGPT Settings.

Keychains that have started OpenAI sign-in use format version 3, including a cancelled first attempt, so older nodwyn binaries refuse them rather than silently discarding the stable host identity. Back up the keychain before trying an older binary.

Documents out

nodwyn writes documents three ways: Word, OpenDocument and HTML through pandoc, PDF from LaTeX through the TeX toolchain you have, and Google Docs through Drive's API.

Word, OpenDocument, HTML. doc-export converts a Markdown (.md) or LaTeX (.tex) file in the workspace; the target's extension picks the format:

(doc-export "report.md" "out/report.docx")   ; also .odt and .html

It needs pandoc 2.15 or later on PATH (dnf install pandoc, apt install pandoc, brew install pandoc); without it the tool says what to install. The target is a write: the confirm shows source, target and formats, a standing write approval for its directory covers it, and replacing a file keeps its previous version. The source may come from a read-only mount.

pandoc reads the document in its sandbox, so an \input, an include or a remote image in it reads nothing. In a .docx or .odt, images that are files in the workspace are embedded; a link, a file outside the workspace or a credential-shaped name is left out and its description kept, and the result says which. An .html page keeps its images by reference, except a link elsewhere, which the page would load when opened. Raw content written into the source (OpenXML, HTML or LaTeX passed through) is shown as text, never interpreted, so a document cannot carry a field, a frame or a script that fetches something when it is opened. Nothing the model writes reaches pandoc's command line.

PDF. doc-export does not write PDF. A TeX engine reads files and can run programs when a document asks, and no sandbox covers that, so a PDF is built as this repository builds its book (make book runs latexmk): through shell-exec, which asks every time unless you give it a standing approval:

# <repo>/.nodwyn/allow.toml
commands = ["latexmk -pdf"]

The approval covers every command line that starts so, -shell-escape included: give it only where you would run any such line yourself.

Google Docs. google-doc-upload sends a .docx, .odt, .html or .txt (at most 5 MiB) to Google Drive, which makes it a new Google Doc, and returns its id and link. Markdown or LaTeX goes through doc-export first. It needs an OAuth2 credential (see above). Register nodwyn with Google as a desktop OAuth client with the Drive API enabled, then:

nodwyn credential add --oauth2 google --client-id ID

The default scope, drive.file, is enough: nodwyn can see and change only the files it created. Bind the credential to Drive's upload host:

# bindings.toml
[[bind]]
class = "google"
dest = "www.googleapis.com"
positions = ["header:Authorization:Bearer"]
methods = ["POST"]
scopes = ["https://www.googleapis.com/auth/drive.file"]

The tool is offered to sessions started after the credential was stored. How an upload is handled is a setting, [docs] google_upload, that you choose. Until you have, the first upload asks you which:

  • Allow by hand (ask): every upload asks, naming the file, its size, the destination and the credential's scopes. This is a security confirmation, answered for one call at a time: --yolo, --judge and an unattended run never upload.
  • Always allow (allow): uploads go without asking, --yolo and unattended runs included, wherever the binding table allows them. The key check below and the log still apply. Only the computer nodwyn runs on can choose it; a paired phone is not offered it.
  • Deny (deny): the upload is refused, and sessions started after that are not offered the tool.

The answer is saved in config.toml for every session and logged as setting.change, with who gave it. Settings in the console changes it, and so does editing the file. A run with nobody to ask (--yolo, --judge, unattended) declines an upload while the setting is unset, and does not choose for you. Before sending, nodwyn reads the file as text (every part of a .docx or .odt) and refuses it if it holds something shaped like a key whose class is not bound for that host; a package that embeds other files is refused, since nodwyn cannot read them. Each upload is logged as doc.upload: the file, its size and hash, the destination and the document's id, never its content or the token.

[docs] in config.toml changes the defaults:

[docs]
google_credential = "google"   # the keychain class; "" offers no Google tool
google_upload_url = "https://www.googleapis.com/upload/drive/v3/files"
google_upload = "ask"          # "ask", "allow" or "deny"; unset, the first upload asks

MCP servers

mcp.toml in the config directory lists the MCP servers nodwyn may use (ADR 23); Connections in the console adds, reviews and approves them. A server offers nothing until you approve its tool list, which is then pinned: a changed name, description or schema withholds its tools until you review it again. Each tool is a primitive server__tool that crosses the guard; one you have not declared a read asks every call.

A remote server is a URL; nodwyn sends it the arguments of the calls you allow. A local server is never run as a process of yours: nodwyn runs it in a container, from an image pinned by digest, with podman or docker:

# ~/.config/nodwyn/mcp.toml
[[server]]
name = "notion"
transport = "container"
image = "ghcr.io/example/mcp-notion@sha256:…"   # a digest; a tag is refused
network = ["api.notion.com"]                     # [] is no network at all
mounts = ["docs", "out:write"]                   # workspace folders, under /workspace
credential = "notion-key"
position = "env:NOTION_TOKEN"
workspaces = ["/home/me/notes"]
# ~/.config/nodwyn/config.toml (both optional)
[mcp]
runtime = "podman"                 # or "docker"; unset: podman if on PATH, else docker
egress = "/opt/nodwyn/nodwyn-egress"   # unset: next to the nodwyn executable

A task never downloads code. Pull the image yourself, by its digest, once and again after you change it:

nodwyn mcp                # the servers, and whether each contained image is here
nodwyn mcp pull notion    # or Pull in Connections

What the container gets, every time: no host environment (only the variables named here), no capability, a read-only root with a small /tmp, bounded memory and processes, no setuid privilege, the folders in mounts and nothing else of yours (never the workspace root, never .nodwyn/), and --pull=never. command and args replace the image's entrypoint, with no shell. nodwyn talks to the server over its standard input and output, so nothing listens on a port. Its standard error is kept, capped and redacted, for the task's details; the model never sees it.

With network = [] the server has no network at all. With hosts, it sits on a network with no route out; its only way out is nodwyn's egress proxy (nodwyn-egress, built by make build next to nodwyn), in a second container, which lets through those hosts alone, on ports 443 and 80 or the port an entry names (uploads.example.com:8443), and refuses the rest. The server finds it through HTTPS_PROXY and its siblings; a client that ignores them reaches nothing. Every attempt, let through or not, is recorded in the session log as mcp.egress; the task shows the refusals.

A credential goes in once, as the variable position names, and only if its class is bound for every host on the list at that position (any method: the server makes the requests):

# bindings.toml
[[bind]]
class = "notion-key"
dest = "api.notion.com"
positions = ["env:NOTION_TOKEN"]
methods = ["*"]

Adding the key in Connections writes these rows. A server whose key is not bound for one of its hosts does not start, and each check is recorded as an egress.check; a binding removed during a task stops further calls. A variable cannot be refreshed later, so an OAuth2 token is refreshed just before the server starts and the server gets the token valid then. With no network a credential has nowhere to go, and is refused.

A contained server's calls go to mcp:NAME; a confirm names it and what it can reach (the contained server notion (network: api.notion.com)). A key written into the arguments is refused unless its class is bound for every host on the list. The containers stop when the task closes. If nodwyn is killed instead, their input ends: the proxy stops, and so does a server that ends with its input, as MCP servers do; anything left shows in podman ps --filter label=nodwyn.mcp, and the networks remain until podman network prune --filter label=nodwyn.mcp. transport = "host-process" is refused.

Choosing models

The control model ([control]) answers (model-turn). Any other model a strategy may use is an entry in config.toml, asked by name with (model-turn "NAME"). A provider offers many models at one address under one key, so the address and the key belong to a server, [servers.NAME], and each model names its server and says which of its models it is. A server is known by its name, not its address: two can share one, such as an API-key server and a ChatGPT plan server at https://api.openai.com/v1.

[servers.openai]
provider = "openai"                 # any chat-completions server; "anthropic" for Claude's own API
url = "https://api.openai.com/v1"   # including the version
credential = "openai-key"           # keychain class; omit for a local server

[servers.local]
provider = "ollama"
url = "http://localhost:11434"

[control]
server = "openai"
model = "gpt-5.1"

[models.coder]
server = "openai"
model = "gpt-5.1-codex"

[models.small]
server = "local"
model = "qwen3:8b"
num_ctx = 81920

A model that names a server takes its provider, dialect, url and credential from the server and sets none of them itself; the rest (context length, reply cap, sampling, prices, vision) is the model's own. [control] names the default model; each of those settings it leaves unset comes from the first [models.NAME] for the same model at the same address, as its efforts do, so a model's settings are said once, in its own entry, and a model chosen in a picker has the same settings as when it is the default. A model may carry labels = ["fast", "cheap"]: lower-case words of your choosing that say what it is for. Settings › Models edits them (under What it is for, with a few common words to press), and a change reaches tasks already open. Labels are set only by you, and are how strategies pick models ((model-names "fast"), labels in (model-info name)). Like efforts, a model's labels are said once, in its own entry: the default model goes by those of its entry.

A task works with its own model alone unless you let it do more. In New task, Helpers lists the other models; tick the ones this task's model may hand self-contained pieces of work to. It then has a delegate tool, which it calls with no task to see the models and their labels and with a model and a task to run the work there; it chooses by the labels, so names can change. The helper has the task's tools except delegate, and if its provider fails, the task's model reads the error and carries on without it. Nothing ticked, the default, gives it no such tool. Ticking a model is your consent that the task and what its helper reads may go to that model's provider. The choice is for that task only, is not kept for the next, is kept when the task is resumed, and is made on this computer, not from a paired device. New task does not offer the default model as a helper of a task on another model, and a task's helpers cannot be changed once it has started. A model may also give those settings itself instead of naming a server, as every model did before servers. In the app, Settings saves the server the first time you choose a model on it, named after its host (openrouter for openrouter.ai), with its key; after that you pick the server by name and the model from the list the server gives (asked without the key: a server that lists its models only to a key says so, and you type the model's name).

Chat Completions dialects. provider = "openai" chooses the protocol; dialect chooses the server's interpretation of it. For Cerebras, add the setting to its server in your user-owned config.toml:

[servers.cerebras]
provider = "openai"
dialect = "cerebras"
url = "https://api.cerebras.ai/v1"
credential = "cerebras-key"

[control]
server = "cerebras"
model = "YOUR_MODEL_ID"              # a model enabled on your account

Cerebras receives prior assistant thinking as reasoning; without this setting it rejects reasoning_content after the first reasoning reply. Omitting dialect, or setting it to "compatible", keeps the existing reasoning_content encoding.

For OpenAI itself, dialect = "responses" speaks its Responses API (POST /responses) instead of chat completions. Some of OpenAI's reasoning models take an effort beside tools only there: on chat completions gpt-6.1-sol refuses reasoning_effort with tools ("use /v1/responses"), and a task always offers tools. The effort goes as reasoning.effort, the system prompt as instructions (a ChatGPT plan's server refuses a system message in the input), nothing is stored on OpenAI's side (store is false, and each request carries the conversation, as before), and a reply's thinking is not sent back, since the API takes it back only encrypted; a seed is sent as it is, and refused.

[servers.openai]
provider = "openai"
dialect = "responses"
url = "https://api.openai.com/v1"
credential = "model-api-openai-com"

Unknown dialects and a dialect on an Ollama server are refused. No dialect is inferred from the URL or the model name. This setting changes the wire encoding only: thinking stays in the log and window, and the address, key binding, output cap and cache settings keep their existing rules. Ollama continues to use its native protocol.

Settings › Models › Providers sets the dialect with the rest of a provider (the Cerebras and OpenAI presets fill it in; you can see it, and change it, before saving). An OpenAI provider saved before the OpenAI preset chose Responses keeps chat completions until you change it; a request it refuses for that reason ends the turn saying so. It is never inferred from the address. Saving another model on that provider, or an inline control model as a server at the same endpoint, keeps it.

Anthropic. provider = "anthropic" speaks Claude's own Messages API (POST /messages), which chat completions does not copy faithfully: an effort, thinking and the prompt cache are asked for in fields of its own.

[servers.anthropic]
provider = "anthropic"
url = "https://api.anthropic.com/v1"   # including the version
credential = "model-api-anthropic-com"

[models.sonnet]
server = "anthropic"
model = "claude-sonnet-5-5"
cache_control = "ephemeral"            # Claude caches only when asked (below)

Its key goes in the x-api-key header, not as a bearer token, so the key's binding is header:x-api-key (Settings writes it; the example in "Choosing models" below shows the file's form), and every request names anthropic-version: 2023-06-01. The system prompt is the request's system; a tool call is a tool_use block, and all the results of one turn are tool_result blocks of a single user message. The Messages API refuses a request without max_tokens, so the model's reply cap always goes (16,384 when a request names none). An effort is sent as output_config.effort with thinking: {"type": "adaptive", "display": "summarized"}, which is how a model is asked to think: it needs a model that takes adaptive thinking (Claude 4.6 and later), and the thinking summary is the reply's thinking. A model that declares no efforts is sent neither field. A reply's thinking goes back with it as the API gave it, each block signed and in its place among the calls (recipe v18), so the model reads the reasoning behind it again instead of working its design out anew at every call; a request that carries blocks also asks for adaptive thinking, and asks the API to drop, not refuse, a block whose conversation changed since it was made (block_binding drop_block, beta thinking-binding-controls-2026-08-01). The window itself leaves out the blocks of a reply made before it folded or trimmed something earlier, which the API would find stale. The first request that carries blocks may miss the prompt cache once, since it adds the thinking setting. A call whose arguments are not a JSON object is sent with none, since input must be one. A reply that stops as refusal ends the turn saying the model declined, with the reason the API gives.

A server of this kind takes a key, vision, cache_control, temperature (0 to 1; the newest Claude models refuse any, so leave it empty) and prices; it takes no dialect, cache_key, output_field or seed, and config.toml refuses them. The model list (GET /models) gives each model's context window, so a model whose entry and catalogue say none is sized from it, asked with the key like a chat request. Requests are not streamed (a credential can be split across tokens, ADR 25), so a turn with a very large reply cap and heavy thinking is one long request.

What providers publish. OpenAI's and Cerebras' APIs say nothing of a model's context, its longest reply, its efforts or its price, so nodwyn carries a catalogue of what providers publish, as models.dev collects it: a snapshot built into nodwyn, regenerated with go generate ./internal/catalogue. A model on a provider it describes (OpenAI, Anthropic, OpenRouter, Cerebras, Groq, DeepSeek, Mistral, xAI, Together, Fireworks, DeepInfra, Moonshot, Z.ai, Baseten, Ollama Cloud), found by the host of its server's address and its exact id, takes from it each of these its entry leaves unset: its context length, its reply cap (the published longest reply, at most a quarter of the context), its efforts, whether it reads images and its prices, a cache write and a long prompt's higher tier included. gpt-6.1-sol at api.openai.com is sized for 1,050,000 tokens, replies up to 128,000, offers low to max and is priced, with nothing typed. Your entry always wins; Settings › Models shows the published values as each field's placeholder and the model's row says as published. The catalogue only describes models: which provider a server is comes from its address in config.toml, matched against hosts nodwyn's source names, never from models.dev.

A model at a server the catalogue does not describe (your own vLLM or LM Studio, a ChatGPT plan, a host it does not list), whose server's model list does not say its context either, is sized by what the catalogue's providers publish for a model of that name, its id spelled as any of them spells it (qwen-3.8-27b is qwen/qwen3.8-27b): their smallest context and reply cap, and a note in the transcript says whose. Prices and efforts are not taken that way: they are each provider's own. A model nobody publishes keeps the default 61,440-token window, and the task says so above its conversation, with a button to Settings to set its context length.

Efforts. A reasoning model takes an effort: how hard it is asked to think before it answers. Which words a provider accepts is the provider's to say, and a provider that does not know a field may refuse the whole request, so nodwyn never guesses from a model's name: a model takes the efforts its entry lists, else those its provider publishes, and a model with neither is offered none and is sent none.

[models.deep]
server = "openai"
model = "gpt-5.1"
efforts = ["low", "medium", "high"]   # the words the server accepts
effort = "medium"                     # what a task starts with; leave out for the provider's default

[models.local-thinker]
server = "local"
model = "qwen3:8b"
efforts = ["off", "on"]               # Ollama's think: on, off, or a level such as "high" for gpt-oss

The word is sent as reasoning_effort to a chat-completions server, as reasoning.effort on the Responses API, as output_config.effort with adaptive thinking to Anthropic (Claude's levels are low, medium, high, xhigh and max, by model), and as think to Ollama (off and on are its booleans, any other word the level a model such as gpt-oss takes). Words are short and lower-case. [control] needs no efforts of its own when a [models.NAME] is the same model at the same address: it takes them from that entry, so they are listed once; its effort is the default effort of the default model, which a new task starts with. A default that is not one of the model's efforts, or an effort on a model that takes none (neither listed nor published), is refused when the file loads. Every request records the effort it asked for (llm.req), and replays to its hash.

Settings › Models. The console sets all of this up without the file, on this computer. (A linked host's Models tab, below, adds, changes and removes models among that host's providers, and no provider.) Each provider is a card with the models on it listed under it, and Add a model on it there; the default for new tasks is its Model · Effort, which waits in Settings' bar with the other settings (the default judge is on the Judge tab):

  • Providers lists each [servers.NAME] but Jev's, which is a judge under Settings › Judge (and the address a model gives inline, read-only), says whether it can be used, and why not: needs its key (none stored, or the server answers 401 and none is configured), keychain locked, key not allowed (stored, but no binding row lets it go to that host), not answering, or model not installed. The key's state is read from the keychain and the binding table before the server is asked, since that is what to fix; Check again asks every server now. Add a provider is a preset (Ollama, OpenAI, Anthropic, Baseten, OpenRouter, Cerebras, or Other server, which is OpenAI-compatible; the preset says the kind, and only fills the form in), an address, for an OpenAI-compatible one a dialect, and an API key for either of those (an OpenAI-compatible one can use a ChatGPT plan credential instead), and, under a More fold, a name (left empty, one is chosen from its kind, such as openai or ollama; a name already in use is refused, and a second provider can share an address under another name, such as an API key and a ChatGPT plan at OpenAI). The key goes to the keychain with two binding rows for the address's host, POST for the model and GET for its model list, as a bearer header (Anthropic's: the x-api-key header), never into config.toml, never into the page; replacing it, under Connections › Credentials (the one place a stored key's value is changed; the provider's form links to it), replaces it in place. A key requires HTTPS or loopback by default; the user's Credentials over remote HTTP setting also permits an HTTP provider. It follows a change of path or port, and never a change of host: a provider moved to another host has no key until you type one. Test sends one short request, as a task would, through the same binding check, and says what came back: the reply, the server did not accept the key (401), no model, it refused the request (an effort the model does not take, for one), or no quota. A provider that models are on is not removed; its key stays in the keychain (Connections › Credentials removes it). A provider's Edit opens its form in its card, under its buttons, with the keychain's form beside it when a key needs the keychain unlocked; Add a provider opens the form below the list, where its button was.
  • The models under each provider are the list the pickers offer: [control] and each [models.NAME]. A provider's Add a model on NAME opens the model form in its card, for that provider, and a model's Edit opens it under the model; Add a model below the list opens it there, with the provider to choose. Add a model takes a provider, a model id (the provider's own list, asked with its key as the form opens, is offered as buttons when it is short) and, under a More fold that is closed when adding, the efforts it takes: As published (offered once the id is given, for a model its provider publishes; the entry then lists none, so the provider's apply, and a model whose typed efforts differ from them is told so), shortcuts (low · medium · high, minimal added, off · on) or any other words, and the effort it starts at. Each model has its own settings in its form, each showing what its provider publishes as its placeholder: its context length, its reply cap, which comes out of the context (empty is the published longest reply at most a quarter of the context, else the default reply cap, or a quarter of a small context), with a line saying what the conversation is left with as they are typed, its prices per million tokens (which price a request when the provider reports no cost, as only OpenRouter does), whether it takes images (what its provider publishes, else yes, until you choose), and, under More settings, sampling, the reply cap field, the session key and the prompt cache for a chat-completions model (no reply cap field on the Responses API, which reads none, and a word that it has no seed), only the sampling (temperature, no seed) and the prompt cache for a model on Anthropic, whose form starts a model you add with the 5 minute ask chosen, or how long Ollama keeps it loaded. The row says the context, reply cap and prices it has, and as published when they are its provider's. Editing the default model saves them in its entry, not in [control], so they stay with the model when another becomes the default. Test it tries a model before it is saved.
  • Default for new tasks is a model from that list and its default effort. Choosing another default keeps the one it replaces in the list.

First run. With no default model, Home has no task window: it draws the provider form and then the model form of Settings › Models, in that order. The provider is chosen from the same presets, starting on Ollama, which needs no key; one that wants a key is saved only once there is a keychain, which the page makes beside the form without losing what was typed. The model form offers what the provider lists and the efforts it takes, tests the model on request, and Save and use it adds the model to the list and makes it the default, at the effort chosen. Its context length, prices and the rest of its settings are what its provider publishes until Settings › Models changes them. Use another provider returns to the provider form, and a provider saved by mistake is removed in Settings › Models.

The pickers. The New task window and a conversation both show a Model and an Effort select under the message. The model select groups the list by provider and says in each label what is wrong with one that cannot be used; the effort select offers the efforts that model takes, and says Not configurable for one that takes none. A model that is not ready is shown as it is: the New task window says why and does not start a task on it (a provider that does not answer is a warning, since it may be back).

  • New task. It starts on what was chosen for the task before it: the model and effort, the judge, the helpers, whether the limits are on, Runs on and, for each folder, In a worktree. Before any choice, that is the default of Settings (the model, its effort and the judge), no helpers, the limits on and a worktree for a git workspace. nodwyn keeps the choices itself (new-task.toml in its configuration folder), so they are there after an app restart and in any browser that opens the console. Picking the default model or judge again makes the next task follow Settings' default, if you change it later; a choice here never changes the default. A model removed from the list since is said to be gone, and another is not picked for you.
  • In a conversation. A compact model-and-effort summary sits below the message box; click it (or focus it and press Enter) to reveal the selects. They show the model and effort the next message is answered with. Warnings and change notices remain visible when the selects are collapsed; if a model must be chosen before continuing, the selects open automatically. Changing either takes effect from the next message, and a line says so after a choice; an unchanged running response shows no change notice. While a response is running it keeps the model and effort it started with, and what you choose waits for it to end (the line says so, naming both); if it cannot be applied then, because the conversation has outgrown that model's context, say, the conversation keeps what it had and says why. A change of effort alone applies without sizing the window again. A closed task shows the model and effort it was left on, and resuming it restores them.
  • A model that is gone. If a model, or the effort a conversation was left on, is no longer offered by config.toml, resuming the task opens it, says what it was left on, and sends nothing until you choose a model for it: it is not moved to another model, which would send its content to a provider you did not choose for it.
  • A paired phone sees the model a task has and the default new tasks start on, and cannot change either, or set up a provider.

A keyed server needs its key stored ((credential-register "openai-key")) and bound to the server's host, like any credential:

# bindings.toml
[[bind]]
class = "openai-key"
dest = "api.openai.com"
positions = ["header:Authorization:Bearer"]
methods = ["POST"]

A key for Anthropic is bound where Anthropic reads it, which is positions = ["header:x-api-key"]; a row that lets a key go as a bearer token does not release it there, and Settings writes the right one.

The binding is checked on every request and logged as egress.check; an unbound key is never sent. Configuring a model is consenting that what a strategy sends it leaves the machine: with narrow-context that can be just a task and a plan, but a strategy that asks a model over the whole conversation sends the whole conversation. Only you configure models; the model cannot.

The model a conversation talks to. A conversation may be moved to another model between turns, and it keeps that model when it is resumed (ADR 40). The choice is one of: control (config.toml's [control] model), a [models.NAME] entry, or a server and a model on it — the last needs no [models.NAME] block, so any of the models an OpenRouter-shaped server lists can be chosen. One key per server is unchanged: another model on the same server is the same address, the same key and the same binding, and a model on another server is reached with that server's key, its binding checked on every request as always. A switch takes effect at the next turn — (model-turn) asks the new model, while (model-turn "NAME") still asks a named one for a turn (ADR 27). The window is then sized for the new model, as it would be had the task been opened on it: from its num_ctx, or from the context the server's model list gives (OpenRouter's and Anthropic's do; OpenAI's and Cerebras' do not, so set it for those models in Settings › Models), and a note in the transcript says how. A choice made while a turn runs waits for it (above): it is queued, and applied as the turn ends. It is refused while an attachment is being added or a run is active, and, once it applies, when the conversation would not fit the new model's context less its reply reserve: compact it first, or choose a model with more context (nothing is judged for a model whose context neither config.toml nor its server says). Whether an attached image may go is asked of the new model, so a text-only model on the same server gets a line saying the image was withheld, not the image. The choice, with its effort, is in the log as session.model, and every request still replays to its recorded hash. Choosing the model is this computer's act: a paired phone opens tasks on the [control] model and may steer them, but not decide where their content goes.

On the wire this is session.open with name, or server and model, and effort; and session.model.set for a session already open, which with the model the conversation is on changes the effort alone. session.info reports the model the conversation talks to now, the efforts it takes, a choice that is queued and why one could not be applied. The console's picker is above. In the TUI, :model shows the same list and :model NAME [EFFORT] chooses (the TUI). Line mode has no command for it.

What a request cost. After each reply the TUI, line mode and the console show its tokens, and when the provider says so, how much of the prompt came from its cache and what the request cost: 12,400→800 tok · 78% cached · $0.012, with the reasoning share of the output when the provider reports it (800 tok (300 reasoning)). Where the provider reports no cache reads, as older Ollama servers do, the share is estimated from the prompt-eval time — a cached token does not take it — against the session's most-cold request of that server, host and model, and shows marked: 12,400→800 tok · ≈78% cached; a reported share always wins, including zero. Ollama servers that send prompt_eval_cached_count supply that reported share; estimates are only for replies without the field. A server that sends no token counts reads tokens not reported, not 0→0. The request's wall time, as this machine measured it from the sending to the reply's complete arrival, gives a speed too — 22.0 tok/s — and it is the one speed every provider gets, prompt processing included, which is what makes providers and models comparable. Over a turn of several requests, the console adds them up, and since each request sends the whole prompt again it also gives the largest single prompt, which is how big the context got (405,467 → 4,936 tokens over 12 requests · largest prompt 58,412). The token budget adds up prompts and replies too, a prompt token read from the cache counted as a tenth (below). OpenRouter reports cache use and cost in every response, cost in USD included; nothing has to be asked for. A server that reports no cost (OpenAI, Cerebras, any other but OpenRouter) is priced at what its provider publishes (above), or at the prices in Settings › Models, in the model's Prices, or in its entry, per million tokens, which win:

[models.coder]
price_input = 3.0          # USD per million prompt tokens
price_cached_input = 0.3   # prompt tokens read from the cache; unset: as published, else price_input
price_output = 15.0        # output tokens, reasoning included

A cost from prices is an estimate, shown with ≈. Each price an entry leaves unset is the published one, so a price typed alone is kept beside them. Published prices also price prompt tokens written to the cache (OpenAI charges a quarter more for them) and a prompt past a provider's tier (OpenAI's past 272,000 tokens) at their own rates while the entry types no price; once it types one, a cache write costs the input price and a long prompt no more than a short one. A reported cost always wins. Ollama reports neither, and its line reads as before; so does an older log. The TUI's :budget adds the turn's spend over every model request (the planner's, sub-agents' and compaction's included) and the judge's requests apart (priced by [judge] price_*, else its provider's, "The judge" above). The token budget counts a prompt token the provider read from its cache as a tenth of one, about what it costs and what it asks of the server; one it does not report on counts in full.

Keeping the cache warm. A provider bills cached prompt tokens at a fraction of the input price (meta/muse-spark-1.3 through OpenRouter: a fiftieth), but only a request that reaches the replica holding the cache gets them. Each request to a chat-completions server therefore names its session: a key derived from the session id, the same for every request of the session and resumed ones, saying nothing else about it. It goes as OpenRouter's X-Session-Id header (Baseten's X-Session-Affinity carries the same key) and as prompt_cache_key, the field OpenAI routes by. A ChatGPT plan's requests also carry it as the session-id, thread-id and x-client-request-id headers Codex sends: with prompt_cache_key alone, the plan's server read under a tenth of a session's prompt from its cache, where an API key's same requests read nineteen twentieths. A server that refuses fields it does not know takes the headers alone, and off sends none:

[control]
cache_key = "header"       # session (the default), header or off; openai only

What the key does is up to the server: OpenRouter keeps the session with one provider, and a provider may or may not keep it on one replica. Compare the cached share of requests that only add to the previous one, in the console or the log's llm.resp events, with the setting on and off. The default loop also folds old reads only when that does not cost more of the cache than it saves (hygiene.scm, docs/scheme.md §9.6).

Claude models cache when asked. The key only routes to a cache that exists, and Anthropic's models have none until a request asks for one: a Claude model through OpenRouter, Bedrock or Vertex, or on Anthropic's own API, is billed for its whole prompt on every request, however little changed, and the log reads cached_tokens: 0 with no cache write. Say so in the model's entry, and nodwyn sends the ask as a top-level cache_control, which the provider applies to the last block of the conversation and moves on as it grows:

[models.claude]
server = "openrouter"
model = "anthropic/claude-sonnet-5.5"
cache_control = "ephemeral"   # five minutes; "1h" for an hour; openai and anthropic only

It is never inferred from the model's name. Leave it out for a model that caches by itself (most others), and for a server that refuses fields it does not know: a route that does not take it may refuse the request, and Settings › Models › Test says so, with what to turn off, before a task does. A request that reuses what the last one wrote reports cached_tokens, read at a tenth of the input price on Anthropic's usual terms; the write costs a quarter more, and a cache lasts the time chosen, so a pause longer than that pays for a write again. Settings has it as Prompt cache, under the model's More settings; a model added on an Anthropic provider starts with it set to five minutes (the file is only ever written with what the form shows). On Anthropic's own API cached_tokens is what the reply says it read from the cache, and the prompt is that plus what it wrote and the uncached rest, which its input_tokens alone is; an hour's write costs twice the input price, but the estimate prices it at the published (five minute) write price.

A spend limit is one of a task's own limits ("A task's limits" above), in US dollars, and off unless you set it on the task. It counts the known cost of the whole task, every turn and a resumed session's history included, and at the limit the task stops until you raise or remove it. A request whose cost nobody knows is not counted, and a note says so the first time.

The console's right panel keeps a compact summary above its content: total task spend, current turn time (including waits for your answer) and context fullness. The three positions stay reserved even before a figure is known. Under them is the task's Limits: None in one line, or a chip for each limit set, with Add a limit ("A task's limits" above). This turn's tool calls, against the task's limit when it has one, are in Statistics' This turn, and the cache rate is Statistics' Token cache rate (Totals): raw prompt tokens read from cache divided by raw prompt tokens across replies that report both, not the percentage of requests hitting cache. Missing or unreported usage stays out; no positive prompt total shows unknown, and reported zero cache reads give 0%; it covers only retained history when the log was truncated, as Totals says. A task whose requests have no known price shows unpriced for spend. For the other figures, a dash means unknown, not zero. Context is a percentage of its recorded high water, or a token estimate if no high water is known. A reserved warning line flags tokens, time, tool calls, spend or context when they reach 90% of the displayed limit, and says when they reach it; the configured budgets still decide when to ask or stop.

The tabs above the content are Progress, for the plan; Changes, still for approved previews, attachments and file recovery; Statistics, for all turn and task figures; and Workspace, for the folder, branch, access, tools, Details and retained project notes. A worktree task has a Review button opening its actual changes in the centre, separate from Changes' approved previews and recovery. Unified is the default, with a Side-by-side option; Close review restores the conversation and reply form while retaining review drafts and reading position in this tab. Smaller labels and tighter spacing keep the four detail tabs on a single row at both desktop panel widths; keyboard focus remains visible. The summary stays visible across tabs. On desktop only the selected content scrolls: a long plan or an expanded step cannot displace the summary or navigation, and tabs keep their reading position across events and switches. Narrow screens use ordinary stacked sections and page scrolling instead of a nested scroll area.

On Home, describe the task and choose its folder, model, and, under Options, its worktree and helpers, as usual. Options › Schedule… opens Schedule, whether or not anything is typed yet: the message for each run is a required field there, started from what the box holds. Under Repeat, choose when it runs:

  • At set times: one or more times of day, on the days of the week you pick (none picked is every day): "Tuesdays and Thursdays at 15:00".
  • Every few minutes or hours: from 5 minutes to 24 hours, counted from midnight, or from the start of Only between two times of day, on the days you pick: "Every 30 minutes from 09:00 to 18:00 on weekdays". Every 45 minutes from 09:00 runs at 09:00, 09:45, 10:30 and so on, and starts again at 09:00 the next day.
  • Monthly: at set times on dates of the month ("1, 15"; a month without the 31st has no run that day) or on a weekday of the month (the first Monday, the last Friday).
  • Once: a date and minute.

Starts is the first minute it may run (empty: now), and Last day its last day, if it has one. Times are this computer's, and the zone is named in the dialog (America/Los_Angeles, not "Local"). Set the dollar spend limit per run and per day (0 means none for either): once the day's runs, their follow-ups included, cost the day's limit, the rest of that day's times are skipped, in one line in the task, and runs go on the next day; Run now is not held to it. The daemon says the rule back in words ("Every 30 minutes from 09:00 to 18:00 on weekdays") with the next times, and refuses what a schedule cannot be (an interval under 5 minutes, hours that end before they start) before anything is saved. A new schedule's first run is its first time after now, so try one with Run now. Saving opens the task immediately; its message is sent when due. Scheduled tasks appear on Home with their next time or Paused, and a card has Pause schedule (or Resume schedule) and Run now; Home says "Nothing scheduled." when there is none.

The Workflows page (in the sidebar, and All workflows on Home's Scheduled section) lists every schedule: its task, the rule in words, when it runs next (or Paused, or held by locked keys), what today's runs cost and how the latest ended, with Pause, Run now and Schedule…. New timetable opens Home's Schedule dialog, beside the task's folder, model and limits.

Workflows from a terminal

nodwyn workflows lists what the console's Workflows page lists, in the daemon's words: each timetable (a scheduled task) and each workflow that watches a source, with a reference in brackets, when it runs next or what it watches, what today's runs cost and the latest events. nodwyn workflows pause|resume|check|run REF acts on one (REF is the reference or the end of it): pause or resume either kind, check polls a source now, run starts a timetable's run now. nodwyn outbox lists the answers held to be posted, each with the start of the answer, and nodwyn outbox post REF or discard REF decides one: the daemon posts a review comment, or saves a draft reply in the thread (never sent), with a key bound for exactly that call. The TUI has the same as :workflows and :outbox. A terminal makes and changes no workflow and edits no answer: those are the console's, since the cards, the live test and the editor are its. They need a running daemon (nodwyn app, nodwyn tui or nodwyn daemon; nodwyn startup on keeps one).

A workflow that watches pull requests, email or a command

Workflows › Watch for events builds one that runs a task when something happens at a source (ADR 53). Its answers are four cards:

  • When: a Gitea or GitHub repository's pull requests (the server for Gitea, owner/name, and the keychain credential to ask with), or a command's output, checked every 1 minute to 24 hours (2 by default). It fires when a pull request is opened, when it gets new commits, or both. What is open when the workflow is first checked is recorded and not acted on, unless you tick Also handle what is there when this starts.
  • Only if: conditions on the event, checked before any model is asked: author is not "me", draft is "no", labels contains "review", on the fields author, base, head, draft, labels and title (a command's own fields too). An event that fails one runs nothing and is not asked about again.
  • Do: the instruction, sent as each run's request, with the task's folder, worktree, model, limits and spend per run and per day, as any task.
  • Then: the answer stays in the task, or is posted as a review: a comment on the pull request, never an approval or a request for changes. A review is held on Home, under Needs you, as Ready to post with Post review, Edit (this computer only), Discard and Open task, and nothing is sent until you choose; Without asking has the daemon post it as the run ends. Either way the daemon posts it, with the token, and only where Connections › Credentials allows that token at the forge's host for POST: the permission to list pull requests (GET) is a separate one, and a post that cannot be made stays in the outbox with the reason. A run that did not finish, or said nothing, queues nothing. You can also have a desktop notice after every run, or none, besides the notice that a held answer needs you.
  • Runs go to: a task per pull request (its first event opens a task named #142 title, and a later one, such as a new commit, arrives there as its next run), or one task for every event. At most 2 runs go at once (settable); events past that wait for the next check.

The event is attached to the run as a file, pull-request-142.md, which the conversation says the workflow attached, and framed to the model as data, never instructions: what a pull request's description says is not your request, which is the instruction. Test the trigger asks the source once and lists what is there and whether each passes the filters, with no model and nothing recorded; the daemon says the whole workflow in a sentence.

Email in Gmail watches a search, as Gmail's own box takes it (your inbox when empty). A thread is the subject: a new email opens a task titled by its subject, and a later message in the thread arrives in it as the next run; reading or archiving a message is not an event, only a new message is. The email is attached as email-….md, its sender, recipients, date and text (the plain-text part, or the HTML with its tags taken out, at most 20 KiB), framed as data like any attachment: a message that tells the model to ignore its instructions is a quoted message. Filters read from, to, subject, labels and date, so from does not contain "me@example.com" leaves your own replies out. The answer can be saved as a draft reply: held on Home as Draft reply · Reply to the sender, with Save draft, Edit, Discard and Open task, and saved by the daemon in the thread, to the sender, with the headers that make it a reply. A draft is never sent; sending is yours, from Gmail. Nothing of the email's headers can add a header to the draft: line breaks in a name or subject are taken out.

It needs a Google credential authorized for reading: nodwyn credential add --oauth2 google --client-id ID --scopes gmail.readonly (and gmail.compose to save drafts: a separate authorization, since it writes), allowed in Connections › Credentials at gmail.googleapis.com for GET (and POST for drafts) at header:Authorization:Bearer, with the Gmail API turned on in the Google project that owns the client id. The token is refreshed when it is about to expire, as any OAuth2 credential is. A refusal says which of these is most likely missing. IMAP is not offered.

A forge, or Gmail, is asked with a key from the keychain through the check a task's own requests meet: only where Connections › Credentials allows that key at that host for that method (header:Authorization:token for Gitea, header:Authorization:Bearer for GitHub and Gmail), within the scopes the credential was authorized for, never over plain HTTP to a host that is not this computer unless Settings allows it, with an OAuth2 token refreshed first and no redirect followed. A poll lists pull requests, or the newest messages, and nothing else. A locked keychain, a missing key or an unbound host is said on the workflow and, for a locked keychain, once on the desktop notices. A command source runs with no shell in the workflow's folder, as the absolute path or the name on PATH you give (a program in the folder is refused, since its repository would choose what runs), for at most a minute, and prints one JSON object per line with an id and, to tell a changed item from a new one, a version. More than 20 events at one check pause the workflow and say so. A workflow is made, tried, changed and removed on this computer only; a paired device sees it, pauses it and asks it to check now.

What has been tried. The watchers, the outbox and the email source have been run only against stand-ins for Gitea, GitHub and Gmail (the test suite's fakes, which answer as those services document), never against the real servers. The first poll of a real forge or mailbox is the first time their pagination, rate limits and error bodies meet the code. Try a new workflow with Test the trigger, which asks once and records nothing, and leave Without asking off until a held answer has been posted by hand once.

On an existing task, Schedule… in its ⋯ menu adds a schedule, or shows the rule in words, its message, when it runs next and its Recent runs, and lets you save changes (behind Edit), pause, resume, run now or remove it (after a confirm). Recent runs lists the latest 20 scheduled times, newest first: when each started, how it ended (Finished, Failed, Stopped at the spend limit, Running, Run now, late for missed times) or why it was skipped, and what it cost with its follow-ups. Run now also works while paused and leaves the automatic next time unchanged. Resuming a paused schedule starts after now, without catching up the paused interval. A new timing starts after now, so the times before the change are not run late; changing only its message or spend, or writing the same timing another way, preserves the times already handled. A schedule made before rules (Every hour, Every day, Weekdays, Every week) runs at the same times as before and opens in the editor as the rule it always was. Removing a schedule keeps the chat. Close frees the task but leaves its schedule enabled; Archive pauses it, and Unarchive leaves it paused. Delete removes both.

The daemon must be running. It checks every 30 seconds and immediately on startup. Times missed during sleep or shutdown become one late run, whose marker gives due and start times and the additional missed count. A busy task, a pending answer or another process holding its log skips the time, with a reason in the conversation. An externally held log cannot be written: the daemon remembers its skip until the writer releases it. If the daemon stops before then, that unrecorded interval becomes a late run on restart. A single data-directory lock prevents two daemons from dispatching the same timer.

Scheduled messages use normal approvals and discard Don't ask. The task’s own limits apply to each run, none unless set on the task; a schedule’s spend limit per run replaces the task’s spend limit while a run goes, and counts each run and its human follow-ups, then resets at the next scheduled or manual run. Statistics keeps whole-task totals and shows This run separately for the loaded scheduled run. Unknown prices remain outside the limit. A scheduled task closes after 15 idle minutes with no pending answer, freeing its interpreter, MCP services and any clean worktree. It resumes when next due. A worktree a run left only ignored files in (__pycache__, node_modules) stays, and the next run brings it up to its base branch's head by fast-forward, its ignored files kept, so each run starts from today's base; a worktree with changes or commits of its own starts where it was left.

When a turn has added over a quarter of its context window since the last handover, it prepares a note for the next scheduled run while its context is warm. This uses the same run budget. The note is recorded without replacing the current conversation: human follow-ups keep that conversation, without the ask for the note or the note. The next scheduled message starts from the note when the note covers the whole conversation; a small run or a follow-up after it is in no note, so the conversation stays whole until a later note covers it. A pending answer prevents preparation, and only a conversation prepares one, never an evaluation or a read in Settings. Creating, editing, resuming or removing schedules stays on this computer; paired devices can pause and run now. There is no scheduling Scheme primitive. For runs with no window open, have the daemon start at login and turn on its desktop notices (Local daemon, below).

Opening or reopening a task with run records loads its latest run, including all turns and human follow-ups in that run. Earlier runs fetches five older previews at a time, in conversation order. Each collapsed run shows its last reply; opening it loads its full conversation, and closing it returns to the preview. Expanding a loaded run again needs no further read. Earlier-page and run-read failures leave the current task usable and can be retried. Loading older runs keeps the existing reading position. Switching tasks discards replies still arriving for the former task. Tasks without run records keep their whole conversation view; older daemons without this feature do too.

The Log and Plan record inspectors cover the loaded latest run and its inherited setup; their scope is labelled when earlier history is omitted. Earlier run contents stay separate from the current model, prompts and turn gauges. Statistics and Changes continue to cover the whole retained task, just as live replies update them. A read-style tool wrapper can run a shell command; the transcript keeps the guard's recorded, redacted command on that tool's row and continues loading the history and its figures.

The console's side panel reads "Totals" from the daemon's log index, in its Statistics tab. It includes the whole retained history, even when a resumed task holds only the end in memory. The index is rebuilt if missing or stale; it is safe to delete. Totals refresh after recorded events and when reopening an open or closed task. "Turns" counts submitted turns; "Runs" appears for CLI driver and scheduled runs, with the latest start time in each row's tooltip. These run markers also provide conversation paging. Changes reads approved previews and attachment metadata from the same index. The figures are: "Total spend", what its model and judge requests cost as far as the log prices them (the same accounting a spend limit counts; with a limit set, the total is a bar against it, and every other figure here is a plain row); "Total running time", how long its turns took, the time it spent waiting for your answers included; and "Total working time", how long they took with those waits left out — answers its judge or an unattended run gave by themselves count as working. Two more figures say where that time went: "Model wait", each request to a model from its sending to its reply, streaming included; and "Tool time", each tool call from its start to its result, with the wait for your approval taken back out. A third, "Model speed", says how fast the models answered: output tokens per second of that wall time, added up over the requests that reported tokens, with the request count — the speed the session experienced, whichever model answered, and the figure to compare providers and models by. "Token cache rate" is the rounded percentage of raw prompt tokens read from the provider's cache over all the session's model replies that reported cache reads and prompt tokens, across turns and models. It adds tokens before dividing, not request percentages: it is not the share of requests hitting cache, and does not use the token budget's discount. Missing or unreported usage stays out of both totals. A reported zero cache read with positive prompt tokens gives 0%; without a positive prompt total the figure is "unknown", not 0%. Where no reply reports cache reads, as older Ollama servers do, the rate is estimated from each reply's prompt-eval time — a cached token does not take it — against the session's most-cold request, and shows marked ≈; a reported share always wins over it. Like the other totals, it covers only the log that remains if history was truncated. Times are in tenths of a second while short. A turn still running counts up to now, into the running time even while it waits for you and into the working time only while it does not; a request or a tool still open stops at the log's last record while a prompt holds the session. A request that never got its reply — a failed one records none — and a tool its turn left behind when it ended count nothing. A turn a crash or a stopped daemon cut off ends where its log does, so the time until the task was resumed is not counted. A run that went on by itself counts the time between its turns, one you answered or brought back does not. Where the log was truncated, the panel says its totals cover what remains.

No keyed request follows a redirect (http-request, Jev, MCP), and no model request does, keyed or not: the binding was checked for the host the request went to, and a redirect would take the key and the body (for a model, the whole window) to wherever the server points. The request fails instead and names that host, the server redirected to HOST; nodwyn does not follow redirects for keyed requests (or for model requests, which are not retried); an MCP call fails with the redirect's status. If that host is where the request belongs, send it there: set the model's url, or the address your tool sends to, and bind the key to that host.

[control] has the same shape and is required: it is the model (model-turn) asks, and a keyed chat-completions server works there too.

[control]
provider = "ollama"               # the default; or "openai"
url = "http://localhost:11434"
model = "qwen3:8b"
num_ctx = 81920                   # its context length, replies included

model is required, with a server or a url; nodwyn has no built-in control model. Unset, num_ctx asks an ollama model for exactly the window's size and a reply (window.high_water plus the model's reply cap, 77824 by default) rather than leaving Ollama at its own, usually smaller, default, which would cut the prompt silently; a chat-completions server decides its own.

A model's reply cap comes out of its context, and the conversation gets the rest. The window is sized for the control model's context less that cap. Its defaults (window.high_water 61440 and low_water 30720, in the window's estimate of bytes/3) suit a small local context. When the context is larger, as num_ctx says, or what its provider publishes (above), or, for a chat-completions server neither says, as the server's model list says (OpenRouter's context_length, vLLM's max_model_len; asked once per process, with the model's key, as OpenAI and Cerebras list their models only to one), the high water grows to three quarters of the context less the reply cap, and the low water to half of that: a 1M-token model trims past 774144 and compacts just under it. When the context is too small for the window, it shrinks the same way: a 32768-token model's reply cap is 8192 (below) and its window trims past 18432. The last quarter is room for compaction's handover note (docs/scheme.md §9.2), so a long task is summarized before anything is trimmed. The session log notes the size it chose, or that nothing said the context (a model the catalogue does not describe, on a server whose list carries no context length); set the model's context length in Settings › Models then, or num_ctx in its entry. Setting high_water or low_water in [window] keeps the window at your size, unless a model's context cannot hold it: then it shrinks to fit that model. A larger window sends more with every request: on a paid model, a request near the high water costs that many prompt tokens, less what the provider caches.

The model's thinking goes back to it on the reply it came with, as the server reported it and redacted: ollama gets it as thinking, a chat-completions server as reasoning_content (OpenRouter reads it as reasoning), or as reasoning with dialect = "cerebras"; with dialect = "responses" it is not sent back (OpenAI takes back only its encrypted reasoning), unless the model keeps that (below). It counts toward the window and is trimmed with its reply. A server that reports only a summary of the thinking, or none, gives back only that. Anthropic's own API is the exception: its signed thinking blocks are kept and given back whole (above).

A reply that carries OpenRouter's reasoning_details (its reasoning as the model's provider gave it, often encrypted or signed) keeps them, with no setting: the log keeps the array as it came, and every later request of the conversation sends it back unchanged, in order, on that reply, in place of its thinking text, so the model keeps its reasoning between tool rounds. This is any openai server with the default dialect; dialect = "cerebras" and dialect = "responses" are unchanged. The details go back only to the server and model they came from: after you move the conversation to another model, that model is sent its replies' thinking text as before, and moving back gives the first model its details again. They are never shown, and they count toward the window: trimming a reply takes its details with it, and a reply made before the window folded or trimmed something earlier goes back with its thinking text instead. Details holding a credential-shaped text, which the log would redact, are not kept, and the log says so (ADR 9, amended 2026-10-15).

A model on a Responses server can keep its reasoning the same way: with keep_reasoning = true in its entry, each request asks for the reply's reasoning encrypted, the log keeps it, and it goes back before the call it came with, so the model need not work out again what it thought before calling a tool. It goes back only to the server and model it came from, since a plan's credential and an API key may not read each other's; earlier turns' stays where it was (reasoning.context all_turns), so the cached prompt holds across messages. The default is false, which keeps none, and false is the setting for gpt-6.1-sol: in three runs of one task each way it saved no time, output or tool calls, reasoned more a reply, and solved the task the same way (ADR 9). A later model may do better with it; measure before turning it on:

[models.openai-plan-gpt-6-1-sol]
keep_reasoning = false     # the default; true only on openai servers with dialect = "responses"

A reply that thought at length three rounds in a row without changing anything (10,000 reasoning tokens or more each) gets a line asking the model to write down the design it reached and make the edit (stall-rounds, stall-reasoning-tokens).

Each reply is capped, thinking included: ollama gets the cap as num_predict, a chat-completions server as max_completion_tokens (the Responses API as max_output_tokens), so a prompt at the window's high water and its reply always fit the context. The cap is the model's max_output (Reply cap in Settings › Models), which must be below its num_ctx; unset, it is the longest reply its provider publishes, at most a quarter of its context (gpt-6.1-sol's 128,000), and for a model nothing is published of, window.output_reserve (16384 by default, Fallback reply cap in Settings › General › Advanced), but a context too small for the window and that reply gives it no more than a quarter, so a small model keeps most of its context for the conversation. A remote reasoning model whose thinking needs more room sets its own; a chat-completions server that only knows the older field takes output_field = "max_tokens":

[models.reasoner]
provider = "openai"
url = "https://api.example.com/v1"
model = "big-reasoner"
credential = "example-key"
max_output = 65536
output_field = "max_tokens"       # only for servers without max_completion_tokens

A reply that reaches the cap is noted in the log; the default loop, the planner and sub-agents ask again, telling the model to be briefer, at most twice (ask-model, docs/scheme.md §9). On the SWE-bench dev set, 99.8% of Qwen replies stayed under 8192 tokens and the longest was about 17k, a thinking burst.

Any model may also set its sampling: temperature (0 to 2) and seed. Unset, the server decides. They go with every request to that model (in ollama's options, or as chat-completions fields) and are recorded with each llm.req, so nodwyn replay --window rebuilds the request exactly. temperature = 0 with a seed makes replies as repeatable as the server allows; many servers are still not bit-for-bit deterministic, especially across hardware or batching.

[control]
provider = "ollama"
url = "http://localhost:11434"
model = "qwen3:8b"
temperature = 0
seed = 42

Images. Files are attached from the console's composer (the browser view, "Attaching") or with :attach PATH in the TUI (the TUI). An image can be attached when the conversation's model takes images. Ollama says whether a model does (ollama show MODEL lists vision among its capabilities), and nodwyn asks it once per session. A chat-completions server does not say, so for a model on one, its entry's answer wins (Settings › Models › Takes images); unset, it is what its provider publishes ("Choosing models" above), and a model nothing is published of is taken to read images:

[models.text-only]
server = "openrouter"
model = "some-text-model"
vision = false                    # it reads no images; openai only

vision = true says a model reads images that its provider's published description says it does not.

A model that cannot read one refuses that request, and the turn ends saying the model may not take images and what to do: choose another model for the conversation, and say No for this one. What you wrote stays in the conversation.

Attaching an image (PNG, JPEG, GIF or WebP, decided by its content; at most 10 MB and 8000 pixels a side) asks a security confirm of its own. It names the file and the control model's host, says whether that host is this machine or another one, and says that the image cannot be redacted: whatever it shows reaches the model as it is, and nothing in it is read as text. --yolo never answers it yes, and --no-confirm, a judged run and an unattended run decline it. The log keeps the image's type, size, digest, dimensions and host, never its pixels; the copy is in .nodwyn/attachments/. An image goes only to the host it was confirmed for: a request to another configured model, at another host, says the image is not for it, and the judge, the planner and sub-agents never get it. The window counts an image as about 85 tokens plus 170 per 512-pixel tile once it is scaled to fit 2048×2048 and a shorter side of 768 (a 1200×800 screenshot is about 1,105). If its stored copy changes or is deleted, later requests say so instead of sending it, which is also how to take an image out of a conversation.

The console's Settings page writes [control] for you: which model is the default and the effort it starts at. The model's settings are its entry's, set in its form under Models; saving the default model moves any it had in [control] (from an older console, or written by hand) to its entry, filling only what the entry leaves unset. Choosing another default keeps the one it replaces in the list with its settings.

The line-mode nodwyn refuses to start without it, printing the table to add, and checks at start that an Ollama control server answers. A daemon session (console, TUI, runs) opens without it, to replay or evaluate; its first model request fails with that table, and is not retried. A key that is missing or not bound fails the same way.

Ingress redaction and the remaining boundaries

P7 uses the shared internal/redact controller for user text, tool results, planner requests, log payloads, and frontend events. Recognized credentials become [REDACTED <class>]; redact.event records the source event sequence, class, and offsets within the decoded string, never the removed value. A credential is recognised where punctuation joins it to the text around it, too: at the end of a sentence (The key is [REDACTED aws-access-key].), in a path or a URL, or between + signs, and the guard checks it there as it would one standing alone (ADR 25). JSON strings and nested tool arguments are decoded before scanning. The mandatory log pass runs after custom filters. Egress L1 allowlists do not turn off ingress redaction.

The log is the context ingress: Session.Record appends the writer's returned event to the live history, and window.Build derives messages from that history. The planner's requests are model turns like any other, in a narrowed context, so they are built from the same redacted history. CI checks the history writer.

Model content and thinking are buffered until the response completes, then redacted and displayed. Scanning separate token chunks would miss split keys. User-origin Scheme still evaluates the original source; :eval results are redacted for display. The standalone eval and repl commands are interpreter tools, outside the conversational context boundary.

Paths remain pinned; credential-shaped files stay denied; child environments remain allowlisted; shell commands still require approval. Recognized secret shapes are covered, not arbitrary passwords, encodings, or confidential prose. Unknown entropy remains a residual for P8's optional L3 classifier.

What the model reads is redacted, so it cannot see or copy a credential-shaped text, a fake one in a test fixture included: where a file holds one it reads [REDACTED anthropic-key]. It can still edit the line: fs-edit matches in the text the model reads, so the placeholder in old matches whatever the file holds, and in new it keeps it, in the same order, or leaves it out to drop it (ADR 43). The credential stays in the file, never reaches the model or the log, and keeping it asks nothing, since the edit writes nothing credential-shaped that was not there. A credential the model writes still asks you, as a security confirm that no standing approval waives. It cannot copy or move a credential, or write the text [REDACTED anthropic-key] over one.

Building nodwyn with nodwyn

  1. nodwyn-stable in this repository; :tools should list go-test, grep-repo, read-adr, git-log, … from dev/ (and the builtin git-status and git-diff).
  2. Give it small, explicit tasks: name the file, the pattern to follow, the test to add, and ask it to run go-test on the package. A 27B model does well with a paragraph and badly with a page.
  3. Review every diff at the confirm. fs-edit diffs are small; an fs-write of a whole existing file deserves suspicion.
  4. When it gets in the way, hand-edit, and add one line to docs/friction.md. That file decides what gets built next.
  5. make accept before committing; you commit — there is no commit tool.
  6. make promote when a phase's acceptance is green.

When a request to a model fails

A model request that fails is sent again up to two more times, so a model server that drops one request under load, or that answers a sample it cannot parse, does not end the turn. The first retry waits two seconds and the second four. When the server says how long to wait (a Retry-After header, in seconds, in milliseconds or as a date), that wait is used instead if it is longer, up to one minute: a rate limit that asks for more than a minute ends the turn at once, saying so, since a turn that sits silent for longer looks hung. A turn that is waiting says model request failed, retrying (1/2) in the conversation and the log, so a stall of up to a minute on a 429 is the server's own wait, not a hang.

Some failures are not retried, because sending the same request again fails the same way: a refusal with status 400, 401, 402, 403, 404 or 422 (a wrong key, a model the provider does not have, an account out of credit, a field the server does not take; the turn ends with what to change), a refusal that says the request is longer than the model's context, whatever its status, a redirect, and a key that is not bound for the host or is locked. Everything else is (a timeout, a connection refused, reset or closed before an answer, 429, 529 and the 5xx statuses), in both profiles and with every provider.

A relay such as OpenRouter passes on the failure of the provider it sent the request to as {"error":{"message":"Provider returned error",...}}, with that provider's status. Such a 404 is retried like a 5xx: it is that provider failing, not the model's id being wrong (since 2026-10-17, when an unattended session ended on one, among thousands of requests to the same model that were answered). If every try fails, the turn ends saying the provider failed rather than that the server has no such model. A relayed 400, 401, 402, 403 or 422 is still not retried.

Each attempt has its own loop.request_timeout, so a request that never answers takes up to three times it before the turn ends. A wait for a retry ends when the turn does: a cancel, or a run's or turn's time limit, stops it, and nothing is sent after that. The retries are not configurable.

When something is wrong

Symptom Look at
no control model is configured add the [control] table it prints to config.toml ("Choosing models")
control model … unreachable is the [control] URL right and the server up? For Ollama, curl <url>/api/version
first turn takes 10–60 s another client holds the model server's slot, or the prefix is cold; later turns are sub-second
every request takes 30–50 s the prefix cache is missing: check :log for llm.req — a trim (elided count jumped) or a new user turn re-prefills once; if it is every request, another client is evicting the server's slot between your requests (a single-slot Ollama, for example)
the model loops on a denied call, or keeps failing to edit one file the progress guard stops repeated denials, and one tool failing on one file in six of the last sixteen responses; inspect the log before continuing. A task's own limits bound each turn when set: --max-tool-calls N, or the task's panel
hard stop: tool_calls budget reached the turn reached the task's tool-call limit; the log has everything; rephrase smaller, or raise or remove the limit on the task's panel
a tool call errored :log 20 — the tool.result line has the exact error the model saw
a confirm never appeared but a file changed :allow — a standing approval covered it; the guard.decision line names which
a task is missing from every list its log cannot be read (empty after a crash, cut short, written by a newer nodwyn, or copied under another name): the other tasks are listed without it, and nodwyn sessions names it on stderr

Local daemon

nodwyn daemon starts the U2 session server in the foreground; it opens a keychain without a passphrase at once, and asks for one at the terminal otherwise. Use nodwyn daemon --no-keychain to skip credential-store startup, and --socket /absolute/private/directory/sock to override its socket path. A daemon that was killed, or lost power, leaves its socket behind; the next nodwyn daemon finds nothing answering on it, removes it and starts, so a service manager can restart the daemon. A socket that still answers is never replaced: the second daemon refuses to start. The protocol supports session lifecycle, send/abort, subscriptions, paged log catch-up, answering prompts (session.answer, session.secret, session.approval.create, session.binding.create), runs, and what the console needs besides: model and keychain setup, credentials, workspaces, attachments, file versions and MCP servers. See the protocol guide for the handshake, methods, permissions and reconnect semantics. nodwyn app starts one itself when none is running (the app).

A daemon that nodwyn app, nodwyn tui or line mode started stops when its window or terminal closes, and with it the scheduled runs. To keep it running, have it start at login: nodwyn startup on (or Settings › General › While you're away › Start at login) writes a systemd user unit (~/.config/systemd/user/nodwyn.service, wanted by default.target) on Linux, or a launchd agent (~/Library/LaunchAgents/nodwyn.daemon.plist) on macOS, which runs this nodwyn's daemon with the PATH and XDG homes it had then. It starts at your next login, and again if it fails; nodwyn app then uses it, and closing the window leaves it running. On a Linux computer nobody logs in to, such as a server, loginctl enable-linger $USER has systemd start your user services, this one included, at boot. nodwyn startup says whether it is on and which nodwyn it runs; nodwyn startup off removes it. A file at that place nodwyn did not write is left alone, and a go run build is refused, since it is gone when it exits. A daemon started at login has no terminal to ask for the keychain's passphrase: it starts locked, Home says when that holds back a scheduled run, and unlocking there (or in Settings) lets the runs go on. A daemon that finds the socket a crash left, which nobody listens on, removes it and listens.

With [notify] desktop = true (Settings › General › While you're away › Desktop notices from the daemon; off until you turn it on) the daemon shows a desktop notification when a task comes to need your answer and no nodwyn window on this computer is open (a window shows its own, Settings › This browser), and once when a locked keychain stops a scheduled run, until it is unlocked. Their words are fixed ("nodwyn needs your answer", "nodwyn can't run a scheduled task"), never the task's. It uses notify-send on Linux and osascript on macOS.

A server you reach from other devices

nodwyn can run on a computer nobody sits at, such as a home server, and be used from a laptop and a phone (ADR 30, amended 2026-10-17). Run the daemon and the console as services of your own account, with lingering on so they outlive your login (the daemon's socket is in $XDG_RUNTIME_DIR, which goes at logout without it):

loginctl enable-linger "$USER"
nodwyn startup on                   # the daemon at login: a user service, locked until you unlock it
nodwyn ui --no-open --listen 127.0.0.1:7777 --name https://nodwyn.example.net

Give the console https in one of three ways: your own proxy that ends TLS in front of it (Caddy, nginx with certbot, a VPN's https), named with --name https://NAME; --tls-cert and --tls-key with a certificate from any ACME client or a CA of your own, which the console reads again when they are renewed; or, on a server the internet reaches on port 443 with no proxy in front, --acme nodwyn.example.net --acme-accept-terms, which has the console ask Let's Encrypt for the certificate itself and renew it (the service needs permission to bind port 443, AmbientCapabilities=CAP_NET_BIND_SERVICE in a unit; --acme-directory names another CA, such as its staging one, to try it first). Over plain http devices pair and answer, but the person cannot sign in with a passkey, so setup stays on the server.

Pairing takes no code. Once, on the server, nodwyn devices invite prints an invitation; open the console's address on your laptop or phone, name the device and enter it, and Connect and make a passkey. Your other devices sign in with that passkey (Sign in with a passkey), with no invitation, or are invited from Settings › Keys & devices of a device signed in with it. An invitation made in a browser on the server itself makes no passkey, so the first passkey always comes from nodwyn devices invite: making the person stays with the terminal and the passkey. That browser's own sign-in is kept by the page, in its storage for the console's address, and no other server on the computer's loopback address is sent it. A device stays signed in for as long as it is used, and 90 days without use sign it out; restarting or upgrading nodwyn signs nobody out. A device signed in with the passkey makes the choices the computer makes (a task's model, judge, limits, schedule, unlocking the keychain), and asks for a touch of the passkey before it changes setup, once for a sitting of changes. The console's journal says each sign-in, invitation and setup change a device made. After a restart, unlock the keychain from any device signed in with the passkey: a schedule due meanwhile waits for it and then runs once, late. nodwyn devices lists and revokes devices on the server; Settings removes a lost phone's passkey. A provider's OAuth sign-in works from your laptop too: the provider's page ends on an address that does not load there, and pasting that address into the card nodwyn shows (Finish) completes it. nodwyn trust and the command line's credential add --oauth2 still need the server; a project's startup files are reviewed and trusted from Workspaces on a device signed in with the passkey (Review…), which shows both files whole and trusts the revision it showed (the browser view, "Other devices").

To have the keychain open by itself after a restart, give the daemon's unit its passphrase as a systemd credential (opt-in; ADR 5, amended 2026-10-17):

# the passphrase, typed once; the file is useless on another computer
systemd-ask-password -n | sudo systemd-creds encrypt --with-key=host+tpm2 --name=nodwyn-keychain - /etc/nodwyn/keychain.cred
# in the daemon's unit, which must then be a system service (User=you):
#   LoadCredentialEncrypted=nodwyn-keychain:/etc/nodwyn/keychain.cred

A user service (nodwyn startup on) has no TPM: systemd-creds --user encrypt --name=nodwyn-keychain - ~/.config/nodwyn/keychain.cred and a drop-in (systemctl --user edit nodwyn.service) with LoadCredentialEncrypted=nodwyn-keychain:%h/.config/nodwyn/keychain.cred work with a key kept in your home, which keeps the passphrase out of plain text but not out of a backup of your home. Know what this gives: the keychain's file is protected at rest (a copy, a backup, a disk taken out), but while nodwyn runs, any process of your account can read the decrypted passphrase, so against code running as you it is as open as a keychain with no passphrase. A task's own file tools refuse the credentials directory; a command you approved does not. A passphrase that does not open the keychain leaves the daemon locked, with a line in its journal, and you unlock it from a device as before.

Several hosts in one window

With nodwyn on your laptop and on a server that is always on, the laptop's window can show both: the server's tasks beside the laptop's, what either waits for under one Needs you, and a new task started on whichever should run it (ADR 54). The laptop links to the server once:

  1. On the server, make an invitation: nodwyn devices invite in its terminal, or Invite a device in Settings › Keys & devices of its console. One made by your device signed in there with your passkey lets the laptop make your choices for the server's tasks (a task's model, limits and schedule, unlocking its keys); one from its terminal lets it start tasks and answer them.
  2. On the laptop, open Hosts in the sidebar and Add a host: the server's address (https://nodwyn.example.net), the laptop's name there (what the server records your answers with, such as laptop), what to call the server here (such as workshop) and the invitation, then Link. The laptop keeps the link's token in its keychain, which has to be open: the form offers to unlock or create it.

The sidebar then groups the tasks under each host, with its state; each host starts folded, and its heading opens its folders (this browser remembers which you opened). Home's Runs on chooses the host a new task starts on, with that host's folders and model; the next task starts on the host chosen last. A task on the server says so in its heading; you answer it, message it and give it files from the laptop, and the server records the answers as the laptop's. A notice of the server's task opens it there.

The server decides what the laptop may do. A laptop linked with an invitation from your passkey's device changes the server's settings from its own window: Settings on the server's card in Hosts, or the server's chip at the top of Settings, opens its General and Models tabs, which are saved on the server. Its limits, its default model and the models of its list change there: on the Models tab Add a model on NAME (on a provider the server already has), Edit and Remove act on the server's list, except that its default model has no Remove. Providers (their addresses and keys) are shown but have no Add, Edit or Remove there. What its tasks may do without asking (approvals, the policy, the judge) and where their content goes (providers) are shown or left out and are changed in its own console, after a touch of your passkey. To refuse the rest, start the server's console with nodwyn ui --no-link-settings (also taken by nodwyn app). The rest of its setup (keys, folders, connections, devices) is changed in its own console: Open its console on Hosts.

A laptop that only answers on the server, its link made with an invitation from the server's terminal or at an address where a browser cannot use a passkey (plain http other than a localhost name, or an IP address), chooses no model there: Home shows the server's default model with Why can't I choose?, and a task's model says so too. Both lead to the server's Settings, which list the steps: sign in to the server's console with your passkey, at its https address, make an invitation in its Settings › Keys & devices, then unlink the server on the laptop and add it again with that invitation. Revoking the laptop there ends the link, and Hosts says the server refuses it; Unlink on the laptop forgets the link and asks the server to forget the laptop too, saying when it could not. The laptop reaches the server through its own nodwyn, so the server's tasks show while the laptop's nodwyn runs. The TUI and line mode show this computer's tasks only. A phone that reaches every host through the server is not built yet.

Downloadable builds and books

Download portable binaries and both books from the repository's GitHub Releases page. Releases begin at v0.1.0, identify their exact source and include SHA-256 checksums. Publication requires a manual workflow run on main with the version supplied; pushes only run checks. Linux and macOS archives cover amd64 and arm64; each includes the Linux egress proxy used by MCP containers. Portable builds open the browser for nodwyn app; macOS builds are unsigned and not notarised. Only Linux amd64 is executed in CI. See the release guide for downloads, verification and publication. Local make promote remains separate.

Git tracks the books' LaTeX sources and generated source catalogue; the PDFs are release attachments. make book and make expert produce ignored local copies. After the PDF history cleanup, use a fresh clone for new work; old clones retain the old objects and commit identities. See the release guide for the migration details.

Letting the model search a folder's earlier tasks

A task's model searches its own conversation (index-search with scope "conversation", and recall) whatever you choose. Searching the folder's other tasks is off until you turn it on for that folder: on Workspaces, Edit (or Name it) beside the folder, tick Let the model search the earlier tasks here, and Save. Its tasks may then use index-search with scope "earlier", which ranks what was asked in the folder's other tasks, the model's replies and what its tools returned, and recall with a task's id, which brings one of them back; a git repository's worktrees count as the repository. Archived tasks are included and marked; deleted ones are gone. The choice is kept with the folder's name in workspaces.toml on this computer, never in the folder, and only this computer can change it, not a paired device. It counts from a task's next call, open tasks included; untick it to turn it off. Off, the model is told it is off and where you would turn it on, so it can ask you. In the TUI, :earlier on and :earlier off do the same for the task's folder, and :earlier says which it is.

The repository's index shows on the same page: a folder's row says how many files it holds, its size and when it last changed, with Forget the index, and a task's Workspace tab says the same for its folder (in the TUI, :index and :index forget). Forgetting loses nothing but the time the next search takes to build it again.

Forgetting a folder in the console

On Workspaces, choose Forget beside a named workspace or a folder listed under Folders tasks used, then confirm. This removes its saved name and mounts and keeps it out of the workspace list and Home's folder suggestions, even after a restart. It does not delete the folder, any files, or task history; tasks already open keep their loaded mounts. If it was the folder picked last for a new task, that pick is cleared. Keep cancels the confirmation. Using the folder again brings it back: Add a workspace, Home's Add a folder, or starting or resuming a task there, from the console, a paired device or a terminal. Forget is available only on the computer running nodwyn. A paired device cannot forget a folder, but can still choose a forgotten one by typing its path.

nodwyn on a phone

A phone reaches the console that nodwyn ui --listen (or nodwyn app --listen) serves to other devices, and pairs with the printed code and a name (the browser view, "Other devices"). Served over https (--tls-cert, or a proxy that ends TLS and --name https://NAME), the console is also an app: Safari's Share › Add to Home Screen on an iPhone, or Chrome's Install app on Android, puts nodwyn on the Home Screen, where it opens full screen (ADR 52). It is the same console from the same server, so there is nothing to download or update but nodwyn itself. An iPhone keeps a Home Screen app's storage apart from Safari's: pair again in the app if it asks.

Turn on Settings › This browser › Notifications in the app to be told when a task waits for an answer. The notice says only "nodwyn needs your answer", and tapping it opens the task. It comes while the app is open or in the background; a notice while the app is closed (Web Push) is not built. Safari shows notifications only to an app on the Home Screen, and Settings says so on an iPhone. Over plain http to another address the console works in a phone's browser but neither installs as an app nor notifies.

The Wails desktop window

make build-webview builds the Wails native window on Linux or macOS; make app builds the universal macOS bundle and bin/Nodwyn-macos-universal.zip. The webview build tag remains the switch for nodwyn app to use a window. The console, model setup, keychain and tasks behave as in the browser; folder selection uses Wails' native dialog. Closing the window stops tasks only when this app owns their daemon. Wails v3 is pinned to a beta release; see the desktop build guide for dependencies and its native test. Portable archives continue to open a browser. A Windows host port and native desktop release runners are still needed before publishing those apps.

Project paths and existing data

Projects have one identity through symlinks: /tmp/x and /private/tmp/x on macOS share workspace mounts, MCP selection, notes and session listings. A linked git worktree remains a distinct checkout of its recorded main project. Old session logs and kept-version indexes are read without rewriting them. Notes under an old path hash are migrated from the requested spelling or earlier session records (read once for each folder, a .aliases-checked marker in its notes directory saying so), leaving a notes.md.before-path-migration backup. Conflicting note contents or duplicate registered aliases require manual reconciliation; nodwyn does not merge permissions or discard one copy. See ADR 45.

Managed children's app service

The separate nodwyn-kids --profile /etc/nodwyn-kids/profile.json deployment serves the restricted My projects interface described in ADR 56. It is not an option that makes an ordinary desktop daemon a child sandbox. Open its private HTTPS hostname, connect each browser with its own one-use child invitation, create a project and describe the app or change. See app opens its separate private preview origin; Your website opens the last checked public publication. Saved means the edit is durable; it does not mean publication has succeeded. An operator path whose parent is a directory alias, such as macOS's /var, keeps the same journalled writes as its physical spelling. Links within a project are still refused.

Only the operator bootstraps a parent invitation. Register a passkey separately on each child hostname; this does not replace another host's registration. Parent controls require a fresh passkey touch and can invite/revoke child browsers. Provider, policy and lower budget configuration remain operator-owned. At the daily allowance, building resumes on the next Los Angeles civil day. Public publication waits for the explicit operator policy (with family phrase filtering when selected) and never runs app code. Neither a tripwire nor CSP can identify every kind of personal content; the parent should review what children publish.

An operator may open a one-use plain child invitation in that child's own profile with /#child-invite=CODE&device=NAME. The managed page removes the fragment before sending requests, consumes it only for an unpaired browser, and never replaces an existing pairing. Use no parent invitation for this.

For operator recovery, nodwyn-kids --profile PATH --revoke NAME removes one named device. Use parent passkey controls for routine administration.

Desktop shortcut icon

The native app uses the same cream n and blue dot as the console. On macOS, pin Nodwyn.app from Applications. On Linux, run python3 scripts/install-desktop.py /absolute/path/to/nodwyn from the source checkout, then pin nodwyn from the application menu. This installs the launcher and icons for your account; see the app for package paths and options.

Office extraction cancellation

Office document extraction honours cancellation and request deadlines before reading and between XML tokens. A cancelled extraction returns an error rather than a successful partial document; spreadsheet cell references and the workbook/deck ordering remain authoritative.

Validation certificates in this repository

Validation in this repository follows a fingerprint of tracked and non-ignored untracked file contents, executable bits and link targets, rather than Git status or commit IDs. Only exit zero with a PASS summary and the same known fingerprint before and after the check certifies the contents. This applies to the validate tool, shell-exec, and background shell-start/shell-read checks in both profiles. A commit or content-preserving rebase keeps that certificate; failed checks, edits during or after checks, and unknown fingerprints do not. Credential-denied files are omitted, symlinks are not followed, and a listing over 16 MiB or file contents over 256 MiB leaves the state unknown. Deleting non-ignored generated files changes this strict content fingerprint too.