# letscode — complete reference for AI coding tools > letscode is a minimal, OpenAI-compatible coding agent for the terminal, written in Python. It wraps `openai.AsyncOpenAI` (with `base_url` set) at the bottom, an event-driven async agent loop in the middle, and a `prompt_toolkit` + `rich` terminal frontend on top. It ships four built-in tools (`read`, `write`, `edit`, `bash`), skills in the agentskills.io format, tree-shaped JSONL sessions, a model catalog with provider routing, and a pluggy-based plugin system with a documented, semver-versioned stable surface. It is an application, not a framework. Python ≥ 3.12. API keys are read from the environment only, never from config files. This file is self-contained: everything an agent needs to use letscode, configure it, and extend it. Rules an agent must not violate are marked RULE. ## 1. Install and run ```bash uv tool install letscode # recommended pipx install letscode # alternative # from source: git clone https://github.com/abilian/letscode && cd letscode && uv sync ``` `letscode --version` and `letscode --init` work with no API key configured (`--init` scaffolds a commented `~/.letscode/config.toml`, never overwriting an existing one). Starting without a key prints an actionable message naming the env var and `--init`, not a traceback. Three run modes: - **Interactive** (default): `letscode` → a streaming TUI. While the model works, **Enter** stops it; **type then Enter** stops and redirects it to the new instruction; **Ctrl+C** stops (press twice within 2s to exit). - **One-shot**: `letscode "what does fib.py do?"` → runs one prompt, prints the answer, exits. Options may appear anywhere: `letscode "prompt" --model X` and `letscode --model X "prompt"` are equivalent. - **Print mode**: `letscode -p "…"` → plain stdout, no Rich rendering, pipe-friendly. Reads piped stdin and merges it with the message argument. Exit codes: `0` ok, `1` provider error, `130` Ctrl+C, `2` usage error. A slash command typed on the command line dispatches like the REPL: `letscode /plugins` prints the plugin list instead of sending "/plugins" to the model. ## 2. Providers and model selection letscode speaks the OpenAI Chat Completions wire format, so any compliant endpoint works with no per-provider code. Two ways to point it at one: **(a) Provider routing via the catalog (preferred).** The built-in model catalog maps each model id to a provider; naming the model picks the endpoint and key automatically: ```bash export DEEPSEEK_API_KEY=... # or GLM_API_KEY / ZAI_API_KEY / GEMINI_API_KEY / OPENROUTER_API_KEY / FIREWORKS_API_KEY / OPENAI_API_KEY letscode --model deepseek-chat "explain this directory" ``` Built-in providers: `openai`, `fireworks`, `deepseek`, `zai`, `openrouter`, `gemini`. An explicit `--base-url` / `--api-key` flag always wins; a model's own catalog provider beats an ambient `OPENAI_BASE_URL`. RULE: an ambient `OPENAI_BASE_URL` set for one provider does NOT override a catalog model that names a different provider — otherwise every catalog model would be misrouted to the ambient host. **(b) Generic OpenAI-compatible endpoint.** For anything not in the catalog (vLLM, LM Studio, llama.cpp, Groq, Together, …): ```bash export OPENAI_BASE_URL="http://localhost:11434/v1" # e.g. Ollama export OPENAI_API_KEY="ollama" # any non-empty string for keyless local servers letscode --model llama3.2 "your prompt" ``` **The `/model` picker.** `/model` with no argument opens an inline picker (filter-as-you-type, arrow keys, Enter to select, Esc to cancel, a scrolling viewport for long lists). Off-TTY it falls back to a numbered list. Switching model — including to a different provider — takes effect on the next message; the LLM client is rebound to the new endpoint. `/model NAME` sets it directly. A pick made interactively persists across `letscode -c`. With `OPENROUTER_API_KEY` set, the picker folds in OpenRouter's full model lineup (fetched lazily on open, cached under `~/.letscode/`, gracefully skipped if unreachable). **Custom catalog.** Add or override models/providers in `~/.letscode/catalog.toml` (`[[models]]` with `id` + `provider`, `[[providers]]` with `name` + `base_url` + `api_key_env`); user entries win on id/name conflict. `/reload` re-reads it. **Thinking level.** `--thinking off|minimal|low|medium|high|xhigh` (env `LETSCODE_THINKING`) sends `reasoning_effort`, rounded down to each model's declared ceiling. ## 3. Configuration Resolution precedence, highest first: ``` CLI flag > env var > project config (./.letscode/config.toml) > user config (~/.letscode/config.toml) > built-in default ``` Project config overrides user config key-by-key; the closest `.letscode/config.toml` walking up from cwd wins. A malformed config degrades to defaults with a one-line warning — it never raises. User config keys (`~/.letscode/config.toml`): ```toml model = "gpt-4o-mini" base_url = "https://api.openai.com/v1" frontend = "basic" tools = "read,write,edit,bash" verbose = false timeout = 600.0 no_context_files = false footer = true thinking = "off" # off | minimal | low | medium | high | xhigh ``` RULE: API keys (`OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, …) are read from the environment ONLY, never from a config file. Do not put keys in `config.toml`. Unknown `[table]`s in the config are preserved verbatim and passed through to plugins (e.g. `[memory]`, `[mcp.servers.]`), so a plugin reads its own namespace without the core knowing about it. Env vars: `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `LETSCODE_MODEL`, `LETSCODE_THINKING`, plus each provider's key. `AGENTS.md` / `CLAUDE.md` files in cwd and its ancestors (and `~/.letscode/AGENTS.md`) are auto-appended to the system prompt; `--no-context-files` skips them. Key paths under `~/.letscode/`: `config.toml`, `catalog.toml`, `sessions//.jsonl` (+ `.name` label sidecar), `history`, `skills/`, `prompts/`, `plugins/` (the shared per-user plugin venv), `cache/update-check.json`. ## 4. Built-in tools Four tools, enabled by default; the LLM invokes them via native tool-calling: | Tool | Behavior | |---|---| | `read` | Read a file, optional line range. | | `write` | Create or overwrite a file atomically. | | `edit` | Search-and-replace edit. | | `bash` | Run a shell command with a timeout. | Restrict with `--tools=read,bash`; disable all with `--no-tools`. Tools route filesystem/shell work through an `ExecutionEnv` (default `LocalEnv`), never `pathlib`/`subprocess` directly, so a sandboxed or remote backend drops in without touching tool code. Tools declare `execution_mode="parallel"` (default; concurrent within one assistant message via `asyncio.gather`) or `"sequential"` (runs alone). ## 5. Slash commands Typed at the interactive prompt (some also work as `letscode /command` on the CLI). - **Basics**: `/help`, `/quit`, `/reset` (clear history, keep system prompt), `/model [NAME]` (picker or direct set). - **Inspection**: `/tools`, `/skills`, `/plugins` (loaded plugins + versions), `/reload` (re-read config + reload skills). - **Sessions**: `/tree` (numbered entry chain), `/fork [n]` (branch a new session at entry n), `/clone` (duplicate + continue on the copy), `/new` (fresh session), `/resume [N]` (list or switch to a prior session), `/name [TEXT]` (show/set the session label; auto-named from the first message). - **Output**: `/copy` (last reply → clipboard), `/export [PATH]` (self-contained HTML transcript), `/share` (upload as a secret GitHub gist via `gh`). - **Skills**: `/skill: [message]` splices a skill body into the next message. - **Steering / follow-up**: `/steer ` (delivered at the next tool-batch boundary), `/follow-up ` (delivered after the run would end), `/queue` (show pending). RULE: to interrupt a *running* tool loop, use abort (Ctrl+C / the type-while-running Enter), not `/steer` — a `user` message may never directly follow a `tool` message on the wire, so steering only drains at tool-free assistant boundaries. - **Compaction**: `/compact [bias]` collapses older history into a summary; the optional bias steers the summariser. - **UI toggles** (frontend-owned): `/verbose` (full vs. truncated tool panels), `/footer` (per-turn model/cost/ctx line). ## 6. Skills A skill is a markdown file with YAML frontmatter (the agentskills.io format); files written for other tools in that format load unchanged. ```markdown --- name: code-review description: Use when reviewing code for correctness, style, or security. --- # Code Review When invoked: 1. Read the changed files. 2. Look for off-by-one errors, race conditions, missing input validation. 3. Suggest improvements with concrete examples. ``` `name` and `description` are required; `description` should say *when* the skill applies (the model uses it to decide whether to invoke). Discovery paths, scanned at startup (project overrides global; identical content is silent, differing content warns): ``` ~/.letscode/skills//SKILL.md ~/.agents/skills/… ~/.pi/agent/skills/… ~/.claude/skills/… ./.letscode/skills//SKILL.md ./.agents/skills/… ./.pi/skills/… ./.claude/skills/… (walked up from cwd) ``` Two invocation routes, same result: (a) `/skill: [message]` prepends the body to your message; (b) every skill is auto-wrapped as a tool named `skill_` (hyphens/non-identifier chars → underscores: `write-a-prd` → `skill_write_a_prd`), which the model calls natively. `/skills` lists them. (`Skill.files` companion files are not implemented yet.) ## 7. Sessions Every interactive run persists as JSONL under `~/.letscode/sessions//.jsonl` — one JSON object per line, one line per finalised message. `` scopes sessions per project location. - `letscode -c` continues the most recent session for cwd; history reloads, the system prompt is recomputed (new skills/tools visible), and (since v0.7) a compact recap prints: message count + last user turn + last assistant turn. - `letscode --no-session` skips persistence entirely. - Labels live in a `.name` sidecar (zero change to the JSONL); auto-named from the first user message unless set with `/name`. Envelope: `{ "_v": 2, "parent_id": , "msg": }`. `parent_id` makes sessions tree-shaped (v1 files load with a synthesized linear chain). Message roles: `user`, `assistant`, `tool`, and `custom`. `custom` messages (`role: "custom"`, a free-form `kind`) live in the transcript but are NOT sent to the LLM by default. The common kind is `compaction`: when input tokens approach ~80% of the model's context window, the loop collapses older turns into one summary `CustomMessage(kind="compaction")`, and a built-in seam renders it as a synthetic message at LLM-call time. Plugins define their own `kind` and render it via `letscode_render_custom_message`. ## 8. Input surfaces Three ways to shape what the model sees without pasting (all work in interactive, one-shot, and print modes): - `@path` — inline a file's contents into the next message: `@README.md summarise this`. - `!cmd` — splice a shell command's stdout: `!git status what changed?`. `!!cmd` runs the command silently (side effect only, nothing sent to the model). - **Prompt templates** — a markdown file in `~/.letscode/prompts/.md` with `{{arg}}` placeholders, invoked as `/ arg`. ## 9. The agent loop and events `async def agent_loop(...) -> AsyncIterator[Event]` drives turns until the assistant emits `stop_reason="end_turn"` or the user aborts. The `Agent` class wraps it with an `AgentState`, an event bus (`agent.subscribe(callback)`), steering/follow-up queues, and `abort()`. Per-turn event sequence: ``` agent_start turn_start message_start (user) → message_end message_start (assistant) → message_update* (streaming deltas) → message_end [tool_execution_start / _update / _end ...] message_start (tool_result, per tool) → message_end turn_end [...next turn until stop_reason="end_turn"...] agent_end ``` Per-turn pipeline hooks, in order: `system_prompt` → `transform_context` → `convert_to_llm` (default: drop `CustomMessage`; then per-kind `render_custom_message`) → LLM stream → on tool calls: `before_tool_call` → `tool.execute` → `after_tool_call`. `on_event` fires for every event throughout. - **Completion-order tool events** (since v0.4): `tool_execution_end` fires in completion order (a fast `read` shows first even if a slow `bash` started earlier); the persisted transcript keeps source order. - **Cancellation**: `agent.abort()` sets a shared `asyncio.Event`; mid-stream it closes the httpx connection to unblock `__anext__` immediately; mid-tool, tools that accept `ctx.cancel_event` short-circuit. Cancellation is not an error — an aborted run yields a clean `agent_end`. RULE (internal): an interrupted in-flight assistant is not persisted; if an abort leaves a dangling tool-result tail, the loop appends a synthetic `(interrupted by user)` assistant note so the next prompt stays wire-valid. - **Errors**: provider errors (rate limit / timeout / network / 5xx) are wrapped as `ProviderError` and surfaced as a *recoverable* `ErrorEvent` (the frontend offers retry with `r`); tool errors are wrapped as `ToolResultMessage(is_error=True)` and handed back to the model. A provider error inside auto-compaction is also recoverable — it never crashes the REPL. - **Retries** (since v0.7): transient failures retry inside `LLMClient.stream()` with exponential backoff (1s→10s cap, 2 retries default), emitting a `RetryEvent` before each attempt. The `Event` union (discriminates on `type`): `agent_start`, `turn_start`, `message_start`, `message_update`, `message_end`, `tool_execution_start`, `tool_execution_update`, `tool_execution_end`, `turn_end`, `retry`, `error`, `agent_end`. Nested `StreamEvent` (inside `message_update.stream_event`): `text_delta`, `thinking_delta`, `tool_call_start`, `tool_call_args_delta`, `tool_call_end`, `usage`, `stop`, `retry`. RULE: a client MUST ignore unknown `type` values and unknown fields — new event types and new optional fields are additive/non-breaking. ## 10. RPC mode `letscode --mode rpc` speaks newline-delimited JSON over stdio (no frontend), for IDE/host-app integration. The wire schema **is** the `Event` Pydantic models via `model_dump(mode="json")` — there is no translation layer. Each event is one JSON line; the client dispatches on `type`. Steering and follow-up are driven over the wire by `steer` / `follow_up` messages. ## 11. Extension model (plugins) A plugin is an ordinary Python package with at least one `@hookimpl` and a `letscode` entry point. `PluginManager.load_entry_points()` discovers the `letscode` setuptools entry-point group at startup. Minimal plugin: ```python title="mypkg/plugin.py" import pluggy from pydantic import BaseModel from letscode.agent.tools import ToolContext, ToolResult, tool from letscode.llm.types import TextPart hookimpl = pluggy.HookimplMarker("letscode") class _GreetParams(BaseModel): name: str @tool(name="greet", description="Say hello to someone.") async def _greet(params: _GreetParams, ctx: ToolContext) -> ToolResult: del ctx return ToolResult(content=[TextPart(text=f"Hello, {params.name}!")]) @hookimpl def letscode_register_tools(registry): registry.add(_greet) ``` ```toml title="pyproject.toml" [project.entry-points.letscode] my_plugin = "mypkg.plugin" ``` **Five extension types**, each registered by a hook: **tools** (`letscode_register_tools`), **commands** (`letscode_register_commands`), **skills** (`letscode_register_skills`), **frontends** (`letscode_register_frontends`), and the **system prompt** (`letscode_system_prompt`). **Lifecycle hooks** dispatched by the loop: `letscode_before_tool_call` (may block/rewrite a call), `letscode_after_tool_call` (may override a result), `letscode_transform_context`, `letscode_convert_to_llm`, `letscode_on_event`, `letscode_render_custom_message`. That is the ten-hook inventory. RULE (stability contract): plugins may import ONLY from the stable surface — `letscode.agent.tools` (`Tool`, `ToolContext`, `ToolResult`, `ToolError`, `tool`, `ToolRegistry`), `letscode.agent.messages`, `letscode.agent.events`, `letscode.agent.state` (`AgentState`, read-only from a plugin's POV), `letscode.agent.subagent` (`subagent_env`), `letscode.agent.execution_env` (`ExecutionEnv`, `LocalEnv`, `FileError`), `letscode.agent.hookspecs`, `letscode.llm.types`, `letscode.plugins.registries`, `letscode.commands.dispatch`, `letscode.frontends.protocol`, `letscode.skills.loader`. Anything else (`letscode.cli.*`, `letscode.agent.loop`, `PluginManager._pm`, `frontends.basic.*` internals) is internal and may change without notice. Versioning rule (semver, since v0.3): adding a hookspec / registry method / public type is a **minor** bump (no breakage); changing a hookspec signature or removing a public type is a **major** bump. A conservative plugin pins `letscode>=X,` / `uninstall ` add/remove plugins by PyPI name, local path, or `git+https://…#subdirectory=…` URL, defaulting to a shared per-user venv (`~/.letscode/plugins/`) that every `letscode` on the machine sees; `--project` targets the active venv, `--isolated` skips the shared one for a run. `letscode run [args]` runs a console script a plugin ships (they land in the shared venv's `bin/`, not on `PATH`). `letscode plugins` lists what's installed and where (user venv vs. active env), flagging duplicates and warning when a shared venv has no site-packages for the running Python (an install that is silently invisible). ## 12. MCP (Model Context Protocol) MCP support is a plugin, not core: `letscode-mcp` (`plugins/letscode-mcp/`). Declare servers in `[mcp.servers.]` tables in `~/.letscode/config.toml`; their tools surface as native letscode tools named `mcp__` (or a chosen prefix). stdio transport; a failing MCP tool returns an error result instead of crashing the session. It proves the extension model absorbs a full external protocol without touching the agent loop. ## 13. Deliberate non-goals Kept outside core on purpose (each can be a plugin, a file, or a container): native Anthropic / Bedrock / Vertex provider modules (use OpenAI-compatible shims), MCP in core (the `letscode-mcp` plugin adds it), sub-agents in core, subscription-OAuth `/login` flows (a v0.8 candidate), `ctx.ui` interaction primitives (a v0.8 candidate), permission popups, plan mode, built-in to-dos, background bash, a first-party web UI (RPC mode is the seam). The guiding rule: a small core; everything else a plugin. ## 14. Where the code lives `src/letscode/agent/{loop,agent,state,events,messages,tools,hookspecs,compaction,execution_env,subagent}.py`; `src/letscode/llm/` (client, catalog/`data/catalog.toml`, thinking, retries); `src/letscode/cli/` (app, install, uninstall, run, plugins, rpc); `src/letscode/frontends/basic/` (the terminal TUI; Textual is an external plugin); `src/letscode/plugins/` (manager, registries, user_venv). `letscode` is a deliberate PEP 420 namespace package (no `__init__.py` under `src/letscode/`). The canonical reference for exact field sets and signatures is the source.