noob-cli

noob-cli is a compact Rust agent for OpenAI-compatible model endpoints. It runs in an isolated Docker container against the current project directory, with persistent configuration and sessions stored outside the image.

The static release binary is 4,354,944 bytes (4.15 MiB) with 40 runtime crates. There is no async runtime or TUI framework.

Showcase

Recorded against a live qwen3.6-35b-a3b endpoint. Idle waits are sped up; the interactions themselves play close to real time.

The context tool reports token use on demand:

noob answering with its own context use through the context tool

Install a skill straight from a GitHub repo, hand it a research task, and keep talking while the detached sub-agent works. Tab opens its live view, here the sub-agent running web search in the background:

Installing the research skill, then a sub-agent web-searching while the prompt stays live

Ask for a three-step plan, then queue two follow-up messages while it builds. The plan finishes on its own and the queued messages dispatch in order, the first one right after the plan completes:

A plan building three files while two typed messages wait queued, then dispatch in order

Install

You need Docker and Git. Everything else, including the Rust toolchain, lives inside the container, so the first build pulls the base images and packages and needs network access. Tested on Linux (amd64 and arm64); running from the checkout with ./dev.sh also wants the Docker Compose plugin.

git clone https://github.com/hec-ovi/noob-cli.git
cd noob-cli
./install.sh

The installer builds noob:local, installs ~/.local/bin/noob, and seeds the web-search skill plus its lazy stdio MCP configuration under ~/.config/noob. It refuses to replace an unrelated noob command unless --force is passed.

Add ~/.local/bin to PATH if your shell does not already include it, then run:

cd /path/to/project
noob

The installed command mounts the directory where you run it at /work. For disposable work, keep it separate from a source checkout:

mkdir -p ~/noob-workspace
cd ~/noob-workspace
noob

It also mounts ${XDG_CONFIG_HOME:-$HOME/.config}/noob at /config, uses the caller's UID and GID, and removes the container when the command exits.

Resume a saved session:

noob sessions
noob --resume latest
# or: noob --resume <session-id>

noob sessions lists saved sessions newest first. --resume latest selects the newest one without copying its ID. --resume is the canonical recovery flag; --restore and --session are aliases. On an interactive resume noob redisplays the prior conversation, and resuming an unknown id prints no saved session <id>; starting fresh. The exit line prints the session ID and the exact command that reopens it.

Installer options:

./install.sh [--prefix <dir>] [--force]

NOOB_INSTALL_PREFIX, NOOB_CONFIG_HOME, NOOB_WORKSPACE, and NOOB_IMAGE override the install prefix, persisted config directory, mounted workspace, and runtime image.

Run from the checkout

For development, or without installing the host command, the default agent mount is the ignored workspace/ directory in this checkout:

./dev.sh
NOOB_WORKSPACE=/absolute/path/to/project ./dev.sh
NOOB_WORKSPACE="$PWD" ./dev.sh exec "inspect the project and run its tests"

./dev.sh creates the default workspace/ directory before mounting it at /work, so generated projects do not land in the noob-cli source tree.

With no configured base URL, noob probes supported localhost ports. To pin an endpoint, copy and edit the example:

cp config/.env.example config/.env

The checkout path mounts config/ as the config directory (NOOB_CONFIG overrides it) and forwards only the five display variables from the Configuration section; the installed noob command forwards the full set.

Commands

noob [--model <name>] [--base-url <url>] [--resume <id>] [--plan] [--verbose] [--yolo]
noob exec -p "<prompt>" [--json] [--resume <id>] [--plan] [--verbose] [--model <name>] [--base-url <url>] [--yolo]
noob sessions
noob doctor
noob --version

Interactive commands:

Command Action
/plan Enter read-only plan mode
/clear-plan Redact prior plan payloads from the active context
/go Approve the plan and restore the full tool set
/status Show endpoint, usage, session, skills, and MCP state
/context Show context use and the automatic-compaction threshold
/sessions List saved sessions newest first
/agents List background sub-agents
/agents cancel <agent-N|all> Cancel and reap detached work
/config Show, set, or unset non-secret .env settings
/compact Compact the current session
/skills List skills
/skills add <path|git-url|owner/repo> Install and reload one skill (owner/repo reads from GitHub, like npx skills add)
/skills remove <name> Remove a workspace-installed skill
/skills reload Run discovery again
/mcp List configured MCP servers and their connection state
/mcp add <name> <url|command...> Install an MCP server on the fly (persisted to .noob/mcp.json)
/mcp remove <name> Drop a project-installed MCP server
/mcp connect <name> Connect now and print the server's tool catalog
/quit, exit, or quit Leave the REPL

During a turn the input stays live: typing edits the next message, and Enter queues it without touching the running turn. The queued message waits as a normal › message row with a [queued] tag above the input and dispatches in order once the turn finishes, landing in the transcript as a plain › message line. Only double-Escape (or Ctrl-C) stops a turn. The dock keeps plan and agent status pinned inside the input frame, in-turn and at the idle prompt alike, while output scrolls above it.

Features

  • Nine core tools: read, write, edit, bash, grep, glob, ls, context, and plan.
  • Conditional SKILL.md, MCP, and self-spawned child-agent tools.
  • Parallel read-only calls with sequential mutation barriers and actual lifecycle timing.
  • Detached sub-agents in the interactive dock. The original call receives a running acknowledgment, then one final report enters context exactly once. A model response that only spawns agents ends its turn right after the acknowledgments, and status polling is answered once per input before the turn is closed for it, so the prompt frees seconds after a spawn instead of sitting behind a waiting loop. The prompt remains usable for ordinary main-agent work while several children run, and a child completion never interrupts an active parent turn. Tab shows bounded live child activity; both the user (/agents cancel) and the model (subagent {"cancel":"agent-N"}) can cancel a job, and double-Escape stops the whole fleet while a queued message and Ctrl-C leave it running. An accepted cancellation or terminal child failure blocks same-turn replacement spawns until the next human instruction.
  • Three child tool profiles: the default tools: "read-only" for local inspection, tools: "web" for local inspection plus one unambiguous web-search MCP server, and tools: "all" for the full registered tool set. Web children cannot run Bash, mutate files, change the plan, or delegate. Dock children are leaves in every profile.
  • A cross-process workspace lease around each write or edit call. File-tool mutations do not overlap, while inference, Bash, file inspection, and MCP calls remain concurrent. A child waits for the lease for a bounded time; a parent file mutation reports the active conflict promptly instead of blocking the conversation. Shell commands that mutate files are outside this guarantee, so the agent contract reserves Bash for builds, tests, and exploration.
  • Read-before-write stamps, atomic writes, deterministic edit fallbacks, and ambiguity rejection.
  • JSONL sessions, newest-first discovery, --resume latest, on-screen replay, context compaction, cache-prefix checks, and repair of dangling calls or interrupted background jobs.
  • Read-only plan mode through /plan, followed by /go.
  • Lazy MCP over stdio and Streamable HTTP. Server schemas enter context only after connection, and /mcp add installs a server mid-session.
  • Runtime skill discovery and atomic /skills add, remove, and reload.
  • A default terminal dock with elapsed status, active tools, mid-turn message queueing, confirmations, cancellation, Tab completion for slash commands, persistent in-place plan and agents panels that stay animated between turns, and single-write batched repaints (no flicker while output streams). On resize the dock erases the frame at its reflowed height and repaints in place, mid-turn and at the idle prompt, so repeated resizes leave no stale frames or blank gaps in scrollback.
  • A session token readout inlaid in the frame's rule, live while the model works and after it stops. It counts what the server actually computed, not what was sent: every request re-sends the whole transcript, so summing raw prompt tokens would grow with the square of the conversation and bill work the cache did for free. A first request measured here prefilled 1,850 tokens; the second re-sent nearly all of them and prefilled 31. The total is written to the session log, so a resumed session keeps counting instead of restarting at zero.
  • Interactive Markdown for headings, emphasis, lists, fenced code, JSON, and width-aware tables.
  • Matrix, ocean, amber, and violet display themes.

🔎 Web search: a skill and a tool

Web search reaches the model as a skill plus a tool, not a built-in.

The tool is websearch, a small Python package (websearch-skill, pinned and installed in its own uv tool environment inside the runtime image), plus a websearch tool in noob that runs it:

websearch init                     # start SearXNG, self-test, report what works
websearch web-search "query"
websearch web-fetch "https://example.com/page"
websearch web-open "site.example~handle" --page 2
websearch arxiv "paper topic"
websearch github "repository topic" --language Rust
websearch doctor

websearch init is the first call of a session: it reads the config env file, brings up a local SearXNG, runs the full self-test, and answers whether search works and with what. websearch doctor is the one to reach for when results dry up later: it checks each engine on its own and separates a parser that can no longer read a provider from a provider that is refusing your IP, which need opposite fixes.

There is no MCP server in this path. Up to websearch-skill 0.2.6 there was one, and it was the wrong shape: an MCP server reads its configuration once at startup and caches its engine fanout, so a SearXNG or a proxy configured afterwards stayed invisible until the client restarted it, which is exactly what the optional layers here need to change at runtime. One process per call reads the environment fresh every time.

The tool takes an optional egress proxy, off by default: set WEBSEARCH_PROXY to a proxy URL (socks5h://user:pass@host:1080), to nordvpn, or to off. The nordvpn shorthand builds the SOCKS5 URL from the NORDVPN_USER and NORDVPN_PASS service credentials, with NORDVPN_HOST selecting a server. With a proxy set, nothing leaves the container around it, including the hostname lookups the fetch guard used to do locally. WEBSEARCH_VPN (nordvpn or any) routes nothing itself; it declares that egress should be tunneled so the doctor verifies it instead of assuming it. The launcher forwards all of these, plus WEBSEARCH_SEARXNG_URL, into the container, so exporting them before running noob is all it takes. To keep the credentials out of your shell history, put them in websearch.env in the config directory instead. The tool otherwise reads .env from its working directory, which inside the container is your project, so the image points it at the config directory: a .env of your own is never read, and a WEBSEARCH_PROXY line in it cannot silently reroute or break every search.

The tool registration is automatic: noob registers a websearch tool whenever the CLI is on PATH, taking an action (init, search, fetch, open, arxiv, github, doctor) plus typed fields. It builds a fixed argv and runs the binary directly, with no shell in between, so no value the model sends can become a flag or a second command. Results come back wrapped as untrusted, the same treatment MCP results got. Set NOOB_WEBSEARCH=off to unregister it, or to a path to point it at a different binary.

The skill is a SKILL.md in the config that tells the model to run init first and which action to reach for after. The installer seeds it, and it doubles as the Bash instructions for a session where the tool is not registered.

The opt-in live test gives qwen a research prompt and asserts that the JSON event stream contains a websearch search call and a grounded answer.

🧩 Skills: instructions the model runs

A skill is a SKILL.md the model activates and then carries out with the ordinary tools, so it adds a capability without adding code. Install one from a local path, a git URL, or an owner/repo GitHub shorthand with /skills add (/skills add hec-ovi/research-skill just works), list with /skills, and drop a workspace one with /skills remove.

Two ship with the installer. web-search is above. coding loads when the task is a change to code that already exists, and carries the directives that hold up under measurement: a library exists only if the project declares it, so check the manifest before importing; read the file and one neighbour and write in their style; prefer an edit over a rewrite, since a rewrite regenerates the bytes that were already right; find the project's own test and lint commands instead of guessing them; and run the thing, because compiling, parsing, and type-checking are not running. No tone rules and no worked examples, so it costs one line in the resolver until the model loads it. Drop it with /skills remove coding if you would rather bring your own.

The external research-skill shows the shape. With the websearch tool registered, noob recognizes that skill's investigation brief and enforces tools: "web", even if a small model requested "all". That child can inspect local files and reach the web, but cannot run Bash, write files, change the plan, or spawn another agent. It returns the complete synthesis; the main agent validates it and alone updates the project-scoped .research/ store. A completed web report is accepted only after at least two successful websearch calls actually gathered sources: init and doctor report on the installation, so they do not count. Without the CLI installed there is no web profile at all, and the parent uses tools: "all" when a task needs the network.

📟 The dock up close

Three small things the persistent dock does while a turn streams above it.

📋 Plan. The plan tool is the live checklist the model and user both see. The active [~] box spins while work runs, and each completed action shows its elapsed time. Long lists show at most six steps windowed on the active one, plus one … +N more row with done and queued counts. A finished plan collapses to one timed line and moves into the chat history at turn end instead of staying stuck to the input; canceling a turn leaves an unfinished plan pinned in its actual state. The unfinished checklist stays pinned above the input across turns and at the idle prompt, updating in place instead of re-printing into the transcript, and the active step keeps spinning between turns (a step delegated to a still-running sub-agent stays visibly alive while the parent waits at the prompt). /clear-plan unpins it and replaces historical plan arguments and results with small placeholders while keeping provider-valid call/result pairs.

👥 Agents. Sub-agents detach after an immediate job acknowledgment, so the prompt becomes usable while they work. Use tools: "read-only" for inspection, tools: "web" for nonmutating web research, and tools: "all" for coding or shell work. Background jobs and the foreground plan are independent state machines that may coexist; the dock renders separate regions, and agent lifecycle is never copied into plan steps. Press Tab on an empty draft for persistent job details and recent activity, or use /agents. Double-Escape, during a turn or at the idle prompt, cancels every running agent after a visible confirmation hint; a lone Ctrl-C stops only the parent turn; a typed message stops nothing, it just queues. Each terminal result is removed from its child instance and injected once into the parent context. A message already being composed wins the completion race and receives ready reports before its own text in the ordinary turn. A failed or canceled report, including one coalesced with a success, leaves the prompt idle instead of invoking parent inference. Cancellation and failure also reject autonomous replacement spawns until a new human turn begins.

⌨️ Queueing. Type while a parent turn is running. Enter queues the message and leaves the turn, its tools, the plan, and every sub-agent untouched; it waits as a normal › message row with a [queued] tag above the input, then dispatches as the next turn once the current one finishes and shows up in the history as a plain › message line. Escape or Ctrl-C cancellation hands queued and unsubmitted text back to the editor instead of firing it.

⎋ Cancel. Escape twice within five seconds cancels a running turn; Ctrl-C cancels at once. A second Ctrl-C during cancellation restores the terminal and exits with status 130.

Configuration

The mounted config directory contains .env, optional AGENTS.md, mcp.json, global skills/, and sessions/.

Key Default Meaning Reload
NOOB_BASE_URL localhost autodetect OpenAI-compatible /v1 base URL .env: each request; CLI, environment, or autodetect: process
NOOB_API_KEY empty API key from .env only each request
NOOB_MODEL default Endpoint model name .env: each request; CLI or environment: process
NOOB_API_STYLE by host chat or responses .env: each request; environment: process
NOOB_REASONING unset on or off. Unset sends no thinking field and the model server decides. Set, every Chat Completions request carries chat_template_kwargs {"enable_thinking": ...}, and reasoning_effort: "none" when off. Hints only: a server started with --reasoning off still wins. Ignored on the responses wire shape .env: each request; environment: process
NOOB_AUTODETECT enabled Set 0 to disable loopback probing process start
NOOB_CTX 131072 Context window used for accounting process start
NOOB_SANDBOX container detection container or workspace process start
NOOB_TASK_CONCURRENCY 4 Concurrent child limit process start
NOOB_TASK_MAX_TURNS 25 Child inference-round limit process start
NOOB_TASK_WALL_CLOCK_S 300 Child wall-clock limit process start
NOOB_TOOL_CAPS enabled Set 0 (or off) to lift every tool-output truncation cap: read, bash, grep, glob/ls, skill, and MCP results flow through whole process start
NOOB_READ_DEDUP enabled Set 0 (or off) to print every read in full. On, a whole-file read of content already in context returns a one-line note instead of the body, and reading again prints it process start
NOOB_SKILL_PATHS none Colon-separated skill directories, each resolved against the workspace and registered as one resolver skill (so a cli/SKILL.md dispatcher is discovered without copying it into a skills root) .env: /skills reload; environment: process start
NOOB_ENV none Comma-separated allowlist of extra environment variable names the host launcher forwards into the container (for a workflow's own variables) process start (launcher)

If startup autodetection selects an endpoint, that selection is fixed for the process. Restart noob to switch from an autodetected endpoint to a newly added .env URL. The launcher forwards a fixed set of NOOB_* and proxy variables plus any names listed in NOOB_ENV, and never forwards NOOB_API_KEY; put secrets in the mounted config .env and protect that directory with normal file permissions. /skills reload reloads skills; /mcp add and /mcp remove reload the MCP server set in place.

The model server needs one request slot for the parent plus NOOB_TASK_CONCURRENCY child slots to keep all of them generating at once. With the defaults, configure at least five slots. For llama.cpp, --parallel controls the total_slots reported by GET /props; set --ctx-size and the KV-cache configuration so the reported n_ctx is at least NOOB_CTX while those slots are active. noob doctor performs that read-only capacity check and also reports disabled tool-calling capabilities. See the current llama.cpp server documentation and the companion stack for the deployment arithmetic.

/context (and /status, and the model-callable context tool) shows the estimated use, configured total, and 75 percent automatic-compaction threshold. When compaction runs, the terminal states whether the configured threshold, an endpoint overflow, or a length finish triggered it, then reports whether old tool output was pruned or the older conversation was summarized. Provider failures include the failed stage or HTTP status and a concrete next check.

/config list shows the effective non-secret settings and their file. /config set ctx 65536 and /config unset ctx update that file atomically. Endpoint, model, and API-style edits apply on the next request unless a CLI flag or exported variable overrides them. Context and child-agent budget edits need a restart. API keys are intentionally not accepted by /config; edit the mounted .env so a secret does not enter terminal history.

Display variables can be set in the shell or the checkout's root .env for Compose:

Key Default Meaning
NOOB_DOCK 1 Set 0 for the classic prompt editor
NOOB_RAW 1 Set 0 for cooked input
NOOB_THEME matrix matrix, ocean, amber, or violet
COLORTERM truecolor in Docker Terminal color capability
NO_COLOR unset Disable color while keeping structure and status

Prompt budget

The fixed first-request overhead is small and locked. noob debug prompt --json prints the exact system prompt and tool schemas the binary sends, and a budget test keeps that artifact under 1,600 tokens (o200k tokenizer) with every tool, a skill, and an MCP server registered, against a hard limit of 2,000.

Measured on the stock install (web-search skill and MCP server, all 13 tools) against qwen3.6-35b-a3b on llama.cpp:

Piece Tokens
System prompt 610
Tool schemas, 13 tools 934
noob total 1,544
Chat template and message framing added by the server 527
First request total 2,071

The server-side framing figure is the model's own chat template (qwen3 re-wraps the tools in its <tools> block with tool-calling instructions), so it changes with the model and its tokenizer; noob never sends those bytes. llama.cpp caches the prefix, so the overhead is prefilled once per slot, not on every turn: in a 16-request session the server computed 517 tokens on the first call and between 23 and 844 on each later one, against a transcript that reached 14,699 tokens. Reproduce with noob debug prompt --json and the server's /tokenize endpoint.

Output surfaces

  • Interactive REPL: terminal dock, Markdown, mid-turn queueing, and confirmations.
  • exec: assistant text on stdout and progress on stderr.
  • exec --json: one JSON object per event.
  • child: one JSON result line on stdout and progress on stderr.

Formatting never changes requests, transcripts, sessions, or cache-prefix bytes.

Planned

Future work, not built yet, in the order it will be built.

  • Native binaries for macOS, Windows, and Linux. Today the shipped artifact is a Linux static binary inside the runtime image, and the host command is a launcher that runs it under Docker. A real per-platform binary comes first, because the front end below has nothing to attach to until it exists.
  • GPU Vulkan front end. A lightweight Rust binary that renders the UI on the GPU through Vulkan instead of the terminal, in the spirit of Zed. Each surface is a separate, isolated part that talks to the others over schema-validated data rather than shared code: the plan, the multi-agent runner, agent management, and the main window. A dedicated code-stream surface shows each generated file on its own as the model writes it.

What each one actually blocks on, down to the file and line, is in docs/NEXT.md.

The devkit skill is not part of this repository and is not open source.

Development and verification

./dev.sh test
./dev.sh size-check
./dev.sh docker
./dev.sh smoke

./dev.sh test runs the full offline suite in the dev container. ./dev.sh size-check enforces an 8 MiB static-binary limit and a 45-crate runtime limit. ./dev.sh smoke runs the opt-in live model and web-search checks serially.

The live checks default to http://localhost:8080/v1 and the model name llm (llama-server serves whatever it loaded under its --alias). To point them elsewhere:

NOOB_LIVE_BASE_URL=http://localhost:8090/v1 \
NOOB_LIVE_MODEL=my-model \
NOOB_LIVE_MCP_URL=http://localhost:18000/mcp \
./dev.sh smoke

Verified end to end

Beyond the offline suite, the stack was driven against the local qwen endpoint. A fresh session created and completed its own visible plan, wrote and verified a file, resumed in a new process, called the context tool, and accurately explained the prior work. The backing llama.cpp server was also exercised with five simultaneous uncapped requests, matching one parent plus four detached children.

See ARCHITECTURE.md for the runtime design.

License

MIT. Copyright Hector Oviedo.