Local, autonomous coding agent powered by Ollama and a small LangGraph loop. One Python process, one local model, no cloud.
OpenCoder is a tiny, dependency-light reimplementation of the agent core of
opencode in Python. It boots a local
Ollama model, runs a ReAct-style loop (reason → tool → observe → repeat), and
writes/reads files inside the current working directory. Everything is local.
Everything is auditable.
- Features
- Architecture at a glance
- Requirements
- Installation
- Quick start
- Usage
- CLI reference
- Configuration (
.env) - Tools available to the agent
- Per-repository persistence
- Safety model
- Testing
- Project layout
- Troubleshooting
- License
- 🤖 Autonomous ReAct loop built on top of LangGraph (
reason_and_decide → tool_execution → finalize). - 🦙 Local-only model execution via Ollama HTTP (
/api/chat). No telemetry, no cloud accounts. - 📋 Plan mode (
--plan) — produces a structured Markdown backlog with five sections and never writes to disk. - 📎 File attachments — pre-load context with
--file PATH(repeatable) or inline@path/to/filementions in the prompt (Copilot-style). - 🧠 Per-repository memory stored in
.coder/memory/{project,user}.md, with session history appended to.coder/sessions/last.jsonl. - 🛡️ Honesty gate — when the last tool call failed, when the agent burned the step budget without progress, or when zero actions were taken, the final output is rewritten with a
❌marker. The success banner is only displayed on real success. - 🔁 Doom-loop detector — repeated identical tool calls are aborted with a clear failure.
- 🔧 Tiny tool surface —
read_file,write_file,list_dir,search,grep,glob,shell. Theshelltool requires interactivey/always/Npermission per command head. - 🐍 Python-syntax validation on
write_file—.pyfiles arecompile()-checked before they hit disk. - 🧪 72 unit + integration tests, ruff clean, no external services required to run the suite.
┌─────────────────────────────────────────────────────────────────┐
│ coder.entrypoint │
│ (CLI parsing, --plan, --file) │
└────────────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ coder.main │
│ build_initial_state() ─ inject memory + @mentions + --file │
└────────────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ coder.graph.langgraph_graph │
│ │
│ ┌──────────────┐ ┌────────────────────┐ │
│ │ assemble_ctx │ ─► │ reason_and_decide │ ◄─┐ │
│ └──────────────┘ └──────┬─────────────┘ │ │
│ ▼ │ continue │
│ ┌─────────────┐ │ │
│ ┌────────│ tool_exec │──────────┤ │
│ │ └─────────────┘ │ │
│ ▼ │ │
│ ┌──────────┐ │ │
│ │ finalize │ ◄─────────────────────────┘ done / error │
│ └─────┬────┘ │
└───────────────│─────────────────────────────────────────────────┘
▼
final_output (with honesty gate)
Per LLM step, the model returns a JSON envelope {"kind": "tool"|"final", ...}.
Tool decisions are executed by the tool_execution node; their result is
fed back into the next prompt. The loop stops on the first kind: "final"
or when --max-steps is reached.
- Python 3.14+
- uv package manager (recommended) or plain
pip - Ollama running locally (
ollama serve) - A model pulled in Ollama — defaults to
qwen2.5-coder:7b, but any instruction-tuned model works. Tested withqwen2.5-coder:7b,gemma3:e4b,llama3.1:8b. rgtool, as it runs faster than typical grepsudo apt install ripgrep
git clone https://github.com/<your-user>/OpenCoder.git
cd OpenCoder
uv sync --extra dev
uv pip install -e .Or use the convenience script:
bash scripts/install.shpython -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"ollama pull qwen2.5-coder:7b
# or any other Ollama model you prefer
ollama pull gemma3:4bcoder --help
# or
uv run coder/entrypoint.py --help# Plan a refactor (no files will be touched)
coder --plan "migrate this project from flask to fastapi"
# Run an autonomous task
coder "add a CHANGELOG.md with the last 3 commits"
# Attach a file as initial context
coder --file src/auth.py "review the attached file and fix any bugs in place"
# Inline @mention works too
coder "explain @coder/main.py and propose 2 improvements"Pass the prompt as the last positional argument. The agent runs the loop once and prints the final answer.
coder "write unit tests for utils/budget.py using pytest"
coder --model qwen2.5-coder:7b "fix the concurrency bug in workers.py"
coder --max-steps 12 "build a tiny key-value store kvstore.py + tests"Launch coder with no prompt to enter the REPL. Type prompts one at a time; the agent persists memory across turns. Type exit or hit Ctrl-D to quit.
coder
> add a README badge for tests
> now add one for ruff
> exit
⚠️ Interactive mode currently does not support--file/@mentionexpansion on each turn. Use one-shot mode for attachment-driven workflows.
--plan forces the agent into a read-only planning role. It can use
list_dir, read_file, grep and glob to understand the repo, but any
attempt to write_file or shell is rejected by the executor (even if the
LLM tries). The output is a Markdown backlog with five sections:
## Goal
## Task Breakdown
## Files to Modify
## Validation Plan
## Risks and Assumptions
coder --plan "introduce caching for the search tool"
coder --plan --max-steps 4 "plan a 12-month savings strategy: 2000€/month, target 8000€"Plan mode also works for non-coding domains (finance, organization, event planning, study programs) — see tests/acceptance/ for examples.
Two complementary ways, à la GitHub Copilot:
1. Flag -f / --file (repeatable)
coder -f src/auth.py "review and fix bugs in the attached file"
coder -f schema.sql -f config.json "validate config matches schema"2. Inline @mention inside the prompt
coder "refactor @coder/main.py for readability"
coder "compare @before.py and @after.py and summarize the diff"The matched path must exist relative to the current working directory and have a file extension. Each attached file is injected before the user prompt as:
<user-attached-files>
<file path='src/auth.py'>
...contents (max 60 KB per file, truncated otherwise)...
</file>
</user-attached-files>
Followed by an explicit instruction telling the agent to treat the contents as
data and only call read_file if it needs more. The agent decides itself
whether the attachment is enough or whether to explore further.
Add --debug-ollama to see the full prompts, the model's "thinking" stream,
and per-step metrics:
coder --debug-ollama "fix the off-by-one in parse_range()"You can also set CODER_DEBUG_OLLAMA=true in .env or as an env variable.
coder [PROMPT] [OPTIONS]
Positional arguments:
PROMPT User prompt for one-shot mode. Omit to enter
interactive mode.
Options:
--model MODEL Ollama model name. Default: $CODER_DEFAULT_MODEL
(qwen2.5-coder:7b)
--plan Plan mode: no file writes, no shell. Produces a
Markdown backlog with 5 sections.
--max-steps N Maximum loop iterations. Default:
$CODER_DEFAULT_MAX_STEPS (30)
--debug-ollama Print prompts, model thinking and metrics.
-f, --file PATH Attach a file as initial context. Repeatable.
Also accepts @path mentions inside the prompt.
-h, --help Show this help and exit.
Exit code is always 0 on a clean shutdown. Whether the task succeeded is
encoded in the printed final_output: an output starting with ❌ indicates
the honesty gate detected failure (failed tool, zero progress, blocked write,
budget exhausted, etc.).
All defaults are loaded from .env at the current working directory, falling
back to the repository root. Environment variables of the form CODER_* and
OLLAMA_* always take precedence.
Copy the template and adjust:
cp .env.example .env| Variable | Default | Description |
|---|---|---|
CODER_DEFAULT_MODEL |
qwen2.5-coder:7b |
Ollama model used when --model is omitted. |
CODER_DEFAULT_MAX_STEPS |
30 |
Default step budget for the agent loop. |
CODER_MAX_CONTEXT_MESSAGES |
30 |
Cap on messages kept in the prompt window. |
CODER_DEBUG_OLLAMA |
false |
Verbose mode: dump prompts/responses. |
CODER_OLLAMA_NUM_CTX |
8192 |
num_ctx sent to Ollama. |
CODER_OLLAMA_TEMPERATURE |
0.3 |
Sampling temperature. |
CODER_OLLAMA_REPEAT_PENALTY |
1.1 |
Anti-repetition penalty. |
CODER_SEARCH_TIMEOUT_SECONDS |
20 |
Per-search timeout (grep/glob). |
CODER_SHELL_TIMEOUT_SECONDS |
30 |
Per-shell-command timeout. |
CODER_BLOCKED_SHELL_TOKENS |
rm -rf /,shutdown,reboot,mkfs,git reset --hard,git checkout -- |
Comma-separated substrings that are never allowed in shell. |
OLLAMA_BASE_URL |
http://127.0.0.1:11434 |
Where to reach the Ollama HTTP API. |
OLLAMA_TIMEOUT_SECONDS |
120 |
HTTP timeout per chat completion. |
| Tool | Mode | Description |
|---|---|---|
list_dir |
both | List entries in a directory (relative to the workspace root). |
read_file |
both | Read a UTF-8 file. |
write_file |
agent only | Write/overwrite a file. .py files are syntax-checked via compile(). |
search |
both | Full-text search. |
grep |
both | Regex search on a path. |
glob |
both | Globbing with brace expansion. |
shell |
agent only | Run a bash command. Asks for y/always/N permission per command head; blocked tokens are rejected; output capped to 30 000 chars. |
Plan mode restricts the toolset to read-only operations and rejects
write_file / shell unconditionally — even if the LLM emits them.
The first time coder runs inside a directory it creates:
<repo>/.coder/
├── memory/
│ ├── project.md # Project-scoped notes (edit freely; auto-loaded into prompt)
│ └── user.md # User-scoped notes (edit freely; auto-loaded into prompt)
├── sessions/
│ └── last.jsonl # Append-only log of every run (prompt, steps, tools, output)
└── ignores # Paths the agent should skip when exploring
Memory files are plain Markdown — read them, edit them by hand, version
them or .gitignore them. They are injected as a ## Memory block in the
system prompt at the start of every run.
sessions/last.jsonl is the audit trail. Each line is a JSON object with the
prompt, plan mode flag, number of steps, full tool_history, and the final
output. Use it to debug or to mine examples.
| Surface | Defense |
|---|---|
| Plan mode | The executor rejects write_file and shell regardless of what the LLM tries; the prompt also tells the model to stay read-only. |
| Shell tool | Interactive y/always/N permission per command head; CODER_BLOCKED_SHELL_TOKENS substring blocklist; 30 s timeout; 30 KB output cap. |
| Write tool | .py files are compile()-checked before being written. SyntaxErrors are surfaced as failures and the file is not written. |
| Honesty gate | The final output is rewritten with ❌ whenever the last tool failed, a doom loop was detected, the step budget was exhausted with no answer, or an agent run produced no actions. The success banner ✅ Task completed successfully! is only shown on real success. |
| Doom loops | The same (tool, args) repeated above the threshold triggers an explicit failure to break the cycle. |
| Prompt injection | Attached files are wrapped in a <user-attached-files> block with an instruction to treat the content as data only. Manually verified against a basic rm -rf injection attempt. |
A full production-readiness report (12-test battery covering coding, finance, organization, attachments, security and edge cases) is available at tests/acceptance/PRODUCTION_READINESS_REPORT.md.
# Full unit + integration suite (no Ollama needed, ~0.4 s)
uv run --extra dev pytest
# Or with the venv directly
.venv/bin/python -m pytest tests/unit tests/integration -q
# Lint
uv run --extra dev ruff check coder testsOptional acceptance tests under tests/acceptance/ are Markdown specs to be executed manually against a live Ollama server.
Current state: 72 / 72 passing, ruff clean.
.
├── coder/
│ ├── entrypoint.py # CLI parser, one-shot vs interactive
│ ├── main.py # build_initial_state, attachment resolution
│ ├── config.py # .env loader, Settings dataclass
│ ├── graph/
│ │ ├── langgraph_graph.py # LangGraph wiring (main runtime)
│ │ ├── graph.py # Legacy hand-rolled loop (kept for reference)
│ │ ├── state.py # AgentState / AgentDecision dataclasses
│ │ └── nodes/
│ │ ├── assemble_context.py
│ │ ├── reason_and_decide.py
│ │ ├── tool_execution.py
│ │ ├── inject_result.py
│ │ ├── manage_context.py
│ │ └── finalize.py # Honesty gate lives here
│ ├── llm/
│ │ ├── ollama.py # HTTP client for /api/chat
│ │ └── system_prompt.py
│ ├── memory/
│ │ ├── automemory.py # .coder/ layout bootstrap
│ │ └── loader.py
│ ├── tools/
│ │ ├── filesystem.py # read_file / write_file / list_dir
│ │ ├── search.py # search / grep / glob
│ │ └── shell.py
│ └── utils/
│ ├── budget.py # token estimation
│ ├── diff.py
│ ├── display.py # CLI banner, progress
│ └── paths.py
├── tests/
│ ├── unit/ # 59 fast unit tests
│ ├── integration/ # 9 in-process graph runs (mocked LLM)
│ └── acceptance/ # Markdown specs for live runs
├── scripts/
│ └── install.sh
├── pyproject.toml
├── .env.example
├── AGENTS.md # Reverse-engineering notes used to design the agent
└── README.md
| Symptom | Likely cause / fix |
|---|---|
Connection refused on first run |
ollama serve not running. Start it in another terminal. |
model 'foo' not found |
ollama pull foo first, or pick a model you already have. |
Output starts with ❌ A final answer was not reached… |
Step budget too low. Try --max-steps 15. |
Output starts with ❌ Agent finished without performing any actions… |
The model produced no valid JSON envelope. Try a stronger model, simpler prompt, or re-run. |
Output starts with ❌ Task did not complete cleanly… |
The last tool failed. Read the appended tool_history in .coder/sessions/last.jsonl for the exact error. |
SyntaxError in Python content from write_file |
The model emitted broken Python. The file was not written. The agent should retry on the next step. |
| Very slow steps (60 s+ per LLM call) | Expected with 7B+ models on CPU. Use a smaller quantization or move to GPU. |
Tests fail with ModuleNotFoundError |
Run uv sync --extra dev && uv pip install -e . to install in editable mode. |
MIT — see LICENSE.
OpenCoder's design is a direct reverse-engineering of the
opencode TypeScript runtime,
documented in AGENTS.md. It uses
LangGraph for the state graph and
Ollama for local model serving.