nano-coder 0.5.0

A 6MB coding agent for the terminal and for agent fleets: multi-provider, ACP, resumable sessions, plans.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
# nano-coder

**A 6MB coding agent. Run a fleet on your laptop.**

nano-coder is a coding agent for the terminal, written in Rust. It uses about 6MB of resident memory, where Node-based agent CLIs take 150–660MB, so you can run dense fleets of agent workers on one machine. It runs interactively, or headless over ACP as a worker for [nano-workforce](https://github.com/nanobpm/nano-workforce) via c8ctl-nano.

## Install

```sh
npm install -g @nanobpm/nano-coder   # prebuilt binaries for macOS and Linux (x64, arm64)
cargo install nano-coder             # or build from source
```

## Features

- **Interactive CLI**: REPL-based interface for conversing with the agent
- **ACP Protocol**: JSON-RPC 2.0 over stdio for headless orchestration (c8ctl-nano compatible)
- **Providers**: OpenAI-compatible and Anthropic endpoints (remote or local), selected per model as `provider/model`, with retry/backoff
- **Tool Calling**: Agent can invoke registered tools during conversation, including a real `bash` tool with timeouts and bounded output
- **Sessions**: Append-only JSONL session logs with resume and input-ID deduplication
- **Lifecycle Hooks**: 6 hook events for observing/intercepting agent behavior
- **Configuration**: TOML-based config file at `~/.config/nano-coder/config.toml`
- **Commands**: `/help`, `/compact`, `/context`, `/verbosity`, `/settings`, `/tools`, `/skills`, `/restart`, `/exit`
- **Streaming output**: answers stream in, thinking shows collapsed (Ctrl-O expands it), tool calls show inline
- **Status line** pinned to the bottom of the terminal, plus manual and automatic context compaction
- **Task plans**: `plan_*` tools keep a plan with notes outside the conversation, so long tasks survive compaction, resume and a change of worker
- **Project instructions**: `AGENTS.md` (or `CLAUDE.md`, `.github/copilot-instructions.md`) from the repository is added to the system prompt
- **Safety**: built-in guards block destructive commands (`rm -rf /`, `DROP DATABASE`, force-pushing `main`, ...), user allow/deny rules, and an optional OS sandbox (Seatbelt on macOS, Landlock on Linux)
- **Skills**: `SKILL.md` folders from the repository, `~/.agents/skills`, and an spm `ai.lock`, loaded on demand with `load_skill`

## Two Execution Modes

### Interactive CLI Mode (default)

```bash
cargo run
```

Starts the interactive REPL where you can chat with the agent and use slash commands.

While a turn is running you can type a message and press Enter to **steer** it: the
message joins the conversation before the next model call (even if the model had
just produced its final answer, the turn continues with the steer). **Esc Esc** (twice within a second) or **Ctrl-C** cancels
the running turn, killing any running bash command; a second Ctrl-C at the prompt exits.
With piped (non-terminal) stdin, lines read during a turn are queued as later prompts.

### ACP Headless Mode (--acp flag)

```bash
cargo run -- --acp
```

Speaks the Agent Communication Protocol (ACP) over stdio using newline-delimited JSON-RPC 2.0 messages. Compatible with c8ctl-nano's `spawnCaptureAcp` executor.

**Protocol methods supported:**
- `initialize` → returns protocol version and capabilities (`loadSession` when persistence is on)
- `session/new` → starts a fresh conversation (and session log), returns sessionId.
  `params.cwd` (absolute, existing directory) becomes the working directory for tools
- `session/load` → `{ "sessionId": ... }` resumes a persisted session, first replaying the
  conversation as `session/update` notifications (as the ACP spec requires)
- `session/prompt` → processes a prompt, supports tool calls. One session is active per
  process; a `sessionId` other than the active one is rejected (use `session/load` to switch). An optional `messageId`
  (or `_meta.inputId`) makes it idempotent: redelivering an ID that already completed
  returns the recorded response without calling the model again
- `session/cancel` → cancels the current turn (normally sent as a notification, which
  gets no reply). The running model call is abandoned, a running bash command is
  killed, remaining tool calls are recorded as cancelled, and the turn's
  `session/prompt` resolves with `stopReason: "cancelled"`

**Steering.** A `session/prompt` for the active session that arrives while a turn is
running is a steer, not a queued prompt. It is added as a user message before the next
model call (echoed as a `user_message_chunk`) and answered when the turn ends with
`{ "stopReason": ..., "_meta": { "steered": true, "inputOf": <main prompt id> } }`.
A steer that arrives too late to join the turn runs as an ordinary prompt, or is
answered with `stopReason: "cancelled"` if the turn was cancelled. Slash-command prompts
and other requests that arrive mid-turn are handled after the turn ends. Everything
handled after the turn, late steers included, runs in the order the client sent it.
`stopReason` is `end_turn`, `cancelled`, or `max_turn_requests`.

**Streaming updates.** During a turn the harness sends `session/update` notifications:
`agent_message_chunk` (with a `messageId`), `tool_call` (`toolCallId`, `title` = tool name,
`kind`, `rawInput`) and `tool_call_update` (`completed`/`failed`, `rawOutput`). These are the
events c8ctl-nano's transcript producer records into the engine's AgentInstance history.

**Project instructions.** `session/new` and `session/load` results include
`_meta.projectInstructions`: the absolute paths of the instruction files that were added to
the system prompt for the session's `cwd` (see [Project Instructions](#project-instructions)).
`_meta.skills` lists the skills found for it, and `_meta.skillWarnings` (when present) explains
any that could not be loaded (see [Skills](#skills)).

**Plans.** Each plan change sends a `plan` update: ACP `entries` (`content`, `priority`,
`status`) plus `_meta.plan`, the full plan with ids, notes and dependencies. To continue a job
on another worker, pass the last `_meta.plan` as `session/new` `params._meta.plan` (bare ACP
`entries` are accepted too). The new session starts with that plan, the `session/new` result
echoes it in `_meta.plan`, and the model is told about it with the first prompt (unless
`_meta.planInPrompt: true` says the client already put the plan in the prompt). `initialize`
advertises this as `agentCapabilities._meta.planSeed`. See [Task Plans](#task-plans).

**Outcomes.** When the model calls `report_outcome`, the `session/prompt` result carries
`_meta.outcome`: `{"status": "completed" | "blocked", "summary": "..."}`. A client can use it
instead of guessing from the stop reason: `blocked` means the model needs help (an
escalation). Redelivering the input returns the same outcome. See [Outcomes](#outcomes).

**Slash commands work via ACP too:**
- `/compact [focus]` - summarizes the conversation; the result has `compacted`, `before`,
  `after`, `tokensBefore`, `tokensAfter`, `summarized` and `fallback`
- `/settings` - returns current settings as JSON
- `/tools` - lists registered tools
- `/plan` - returns `plan` (JSON) and `text` (the rendered plan)
- `/providers` - lists providers
- `/model provider/model` - switches model

## Architecture

```
src/
├── main.rs      # Single binary entry point (interactive + ACP modes)
├── agent.rs     # Agent core: conversation management, tool execution loop
├── acp.rs       # ACP JSON-RPC protocol handler
├── hooks.rs     # Lifecycle hook registry and event system
├── tools.rs     # Tool registration and dispatch system
├── llm.rs       # Provider-neutral messages and the async LLMClient trait
├── providers/   # Provider registry + presets, HTTP transport with retries
│   ├── openai.rs    # OpenAI Chat Completions (and compatible servers)
│   ├── anthropic.rs # Anthropic Messages API
│   ├── github_copilot.rs # UNOFFICIAL Copilot-subscription provider
│   ├── retry.rs     # Retry classification and backoff
│   └── mock.rs      # Offline scripted client
├── bash.rs      # bash tool: timeout, file capture, bounded output
├── files.rs     # read_file / write_file / edit_file tools
├── shell.rs     # Bash parser used by the permission checks
├── permissions.rs # Allow/deny rules and built-in guards against destructive commands
├── sandbox.rs   # Seatbelt (macOS) / Landlock (Linux) sandbox for shell commands
├── output.rs    # Head/tail output bounding, spilling long output to disk
├── session.rs   # Versioned append-only JSONL session log
├── context.rs   # Token accounting, context-window heuristics, overflow detection
├── status.rs    # Bottom-of-terminal status line
├── ui.rs        # Verbosity levels and the streaming output renderer
├── lineedit.rs  # Key-by-key prompt input (Ctrl-O, steering on the status line)
├── instructions.rs # AGENTS.md / CLAUDE.md discovery for the system prompt
├── plan.rs      # Task plan and the plan_add / plan_update / plan_show tools
├── goal.rs      # report_outcome tool (completed / blocked)
├── commands.rs  # Slash-command table for /help and the as-you-type menu
├── skills.rs    # SKILL.md discovery, ai.lock sources and the load_skill tool
├── reminders.rs # <system-reminder> notes appended to tool results
├── settings.rs  # /settings menu and config-file writer
└── config.rs    # Configuration file loading and management
```

Mode selection: `--acp` flag enables ACP headless mode; default is interactive CLI.

## Lifecycle Hooks

The harness exposes 6 lifecycle hook events:

| Hook | When it fires |
|------|---------------|
| `before_context_load` | Before processing user input |
| `after_context_load` | After adding user message to conversation |
| `before_llm_send` | Before sending messages to LLM |
| `after_llm_response` | After receiving LLM response |
| `before_tool_call` | Before executing a tool |
| `after_tool_call` | After tool execution completes |

## Built-in Tools

- `get_time` - Get current date and time
- `echo` - Echo back input text
- `bash` - Run `bash -c <command>` (stdin closed, own process group). Arguments:
  `command`, optional `timeout_seconds` (default `bash_timeout_secs`, 600) and
  `max_output_length` (default 40,000, max 1,000,000 characters each for stdout and stderr).
  Returns stdout, then `Stderr:` and `Exit code: N` when relevant, or `(no output)`.
  Long output keeps its head and tail with `...N bytes truncated; complete output in <path>...`;
  the full capture stays in that file.
- `read_file` - Numbered lines of a text file; `path`, optional `offset` (1-based) and `limit`
  (default 2000 lines). Refuses binary files.
- `write_file` - Create or overwrite a file (`path`, `content`), creating parent directories.
- `edit_file` - Replace exact text (`path`, `old_string`, `new_string`, optional `replace_all`).
  Fails unless `old_string` matches exactly once (or `replace_all` is set).
- `plan_add`, `plan_update`, `plan_show` - The agent's task plan (see [Task Plans]#task-plans).
- `report_outcome` - Report the task `completed` or `blocked`, with a `summary`; ends the turn
  (see [Outcomes]#outcomes).
- `load_skill` - Return a skill's instructions and list its other files; `name`. Offered only
  when skills were found (see [Skills]#skills).

Any other tool's result longer than 40,000 characters is cut the same way as bash output,
with the whole result saved under the temp directory (`nano-coder-<pid>/tool-<id>-<name>.txt`)
and its path in the marker. `read_file` pages instead.

Relative paths resolve against the working directory (ACP `session/new` `cwd`). Writes are
atomic (temp file + rename). There is no permission prompt; every call is checked against
the [permission rules and sandbox](#permissions-and-sandbox) instead.

## Permissions and Sandbox

nano-coder never stops to ask for approval (it runs headless in agent fleets). Every tool call
is checked before it runs instead, and a blocked call returns an error telling the model to
stop and ask the user rather than work around the block.

**Order of checks:** `deny` rules, then `allow` rules, then the built-in guards. Deny always
wins. An allow rule approves a shell command only when *every* command in it matches, so
`Bash(git *)` does not approve `git status && rm -rf /`.

**Rules** name a tool and an optional pattern:

| Rule | Matches |
|---|---|
| `Bash(rm -rf *)` | a shell command; `*` matches anything, including spaces and `/` |
| `Bash(git push:*)` | `git push` alone or with any arguments |
| `Read(~/.ssh/**)` | `read_file` paths; `**` crosses directories, `*` does not |
| `Edit(**/.env)` / `Write(...)` | `write_file` and `edit_file` paths (relative to the working directory, or absolute) |
| `write_file`, `bash`, any tool name | every call to that tool |

**Shell commands are parsed, not pattern-matched as text.** The command line is split on `;`,
`&&`, `||`, `|`, `&`, newlines and parentheses; quotes are removed; `$(...)`, backticks and
`<(...)` are parsed as further commands. The guards and rules then see through assignments
(`FOO=1 cmd`), wrappers (`sudo`, `env`, `timeout`, `nice`, `xargs`, `nohup`, `command`, ...),
`bash -c '...'`, `eval`, `ssh host cmd`, `find -exec`, and here-documents fed to a shell. A command
that cannot be parsed, or a script computed at run time (`bash -c "$CMD"`,
`eval "$(curl ...)"`), is blocked.

**Built-in guards** (`builtin_rules = true`) block:

- recursive `rm` (and `mv`, `find -delete`, `chmod -R`/`chown -R`) on `/`, your home directory
  or its top-level folders, the working directory or its parents, top-level and system
  directories, and `.git`. `rm -rf *` counts as the working directory. An unset variable counts
  as empty, so `rm -rf "$DIR/"*` is blocked unless written `"${DIR:?}/"*`. Paths follow a `cd` and
  variable assignments earlier in the same command (`cd .. && rm -rf project` is blocked)
- `mkfs`, `fdisk`, `wipefs`, destructive `diskutil`, `dd of=/dev/...` and redirects to
  devices, fork bombs, `shutdown`/`reboot`
- destructive SQL (`DROP DATABASE|SCHEMA|TABLE`, `TRUNCATE`, `DELETE FROM` without `WHERE`,
  `ALTER TABLE ... DROP`, `FLUSHALL`, `dropDatabase()`) in a command that uses a database client
  (`psql`, `mysql`, `sqlite3`, `mongosh`, `redis-cli`, also via `docker exec`) or inline
  interpreter code (`python -c`), plus `dropdb`, `rails db:drop`, `prisma migrate reset`,
  `manage.py flush`
- `terraform destroy`, `pulumi destroy`, `kubectl delete namespace|--all`, `aws s3 rb`
- `git push --force` (or `+refspec`, `--all`, wildcard refspecs) to, or deleting, a protected
  branch, and `git push --mirror`

Add an allow rule for anything legitimate they block, e.g.
`allow = ["Bash(sqlite3 test.db *)"]`, or set `builtin_rules = false`.

**These checks catch mistakes, not adversaries.** A model can write a script and run it, and
nothing inspects that. The boundary is the OS sandbox, plus credentials: don't give the agent
production database URLs or broadly scoped tokens.

**Sandbox** (`--sandbox workspace`, off by default) runs each shell command under Seatbelt
(`sandbox-exec`) on macOS or Landlock on Linux (6.2+). Commands can read everywhere, but
write only to:

- `workspace`: the working directory, its git directories (including a worktree's shared
  one), temp directories, package-manager caches (`~/.cargo/registry`, `~/.npm`, `~/.cache`,
  `~/Library/Caches`, `~/.gradle`, `~/go/pkg/mod`, ...) and `writable` paths
- `read-only`: temp directories and `writable` paths

`write_file` and `edit_file` are held to the same directories. `network = false` blocks
outbound connections (macOS: except to localhost; Linux: all TCP, which needs Linux 6.7+).
If the sandbox is enabled but cannot be applied, commands fail instead of running
unsandboxed. When a sandboxed command fails with a permission error, the result tells the
model where it may write.

## Commands

Typing `/` at the prompt lists the commands under it, and each further character narrows the
list. Tab completes the command, or the part all matches share. Esc hides the list. The
list is built from the same table as `/help` (`src/commands.rs`).

- `/help` - Show available commands
- `/compact [focus]` - Summarize older messages with the current model, keeping the latest
  message. Optional text tells the summary what to focus on. Esc Esc or Ctrl-C cancels
- `/verbosity [quiet|normal|verbose|debug]` - Show or set how much is printed (see below)
- `/context` - Show context usage, window, session token totals, auto-compaction state and the loaded instruction files
- `/settings` - Interactive settings menu:
  - **Model**: pick a provider, then a model from its live model list (or type an ID)
  - **Add or edit a provider**: name, API kind (OpenAI-compatible, Anthropic, Copilot),
    base URL, key source (env var, shell command, or a literal key; the file is then
    written with mode 0600) and default model
  - temperature, max tokens, system prompt
  - **Context**: auto-compaction on/off, threshold, context-window override
  - **Verbosity**
  - **Save to config file**: writes only the keys you changed into the config file
    (`--config` or `~/.config/nano-coder/config.toml`), keeping comments and
    other settings. Leaving with unsaved changes asks whether to save
- `/tools` - List registered tools
- `/skills` - List the skills the agent can load, where each lives, and any loading warnings
- `/plan` - Show the agent's task plan with all notes
- `/model [provider/model]` - Show or switch the model (conversation is kept)
- `/providers` - List providers, endpoints and whether their API key is available
- `/session` - Show the session ID and log path
- `/restart` - Start a fresh session (clean context) without exiting
- `/exit` - Exit the agent

## Building and Running

```bash
cargo build --release
./target/release/nano-coder
```

Or run directly:

```bash
cargo run
cargo run -- --model anthropic/claude-sonnet-4-5
cargo run -- --model ollama/qwen2.5:1.5b
cargo run -- --resume sess-20260923T012518-7e7923f8
```

Flags: `--login github-copilot`, `--list-models PROVIDER`, `--acp`, `--model provider/model` (or `AGENTIC_HARNESS_MODEL`), `--resume SESSION_ID`,
`--config PATH`, `--verbosity LEVEL` (`-v`), `--sandbox off|workspace|read-only` (or `NANO_CODER_SANDBOX`),
`--allow RULE` and `--deny RULE` (repeatable; added to the config's rules).

## Configuration

Create `~/.config/nano-coder/config.toml` (every field is optional). Directories from before the rename (`agentic-harness`) are still used if the new ones don't exist:

```toml
model = "anthropic/claude-sonnet-4-5"   # provider/model
default_provider = "mock"               # used when the model has no known provider prefix
temperature = 0.7
max_tokens = 4096
max_iterations = 50                     # LLM calls per user input
system_prompt = "You are a helpful assistant with access to tools."
bash_timeout_secs = 600
persist_sessions = true
# session_dir = "/path/to/sessions"    # default: <platform data dir>/nano-coder/sessions
auto_compact = true                     # summarize automatically when the context fills up
auto_compact_threshold = 0.8            # fraction of the context window
# context_window = 128000               # override the window (providers can set it too)
verbosity = "normal"                    # quiet | normal | verbose | debug (or --verbosity)
timestamps = true                       # prefix CLI messages with the local time (HH:MM:SS)
project_instructions = true             # load AGENTS.md etc. (see Project Instructions)
project_instruction_files = ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md"]
plan_tools = true                       # offer the plan_* tools (see Task Plans)
outcome_tool = true                     # offer report_outcome (see Outcomes)
reminders = true                        # append <system-reminder> notes to tool results

[skills]                                # see Skills
enabled = true
dirs = [".agents/skills", ".github/skills", ".claude/skills"]   # relative to the git root
user_dirs = ["~/.agents/skills"]
ai_lock = true                          # load skills pinned in ai.lock
fetch = true                            # fetch ai.lock commits missing from the spm store
allowed_hosts = ["github.com"]          # hosts ai.lock entries may be fetched from ("*" = any)

[permissions]                           # see Permissions and Sandbox
builtin_rules = true                    # block destructive commands unless allowed
allow = []                              # e.g. ["Bash(sqlite3 test.db *)"]
deny = []                               # e.g. ["Bash(git push:*)", "Edit(**/.env)"]
protected_branches = ["main", "master", "trunk", "develop"]

[sandbox]
mode = "off"                            # off | workspace | read-only (or --sandbox)
writable = []                           # extra writable paths, e.g. ["~/.local/state/myapp"]
network = true                          # false blocks outbound connections
tool_caches = true                      # workspace mode: allow ~/.cargo/registry, ~/.npm, ~/.cache, ...
```

The default model is `gpt-4o-mini` on the `mock` provider, so the harness still works offline.

## Providers

A model is written as `provider/model`. The first path segment picks the provider if it
names one; otherwise the whole string is a model on `default_provider`. So
`openrouter/anthropic/claude-sonnet-4.5` sends `anthropic/claude-sonnet-4.5` to OpenRouter.

Built-in presets:

| Provider | Kind | Base URL | API key env |
|---|---|---|---|
| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |
| `anthropic` | anthropic | `https://api.anthropic.com/v1` | `ANTHROPIC_API_KEY` |
| `openrouter` | openai | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` |
| `fireworks` | openai | `https://api.fireworks.ai/inference/v1` | `FIREWORKS_API_KEY` |
| `groq` | openai | `https://api.groq.com/openai/v1` | `GROQ_API_KEY` |
| `together` | openai | `https://api.together.xyz/v1` | `TOGETHER_API_KEY` |
| `deepseek` | openai | `https://api.deepseek.com/v1` | `DEEPSEEK_API_KEY` |
| `kimi` | openai | `https://api.moonshot.ai/v1` | `MOONSHOT_API_KEY` |
| `mistral` | openai | `https://api.mistral.ai/v1` | `MISTRAL_API_KEY` |
| `gemini` | openai | `https://generativelanguage.googleapis.com/v1beta/openai` | `GEMINI_API_KEY` |
| `qwen` | openai | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |
| `ollama` | openai | `http://localhost:11434/v1` | — |
| `llamacpp` | openai | `http://localhost:8080/v1` | — |
| `github-copilot` | github-copilot | from session token | `GITHUB_COPILOT_OAUTH_TOKEN` or `--login` (unofficial, see below) |
| `mock` | mock | — | — |

`kind = "openai"` means OpenAI Chat Completions, which also covers vLLM, LM Studio,
llama.cpp, DwarfStar ds4 and similar servers. `kind = "anthropic"` is the Anthropic
Messages API.

A `[providers.<name>]` table can add a new endpoint or override any field of a preset:

```toml
[providers.ollama]                      # point the preset at another host
base_url = "http://merlin.local:11434/v1"
default_model = "qwen3:8b"              # used by `--model ollama`

[providers.ds4]                         # a custom OpenAI-compatible endpoint
kind = "openai"
base_url = "http://localhost:8100/v1"
extra_body = { think = false }          # merged into every request body

[providers.openai]
drop_params = ["temperature"]           # for models that reject temperature

[providers.openrouter]
headers = { "HTTP-Referer" = "https://example.com", "X-Title" = "nano-coder" }
extra_body = { provider = { sort = "throughput" } }

[providers.work]
kind = "anthropic"
base_url = "https://llm-gateway.example.com/anthropic/v1"
api_key_env = "WORK_GATEWAY_KEY"        # or api_key = "..." (prefer the env var)
# api_key_command = "op read op://vault/gateway/key"   # used when the env var is unset
timeout_secs = 300                      # idle timeout: max silence between streamed bytes (default 600)
max_retries = 3
```

`qwen` is Qwen Cloud (Alibaba Cloud Model Studio), e.g. `--model qwen/qwen3.8-max`. The
preset uses the Singapore endpoint. API keys are bound to a region, so for another region
or your workspace domain override `base_url`, e.g.
`https://dashscope-us.aliyuncs.com/compatible-mode/v1` or
`https://<WorkspaceId>.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1`.

`kimi` is the Kimi API from platform.kimi.ai, e.g. `--model kimi/kimi-k3` or
`kimi/kimi-k2.7-code`. The preset drops `temperature` (K3 fixes it) and sets
`replay_reasoning = true`, which sends each assistant message's `reasoning_content` back
as thinking models like K3 require. Set `extra_body = { reasoning_effort = "low" }` to
make K3 think less.

Other per-provider fields: `replay_reasoning`, `max_tokens_param` (`max_tokens`, or `max_completion_tokens`
which is the `openai` default), `retry_initial_backoff_ms`, `retry_max_backoff_ms` and
`retryable_statuses`.

The old top-level `api_key` / `base_url` still work. They apply to `default_provider`,
which becomes `openai` if it was `mock`.

List a provider's models with `--list-models <provider>` (OpenAI-compatible endpoints and
`github-copilot`).

### GitHub Copilot (unofficial)

The `github-copilot` provider uses a GitHub Copilot subscription by authenticating **as the
VS Code Copilot Chat extension**: a device-flow login with VS Code's OAuth client ID, an
exchange for a short-lived Copilot session token, and Chat Completions calls with VS Code's
editor headers. This is the approach several open-source agents (e.g. pi) take, but it is
**not a GitHub-sanctioned integration**. It may breach GitHub's terms or your organisation's
Copilot policy, it can break without notice, and misuse could get an account flagged. It is
never used unless you select it.

```bash
nano-coder --login github-copilot          # interactive; saves the OAuth token (0600)
nano-coder --list-models github-copilot
nano-coder --model github-copilot/gpt-4.1
```

Headless workers can't do the device flow; set `GITHUB_COPILOT_OAUTH_TOKEN` to a token from a
previous login instead (credentials live in `<data dir>/nano-coder/github-copilot.json`).
Tool follow-ups are sent with `X-Initiator: agent`, so a turn is billed like one VS Code
request. `GITHUB_COPILOT_DOMAIN` selects a GHE.com host. Models that Copilot serves only
through its Responses API are not supported.

The sanctioned route is the [Copilot SDK](https://github.com/github/copilot-sdk), which
drives the Copilot CLI's own agent loop rather than exposing the model.

### Retries

Retry behaviour comes from unreal-agent's retry logic (MIT). Connection errors and
HTTP 408/409/425/429/5xx/529 are retried with exponential backoff (1s doubling to
30s, minus up to 20% jitter, 5 retries). `Retry-After` headers are honoured, and so is
"try again in Xs" in rate-limit messages. Overloads (`overloaded_error`,
`server_is_overloaded`, 529) back off from 10s up to 60s. Errors that can never succeed
on retry fail immediately: authentication and permission errors, invalid requests,
`context_length_exceeded`, quota and billing errors, and policy errors.

## Output and Verbosity

In the interactive CLI, answers stream in as the model writes them. Output detail is set
with `/verbosity`, `--verbosity`, or `verbosity` in the config (default `normal`):

| Level | Shows |
|-------|-------|
| `quiet` | Final answers only |
| `normal` | Streamed answers, collapsed thinking, one line per tool call and its result |
| `verbose` | Also the first lines of each tool's output |
| `debug` | Also lifecycle hook events (`[hook] ...`) |

**Thinking.** Reasoning streams as a single line that updates in place
(`∴ Thinking: ...`) and becomes `∴ Thought for 3.1s · 812 chars` when the answer starts.
**Ctrl-O** switches to showing thinking in full, including the block that is streaming. At
the prompt it prints the last thinking in full. Press it again to collapse. Reasoning is read
from `reasoning_content` / `reasoning` fields (DeepSeek, llama.cpp, vLLM, OpenRouter, Ollama),
from `<think>...</think>` in the content, and from Anthropic `thinking` blocks. Anthropic
thinking blocks are kept with the conversation so tool use keeps working when thinking is
enabled (e.g. `extra_body = { thinking = { type = "enabled", budget_tokens = 4000 } }`).

**Tool calls** show as `● bash ls -la`, then `⎿` with the first line of the result (or the
error).

**Input.** On a terminal, input is read key by key. While a turn runs, what you type shows
on the status line; Enter sends it as a steer, Esc Esc or Ctrl-C cancels the turn. The prompt supports
Backspace, Ctrl-U (clear), Ctrl-W (delete word) and Ctrl-D (exit on an empty line).

Streaming uses server-sent events. Set `stream = false` on a provider whose endpoint doesn't
support it. ACP mode doesn't stream text, but sends each response's reasoning as an
`agent_thought_chunk` update.

Every message (your prompt, answers, tool calls and results, thinking, notes) starts with
the local time as `HH:MM:SS`. The prompt's time is rewritten when you press Enter, so it
shows when the message was sent. Turn this off with `timestamps = false`.

## Status Line and Compaction

In an interactive terminal the bottom row shows the provider/model, context usage
(`~` marks an estimate; without it the figure is anchored to the provider's reported usage),
a fill bar, message count, session input/output tokens, the auto-compaction threshold and
count, and what the agent is doing. It uses a terminal scroll region, follows resizes, and is
off when stdin/stdout isn't a TTY or `AGENTIC_NO_STATUS` is set.

The context window comes from, in order: `context_window` in the config, `context_window`
on the provider, the window the endpoint reports, a built-in table of known models, then
128k. `/context` shows which one applied. If a provider rejects a request as too long, the
harness takes the limit from the error, compacts, and retries once.

The endpoint is asked at startup and on `/model` (at most 5 seconds; skipped when config
sets the window). The window a server has *loaded* is preferred over the model's maximum:

| Server | Source |
|---|---|
| vLLM | `/v1/models` `max_model_len` |
| DwarfStar ds4, OpenRouter, Together, Kimi | `/models` `context_length` |
| Groq / Mistral | `/models` `context_window` / `max_context_length` |
| llama.cpp | `/props` `n_ctx` (per slot) |
| LM Studio | `/api/v0/models` `loaded_context_length` |
| Ollama | `/api/ps` for a loaded model, else `num_ctx`; never the model maximum, since Ollama runs with a smaller default |
| GitHub Copilot | `/models` `max_prompt_tokens` |

Compaction asks the current model to summarize older messages, keeping the recent tail
(up to 20k tokens, never starting at a tool result). Auto-compaction runs before a model
call when usage passes the threshold. It won't run again until the context has grown by
another 10% of the window, so a context that can't shrink isn't summarized on every call.
If summarizing fails, the older messages are dropped with a note. The session log records
the new conversation, so `--resume` continues from it.

## Project Instructions

When a session starts, the harness looks for instruction files in every directory from the
git root (the nearest ancestor containing `.git`) down to the working directory. Outside a
repository only the working directory is checked. In each directory the first file found from
`project_instruction_files` is used, so `AGENTS.md` wins over `CLAUDE.md`, which wins over
`.github/copilot-instructions.md`. The files are appended to the system prompt, root first,
under a "Repository instructions" heading that tells the model to follow them. So the model
has them before it makes any change, without having to decide to read them. Each file is
capped at 32 KiB and the total at 64 KiB.

Instruction files deeper in the tree than the working directory, such as `pkg/AGENTS.md`,
are loaded lazily. The first time `read_file`, `write_file` or `edit_file` touches a path
under such a directory, its instructions are appended to that tool result, once per
directory. After a compaction they are attached again the next time they apply. Files that
the `bash` tool touches don't trigger this.

Instructions are read again when a session is resumed, so edits to `AGENTS.md` take effect.
`/context` lists the loaded files. To turn loading off, set `project_instructions = false`,
or set `AGENTIC_NO_PROJECT_INSTRUCTIONS`.

## Skills

A skill is a folder with a `SKILL.md`: YAML front matter with a `name` and a `description`,
then instructions, plus any scripts or reference files it needs. Only the name and description
of each skill go into the system prompt, under a "Skills" heading (8 KiB at most). When a task
matches one, the model calls `load_skill`, which returns the instructions (32 KiB at most),
the skill's directory, and a list of its other files for `read_file`.

Skills are found in this order, and the first skill with a given name wins:

1. **Repository**: `.agents/skills`, `.github/skills` and `.claude/skills` under the git root,
   at any depth up to four folders (`skills/<group>/<skill>/SKILL.md` works).
2. **`ai.lock`**: skills pinned by [spm]https://github.com/camunda/spm-cli. Each locked
   skill is loaded, and so is each skill bundled in a locked plugin (its
   `.claude-plugin/plugin.json` `skills` folder, default `skills/`). Hooks, commands and MCP
   servers in plugins are not loaded.
3. **User**: `~/.agents/skills`.

nano-coder reads `ai.lock` itself and never runs `spm install`, which edits the workspace
(`.gitignore`, vendor folders). Changes like that would end up in commits and PRs. Each
pinned commit is read from spm's store (`$SPM_HOME/store`, default `~/.spm/store`) if it is
there. Otherwise it is fetched once into the nano-coder cache (`<cache dir>/nano-coder/skills`).
`ai.lock` is committed to the repository, so its entries are checked the way spm checks them:
a full 40-character commit, a store key that matches the URL and commit, and paths that stay
inside the checkout. Fetches are limited to `skills.allowed_hosts` (`"file"` allows
`file://`). With `fetch = false`, only commits already in the spm store or the cache are used.
An `ai.json` without an `ai.lock` is skipped with a warning, because unpinned references are
never resolved.

Skills are found again when a session starts or is resumed. Problems are listed at startup
and by `/skills` (and returned as `_meta.skillWarnings` over ACP); they never stop a session.
To turn skills off, set `skills.enabled = false` or set `NANO_CODER_NO_SKILLS`.

## Task Plans

The `plan_*` tools give the agent a plan that lives outside the conversation. This helps most
with small context windows: the model can write down what it has done and learned, then let the
conversation be summarized without losing track.

- `plan_add` - add steps (title strings or `{title, after, note}`), and optionally set the `goal`.
  Items get numeric ids; `after` lists items that must be finished first.
- `plan_update` - set an item's `status` (`pending`, `in_progress`, `done`, `blocked`,
  `dropped`), rename it, or add a `note`. `updates: [...]` changes several items in one call.
- `plan_show` - the whole plan with every note.

Each change returns a compact checklist ending with what is in progress or ready next. A bad
call (unknown id, bad status) changes nothing.

The harness keeps the plan in front of the model:
- **Compaction.** The summary message is followed by the full plan with notes (up to 8,000
  characters; notes on finished items go first when it's too long).
- **Resume.** Every change is written to the session log, and `--resume` / `session/load`
  restore the latest plan.
- **Another worker.** ACP `session/new` can be seeded with `_meta.plan` (see ACP above). The
  model then gets the plan, and is told to check which "done" work was already committed or
  pushed.

In the terminal, normal verbosity shows plan changes as a checklist instead of tool calls
(verbose shows both). The status line shows `plan 2/5`, and `/context` and `/plan` show more.
To turn plans off, set `plan_tools = false`.

**Reminders.** A tool result can end with a `<system-reminder>` note that the model sees with
the result:
- after 12 tool calls with no plan change while items are open, naming the item in progress
  (or the next one) and asking for `plan_update`; again every 12 calls;
- once per session, after 10 tool calls in one turn with no plan, suggesting `plan_add`.

Plan and outcome calls don't count. Set `reminders = false` to turn them off.

## Outcomes

`report_outcome` is the model's explicit end-of-task signal: `status` is `completed` (the
whole task is done and checked; the summary lists PRs or commits) or `blocked` (after three
or more different failed attempts, or when only a person can unblock it; the summary says
what is needed). The call ends the turn. Other calls in the same response still run, then the
summary becomes the final answer (`Blocked: ...` for blocked), and the outcome is returned
in ACP `_meta.outcome` and recorded in the session log's `turn_end` record. If the harness
stops after the call but before the turn ends, resuming the input finishes it with the
recorded outcome without calling the model again. In the terminal the call shows as
`✔ completed` or `■ blocked` followed by the summary. Set `outcome_tool = false` to leave the
tool out.

## Sessions

When `persist_sessions` is on, each conversation is written to `<session_dir>/<id>.jsonl`.
The file is an append-only log whose first record is a versioned header, followed by
`input`, `message`, `turn_end` and `replace` (compaction / system-prompt reset) records.
Resume a session with `--resume <id>` or ACP `session/load`.
Record times and each message's `timestamp` are RFC 3339 with the local UTC offset
(for example `2026-09-24T13:02:12.44+12:00`). Older logs with UTC times still load.

- An unsupported format version is an explicit error on resume.
- Only records ending in a newline count as committed. A half-written last line from a
  crash is discarded and truncated before the next write.
- Tool calls left without results by a crash get a synthetic error result when the session
  is loaded, so the conversation stays valid for providers.
- Input IDs are remembered. Redelivering a completed input returns its recorded response.
  Redelivering an input whose turn was interrupted resumes that turn without duplicating
  the user message.

## Formal Verification

`tla/` holds TLA+ specifications of ACP turn routing (steer, cancel, deferred
messages) and of session-log crash recovery, model-checked with TLC. Run
`tla/check.sh` (needs Java and `tla2tools.jar`); see `tla/README.md` for the
properties and findings.

## Extending

### Adding Tools

```rust
let tool_def = ToolDefinition::new(
    "my_tool",
    "Description of what the tool does",
    json!({ "type": "object", "properties": { ... } })
);
agent.tools().register(tool_def, Box::new(|args| {
    // Tool implementation
    Ok(json!({ "result": "..." }))
}));
```

The model sees a JSON string result as plain text and any other value as serialized JSON.

### Adding Hooks

```rust
agent.hooks().register(HookEvent::BeforeToolCall, Box::new(|ctx| {
    let tool_name = ctx.data.get("tool_name").and_then(|v| v.as_str());
    println!("About to call tool: {}", tool_name);
}));
```

### Custom LLM Client

Implement the async `LLMClient` trait (or add a `ProviderKind` in `src/providers/`):

```rust
#[async_trait::async_trait]
impl LLMClient for MyClient {
    async fn chat(&self, request: &ChatRequest<'_>) -> Result<LLMResponse> {
        // request.messages, request.tools, request.temperature, request.max_tokens
    }
    fn model_name(&self) -> &str { "my-model" }
    fn provider_name(&self) -> &str { "mine" }
}
```

## Acknowledgements

Retry classification, output bounding, bash result formatting and the session-log design
are adapted from [unreal-agent](https://github.com/unreallabsai/unreal-agent)
(MIT, Copyright (c) 2026 Unreal Labs). System reminders and the outcome tool follow ideas in
[grok-build](https://github.com/xai-org/grok-build)'s `<system-reminder>` notes and
`update_goal` tool. The permission model (deny wins, allow rules must cover every command in
a chain, wrappers stripped before matching) and the Seatbelt/Landlock sandbox profiles follow
grok-build's and Codex's designs.