A minimal, terminal-based coding agent that talks to a local Ollama model. Its one defining constraint is token discipline: the model never reads a whole file. Every look at your code goes through a narrow, budgeted tool, and large tool output is purged from the conversation as soon as the turn that needed it is over.
The name is the Greek metron — a measure. That is the whole design: measure what the model is allowed to see.
For the reasoning behind the boundary, read A Local-First Coding Agent Needs a Measurable Boundary. The article explains the design and its limits; the repository and release assets remain the authoritative source for the implementation. If you are evaluating review practices for agent-assisted changes rather than building a local tool, the free AI-assisted code review kit provides a separate bounded exercise, worked answer, and worksheet. It is complete without a subscription.
=== metron (model: qwen3.8:27b-mlx) ===
Context-disciplined terminal coder. /help for commands, /exit to quit.
config: .metron.json
metron > make Greet return "hola" instead of "hello"
[executing: find_symbol]
[executing: view_slice]
[executing: apply_patch]
Greet now returns "hola" (greet.go:4).
For a detailed evaluation sequence and discussion guide, read the Metron evaluation brief.
A 32B model on a laptop has a finite context window, and the fastest way to waste it is to paste in a 2,000-line file so the model can change one line. metron makes that impossible:
| Budget | Default | Enforced by |
|---|---|---|
| Lines per file read | 121 | view_slice rejects wider ranges outright |
| Characters per line | 500 | view_slice clips longer lines and marks the cut |
| Files listed | 60 | list_files truncates and says so |
| Search matches, total | 10 | search_text truncates and says so |
| Search matches per file | 2 | ripgrep's --max-count |
| Model round-trips per turn | 10 | the agent loop returns max turns exceeded |
| Slice retained after a turn | none | history compaction replaces it with a placeholder |
| Messages carried between turns | 60 | oldest exchanges are dropped once a turn completes |
Every one of those is configurable — see Configuration.
The model is told, in the system prompt, that guessing and asking for whole files are both off the table. The tool surface is what makes that instruction enforceable rather than aspirational.
-
Go 1.26.6+ to build. The patch floor is security-sensitive because released binaries include the Go standard library.
-
A running Ollama server with a tool-capable model pulled. Tool calling is not optional here — without it the agent cannot see your code at all. Check with
curl -s localhost:11434/api/tags | grep tools. -
ripgrep (
rg) — required bysearch_text. -
Universal Ctags — required by
find_symbol. The BSDctagsshipped with macOS/Xcode does not work; it rejects the--fields=+nKflag metron relies on. Install it with Homebrew and put its binary directory first onPATH:brew install universal-ctags export PATH="$(brew --prefix universal-ctags)/bin:$PATH"
The
exportapplies to the current shell; add the same line to your shell profile if you want it to apply to future sessions. -
git, since
apply_patchedits your files withgit apply.
metron checks all of this at startup and prints a warning per problem, naming the tool that will be unavailable and how to fix it. Nothing is fatal: a missing binary disables one tool, and the model is told so when it tries to use it.
The test suite needs none of these — see Testing.
To try a published binary, open the latest release
and choose the archive for your platform: darwin_arm64, darwin_amd64, linux_arm64, or
linux_amd64. Each archive includes the metron binary and its runtime documentation.
For a source-based install:
go install github.com/mdabydeen/metron/cmd/metron@latestAfter downloading a release archive, extract the metron binary and put it on your
PATH. Replace the archive name with the one for your platform:
ARCHIVE=metron_0.1.0_darwin_arm64.tar.gz
mkdir -p "$HOME/.local/bin"
tar -xzf "$ARCHIVE"
install -m 0755 metron "$HOME/.local/bin/metron"
export PATH="$HOME/.local/bin:$PATH"
metron --version
metron --doctorRun the first check from inside a Git repository. The project boundary is part of
metron's safety model, so --doctor reports the project is not a git repository
when it is run from a temporary directory or a folder outside a repository. Start with
the repository you want to inspect, then run the check there:
cd ~/code/my-project
metron --doctorIf the check reports that the configured model is missing, list the models available to
your Ollama server and set model in .metron.json (or in the home configuration file)
to one that advertises tool support:
ollama list
cp metron.example.json .metron.json
# edit .metron.json, then check again
metron --doctorThe built-in model name is a placeholder. --doctor does not download a model or make
an inference request; it reports the missing setup so you can choose the model and
project deliberately.
The archive names use the pattern metron_<version>_<os>_<arch>.tar.gz; the release page
contains the matching Darwin and Linux variants.
Or build from a clone:
make build # -> bin/metron
make install # build, then sudo cp to /usr/local/bin
go run ./cmd/metron # run without buildinggo install does not stamp a version, so metron --version reports dev for those builds.
Released binaries and make build both carry the real one.
After installation, run metron --doctor. It checks the resolved project and
configuration, required local binaries, Ollama connectivity, the configured model, and
whether that model advertises tool support. It performs no inference and exits non-zero
when the setup is incomplete, so it is also suitable for bootstrap scripts.
Release archives include SPDX SBOMs. The release workflow is also configured to publish GitHub build-provenance attestations; verify that an attestation is present for a specific release before using the command below:
gh attestation verify metron_<version>_<os>_<arch>.tar.gz --repo mdabydeen/metronRun metron anywhere inside the repository you want it to work on. It finds the enclosing
git work tree and treats that as the project: tools resolve paths against it, and the ctags
index is written to <project>/.tags regardless of which subdirectory you started in.
Outside a repository, the working directory is the project.
cd ~/code/my-project
metronThe project directory is also a boundary. Every path a tool touches is resolved and
refused if it lands outside — view_slice will not read ~/.ssh/id_rsa, and apply_patch
will not write to ../... Symlinks are followed before the check, so a link out of the tree
does not get around it. See SECURITY.md.
For scripts and CI, -p runs a single request and exits. The answer goes to stdout and
everything else to stderr, so the result is pipeable:
metron -p "which files define Greet?" 2>/dev/nullThere is nobody to answer the approval prompt in this mode, so apply_patch fails closed:
patches are refused unless you pass --yes; auto_approve_patches never bypasses this
one-shot safeguard. --version prints the build stamp; -h lists the flags.
Type a request at the prompt. Each line is one turn: metron sends it to the model, runs
whatever tools the model asks for (echoing [executing: <tool>] as it goes), and prints the
final answer. Conversation history persists across turns within a session and is never
written to disk.
| Flag | Effect |
|---|---|
-p, --prompt |
run one request non-interactively and exit |
--yes |
apply patches without asking (required by -p to edit files) |
--doctor |
check dependencies, Ollama connectivity and model tool support, then exit |
--version |
print the version and exit |
-h |
list the flags |
| Command | Effect |
|---|---|
/help |
list the commands |
/config |
print the effective settings and which file they came from |
/reset |
clear the conversation history, keeping the system prompt |
/history |
show how many messages and bytes the session is carrying |
/tags |
rebuild the ctags index — do this after a big refactor |
/exit |
quit (also exit, quit, /quit, or Ctrl-D) |
metron edits your working tree, but not behind your back: it prints the model's diff and waits
for a y before touching anything. Anything else — n, a bare Return, or EOF — declines, and
the refusal goes back to the model as text so it explains the change instead of retrying.
Patches are also dry-run through git apply --check first and rejected as a whole if they do
not apply cleanly, so a bad patch leaves your files untouched. Even so, work on a clean branch
and commit before you start, so git diff shows you exactly what the model did.
Set auto_approve_patches to true to skip the prompt.
A big local model can spend minutes on one answer. Ctrl-C during a reply cancels that turn and returns you to the prompt with the session intact; Ctrl-C at an idle prompt quits.
Settings resolve in three layers, each overriding the one before:
built-in defaults < config file < environment variables
JSON, and every key is optional — anything you leave out keeps its default. metron uses the first file it finds:
$METRON_CONFIG, if set (an explicit path short-circuits the search)<repository-root>/.metron.json— per-project, found even when metron starts in a subdirectory$METRON_CONFIG_DIR/.metron/config.json, else~/.metron/config.json
Set METRON_CONFIG_DIR to use a specified base directory. This works with both release-archive and Go installations; the config is read at runtime, independent of where the binary is installed.
For example: METRON_CONFIG_DIR=/srv/metron metron uses
/srv/metron/.metron/config.json.
Copy metron.example.json to get started; it lists every key at its
default value.
cp metron.example.json .metron.json| Key | Default | Meaning |
|---|---|---|
endpoint |
http://localhost:11434/api/chat |
Ollama chat endpoint, path included |
model |
qwen2.5-coder:32b |
model name to request |
timeout_seconds |
180 |
seconds of silence before a model call is abandoned |
stream |
true |
print the reply as it is generated |
temperature |
0.1 |
sampling temperature |
top_p |
0.95 |
nucleus sampling cutoff |
num_ctx |
16384 |
context window requested from Ollama |
max_output_tokens |
4096 |
maximum tokens Ollama may generate in one model call |
max_turns |
10 |
model round-trips allowed in one user turn |
compact_threshold_bytes |
400 |
tool output above this size is purged after the turn |
max_history_messages |
60 |
messages kept after a turn, excluding the system prompt |
max_slice_lines |
120 |
widest span view_slice will read |
max_line_chars |
500 |
longest single line view_slice will emit |
auto_approve_patches |
false |
apply patches without showing the diff and asking |
search_max_matches |
10 |
total search_text results |
search_max_per_file |
2 |
search_text results per file |
list_max_entries |
60 |
paths list_files will return |
disabled_tools |
[] |
tool names to withhold from the model entirely |
allowed_commands |
[] |
argv prefixes run_command may execute; empty withdraws the tool |
command_timeout_seconds |
120 |
wall clock one run_command gets |
max_command_output_bytes |
4000 |
combined output run_command returns |
A file that exists but cannot be read or parsed is a startup error, not a silent fallback —
including unknown keys, so a typo like "modle" is reported instead of ignored. Values are
validated (positive budgets, top_p in (0, 1]) before the agent starts.
max_output_tokens is sent to Ollama as num_predict. This makes every model call finite;
Ollama's own default permits unlimited generation. The limit applies separately to each
round-trip in an agent turn, including replies that request tools.
The default model is probably not yours. qwen2.5-coder:32b is a placeholder; set model
to something you have actually pulled and that reports the tools capability.
timeout_seconds bounds silence, not total generation time. It is an idle watchdog: it
resets every time a chunk arrives, so a reply that takes ten minutes but keeps producing tokens
is never cut off, while a genuinely hung server still gives up after 180 seconds. Raise it only
if your hardware needs longer than that to produce the first token.
Setting stream to false restores the old behaviour — one blocking request, no output until
the reply is complete. The watchdog then covers the whole call, which is where the old
context deadline exceeded failure came from, so raise timeout_seconds if you turn streaming
off on slow hardware.
Two variables override the file, for one-off runs:
| Variable | Overrides |
|---|---|
OLLAMA_HOST |
endpoint |
OLLAMA_MODEL |
model |
METRON_CONFIG |
which config file is read |
METRON_CONFIG_DIR |
base directory containing .metron/config.json |
METRON_ALLOW_PROJECT_COMMANDS |
when 1/true/yes, let a project .metron.json grant allowed_commands |
OLLAMA_MODEL=gemma4:12b-mlx metron/config inside the REPL prints the result of all of this, so you never have to guess which
layer won.
The per-project <repository-root>/.metron.json is untrusted: a repository can ship one,
and so it could be used to turn metron loose. It may tune budgets and the model, but it
cannot enable auto_approve_patches or grant allowed_commands. A project file that
asks for either is ignored with a warning on stderr at startup and under --doctor. To let
a specific project's allowed_commands through for a run, set METRON_ALLOW_PROJECT_COMMANDS:
METRON_ALLOW_PROJECT_COMMANDS=1 metronYour own config -- ~/.metron/config.json, or the file METRON_CONFIG names -- is trusted
and may set either, as may the OLLAMA_HOST and OLLAMA_MODEL overrides. See
SECURITY.md for the full threat model, including the residual risks metron
does not close.
metron only advertises tools that can actually run. A missing ripgrep withdraws list_files
and search_text; a BSD ctags withdraws find_symbol; running outside a git repository
withdraws apply_patch; an empty allowed_commands withdraws run_command.
disabled_tools withdraws whatever you name, and an unknown name there is a startup error
rather than a silent no-op.
This is a budget, not just tidiness. Tool schemas are sent with every single request, so
an unusable tool is a tax on every turn — and naming it in the system prompt invites the
model to call it and waste a turn finding out. /config reports both the set and its cost:
tools: list_files, find_symbol, search_text, view_slice, apply_patch (1150 schema bytes, ~287 tokens per request)
On a Mac with no ripgrep and the system ctags, that same line reads:
tools: view_slice, apply_patch (493 schema bytes, ~123 tokens per request)
If the model calls a tool that was not advertised, the call is refused before it runs, with a message telling it not to retry.
Six tools, all methods on the tools.Env that holds the project root, the budgets and the
command allowlist. This is the complete surface the model has for touching your code.
Answers "what is here?" — the question the other four tools all assume you already know the
answer to. Runs rg --files, optionally narrowed by a glob (internal/**/*.go), capped at
list_max_entries with a [truncated to N entries; narrow with a glob] line.
Because it goes through ripgrep it honours .gitignore for free, so build output and vendored
trees never reach the model's context.
Answers "where is this defined?" from a ctags index. Builds .tags lazily on first use
(ctags -R --fields=+nK, excluding .git and vendor) and reuses it afterwards, then matches
the symbol name exactly.
Greet [func] -> greet.go:3
Greet [type] -> internal/other/dup.go:9
The index is not refreshed automatically. Run /tags after a large refactor.
A deliberately crippled ripgrep: rg -n --max-count=<per-file> <pattern> ., with the overall
result count capped afterwards. Both caps matter, and only one of them can be delegated:
ripgrep's -m and --max-count are the same flag and it is per file, so the total
budget is applied by metron itself. Over-budget output is truncated with a
[truncated to N matches; narrow the pattern] line so the model knows it is not seeing
everything. A clean miss returns No matches found.; a bad regex or a missing rg comes back
as an error the model can read and react to.
Reads one line range from one file, numbered %5d | (that delimiter is also the marker
history compaction looks for later). Ranges wider than max_slice_lines are refused, as are
inverted ranges. A range running past EOF is silently clamped.
Applies a unified diff via git apply --check - followed by git apply -, both fed over
stdin. Patch failures are returned as text, not Go errors, so the model sees git's own
complaint and can correct the diff itself. Only a missing git binary surfaces as a real
error, since that is an environment fault rather than a bad patch.
Runs one command in the project and returns its exit status and output, so the model can
check its own work instead of asserting it. Off by default — with no allowed_commands
set, the tool is not offered at all.
There is no shell. The command is split on whitespace and executed directly, so ;, &&,
|, redirection and globs are never interpreted; they arrive as literal arguments and the
program rejects them. The allowlist matches whole argv tokens, so "go test" permits
go test ./... and refuses go tool, gotcha test and env go test.
Each run is asked about at the prompt, killed with its whole process group at
command_timeout_seconds, and clipped to max_command_output_bytes — keeping the head and
the tail, since that is where a compiler puts the offending file and the summary.
A non-zero exit is data, not an error: "the tests still fail" is exactly what the model asked to find out.
{ "allowed_commands": ["go test", "go build", "go vet"] }Choose these entries with the care you would give a sudoers file. "go" permits
go run ./anything and go generate directives supplied by the repository; "make" permits
whatever its Makefile recipes do. Allowed commands also inherit the full process environment,
including exported credentials such as GITHUB_TOKEN and AWS_* variables. See
SECURITY.md.
list_files follows .gitignore, but view_slice reads a named path directly, so it can still
read a gitignored file such as a local .env when the model knows its name. Path confinement
resolves and checks a path before using it; a concurrent symlink swap leaves a residual
time-of-check-to-time-of-use risk, as described in SECURITY.md.
cmd/metron/main.go REPL and commands. No conversation state lives here.
internal/config Settings: defaults, JSON file, environment, validation.
internal/ollama HTTP client for Ollama's /api/chat, plus the shared wire types.
internal/agent The agent loop, the system prompt, tool schemas, compaction.
internal/tools The six tools, the project confinement, the dependency check.
Seams are interfaces, so each layer can be driven in isolation: agent.New takes a Chatter
(the one-method subset of *ollama.Client), and run takes a stepper plus a scanner and an
io.Writer instead of touching stdin directly.
Agent.Step appends the user message, then iterates at most max_turns times:
- Call the model with the full history and all five tool schemas.
- Append the reply to history.
- If the reply has no tool calls, compact the history and return the reply — done.
- Otherwise run every tool call in the reply, appending each result as a
role: "tool"message, and go back to step 1.
Exceeding the limit returns a max turns exceeded error. Unknown tool names and tool failures
are appended to history as strings rather than raised as Go errors: the model sees its own
mistakes and gets a chance to recover.
After every reply metron prints what the turn cost:
[1240 prompt / 89 generated tokens · 3 tool calls]
The counts come from Ollama's own prompt_eval_count and eval_count. Once the prompt passes
80% of num_ctx a warning follows it, since that is the point at which the next turn starts
losing room rather than merely using it. /history shows what the session is carrying and
/reset clears it.
compactContext runs once per completed Step, after the model has given its final
non-tool-call answer. Any tool message longer than compact_threshold_bytes that contains
the view_slice delimiter (" | ") is replaced with:
[File slice redacted after turn completion]
The model still saw the full slice while it was working; it just does not carry it into the
next user turn. search_text and find_symbol output is left alone — it is already bounded
and small.
Keep new tools narrow and output-bounded — that constraint is the entire point of this codebase. Three edits:
- Implement it in
internal/toolsas a plain function returning(string, error). - Add its JSON schema to the package-level
toolDefsslice ininternal/agent/loop.go. - Add a
casefor it inAgent.dispatch.
Then extend TestDispatchRoutesToolsAndReportsErrors and TestStepAdvertisesEveryTool.
Three tiers, in increasing order of what they need from the world.
make test # go test ./...
make race # go test -race
make cover # coverage profile + total
make vet # go vet ./...
make check # all of the above100% statement coverage across all five packages, race-clean, and no external services:
- Ollama is replaced by
httptestservers that assert on the outgoing request body and return scripted replies, including tool calls. rgandctagsare replaced by executable shell-script shims written into a temp directory that becomes the entirePATH(seeinternal/tools/helpers_test.go). An empty shim directory is how the "binary not installed" branches get exercised.- git is used for real, in throwaway repositories under
t.TempDir(); those tests skip themselves if git is missing. - Every test that touches the filesystem runs in its own
t.TempDir()viat.Chdir.
e2e_test.go ties it together: a scripted fake Ollama drives the real HTTP client, agent
loop, and tools through a find_symbol → view_slice → apply_patch session against a real
temporary git repository, and asserts the file on disk actually changed.
Shims prove metron calls ripgrep correctly; they cannot prove ripgrep behaves the way metron
assumes. The integration build tag closes that gap by running the tools against the real
thing, and the image exists because many developer machines cannot — macOS ships BSD ctags and
no ripgrep at all.
make docker-test # full suite + integration tests
make docker-race # ... with the race detector
make docker-cover # ... with a coverage totalThe image (Dockerfile.test) is golang:1.27-alpine plus ripgrep,
Universal Ctags, git and a C toolchain, and it runs the tests unprivileged on purpose:
several cases assert on permission failures, which root would bypass and silently skip.
docker-compose.yml exposes the same three runs as services.
This tier earns its keep. It is what caught the search budget being unenforced: -m and
--max-count are the same ripgrep flag, so the original --max-count=2 -m 10 collapsed to
"10 matches per file, no total cap" — the opposite of the advertised budget. No shim would
ever have noticed.
If
docker pullhangs on your machine witherror getting credentials, the Docker credential helper is the problem, not the image:DOCKER_CONFIG=$(mktemp -d) docker pull golang:1.27-alpinedoes an anonymous pull.
make test-live # smallest tool-capable model installed
METRON_TEST_MODEL=gemma4:12b-mlx make test-live # pin one
METRON_TEST_TIMEOUT_SECONDS=1800 make test-live # slower hardwareTalks to your actual Ollama server. It discovers a tool-capable model from /api/tags rather
than assuming the default is installed, and skips cleanly if the server is unreachable or no
such model exists. It checks that a real model round-trips, that it actually drives the tools
instead of answering from imagination, and that a patch session edits a real file.
Because model behaviour is not deterministic, the patch test reports a model that fails to produce a valid diff as a skip with the transcript, not a failure — the agent loop breaking is a failure; a small model writing a bad diff is a fact about the model.
Observed on a 12B local model (gemma4:12b-mlx), with neither ripgrep nor Universal Ctags
installed, so only view_slice and apply_patch were actually usable:
[executing: find_symbol] [executing: search_text] [executing: find_symbol]
[executing: view_slice] [executing: view_slice] [executing: apply_patch]
reply: Changed "hello" to "hola" in `greeter.go`.
--- PASS: TestLivePatchWorkflow (9.45s)
That run is also why tool failures are phrased at the model rather than at you. With the
earlier bare Error: ripgrep error:, the same model retried search_text eight times and
exhausted the turn budget without editing anything. Told plainly that the tool is unavailable
and not to retry, it tried once, fell back to view_slice, and finished the job.
- No built-in fallback if
rgor a compatiblectagsis missing; the affected tools are withheld from the model and the startup check warns you. .tagsis built once and never invalidated automatically — use/tags.- Trimming history drops the oldest exchanges silently; the model is not told that earlier context is gone.
- Conversation history is lost when you exit.
- Command execution must be explicitly allowlisted, so the default configuration cannot run tests after an edit.
See the project maturity audit and roadmap for prioritized gaps, and CHANGELOG.md for what has landed.
If your team is reviewing how an AI-assisted change should be authorised, the public Stopline team evaluation guide walks through one allow, ask, or block decision with explicit evidence and recovery ownership. The free AI-assisted code review kit provides a separate exercise, worked answer, worksheet, and facilitator guide. Teams that want facilitated discussion can read the proposed private workshop scope. These are evaluation and teaching resources; they do not establish Metron production readiness, support, or certification.
Metron is early, and compatibility depends on the local model and environment. Use the public
Q&A discussion to share the model,
hardware, metron --doctor result, task, and any tool or patch-approval issue you observed.
As one maintainer smoke check, gemma4:12b-mlx on macOS arm64 with a local Ollama server
returned doctor: ready after Universal Ctags was placed first on PATH. A no-write
list_files request then confirmed that the repository-root README.md existed. This is one
setup and one task; it does not establish general compatibility, performance, support, or
production readiness.
Please report reproducible defects through GitHub Issues and security concerns through SECURITY.md.
Bug reports and pull requests are welcome. Read CONTRIBUTING.md first: metron is small on purpose, and the constraints there are what a PR is judged against. Participation is governed by the Code of Conduct.
Security issues go through SECURITY.md, not the public tracker. That file also documents what metron actually does to your working tree, which is worth reading before you point it at a repository.
Apache-2.0. Copyright 2026 Mike Dabydeen.