A coding agent for the terminal that runs local models in tandem through LM Studio. I built it in Rust in order to become better acquainted with the language.
- The planner, a dense model at medium reasoning effort, writes a plan in two parts, what will change for you, which you approve, and the steps for the worker, and later reviews the diff.
- The worker, a fast mixture-of-experts model with thinking off, runs the tool loop: reads, searches, edits, runs commands, follows the plan.
- The scout, a small model, answers the other two's questions about where things live, decides whether a task needs a plan, and writes the note a long conversation is folded into.
Built on a Mac Studio with qwen/qwen3.8-27b as the planner,
qwen3.5-122b-a10b as the worker and qwen/qwen3.5-9b as the scout. Any
models LM Studio serves will do.
Weft draws an input box at the bottom of the terminal and writes the
transcript above it, into the terminal's own scrollback, so it is still there
when you leave. Here it is on a React + Node monitoring board, given "sort the
monitor tiles so anything that's alerting or warning comes first in its
group" (with /plan, so it plans whatever the task's size), five and a half
minutes sped up:
Each ⏺ line is a tool call with the first line of its result under it;
looks asked for together run at once. An edit or a new file shows the code
itself, removed lines in red and added lines in green, numbered as they sit
in the file. The plan sits in a cyan gutter, and its steps and the tests its
Done when asks for become the checklist, ticked off as the worker goes.
When the worker is done, weft runs the project's checks itself, runs the new
test again with the change taken out (it should fail, and did), then the
reviewer reads the diff, looking around the code with read-only tools. Here
the worker's first checklist left out the plan's tests and was refused, so it
put them back and wrote one before the reviewer approved. Edits, new files
and shell commands ask first, and one key answers. The whole run, as one
image: docs/weft.png.
The task ends with a result card: each Done when line with the test that
covers it, and whether that test fails without the change:
Every plan waits for your word. It opens with what will change for you, in
the app's terms, and any choices to confirm; the worker's file-by-file steps
fold to one line, and /steps opens them:
Keys
| Key | Does |
|---|---|
| enter | sends the task |
| shift-tab | cycles how much weft asks: ask → edits auto → all auto → plan first |
ctrl-j, or \ + enter |
a newline |
| ↑ ↓ | history |
| esc | stops the task |
| y / a / n | answers an approval: once, always this session, no |
| ctrl-c twice | leaves |
The input box uses the Emacs keybindings bash and zsh use: ctrl-a and ctrl-e for the ends of the line, ctrl-k to cut to the end, alt-b and alt-f to move by a word, and so on.
Commands (/ opens the menu)
| Command | Does |
|---|---|
/plan <task> |
plans first and waits for your word on the plan |
/go <task> |
straight to work, no plan |
/accept |
edits and new files stop asking for the session; shell commands still ask |
/undo |
puts back the files the last task changed |
/compact |
folds the earlier tasks into a note now |
/clear |
starts a fresh conversation |
/resume |
lists this folder's earlier sessions; a number, or /resume 2, picks one up |
/restart |
leaves and comes straight back to this session, on the installed build |
/handoff |
compacts, then hands this session to [handoff] command in the same terminal |
/think |
toggles the worker's thinking |
/steps |
the worker's steps of the last plan, folded on the screen |
/todos |
the worker's checklist; /todos clear empties it |
/tasks |
this session's tasks and how each ended |
/report |
the last task's result card again; /report 2 for the second |
/memory |
what weft has kept about this project |
/cost |
tokens used this session |
/mcp |
the MCP servers and their tools |
/help, /quit |
- Project note. Every task opens with a few lines on the folder: its
path, stack and git state, the file tree three levels deep, your
WEFT.md, and what weft remembered from earlier tasks. - Triage. The scout's small model (the worker, without one) is asked,
in one word, whether the task needs a plan. A question or a one-spot change goes straight to work; anything wider
is planned first.
/plan <task>and/go <task>override it. - Plan. The planner looks at the code with read-only tools, or asks the
scout, then writes a plan in two parts. The first is for you, in the app's
terms with no file names: what you asked for, in your words; what will
change for you; choices to confirm, where it read the request one way
of several or did something other than what you named (marked ⚠); how
the pieces fit together; and
## Done when, what must work as you would try it. The second, from## Steps, is for the worker: the files and changes (naming the helpers nearby code already uses), the command that verifies, and the pitfalls. When the change alters an idea the code holds in more than one place (which day a week starts on, a unit, a format), the plan adds## Searched for: the ways the code expresses it and where each search found them, so the worker and the reviewer can check none is left on the old idea. A plan that skips the first part is asked for again, once; out of look-arounds, the planner writes from what it has. The screen shows your part and folds the worker's to one line;/stepsopens it. - Work. The plan's steps become the worker's checklist, shown as it
goes (
☒done,◼under way,☐not started); the status line names the step under way and the tool running now. The worker follows the plan with the full tool set, runs the check, and reports what changed aspath:line. Each## Done whenline becomes aTest:step: a test that drives it through the path the user would take. The worker may rewrite the list, but a new list that leaves out an openTest:orFix:step is refused. If the worker answers with steps still open, it is sent back, up to twice, to finish them or say why it cannot. - Checks. If files were touched and the project has a
verifycommand (see[[project]]below), weft runs it itself. A failure goes back to the worker, thinking on, with the end of the output, up to three times. - Tests without the change. A passing test can still prove nothing, so
before each review weft copies the project to a temporary folder, puts
the task's changed code (not its tests) back as it was, and runs the tests
the task added there. Each should fail. One that passes without the
change tests something that was already so, and the reviewer is told
which. Your working tree is never touched. The tests run with the
[[project]]'stestcommand, or one weft guesses: vitest or jest frompackage.json, pytest, orcargo test. weft also lists every existing assertion the task changed or took out, renames aside, so a check moved off its edge to make it pass is seen. - Review. If files were touched, by the edit tools or through
bash(git shows what changed), the planner model reads the diff, the checks' result, the run without the change, the changed assertions, the task and the plan. It may look past the diff with read-only tools, a few calls, and goes through a list: failing checks; a test for eachDone whenline that fails without the change; changed assertions the request does not call for; new code using the helpers its neighbours use; and anything still naming what was removed or working out the old idea by hand. It answersAPPROVEorFIX, names the test that covers eachDone whenline, and lists its asks, which join the checklist. OnFIXthe worker goes again, thinking on, the checks run again, and the reviewer reads the new diff; earlier asks it does not make again are ticked off. Two rounds by default: one fix pass; asks from the last round wait on the checklist for "continue". An answer with no verdict line is asked for again, once, with no tools; still none, and the review is called inconclusive: not approved, and the worker is not sent after a guess. - Result. The task ends with a card that says whether the plan is
complete. It is complete when no step is open, the checks pass, the
reviewer approved, and every
Done whenline has a test. EachDone whenline shows its test and how it did without the change (✓fails without it,⚠passes anyway,✗no test). Under the line come the steps, the checks, the review and the time; then anything to look at (a test that passes without the change, an existing assertion changed), why it is not complete, and the steps still open. The card is green when complete, yellow when complete with something to look at, red when not. A task that took a minute or more sends a notification with the card's headline./taskslists the session's tasks with theirs, and/reportshows a card again; both come back with/resume.
Tasks given one after another share the worker's conversation, so "now do the
same for X" works. A task that reaches its cap of 120 tool rounds ends with
a report of what is done and what is left, and "continue" picks it up. A task that stops early, by esc or by running out of
room, keeps the steps it finished, closed with a line saying why, so the next
task or a resume can carry on from them. The checklist lasts the session (and
comes back with /resume): a task that ends with steps open lists them on
its result card, and the next task is shown them, so "continue" picks up at
the first open one.
Large work runs in rounds. When the plan spans more than a few files,
the planner adds a ## Tasks section and the worker does them one at a time.
When a round is done, its reads and edits are folded into a short note and
the next round begins from that, so a big ask no longer piles up in one
window. The review comes once at the end.
Every plan waits for your word. Once the plan is on the screen, enter, or
a typed "yes", lets the worker go; "no" or esc stops the task there; anything
else you type is notes, and the planner writes the plan again with them in
mind, up to three times. Your notes then travel with the task: the worker,
the reviewer and the result card see them, and they outrank the task's first
wording, so a change of mind ("no, the other way") is not reviewed back to
the original. "continue" after a stall keeps them too. A line starting with / runs as a command (/steps,
say) and the plan keeps waiting. A task given as /go <task> and the all-auto
mode go straight on, as does weft run with no terminal to ask;
confirm = "never" in the config goes straight on always.
Undo. /undo puts back the files the last task changed, and the task
before that on a second /undo. Weft's own edits and new files are always
covered. In a git repository, so is whatever a shell command changed: git
keeps a copy of the tracked tree before each task (git stash create, which
leaves the tree alone), and undo restores from it and removes files that
appeared. Your own uncommitted edits from before the task survive. The worker
is told on its next task.
The scout. Both the planner and the worker have a scout tool: one
question about where something lives or how it fits together. A separate
model run searches and reads the project and answers with paths, line numbers
and a few sentences, never file contents. What it read stays in its own
context and is thrown away, so the worker's window holds only the answer. The
models call it themselves; it shows as one ⏺ Scout … line. A small model
suits it: a 9B answers in 5–15 seconds where the 122B takes 20–30. Small
models sometimes cite a file they never opened, so the paths in every answer
are checked against the folder and the scout is sent back once if one is
missing.
The delegate. The worker can hand a self-contained part of a task to a
helper: a fresh worker on the same model, with the same tools and its own
context. The helper's edits and shell commands show on the screen, indented
under the Delegate line, and ask for approval like any other; only its
report comes back to the worker that asked, so the files it read stay out of
that context. The reviewer sees the helper's changes too, and an a given to
either the worker or a helper holds for both. A helper cannot delegate in
turn.
LM Studio exposes reasoning-effort levels only for models whose template
declares them. The 27B's does, so the planner uses the OpenAI-style
/v1/chat/completions route and asks for low, medium or xhigh.
The 122B's template has only an on/off switch, and LM Studio does not expose
it, so the worker uses the raw /v1/completions route: weft renders the
prompt in Qwen's chat format itself, starts the assistant turn with an empty
<think></think> block, and parses the <tool_call> XML the model writes. On
a small task that is the difference between a few hundred and a few thousand
generated tokens.
weft init writes a starter config, and a WEFT.md in the current folder,
where they are missing. At startup weft checks that the configured models are
loaded in LM Studio and says so if one is not. A misspelt key in the config is
an error that names the key, not a silent default. The whole config:
# ~/.config/weft/config.toml (chmod 600; it holds the token)
[lm_studio]
url = "http://127.0.0.1:1234"
api_token = "..." # or set LM_API_TOKEN
[planner]
model = "qwen/qwen3.8-27b"
plan_effort = "medium" # low | medium | xhigh
review_effort = "low" # the review is one read of the diff; low is 3x faster than medium
when = "auto" # auto | always | never
confirm = "asked" # the plan waits for your word: asked (all but /go) | always | never
review_rounds = 2 # reviews per task, each FIX but the last gets a fix pass; 0 turns review off
[worker]
model = "qwen3.5-122b-a10b"
thinking = false # the plan carries the reasoning; fix rounds think anyway
[scout]
# model = "qwen/qwen3.5-9b" # default: the worker's, on the raw route with thinking off
# effort = "low" # only for a model that honours reasoning_effort
[compact]
at = 48000 # fold the conversation once the worker's context reaches this; 0 never
window = 65536 # the worker's context window
[vision]
# model = "qwen/qwen3.8-27b" # the model that looks at images for see_image; default: the planner's
effort = "low"
[approve] # tool calls that never ask
# tools = ["edit_file"]
commands = ["cargo test", "cargo build", "git status", "git diff", "git log", "ls"]
[handoff] # what /handoff becomes; run without a shell
# command = ["weft-web", "--config", "{config}", "resume", "{id}"]
[[hooks]]
on = "after_edit" # after_edit | before_bash | after_task
run = "cargo fmt" # run with sh -c, in the project root
[[project]] # one per folder, matched by path; nothing goes in the repository
path = "~/Code/myapp"
verify = "npx tsc --noEmit && npx vitest run" # weft runs it after the worker; failures go back
test = "npx vitest run --reporter=verbose {files}" # runs the new tests without the change; guessed if left out
deploy = "npm run deploy" # how changes go live; the worker uses it, and "it's live" without it is flagged
serve = "npx vite --port {port} --strictPort" # a dev server the worker may start to look at a change
app = "http://127.0.0.1:8210" # the running app, which `live` reads (GET only)
database = ".data/app.sqlite" # its SQLite file, which `live` queries read-only
verify_timeout_s = 300
notes = """
- Commits are one line.
"""A WEFT.md in the project root is read at the start of every task and shown
to the planner, the worker, the reviewer and the scout as your standing
instructions: how the tests are run, what must never be done, how commits are
written. ~/.config/weft/WEFT.md does the same for every project, and a
[[project]]'s notes in the config do it for one folder without a file in
its repository. Keep them short; they are in every prompt.
What weft learns on its own goes through its remember tool into
~/.local/share/weft/memory/<folder>.md, one fact per line: how the tests
run, a convention, a trap it hit. The file is shown after your notes at the
start of every task, in this session and the next. /memory prints it, and
you can edit or delete lines as you like.
Edits, new files, shell commands and MCP tools ask first: [y] once [a] always this session [n] no. The terminal's bell rings when a question
comes up, and a macOS notification follows if it is still waiting after 30
seconds; a task that ran a minute or more sends one when it is done. The [approve] section of the config names
what never asks: tools by name, and shell commands by their leading words.
cargo test lets cargo test --lib through and not cargo testx; every
part of a chain joined with &&, &, ;, | or a newline has to match;
and a command with a $(…), a backtick or a > in it (2>&1 aside)
always asks, since a redirect can write over any file. The rules live in your
config, never in the project, so a repository cannot grant itself permissions.
Hooks are shell commands of yours that weft runs at set moments. after_edit
runs after every edit or new file with WEFT_PATH set, and what it prints
goes to the model with the tool result, so a formatter's changes or a linter's
complaint reach the worker at once. before_bash runs before every shell
command with WEFT_COMMAND set; a non-zero exit stops the command, and what
the hook printed is the reason the worker sees. after_task runs when a task
ends with WEFT_TOUCHED listing the files it changed, and its output is shown
on the screen.
A Markdown file in .weft/commands/ is a command: .weft/commands/review.md
is /review in the menu, and the file is the task. ~/.config/weft/commands/
holds the ones you want in every project; a project file wins on the same
name. Header lines before the first blank line may set a description for
the menu and a mode (plan, go or auto). $ARGUMENTS in the body
stands for whatever follows the command; without it, the arguments are
appended.
description: review the working tree before a commit
mode: go
Read `git diff` and list anything a careful reviewer would flag: bugs, missing
tests, naming, and departures from WEFT.md. Do not change files.Tools from MCP servers sit beside the
built-in ones. List the servers in ~/.config/weft/mcp.json, in the same
shape LM Studio and Claude Desktop use, so an entry can be copied across:
{
"mcpServers": {
"deep-research": {
"command": "/opt/homebrew/bin/node",
"args": ["/path/to/minimal-mcp-deep-research/dist/index.js"]
}
}
}command, args, env and cwd are understood. Weft starts every server
over stdio when it starts, and the worker sees each tool as server__tool,
for instance deep-research__research. Every MCP tool asks before it runs. A
server that fails is reported and skipped; /mcp and weft mcp show what
came up. The planner and reviewer keep to the read-only built-ins.
Everything a session does is written to
~/.local/share/weft/sessions/<folder>/<id>.jsonl as it happens: each task,
every message of the worker's conversation, each tool call and result, each
model turn's tokens and seconds, the plans and the reviews. It lives outside
the project, so nothing shows up in git status and the worker never stumbles
on its own history. When you leave, weft prints the session's id and the
command that picks it up again.
Resuming. weft resume reopens the folder's last session and weft resume <id> an earlier one; inside weft, /resume lists them and a number
picks one. The screen replays each task with the first line of its answer,
names the tasks compaction folded away, and shows the last plan if there was
one. The worker gets its conversation back,
with a note that the project may have changed and a fresh folder snapshot, and
the file is appended to from there. /restart leaves and comes straight back
into the same session on the installed binary, which is how a new build is
picked up without losing your place. Approvals given with a, the shift-tab
mode and the undo history belong to the process and start afresh.
Handing a session off. /handoff is /restart into another program.
weft compacts the conversation, saves it, and in the same terminal becomes
the command set in [handoff], given the session's id. Nothing has to be
copied, and the compaction is saved, so the program takes over the folded
conversation. It waits for any running task to finish, and if the compaction
fails or esc stops it, weft stays. Without a [handoff] section it says so
and does nothing. weft knows nothing about the program; the command is run
without a shell, with {id} and {config} filled in:
[handoff]
command = ["weft-web", "--config", "{config}", "resume", "{id}"]With that, /handoff moves a session onto the phone:
weft-web, a separate project, runs
weft sessions in pseudo-terminals on the Mac, which a browser on the tailnet
and any terminal can join at once. The terminal you typed /handoff in becomes
one of them, and ctrl-] leaves the session running. Run inside a weft-web
session, /handoff just carries on in place, since the session is already
there. Approvals, the shift-tab mode and the undo history start afresh, as
with /restart.
Compaction. After a task, once the worker's last prompt has reached
[compact] at, the earlier tasks are folded into a handover note written by
the scout's model: what was done, which files changed and how, decisions,
loose ends. The last task stays verbatim and the worker carries on from the
note. /compact does it on demand. Inside a task, as the window fills, old
tool results and the file text of old edits are dropped first (the disk has
them), and when that is not enough the task's earlier steps are folded into a
progress note and the worker carries on. Both change earlier messages, which
costs a partial re-prefill, so they wait until the window needs it.
The worker runs on the raw completions route, which carries no images, so
see_image sends a picture to a vision model on the chat route and brings
the words back: a description, or the answer to a question about it. Your
models are all vision models; the planner's is used unless [vision] names
another. Point the worker at a screenshot in the task text, or drop one on
the terminal, and it will look: see_image reads a picture by its full path
wherever it is. A screenshot's name has a narrow space before "PM" that a
model types as a plain one; both spellings are tried. An answer is kept for
the session, so the planner and the worker asking about the same picture
cost one look. macOS keeps iCloud Drive, Desktop and Documents from a
terminal until it is let in (System Settings › Privacy & Security); a
refusal says so.
weft # the interface, in the current folder
weft resume [id] # the interface, continuing the last (or the given) session
weft run "task" # one task on a plain line printer, then exit (for scripts)
weft run --session last --plan|--go --accept --json "task" # continue a session; plan or not; edits without asking; JSON lines
weft -y … # approve every tool call without asking
weft --think … # let the worker think before each step
weft sessions # this folder's sessions
weft tools # the worker's tools and what each costs in every prompt
weft init # a starter config and WEFT.md, where missing
weft mcp # start the MCP servers and list their tools
weft probe # ask both models one question; shows each route working
weft tool search '{"pattern":"fn main","glob":"*.rs"}' # run one tool by handEvery path is resolved against the project root and refused outside it,
symlinks included; the one exception is a picture see_image is given by its
full path. pkill, killall and force pushes are never run, and neither is
anything that stops a process (kill) or leaves one running (&, nohup):
the server on a port is often a service, which stays down once killed. A
project's deploy line is how its changes go live.
To look at a change in a browser, the worker has server: it runs the
project's serve line (or a command it gives, with {port} in it) on a free
port weft picks, waits until it answers, and stops the whole process group
when the task ends. look saves a screenshot with headless Chrome for
see_image; a phone width (under 500px, which headless Chrome won't lay out)
is shot through a frame and cropped with sips. Set CHROME if Chrome is
not in /Applications.
When a project names its app or database, the planner, reviewer and
worker get live: a GET from the running app (with pick, a JSON pointer,
for one part of a large answer) or one SELECT on its database, opened
read-only in sqlite's safe mode. The planner is told to check a reported bug
against it before explaining it from the code, and to say so in the plan when
the app already does what was asked.
| Tool | Does |
|---|---|
read_file |
numbered lines, up to 200 at a time |
list_files |
a folder listing, honouring .gitignore |
search |
a regex over the project, path:line: text, capped at 100 matches |
edit_file |
replaces one exact passage, which must match exactly once; reports path:line |
write_file |
new files only |
bash |
runs in the project root with a timeout; last 200 lines of output |
scout |
one where-or-how question, answered by a read-only run of another model |
delegate |
one self-contained piece of the task, done by a fresh worker that reports back |
see_image |
a description of, or an answer about, an image file, from a vision model |
remember |
one fact about the project, kept for later tasks and sessions |
todo |
the worker's checklist for work of several steps, shown as it goes |
server__tool |
any tool of a configured MCP server; asks first |
Looks the model asks for together (reads, listings, searches, pictures) run side by side; edits, commands and MCP tools run one at a time, in order.
The comments marked // Rust: explain each language feature where it first
appears. In order:
src/lm/— enums with data (Message,StreamEvent),String::drain, a struct with a lifetime (Parser<'a>), async streaming with channels.src/tools/— theTooltrait,Box<dyn Tool>,#[async_trait], path handling,tokio::processwithjoin!on both pipes.src/agent/— theModeltrait so tests can script a fake model, the worker loop,tokio::select!for cancellation, a forwarding task. Thentandem.rs: the same loop reused for the planner and the reviewer, and the verdict parser.proof.rs: reading a unified diff, a small tokenizer, a copy of the project made withcpand removed after.report.rs: a result built from the rest, serialised into the transcript as an event.scout.rsanddelegate.rs: the loop wrapped up as aToolthe other models can call, the latter with anArc<dyn Approver>carried in through the tool context.compact.rs: slicing a conversation and rewriting it around a note.convo.rs: the one place that writes and reads the shape of a task message, with a round-trip test.src/mcp.rs— a JSON config withserde, servers started together withjoin_all, a type alias for a long generic,Arcsharing one connection between its tools, the sameTooltrait implemented for someone else's tools.src/transcript.rs— a taggedserdeenum as a file format, one JSON line per record, aMutex<File>writer, folding records back into aSavedsession withmatch.src/hooks.rs,src/commands.rs,src/checkpoint.rsandsrc/tools/memory.rs— small modules around one idea each: atokio::process::Commandwith a timeout and an environment, a file format with a header, aMutexof kept copies that also shells out togit, an append to a file that stays idempotent.src/models.rs,src/session.rsandsrc/ui/— one struct that turns the config into model handles; the session shared through anArcwithMutexes held only briefly and anAtomicU32read off a forwarding task; the screen as atokio::select!loop over key events, agent events and a tick, split acrossevents.rs,keys.rs,slash.rsanddraw.rswithimplblocks in child modules; ratatui's inline viewport withinsert_beforefor the transcript; approvals asoneshotchannels;ui/tests.rsdriving it all through the key handlers with ratatui's test backend.src/main.rs— clap subcommands,spawn_blockingfor stdin,execfor/restart.
cargo test
cargo clippy --all-targets
cargo fmt

