Puppet Master
A research concept: cut the tokens, and the cost, of agentic development by letting a cheap local model do Claude's bulk work.
Puppet Master is a stdio MCP server. Claude stays the planner and reviewer; the bulk reading, first-draft writing and mechanical edits go to a small model that runs on your own machine (or any OpenAI-compatible endpoint). Every guardrail is enforced in code, not in a prompt:
- Reads return only claims whose quoted lines were found in the file. Unverified claims are stripped before Claude sees them.
- Writes are staged as a patch, run through your project's test/lint checks in a throwaway
worktree, and reach the repo only through
puppet_apply, which refuses a patch whose check failed. - Budgets keep every request inside the worker's context window, with BM25 retrieval to hand it only the blocks of a large file that match the question.
This is a research prototype, not a finished product. The measured results below come from one small machine, one default model and a 27-task benchmark of this repo. Docs: setup, specs, measured results.
Accuracy depends on the worker model. Reading and writing accuracy rises with a better local model: a larger model, a higher-fidelity quantization, or a reasoning model with thinking on. The numbers below are for a heavily quantized model that fits an 18GB laptop, which is close to the floor. Better models are expected to do better, and the bake-off tool (
puppet-eval) lets you measure that on your own hardware. See Using other models.
Results
All figures come from the saved runs in evals/results and from the test suite.
Test suite
| Tests | 363 passed, 22 skipped, 4 live tests deselected (they need a running model server) |
| Skipped | Port-binding tests, which skip themselves inside a sandbox that forbids binding a port |
| Line coverage | 89% of 1,847 statements (pytest --cov) |
| Lint | ruff check and ruff format --check clean |
Benchmark: the worker model on this repo's own tasks
puppet-eval runs 20 read questions and 7 "fix the failing test" write tasks through the real read
and write code, so claim verification and the staged-patch checks count toward the score.
puppet-savings estimates what Claude would have spent doing the same work itself
(chars / 4 tokens, output tokens weighted 5x).
Worker model: Flint UD-IQ2_XXS (a 40B-A2.5B mixture-of-experts model, 2-bit quantization,
11.86 GB), run by llama.cpp at temperature 0, 16K context, thinking off.
| Run | Read answers correct | Write checks pass | Claude tokens saved | Speed |
|---|---|---|---|---|
| Run 1 | 100% (20/20) | 86% (6/7) | 74.1% (reads 58.5%, writes 88.2%) | 26.5 tok/s |
| Run 2 (same setup) | 95% (19/20) | 86% (6/7) | 74.2% (reads 57.8%, writes 89.0%) | 26.4 tok/s |
The raw results are in evals/results/. Across both runs there were 0 errors, 0 unverifiable outputs and 0 reasoning tokens. Read accuracy is a keyword check: an answer counts if it mentions the required terms, so it can
over-credit as well as under-credit. Two answers first marked wrong said "rounding up" for ceil;
the grader now accepts listed alternatives and the saved answers were re-scored offline (no model
re-run). One answer is still a true miss (a vague answer that omitted two required checks). On 20
questions, read this as roughly 95-100%, not a precise rate.
Savings are estimates from character counts, not billed-token measurements, and they cover
delegable work only; planning and review stay with Claude.
A second model, for contrast (JetBrains Mellum2.1 12B-A2.5B, Q4_K_M, 8.1 GB), on the same tasks:
| Flint IQ2_XXS | Mellum2.1, thinking off | Mellum2.1, thinking on | |
|---|---|---|---|
| Read answers correct | 100% | 65% | 85% |
| Write checks pass | 86% | 71% | 71% |
| Claude tokens saved | 74% | 58% | 57% |
| Avg model time per read | 7.8 s | 7.0 s | 89 s |
The same tool gave very different accuracy on different models and settings, which is the point: the guardrails keep a weak worker safe, but a stronger worker means fewer stripped claims and more patches that pass. No larger or higher-precision model has been benchmarked yet.
Test machine
| Machine | Apple Silicon Mac with an M3 Pro chip |
| Memory | 18 GB unified (about 12 GB usable by the GPU by default) |
| OS | macOS (Darwin 27) |
| Inference | llama.cpp llama-server, Metal, flash attention, 8-bit KV cache, 16,384-token context |
| Model | flint-UD-IQ2_XXS.gguf from unsloth/flint-GGUF, 11.86 GB |
Installation
An AI agent doing this setup should follow INSTALL_FOR_AGENTS.md.
You need Python 3.11+ (developed and tested on macOS; Linux and non-llama.cpp servers follow documented behavior but are untested, see docs/setup.md), uv, git, and a model server
(step 2).
1. Get the code
git clone https://github.com/khr898/puppet-master.git
cd puppet-master
uv sync
2. Run a worker model
Pick one. All of them speak the OpenAI-style /v1/chat/completions API.
- Local, managed by Puppet Master (llama.cpp): follow
docs/setup.md. Puppet Master then starts and stops
llama-serveron demand and frees the memory when idle. - Local, you run the server: Ollama, LM Studio, vLLM, mlx_lm or
llama-serveryourself. - Hosted: OpenRouter or any OpenAI-compatible API.
The last two are covered in Using other models.
3. Register the MCP server
Claude Code CLI:
claude mcp add puppet-master -- uv --directory "$(pwd)" run puppet-master
Claude Desktop: add this to the MCP config JSON (use the absolute path to your clone), then open a new Code-tab session:
"puppet-master": {
"command": "uv",
"args": ["--directory", "/path/to/puppet-master", "run", "puppet-master"]
}
4. Turn it on in a project
Add a
puppet-master.tomlwith the project's check commands. This repo's own file is an example:[check] default = "test" [check.commands] test = ["uv", "run", "pytest", "-q", "-x"] lint = ["uv", "run", "ruff", "check", "."]Only commands listed here can run, as argument lists with no shell.
Add
.puppet-master/to the project's.gitignore. Logs, staged patches and attempt counts live there.
5. Verify
In a new Claude session, ask it to call puppet_status. It should report the worker as up (or
startable) with its token budgets. Then check the numbers after some real use:
uv run puppet-stats --since 7d
Tools
| Tool | What it does |
|---|---|
puppet_status |
Worker health and the current token budgets |
puppet_read |
Summarize or search files, line ranges or globs; returns only verified claims |
puppet_write |
Draft edits to up to 3 files; stages a patch and runs the check |
puppet_check |
Re-run a project check on a staged patch |
puppet_apply |
Apply a staged patch, only if its check passed |
puppet_reject |
Discard a staged patch and record the miss |
puppet_ask |
Free-form draft (commit message, docstring); treat as unverified |
puppet_server |
Start, stop or restart the managed llama-server (not for hosted APIs) |
Using other models and servers
Puppet Master talks to any server that exposes POST /v1/chat/completions. Three settings
select it, and each can be set by environment variable or in puppet-master.toml:
| Setting | Environment variable | puppet-master.toml |
Default |
|---|---|---|---|
Server URL, without /v1 |
PUPPET_BASE_URL |
[server] base_url |
http://127.0.0.1:8000 |
| Model id sent with each request | PUPPET_MODEL |
[server] model |
flint |
| API key (hosted APIs) | PUPPET_API_KEY |
not supported, on purpose | none |
Environment variables win over the file. The API key is read only from the environment and is
sent as Authorization: Bearer <key>, so it can never be committed with a project config. Put it in
the MCP registration (below) or your shell profile, never in a file inside a repo.
Set the model's real context window and budgets in puppet-master.toml so requests are sized to
fit. If these are too high for the model, requests fail or get truncated:
[server]
ctx = 32768 # the context the server actually gives the model
temperature = 0 # keeps quotes and edits reproducible
[budget]
chunk_tokens = 20000 # file tokens per read chunk; keep well under ctx
focus_tokens = 6000 # tokens a focused (BM25) read shows the model
Pass the environment variables when registering the server. With the CLI:
claude mcp add puppet-master \
-e PUPPET_BASE_URL=http://127.0.0.1:11434 \
-e PUPPET_MODEL=qwen2.5-coder:14b \
-- uv --directory "$(pwd)" run puppet-master
In Claude Desktop's JSON, add "env": { "PUPPET_BASE_URL": "...", "PUPPET_MODEL": "..." } beside
command and args.
Recipes
| Server | Start it | PUPPET_BASE_URL |
PUPPET_MODEL |
|---|---|---|---|
| llama.cpp (managed) | Puppet Master starts it from ~/.puppet-master/server.toml |
http://127.0.0.1:8000 |
the --alias you set |
| Ollama | ollama serve, then ollama pull <model> |
http://127.0.0.1:11434 |
the tag, e.g. qwen2.5-coder:14b |
| LM Studio | Start the local server in the app | http://127.0.0.1:1234 |
the id shown in the app |
| vLLM | vllm serve <model> |
http://127.0.0.1:8000 |
the <model> you served |
| mlx_lm | mlx_lm.server --model <model> |
http://127.0.0.1:8080 |
the <model> you served |
| OpenRouter | Create a key at openrouter.ai | https://openrouter.ai/api |
any model id from their catalog |
| Other hosted API (OpenAI-compatible) | Get a key from the provider | the provider's base URL, minus /v1 |
the provider's model id |
For a hosted API, add -e PUPPET_API_KEY=... to the registration command (it is stored in your
local Claude config, not in this repo).
Notes:
- Ollama's default context is small. Set a larger one (for example
OLLAMA_CONTEXT_LENGTH=16384in the server's environment, ornum_ctxin a Modelfile) and matchctxinpuppet-master.toml. - Unmanaged servers:
~/.puppet-master/server.tomlis only for the managed llama.cpp server. With any other server, skip it.puppet_serverthen reportsno_server_config, which is expected, and health is checked through/healthor/v1/models. - Structured output: reads and writes ask for JSON-schema output (
response_format). A server that ignores it still works, because replies are validated in code and a bad one is retried once, then reported, but a server with grammar-constrained decoding (llama.cpp, vLLM) gives the cleanest results. - Reasoning models: thinking tokens share the context window. Disable thinking on the server
where you can, or set
[budget] reasoning_reserveso chunks leave room for it. - Privacy and cost of hosted APIs. With a hosted endpoint, every file the worker reads leaves your machine for a third party, and each call costs money. That can cancel the savings and may break your code-sharing rules. Use a local server for private code.
Measure a model before trusting it
puppet-eval scores whichever model the configured server is running, on this repo's tasks (20 read
questions about this repo and 7 small fix-the-failing-test write tasks in evals):
uv run puppet-eval --label my-model
uv run puppet-eval --compare flint-iq2xxs my-model
uv run puppet-savings --label my-model-savings # runs the model: estimated Claude tokens saved
uv run puppet-savings --show flint-iq2xxs-savings # print a saved run without running a model
Results are saved to evals/results/<label>.json; compare them with the tables in
docs/results.md.
Reading the log
Every tool call is appended to .puppet-master/log.jsonl in the project. puppet-stats prints calls
per tool, the accept rate, report sizes, worker tokens, durations, and the measured Claude-token
savings: each call logs what it cost Claude (the report plus the cited lines or patch Claude reads
to check it) and, for delivered work, what Claude would have spent itself. A write counts as saved
only when applied, so rejected and failed delegations count against the worker. All figures are
chars / 4 estimates. Local training samples (.puppet-master/samples/) can be switched off with
[log] samples = false.
Develop
uv sync
uv run pytest -q # 363 tests; live tests are deselected
uv run pytest -m live # needs a model server on :8000
uv run ruff check . && uv run ruff format --check .
In a sandbox that blocks uv's default cache, prefix each command with UV_CACHE_DIR=.uv-cache.
Limits
- One benchmark, one machine, one default model. The savings are estimates, not billed tokens.
- Not yet measured: models larger or less quantized than the default, and a multi-week trial of real usage against a Claude usage limit.
- The checks run your project's own test and lint commands, so they are only as good as your tests.