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 undoor 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.Nor anything else — no; the model is tolddenied: not confirmed by the userand 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 (withwrites = "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) tobindings.toml. It is offered only withpolicy.unbound = "confirm"; by default a keyed request no binding covers is denied outright.- Read the reason lines.
command line names a path outside the repositoryanduses the network tool curlare the guard telling you what aywould do. A path anywhere in abash -cline 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 yourPATHdoes. What the command does with a path counts too: listing a folder anywhere (ls,findwithout-execor-delete,which), reading orsource-ing what is in such an environment (conda's activation script) and aforloop's list ask nothing; reading a file elsewhere, writing or running something outside, andcdoutside 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.goandcredentials.jsare 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, … andTMPDIR), 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 --unattendedwhether you say so or not, and--judgeis 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 atshell-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 hasTMPDIRset 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; oropenaiathttps://api.openai.com/v1with an API key, whose Decisions API answers them withgpt-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,--judgeand an unattended run never upload. - Always allow (
allow): uploads go without asking,--yoloand 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 asopenaiorollama; 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,POSTfor the model andGETfor its model list, as a bearer header (Anthropic's: thex-api-keyheader), never intoconfig.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.tomlin 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
nodwyn-stablein this repository;:toolsshould listgo-test,grep-repo,read-adr,git-log, … fromdev/(and the builtingit-statusandgit-diff).- Give it small, explicit tasks: name the file, the pattern to follow, the
test to add, and ask it to run
go-teston the package. A 27B model does well with a paragraph and badly with a page. - Review every diff at the confirm.
fs-editdiffs are small; anfs-writeof a whole existing file deserves suspicion. - When it gets in the way, hand-edit, and add one line to docs/friction.md. That file decides what gets built next.
make acceptbefore committing; you commit — there is no commit tool.make promotewhen 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:
- On the server, make an invitation:
nodwyn devices invitein 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. - 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 aslaptop), what to call the server here (such asworkshop) 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.