A small CLI AI agent written in Go. One agent loop, three LLM backends —
Anthropic (cloud), Ollama
(local), and any OpenAI-compatible /chat/completions endpoint — plus
sandboxed filesystem tools (read, list, search, glob always on; write and
edit behind a flag), an allowlisted run_command, and optional MCP servers
for anything beyond that.
curl -fsSL https://raw.githubusercontent.com/reno/pico-code/main/install.sh | shDownloads the right prebuilt binary for your OS/arch (Linux or macOS, amd64
or arm64) from the latest release
into /usr/local/bin (or ~/.local/bin if that isn't writable) — no Go
required. Windows isn't supported yet: command timeouts rely on POSIX
process groups.
Many systems already have a pico — usually nano's Pine-compatibility alias
at /usr/bin/pico. Set BIN_NAME to install under a different command name
and keep both, and INSTALL_DIR to choose where it lands:
curl -fsSL https://raw.githubusercontent.com/reno/pico-code/main/install.sh \
| BIN_NAME=pico-code INSTALL_DIR=~/.local/bin shOr via Homebrew (macOS/Linux), which installs the command as pico-code so it
never shadows an existing pico:
brew install reno/tap/pico-codeOr with Go 1.26+ already installed:
go install github.com/reno/pico-code/cmd/pico@latestBuilding from source:
git clone https://github.com/reno/pico-code.git
cd pico-code
make build # -> bin/picoPick a backend and talk to it:
# Local, no account needed — see "Ollama" below for the one-time setup.
pico --provider=ollama --model=qwen3:8b
# Cloud — needs ANTHROPIC_API_KEY set first.
pico --provider=anthropic --model=claude-sonnet-4-5
# Any OpenAI-compatible endpoint (OpenAI itself, vLLM, LM Studio, ...).
pico --provider=openai --model=gpt-4oEither command drops you into a > prompt. Ctrl+D (EOF) exits cleanly;
Ctrl+C interrupts the process the way it would any CLI tool — one to stop,
a second if the first doesn't. --tui behaves differently on purpose: see
While chatting.
export ANTHROPIC_API_KEY=sk-ant-...
./bin/pico --provider=anthropic --model=<a Claude model ID>Nothing else to install. Retries on 429/5xx happen inside the SDK; a bad key or an unreachable network surfaces as a clear error rather than a hang.
Every request marks two cache_control breakpoints: one on the system
prompt, one on the last block of the last message. Because history only
ever grows by appending, each new turn shares an identical prefix with the
last one up to that second breakpoint, so the provider serves it from
cache and only charges full price for the newly appended tail. A
/compact (or automatic compaction, 8.2) rewrites the oldest turns into a
fresh summary, which simply starts a new cache entry rather than risking a
stale one.
Using claude-sonnet-4-5's published rates ($3.00 input / $3.75 cache
write / $0.30 cache read per million tokens — see internal/config/pricing.go),
a 2,000-token stable prefix reused across 10 requests costs $0.0600
without caching vs. $0.0129 with it: a ~78% reduction on the
stable-prefix portion of input cost, improving further the longer the
conversation runs. It doesn't touch the growing tail of new messages each
turn adds, which is never cached and always full price.
export OPENAI_API_KEY=sk-... # optional — some endpoints need no auth
export OPENAI_BASE_URL=http://localhost:8000/v1 # optional, default api.openai.com/v1
./bin/pico --provider=openai --model=<model ID>One adapter for anything that speaks the same flat /chat/completions +
tool_calls shape as OpenAI: OpenAI itself, vLLM, LM Studio, or a local
Ollama's own /v1 endpoint. OPENAI_BASE_URL is what points it at any of
those; an unset OPENAI_API_KEY is not an error, since many
OpenAI-compatible servers run with no auth at all.
# once: install Ollama (https://ollama.com/download), make sure it's
# running (the desktop app does this for you; on Linux, `ollama serve`),
# and pull a model
ollama pull qwen3:8b
./bin/pico --provider=ollama --model=qwen3:8bOLLAMA_HOST overrides the default http://localhost:11434. Ollama
silently truncates context if its num_ctx option is left unset — pico
code always sets it explicitly (--num-ctx, default 4096) and logs the
effective value at startup.
Not every local model supports native tool calling, and some that advertise
support still narrate a tool call in prose instead of using it. So
--provider=ollama defaults --tools to prompted unless you pass
--tools explicitly; pass --tools=native yourself if your model handles
it reliably. (--provider=anthropic and --provider=openai keep
defaulting to native.)
--tools=prompted injects tool schemas into the system prompt and parses a
fenced JSON block back out of the reply instead of relying on the model's
native tool-calling — it can't stream, so it also forces --stream=false
for that turn (a log line says so), and it isn't available at all under
--tui, which always streams; combine --tui with --provider=ollama and
you'll need to add --tools=native explicitly.
| Flag | Default | Meaning |
|---|---|---|
--provider |
anthropic |
anthropic, ollama, or openai |
--model |
(none) | model ID for the selected provider |
--max-turns |
25 |
agent loop turns before it stops on its own |
--token-budget |
100000 |
cumulative tokens before it stops on its own |
--workspace |
. |
root directory filesystem tools are confined to |
--yes |
false |
skip interactive approval prompts |
--tools |
native (prompted for --provider=ollama) |
native or prompted; prompted mode ignores --tui and forces non-streaming |
--stream |
true |
stream a reply as it arrives, plain mode only (the TUI always streams) |
--tui |
false |
bubbletea TUI instead of the plain read-eval-print loop |
--log-level |
info |
debug, info, warn, or error |
--num-ctx |
4096 |
Ollama's context window (num_ctx); ignored by other providers |
--allow-write |
false |
register the write_file and edit_file tools |
--session |
(none) | name a session to resume or start; saved after every turn |
--allow-commands |
(none) | comma-separated binary allowlist; registers run_command only if non-empty |
--context-window |
200000 |
context window compaction measures usage against; ignored by Ollama, which uses --num-ctx |
--think |
false |
ask the model for a reasoning trace ahead of its reply; currently Ollama only |
--mcp-config |
(none) | path to a JSON file listing MCP servers ({"mcpServers": {name: {command, args, env}}}) |
PICO_CODE_PROVIDER is an environment fallback for --provider, checked
only when the flag isn't explicitly set. ANTHROPIC_API_KEY, OLLAMA_HOST,
OPENAI_API_KEY, and OPENAI_BASE_URL are read from the environment
only — there's no flag for any of them, so a credential never ends up in
shell history.
Both the plain REPL and the TUI understand the same commands:
/usage— cumulative and last-turn token counts, plus an estimated USD cost when the active model has a pricing entry (see below); Ollama always shows $0, since it runs locally/new— start a fresh, unsaved conversation/save [name]— save the current conversation as a session/load <name>— replace the current conversation with a saved session/mcp [reconnect <name>]— list configured MCP servers and their status, or reconnect one (only meaningful with--mcp-configset)
--session <name> does the same save/resume automatically, after every
turn, in both — kill the process mid-conversation and --session <name>
again picks up where it left off.
/usage's cost figures come from a small per-model pricing table checked
into internal/config/pricing.go — snapshotted from published rates at
some point in time, not fetched live. Prices change; treat the number as a
budgeting estimate, never as a billing-accurate figure. A model with no
entry in the table (a typo, a brand-new release, most non-Anthropic
models) shows no cost line at all rather than a misleading $0.00.
Ollama is the one exception: since it always runs locally, /usage always
reports it as an explicit $0.0000 instead of omitting the line.
--tui swaps the REPL for a full-screen interface (scrollback, a spinner
while the model thinks, tool calls shown with a running/✓/✗ status,
markdown rendering for finished replies, an approval modal for tools that
need sign-off). A command's output appears in the scrollback prefixed with
the command line itself, since the TUI otherwise never echoes what you
typed. Its Ctrl+C/Ctrl+D also differ from the plain REPL's on purpose:
Ctrl+C there cancels only the in-flight turn and returns to the prompt,
and Ctrl+D is the one that exits — a per-turn cancellation the plain REPL
doesn't have, since its context is shared process-wide rather than derived
fresh per turn.
With --think on (16.1), a model's reasoning trace shows as a dim,
collapsed-by-default block above its reply — ctrl+t expands or collapses
it. Like tool-call blocks, it's visible only while a turn is in flight and
isn't kept once the reply finalizes; the plain REPL never shows it at all,
even with --think on, since piped/redirected output stays ANSI-free by
design.
- Filesystem tools are confined to
--workspace, resolved throughEvalSymlinksso a symlink can't point a read or write outside it. Reads of.env,.git/config,*.pem, andid_*are denied outright. run_command(registered only when--allow-commandsnames at least one binary) executes only that allowlisted binary, directly (exec.Command, never a shell — no|,&&,$(), or similar can do anything), with a timeout and truncated output.write_file,edit_file,run_command, and any tool from an MCP server all need approval unless--yesis passed; the plain REPL prompts on the terminal, the TUI shows a modal.- Tool output is truncated (head + tail, with an elision marker) before it ever reaches the model's context, so one large file can't blow the context window.
- A provider or model-resolution failure (an unreachable Ollama daemon, a
typo'd
--model) is rewritten into a one-line message naming the actual problem instead of the raw transport error (cmd/pico/errors.go).
flowchart TD
U[user turn] --> C[call the provider]
C --> T{reply has tool calls}
T -- no --> Done[return the reply]
T -- yes --> R[run every tool call concurrently]
R --> A[append results in call order]
A --> G{guard tripped}
G -- yes --> Stop[stop with an explanation appended]
G -- no --> C
A guard trips on max turns, the token budget, a wall-clock timeout, or the same tool call repeating three times in a row.
Every tool call the model issues is answered by exactly one tool result, in
the same message, in the same order — even if the tool panics, times out,
or the run is cancelled mid-call. That invariant is asserted in the history
package itself (history.Validate), not just hoped for, and it's what makes
compaction and session persistence safe to build on top of: a saved or
compacted history is always in a state the model can resume from cleanly.
make build # bin/pico
make test # go test ./... — offline, no network, no API keys
make lint # gofmt -l . && go vet ./... && golangci-lint run
make run # go run ./cmd/pico --provider=ollamaReleases are cut by pushing a vX.Y.Z tag; .github/workflows/release.yml
runs GoReleaser to build binaries for
linux/darwin × amd64/arm64, publish a GitHub release with install.sh-
compatible archives, and push an updated cask to the reno/homebrew-tap
repo (needs a HOMEBREW_TAP_GITHUB_TOKEN secret with write access to that
repo). Validate the config locally with goreleaser check or
goreleaser release --snapshot --clean.
make test never touches the network. Provider adapters replay recorded
HTTP exchanges from testdata/golden/ through an httptest.Server; running
a test with RECORD=1 (and real credentials, for the adapters that need
them) re-records against the live API instead, scrubbing any credential
before the fixture is written back to disk.
Small local models are the reason most of the guard rails in this codebase exist:
- They invent tool names. The registry rejects an unknown name with an error result — handed back to the model as data, not a crash — and the model usually corrects itself on the next turn.
- They repeat themselves. A model stuck in a loop calling the same tool with the same arguments gets cut off after three identical calls in a row, with an explanation appended to the transcript instead of spinning forever.
- They emit arguments that don't match the schema, or send them as a JSON-encoded string instead of an object (Ollama does this often enough that the adapter normalizes both shapes before decoding). Validation failures go back to the model as an error result with the actual message, which is usually enough for a capable-but-small model to fix on retry.
- Some don't call tools at all — they narrate the call in prose. That's
what
--tools=promptedis for: schemas injected into the system prompt, a fenced JSON block parsed back out of the reply instead of relying on native tool-calling. - Streaming exposed a real gap in
encoding/json. A JSON string chunk containing a truncated multi-byte UTF-8 sequence gets silently replaced with U+FFFD by Go's own decoder — which mattered for testing "a tool argument split mid-character" honestly: splitting raw UTF-8 bytes across SSE chunks is untestable for this exact reason, so the fixture instead splits a\uXXXXescape sequence, which is always well-formed at the outer JSON-encoding level regardless of where it's cut.
More generally: almost everything CLAUDE.md calls a "provider gotcha" was discovered by actually running a small model against this loop, not anticipated up front. The cloud model mostly behaves; the local one is what taught this codebase to be defensive.