Local-LLM coding-agent harness (via Ollama) with policy/observability hooks written in mq's embeddable query language — a standard ReAct-style tool-calling loop where hooks only answer narrow policy questions at six fixed interception points and never drive the loop itself.
Important
This project is under active development and has not been thoroughly tested end to end yet. Providers, tools, and hooks work individually in unit tests, but the full agent loop hasn't seen broad real-world verification — expect rough edges.
- Policy without a general-purpose scripting language — hooks are written in
mq's embeddable query language, which can't write files, hit the network, or spawn processes, so a hook can observe, block, or transform agent behavior but never cause side effects itself. - Nothing hidden — every tool call and result streams live to the terminal, split across stdout (the conversation) and stderr (the execution trace). See Live execution display.
- Local-first — runs entirely against a local Ollama server (including gpt-oss), no API key or cloud round-trip needed.
- Extensible without forking — project-local skills, subagents, hooks, optional WASM tool plugins, and optional MCP servers all live under
.agent/in the target repo, not in minder itself. - Lean by default —
cargo install minder-clibuilds a minimal binary with no WASM runtime or MCP client compiled in; both are opt-in Cargo features for when you actually need them. Prebuilt GitHub Release binaries ship with both enabled. - Unattended runs —
minder loopworks through a Markdown checklist turn by turn, with retry/backoff on transient errors and a session that survives a restart.
| Crate | Description |
|---|---|
minder-cli |
Command-line interface — minder, minder chat, minder loop |
minder-core |
Session/turn loop, tool-calling protocol, and hook port trait |
minder-providers |
Ollama and OpenAI-compatible local LLM client implementations |
minder-tools |
Built-in tools (file, shell, git, web) |
minder-tools-wasm |
WASI plugin loader and sandboxed host runtime for tool plugins |
minder-tools-mcp |
MCP client — spawns configured servers and exposes their tools |
minder-hooks |
mq-based hook engine (.agent/hooks/*.mq) |
- Why minder?
- Packages
- Install
- Quick start
- Scripting and automation
- Providers
- Tools
- Skills
- Memory
- Subagents
- Hooks
- Tool plugins (WASM)
- MCP servers (optional)
- Plugins
- Autonomous loop mode
- Development
- Support
cargo install minder-cli
minder "..."Prebuilt binaries for Linux/macOS/Windows are attached to each GitHub Release.
Build from source, shell completion
git clone https://github.com/harehare/minder.git
cd minder
cargo build --workspace --release
# or install the binary onto your PATH:
cargo install --path crates/minder-cliRun in place without installing with cargo run -p minder-cli -- "...". The rest of this README uses minder "..." for brevity.
minder completion bash > /etc/bash_completion.d/minder # or wherever your distro sources completions from
minder completion zsh >> ~/.zshrc
minder completion fish > ~/.config/fish/completions/minder.fishAlso supports elvish and powershell. Generated by clap_complete straight from the CLI's own argument definitions, so it never drifts out of sync with minder --help.
minder takes a single task string as its only argument and runs it to completion.
$ ollama serve & # skip if already running as a background service
$ cd path/to/some/project
$ minder "list the top-level files and summarize what this project does"
loaded hooks from .agent/ # only printed if .agent/hooks/*.mq exist
→ ls recursive=false
✓ ls: Cargo.toml README.md crates/ ...
The project is a Rust workspace with six crates under crates/... (etc.)Each turn: the model reads the prompt, optionally calls a tool (read_file, bash, grep, ...), minder runs it in the current working directory, and the result feeds back — repeating until the model replies without requesting another tool call. Everything the agent touches (files read/written, commands run) is scoped to the directory minder was launched from.
Every run's transcript is saved under .agent/sessions/ (gitignored automatically), so you can pick a conversation back up later:
| Command | Does |
|---|---|
minder chat |
Interactive session: type tasks, one process, shared context |
minder --continue "..." |
Resume the most recent session in this project, run one more task |
minder --continue |
Resume the most recent session interactively |
minder --resume <id> "..." |
Resume a specific session by id (or an unambiguous prefix) |
A few real tasks to try:
minder "run the tests and summarize any failures"
minder "find all TODO comments under src/ and turn them into a checklist"
minder "check git status and stage+commit the pending changes with a sensible message"A baseline policy is always active, even with no .agent/hooks/ at all: it blocks bash rm -rf, and reading/searching paths under .env, node_modules, or .git. Everything else stays unrestricted by default — see Hooks to add more policy before pointing minder at real work.
Live execution display, undo, interrupting a turn, Markdown output, @path attachments, !shell commands, REPL commands
Every tool call streams to the terminal as it happens, not just the final answer, split across two streams so piping minder's answer elsewhere stays clean:
- stdout — the conversation itself: assistant text, including commentary on turns that also call a tool.
- stderr — the execution trace:
● tool_name(key=value)before each call, then either a diff stat line (+N -N) and a colorized unified diff (forwrite_file/edit_file, capped at 40 lines) or a✓/✗one-line result summary.
$ minder "fix the off-by-one in the pagination helper"
● grep(pattern=page_size)
✓ src/pagination.rs:42: let end = start + page_size;
● edit_file(path=src/pagination.rs)
✓ +1 -1
--- a/src/pagination.rs
+++ b/src/pagination.rs
@@ -40,2 +40,2 @@
- let end = start + page_size;
+ let end = start + page_size - 1;
Fixed the off-by-one: `end` was one past the last valid index.Colors turn off automatically when stderr isn't a terminal or NO_COLOR is set, and every line is overridable per-project from .agent/hooks/*.mq — see Customizing the display. A spinner runs while the model is thinking or a tool call is executing (⠋ Thinking (2.3s) · ollama (qwen3-coder:30b)), replaced by the normal ✓/✗ line once that step finishes; turn it off with show_status_bar = false in .agent/config.toml (or MINDER_SHOW_STATUS_BAR=false), or toggle it for the rest of the session with /status. The first-run model pull uses the same spinner (⠋ Downloading qwen3-coder:30b -- pulling manifest (42%)), redrawn in place instead of printing a new line per update. Once a turn's final answer is in, a trailing ↑1024 ↓256 tokens line shows that turn's real token cost, summed across every round-trip it took — also written to MINDER_LOG_FILE if set.
Esc or Ctrl-C during a turn (not just while typing) stops it instead of killing the whole process — a running bash/git_* call gets a moment to wind down before being force-aborted regardless, and a second Esc/Ctrl-C forces this immediately. The turn's partial messages are discarded; the rest of the conversation stays intact.
You don't have to wait for a turn to finish (or interrupt it) to say something either: just start typing while it's running and press Enter. That line doesn't start a new turn — it's spliced into the current one at the next safe point, so the model sees it alongside whatever it was already doing instead of losing that progress.
At the idle prompt, Ctrl-C doesn't quit immediately: it clears the line first if text is typed, then shows a Ctrl-C again to exit hint on a second press within two seconds. Ctrl-D or exit/quit always quits immediately.
Interactive chat/resumed sessions run in a fullscreen alternate-screen view (like less or top) with the input box pinned to the last line. PageUp/PageDown or the mouse wheel scroll back through the conversation; new output snaps the view back to the bottom unless you're mid-scroll.
Assistant replies stream as plain Markdown text, not rendered styling — headings/bold/lists show up as literal #/**/- characters. The one exception is fenced code blocks, which get real syntax highlighting once the closing ``` arrives. Pipe stdout (or --output json's answer field) through a Markdown renderer of your own (glow, mdcat, a browser, ...) if you want it rendered instead. The same format flows the other way too: skills (SKILL.md), subagents (AGENT.md), and minder loop's checklists are themselves Markdown files under .agent/.
Every write_file/edit_file/delete_file call is checkpointed: the first time a turn touches a file, its prior content is kept in memory before the change lands. /undo restores every file the most recently completed turn touched, including a delete_filed one. It only reaches back one turn, and isn't git-based — it won't catch a file a bash command edited or deleted directly.
An @path word — a file or directory, relative to the working directory or absolute — attaches that path's contents to the task, whether typed at the chat prompt or inside a one-shot task string:
minder "review @src/pagination.rs for off-by-one bugs"
minder chat
❯ summarize what's in @src/A file's content is inlined in a fenced code block; a directory gets a shallow (one-level) listing. Only a path that actually exists on disk expands — @here in "thanks @here for the review" is left untouched. In chat, Tab-completes and live-hints like /-commands do.
A line starting with ! inside chat runs the rest as a shell command (30s timeout) in the working directory, prints its combined stdout/stderr, then hands that output to the model as the turn's input — no bash tool round-trip needed for a quick look before asking a question:
❯ !git status
On branch main
nothing to commit, working tree cleanThe model then sees "I ran git status, here's the output: ...```" as its input for that turn, the same way an @path mention's file content becomes part of the input. Output over 20,000 characters is truncated.
A line starting with / inside minder chat (or any interactive session) is a REPL command instead of a task:
| Command | Does |
|---|---|
/help |
Lists these commands |
/model |
Shows the active provider and model |
/model <provider> [model] |
Switches the active model mid-session, keeping conversation history (e.g. /model ollama qwen3:14b) — both arguments Tab-complete/hint against locally pulled models |
/models |
Lists locally pulled models, marking the active one |
/clear |
Clears the conversation history (the session file stays, so --continue still works, just with nothing to continue) |
/status |
Toggles showing the active provider/model in the spinner while a turn runs — see above |
/thinking |
Toggles showing the model's extended-thinking output (only fires for a reasoning model, e.g. deepseek-r1/qwq) |
/undo |
Reverts the write_file/edit_file changes from the most recently completed turn — see above |
/model <provider> [model] rebuilds the provider (same env-var/.agent/config.toml precedence as startup) and swaps it into the running session, without losing context. It doesn't change the agent tool's own default model, so a subagent delegated afterward still uses whichever model the session started with unless its AGENT.md (or the call itself) overrides it.
Piped input and structured output turn minder into a general-purpose filter for a script or pipeline — the same role jq/mq play, but with an LLM doing the transformation. Both only apply to a one-shot task; interactive chat/loop read their own input from stdin and always stream live text.
cat error.log | minder "pull out every distinct error message as a bullet list"
curl -s https://example.com/report.json | minder "summarize the top 3 metrics"
git diff | minder "write a commit message for this diff"Piped stdin is folded into the task as extra input, capped at 200,000 characters (truncated beyond that, so one huge pipe can't blow the context budget).
$ minder --output json "does this repo's Cargo.toml set edition 2024?"
{"provider":"ollama","model":"qwen3-coder:30b","answer":"Yes, edition = \"2024\".","error":null}--output json holds back the live text stream and prints exactly one JSON object after the turn completes: {"provider", "model", "answer", "error"} (answer/error mutually exclusive) — useful for calling minder as a subprocess.
minder talks to a local Ollama server — install it (https://ollama.com/download, or brew install ollama / curl -fsSL https://ollama.com/install.sh | sh) and run ollama serve before pointing minder at it:
minder "..." # pulls the default model (qwen3-coder:30b, ~19GB) on first run if it isn't already local
OLLAMA_BASE_URL=http://your-ollama-host:11434 MINDER_MODEL=gpt-oss:20b minder "..."If the requested model isn't pulled yet, minder pulls it automatically before the first turn, printing progress as it downloads — no separate ollama pull step needed. This also runs on /model switches mid-session.
| Env var | Default | Notes |
|---|---|---|
MINDER_MODEL |
qwen3-coder:30b |
Any Ollama model name — auto-pulled on first use if not already local |
OLLAMA_BASE_URL |
http://localhost:11434 |
Point at a remote/non-default Ollama host |
MINDER_NUM_CTX |
8192 |
Context window in tokens — well above Ollama's own 2048-4096 default, which silently truncates and is a common cause of broken tool-calling |
MINDER_REQUEST_TIMEOUT_SECS |
900 |
Fails a stalled request instead of hanging forever |
Set these once per project instead of re-exporting env vars every session:
# .agent/config.toml
model = "gpt-oss:20b"
# ollama_base_url = "http://localhost:11434"
# num_ctx = 16384 # overrides the 8192 default
# request_timeout_secs = 1800 # overrides the 900s default
# show_status_bar = false # hide the spinner's provider/model suffix, see aboveEvery field is optional and the file itself is optional. Precedence is env var (a one-off override) > .agent/config.toml (this project's default) > the built-in default.
gpt-oss (gpt-oss-20b/gpt-oss-120b, 20b needs ~16GB RAM/VRAM, 120b needs ~65GB+) runs like any other Ollama model — Ollama v0.11.4+ is needed for correct tool-calling support. Its reasoning effort (low/medium/high) isn't configurable through minder today; it runs at Ollama's default.
Anything speaking the OpenAI chat-completions API — llama.cpp's llama-server, LM Studio, vLLM, LocalAI — works via the openai-compat provider instead of Ollama:
MINDER_PROVIDER=openai-compat MINDER_OPENAI_COMPAT_BASE_URL=http://localhost:8080/v1 MINDER_MODEL=qwen3-coder-30b minder "..."| Env var | Notes |
|---|---|
MINDER_OPENAI_COMPAT_BASE_URL |
Required — e.g. http://localhost:8080/v1 (llama-server), http://localhost:1234/v1 (LM Studio) |
MINDER_MODEL |
Required — no built-in default, unlike Ollama |
MINDER_OPENAI_COMPAT_API_KEY |
Optional bearer token, for a gated/remote deployment |
Unlike OllamaProvider, this has no equivalent to /models, auto-pull, or context-window auto-detection — those are Ollama-specific niceties with no standard equivalent across arbitrary OpenAI-compatible servers.
Always registered:
| Tool | Does |
|---|---|
read_file |
Reads a file, optionally restricted to a 1-indexed inclusive line range |
write_file |
Creates or overwrites a file, creating parent directories as needed |
edit_file |
Replaces old_string with new_string in a file (must match exactly once unless replace_all) |
delete_file |
Moves a file to .agent/trash/ instead of deleting it outright; refuses directories |
bash |
Runs a shell command and returns combined stdout/stderr (default 120s timeout) |
glob |
Finds files matching a glob pattern, e.g. **/*.rs |
grep |
Searches file contents by regex, honoring .gitignore, up to 200 matches |
ls |
Lists a directory, honoring .gitignore; recursive for a tree view, up to 500 entries |
git_diff |
Shows git diff output, against a ref, staged or unstaged, optionally path-scoped |
git_log |
Shows commit history |
git_status |
Shows git status |
git_commit |
Creates a commit |
web_fetch |
Fetches an http(s) URL as text; rejects non-http(s) schemes and literal loopback/private-network hosts (partial SSRF guard, not a complete one — use a hook for stronger guarantees). Also strips zero-width/bidi-override/Unicode-tag characters from the response — the hidden-text tricks behind "ASCII smuggling" prompt injection — before it ever reaches the model |
agent |
Delegates a task to a named subagent — always available via a built-in general-purpose subagent, no project config required; see Subagents |
memory |
Reads/writes persistent notes under .agent/memory/*.md that survive across sessions — list/read/write/append actions; see Memory |
Registered only when configured:
| Tool | Enabled by |
|---|---|
web_search |
TAVILY_API_KEY set — omitted entirely otherwise, so the model never sees a tool it can't use |
skill |
one or more .agent/skills/*/SKILL.md files present — see Skills |
Additional tools can be supplied per-project as WASM plugins or from MCP servers (opt-in feature).
There's no built-in todo-list tool — that's deliberately left to the two extension points above instead of core, since it's pure project convention with no harness-level behavior riding on it: copy skills/todo-md/ to .agent/skills/ for a TODO.md-based checklist (no build step, works with any provider), or copy tools/todo.wasm/tools/todo.toml to .agent/tools/ for a structured todo_write tool with the same schema minder's old built-in had — see tools/todo-plugin/src/main.rs for its source (tools/todo-plugin/regenerate.sh rebuilds it). The wasm version persists its list to a file, so it survives across sessions too, not just within one.
.agent/skills/commit-messages/SKILL.md
---
name: commit-messages
description: Writes commit messages in this repo's conventional-commit style
---
# Commit messages
Use Conventional Commits: `<type>(<scope>): <summary>`, imperative mood...Each skill is a directory with a SKILL.md: ----delimited frontmatter (name, description) followed by instructions as the body. minder discovers every .agent/skills/*/SKILL.md at startup and registers a single skill tool listing each skill's name/description — cheap to keep in context every turn. The model calls skill with a name to pull that skill's full body into the conversation only when it's actually relevant.
Skill names must be unique, and startup fails if a SKILL.md is missing frontmatter or the name/description fields. See skills/commit-messages/SKILL.md and skills/todo-md/SKILL.md for runnable examples (copy skills/ to .agent/skills/ in a project to try them).
Skills are curated by the user; memory is the model's own space to write to. Unlike --continue/--resume (the same conversation, resumed), .agent/memory/*.md persists facts across different tasks and sessions — user preferences, project decisions, anything worth not re-discovering next time.
The memory tool takes an action (list, read, write, append) and a name, which maps to .agent/memory/<name>.md. Always registered, and the directory is created on first write. Its description carries a snapshot of existing entries (name + first line) taken at startup, cheap to keep in context the same way skill's listing is — call list for the current, possibly-changed-since-startup index.
.agent/memory/user-preferences.md
Prefers tabs over spaces in this repo. Wants commit messages under 60 chars.Nothing writes here automatically — it's up to the model (or a project skill nudging it) to decide what's worth remembering.
.agent/agents/code-reviewer/AGENT.md
---
name: code-reviewer
description: Reviews a diff for correctness bugs, not style
tools: read_file, grep, glob, git_diff, git_log
---
# Code reviewer
Review the diff you're handed for correctness bugs only...Each subagent is a directory with an AGENT.md: the same frontmatter shape as a skill (name, description), plus an optional tools field (comma-separated tool names) and a body that becomes that subagent's system prompt. minder registers a single agent tool listing every available subagent; the model calls it with {name, task} to delegate a self-contained piece of work. A built-in general-purpose subagent (full tool access, no AGENT.md required) is always available, even with no project config.
Delegating runs a brand-new AgentSession in-process, to completion, with its own history (it starts with no memory of the parent conversation, so task needs to carry enough context) and its own system prompt. It shares the LLM provider/client, the project's hooks, and the live execution display with the parent.
A subagent's tool list is either every tool the parent has (tools omitted) or restricted to the comma-separated names in tools (unknown names are silently dropped, not an error). The agent tool itself is always excluded from a subagent's own tool list, so delegation never recurses more than one level.
If the model requests more than one agent call in the same turn, they run concurrently — each delegation starts with no shared history with the others, so there's nothing for them to race on. Every other tool call still runs strictly sequentially, in the order the model requested it.
Subagent names must be unique, and startup fails on a missing frontmatter field, same as skills. See agents/code-reviewer/AGENT.md for a runnable example (copy agents/ to .agent/agents/ to try it).
Pass background: true in an agent call to start it and get an id back immediately instead of waiting for its final answer — useful for a long-running task, or several independent ones you want running while you keep doing other work:
| Tool | Does |
|---|---|
list_agents |
Lists background runs (newest first) with id, status, and elapsed time |
agent_output |
Fetches a run's result by id; pass wait_secs to block until it finishes |
agent_stop |
Cancels a run by id (best-effort — stops it before its next step, not mid-flight) |
These three are only ever offered to the top-level conversation, not to a subagent's own tool list, so delegation still can't recurse.
.agent/hooks/security.mq
def on_tool_call(call):
if (call["name"] == "bash" && contains(call["arguments"]["command"], "rm -rf")):
{"action": "block", "reason": "destructive bash command blocked by policy"}
else:
{"action": "allow", "value": call};Every hook returns {"action": "allow", "value": ...} or {"action": "block", "reason": "..."} (the gate-only hook before_compact returns {"action": "allow"} with no value). Hooks are optional — an undefined hook function is a no-op. A buggy on_tool_call fails closed (blocks the action); every other hook fails open (the buggy transform is skipped).
| Hook point | mq function | Fires |
|---|---|---|
before_agent_start |
before_agent_start(prompt) |
Once, before the first LLM call |
on_context |
on_context(messages) |
Before every LLM call |
on_tool_call |
on_tool_call(call) |
Before a tool executes (fails closed) |
on_tool_result |
on_tool_result(result) |
Before a tool's result re-enters history |
before_compact |
before_compact(messages) |
Before history is truncated under context pressure |
on_budget |
on_budget(info) |
After every provider round-trip, with {"turn": Usage, "session": Usage, "turn_count": n} (Usage is {"input_tokens", "output_tokens"}) |
on_budget is the one exception to "fails open": it's default-off (an undefined on_budget always allows), but once you define one it's how you cap spend on an unattended loop/schedule run — see hooks/budget.mq for a runnable example (copy it to .agent/hooks/ to try it, alongside hooks/security.mq).
The agent module, default policy, overriding results, customizing the display
A small set of convenience functions is always loaded before any hook file, bare-callable with no import needed (an agent_ prefix keeps them out of your own functions' way):
| Function | Returns |
|---|---|
agent_content_blocks(messages) |
Every message's content array, flattened into one array of blocks |
agent_tool_calls(messages) |
All tool_use blocks so far, unwrapped to {id, name, arguments} |
agent_tool_results(messages) |
All tool_result blocks so far, unwrapped to {tool_call_id, content, is_error} |
agent_assistant_texts(messages) |
Every assistant-authored text string, in order |
agent_tool_names(messages) |
Distinct tool names called so far |
agent_error_count(messages) |
Count of tool results with is_error: true |
agent_consecutive_errors(results) |
Trailing streak of is_error: true results (feed it agent_tool_results(messages)) |
agent_last_n(items, n) |
The last n items of any array |
# .agent/hooks/circuit_breaker.mq -- stop the turn once 3 tool calls in a row have failed.
def on_context(messages):
if (agent_consecutive_errors(agent_tool_results(messages)) >= 3):
{"action": "block", "reason": "3 consecutive tool failures -- pausing for a human"}
else:
{"action": "allow", "value": messages};default_policy.mq loads right after the agent module and defines a baseline on_tool_call: it blocks bash commands containing rm -rf, and any read_file/grep/glob/ls call whose path/pattern touches .env, node_modules, or .git — active in every project with zero .agent/ setup. Defining your own on_tool_call fully replaces it; call default_on_tool_call(call) from yours to keep the baseline and layer more checks on top:
def on_tool_call(call):
if (call["name"] == "web_fetch"):
{"action": "block", "reason": "no network in this project"}
else:
default_on_tool_call(call); # still blocks rm -rf, .env, node_modules, .gitThe baseline is data, not an if/elif chain — default_denylist() is a plain array of rules ({"match": "contains" | "path_segment", "arg_keys": [...], "needle": "...", "reason": "..."}), matched by default_matchers(). Adding a project-wide blocked command or path is appending a rule to that array. default_on_tool_call_with(call, extra_rules) layers extra_rules on top of the baseline without rewriting on_tool_call from scratch:
# .agent/hooks/security.mq
def on_tool_call(call):
default_on_tool_call_with(call, [
{"match": "contains", "arg_keys": ["command"], "needle": "| sh", "reason": "no piping to a shell"},
{"match": "path_segment", "arg_keys": ["path"], "needle": "secrets", "reason": "secrets/ is excluded by policy"},
]);
default_policy.mq also defines a baseline before_agent_start and on_tool_result aimed at prompt injection: local models don't reliably enforce an instruction hierarchy the way hosted frontier models do, so tool output (file contents, command output, fetched web pages) needs to be labeled untrusted explicitly rather than left implicit. before_agent_start appends a fixed paragraph to the system prompt telling the model to treat tool output as data, never as instructions; on_tool_result scans each result against default_injection_patterns() (another plain data array, e.g. "ignore previous instructions") and, on a hit, prepends a warning line rather than blocking — matched text isn't proof of an attack, and an agent working with untrusted data still needs to see it. Both compose the same way: default_before_agent_start(prompt) / default_on_tool_result_with(result, extra_patterns) from your own override.
on_tool_call can also override: {"action": "override", "value": {"content": "...", "is_error": false, "metadata": null}} supplies the tool's result directly. The real tool never runs, but the result still flows through on_tool_result afterward like any other — useful for mocking a tool in tests, or short-circuiting it once some condition is met:
def on_tool_call(call):
if (call["name"] == "web_fetch"):
{"action": "override", "value": {"content": "(network disabled in this environment)", "is_error": false, "metadata": None}}
else:
{"action": "allow", "value": call};The live execution display is driven by two more optional hook functions, checked before minder's own built-in formatting. Both fail open: a broken or undefined render function falls back to the built-in look.
| Function | Called with | Controls |
|---|---|---|
render_tool_call(call) |
the upcoming ToolCall |
how the ● name(...) header line prints |
render_tool_result(arg) |
{"call": ToolCall, "outcome": ToolExecOutcome} |
how the result/diff line(s) print |
Each returns {"action": "default"} (built-in formatting), {"action": "hide"} (print nothing), or {"action": "text", "value": "...", "style": "..."} (print this instead — style is one of green/red/yellow/cyan/dim/bold, or omitted for no styling):
# .agent/hooks/display.mq -- quiet git_status noise, and prefix bash calls with a shell-style `$`
def render_tool_call(call):
if (call["name"] == "git_status"):
{"action": "hide"}
else:
{"action": "default"};
def render_tool_result(arg):
if (arg["call"]["name"] == "bash"):
{"action": "text", "value": "$ " + arg["call"]["arguments"]["command"], "style": "cyan"}
else:
{"action": "default"};Both functions only see the one call/outcome in front of them, not the conversation — a display decision that needs history should reach for the agent module inside on_context/before_compact instead.
Tools can also be provided by sandboxed WASI plugins, discovered from .agent/tools/. This is behind an opt-in wasm Cargo feature — wasmtime (and its Cranelift codegen) is one of the heavier dependencies in the tree, so it's left out of the default build:
cargo install --path crates/minder-cli --features wasmBuilt without --features wasm, minder ignores .agent/tools/*.wasm entirely. Prebuilt GitHub Release binaries are built with wasm (and mcp) enabled, so this only matters if you're building from source or publishing your own minimal binary.
.agent/tools/weather.wasm
.agent/tools/weather.toml
Every .wasm needs a sidecar .toml manifest of the same name declaring its capabilities — a plugin with no manifest fails to load rather than silently running with zero capabilities:
network = false # grants the one host-mediated fetch primitive (see below)
[[fs]]
host_dir = "./data" # resolved relative to the working directory
guest_dir = "/data"
read_only = true
[limits]
timeout_secs = 30
max_memory_pages = 256
fuel = 5_000_000Plugins are plain wasm32-wasip1 modules (no component model) exporting minder_tool_name, minder_tool_description, minder_tool_parameters_schema, minder_tool_execute, plus minder_alloc/minder_dealloc for passing JSON across linear memory — see crates/minder-tools-wasm/tests/fixtures/echo_plugin for a minimal example (regenerate.sh alongside it has the build command), or tools/todo-plugin/src/main.rs for a complete one that persists state through a granted [[fs]] dir (a todo_write tool, copy tools/todo.wasm/tools/todo.toml to .agent/tools/ to try it — see Tools). Filesystem access is granted per-plugin via WASI preopens (none by default); network isn't a raw socket — network = true grants a single host_web_fetch import reusing the built-in web_fetch's SSRF guard. Execution is metered with wasmtime fuel, so a runaway plugin traps instead of hanging.
MCP is a client/server protocol built around subprocesses and long-lived JSON-RPC sessions, which doesn't fit the WASI sandbox above, so it's wired in on the host side instead, behind an opt-in mcp Cargo feature so the rmcp dependency and its subprocess-spawning code aren't part of the binary unless you ask for them:
cargo install --path crates/minder-cli --features mcpWith the feature enabled, minder discovers .agent/mcp.toml, launches each configured server as a child process over the stdio transport, and registers every tool it advertises as mcp__<server>__<tool>:
# .agent/mcp.toml
[[server]]
name = "filesystem"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
[[server]]
name = "github"
command = "docker"
args = ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"]
env = { GITHUB_PERSONAL_ACCESS_TOKEN = "..." }Built without --features mcp, minder ignores .agent/mcp.toml entirely (the mcp tool / minder-tools-mcp crate is compiled out, not just disabled at runtime). A configured server that fails to start, initialize, or list its tools is a hard error at startup, same as a broken wasm plugin or hook file. Remote tool calls go through on_tool_call/on_tool_result hooks exactly like built-in and wasm tool calls.
minder supports Agent Plugins, the open, vendor-neutral format introduced by Vercel for packaging skills and MCP servers into one distributable directory instead of hand-copying SKILL.md files and editing .agent/mcp.toml for every project:
.agent/plugins/<plugin-name>/
├── plugin.json # required: {"$schema": "...", "name": "..."}, everything else optional
├── skills/ # same SKILL.md layout as .agent/skills -- see Skills above
│ └── <skill-name>/SKILL.md
└── mcp.json # same idea as .agent/mcp.toml, JSON instead of TOML -- see MCP servers above
// .agent/plugins/my-plugin/plugin.json
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "my-plugin",
"version": "1.0.0",
"description": "What this plugin adds"
}minder discovers every .agent/plugins/*/plugin.json at startup (missing plugins/ is not an error), then treats each plugin's skills/ directory exactly like .agent/skills: all skills from the project and every plugin are merged into one list, and a name collision anywhere in that merged set is a startup error, not a silent override. Plugin names themselves must be unique and follow the spec's name pattern (lowercase alphanumeric, ./-, 1-64 characters).
A plugin's mcp.json is loaded the same way .agent/mcp.toml is (behind the mcp feature — see MCP servers), and supports stdio and streamable-http servers:
// .agent/plugins/my-plugin/mcp.json
{
"mcpServers": {
"filesystem": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
},
"remote": {
"type": "streamable-http",
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer ..." }
}
}
}The legacy "sse" transport type parses but fails at connect time with a clear error — rmcp (the MCP client library minder uses) only implements stdio and streamable-http, and the MCP spec itself has since superseded SSE with streamable-http. Use streamable-http instead.
Only the two portable component types from the spec (skills, MCP servers) are wired in; a plugin's client-specific extensions are parsed but not currently acted on. See plugins/example-plugin/ for a runnable example (copy plugins/ to .agent/plugins/ in a project to try it).
minder loop TODO.md
minder loop TODO.md "ship the v2 pagination rewrite" # optional overall-goal hintminder loop <file> ["<goal>"] drives the same AgentSession turn after turn against a Markdown checklist, with no user in the loop, and keeps watching the file for new work once it's clear:
-
Query
<file>for GFM checklist lines that are still unchecked, entirely insidemq-lang— embedded the same way as the hooks engine, just pointed at a file on disk:read_file(path) | split(., "\n") | filter(., fn(line): is_regex_match(line, "^\\s*[-*+]\\s+\\[ \\]") end)This matches lines with a regex rather than walking markdown's list/checkbox AST, since mq-lang's only builtin that parses markdown into that AST (
collection) works over an entire directory tree — overkill for re-checking one file every few seconds. -
If nothing comes back, the file is done: minder logs that it's idle and polls
<file>on an interval for new items. -
Otherwise the remaining items are folded into a prompt ("pick the first unfinished item, implement it, then check it off") and handed to
run_turn. -
Repeat — the item just finished no longer shows up as unchecked, so the next prompt derives naturally from the file's current state.
<!-- TODO.md -->
## Backend
- [x] Set up database schema
- [ ] Add user authentication endpoint
- [ ] Write tests for auth endpoint$ minder loop TODO.md
[loop 1/50] 2 item(s) remaining in TODO.md
→ write_file path=src/auth.rs
...
[loop 2/50] 1 item(s) remaining in TODO.md
→ edit_file path=tests/auth_test.rs
...
[loop] TODO.md has no unchecked items -- polling every 5s for new work (Ctrl-C to stop)At that point the process keeps running: add a new - [ ] ... line to TODO.md and the next poll picks it up automatically, no restart needed.
Safety limits keep a stuck agent from spinning forever, all overridable via env vars:
| Env var | Default | Guards against |
|---|---|---|
MINDER_LOOP_MAX_ITERATIONS |
50 | Runaway spend — a lifetime cap on actual working turns (idle polling doesn't count) |
MINDER_LOOP_POLL_INTERVAL_SECS |
5 | How often to re-check the file while idle |
MINDER_LOOP_QUERY |
read_file(path) | split(., "\n") | filter(., fn(line): is_regex_match(...) end) (see above) |
Lets you point at a differently-structured file |
If the unchecked count doesn't drop for two consecutive working iterations, the loop stops with an error rather than burning turns on a task the model isn't making progress on.
Since a loop is meant to run with nobody watching, minder guards against the ways an unattended process actually dies:
- Transient errors don't end the run — rate limits, 5xx responses, and network blips are retried with backoff before ever reaching your prompt or the checklist logic.
- The session survives a restart —
minder loop <file>keys its session by<file>'s canonical path and saves after every turn, so re-running the exact same command after a crash or a Ctrl-C resumes the conversation instead of starting over. - A durable log, even off-terminal —
MINDER_LOG_FILE=path/to/logappends every turn/tool-call/tool-result/retry as a plain-text line, useful undernohup/tmux/systemd. SetMINDER_LOG_FORMAT=jsonfor JSON Lines instead. - Checking status from another terminal —
MINDER_STATUS_FILE=path/to/status.jsonkeeps a small JSON snapshot of what the agent is doing right now, rewritten atomically on every state change.
Requires just. The same recipes run in CI (ci.yml):
just test-all # fmt --check, clippy -D warnings, doc tests, nextest
just fmt # cargo fmt --all -- --check
just lint # cargo clippy --all-targets --all-features --workspace -- -D clippy::all
just test # cargo nextest run --workspace --all-features
just deps # cargo machete (unused dependencies)
just audit # cargo deny check (licenses/bans/sources/advisories)The live round-trip test needs ollama serve running locally and is #[ignore]d by default:
cargo test -p minder-providers -- --ignored ollamaEvery push/PR to main runs tests, rustfmt, clippy, and cargo-deny (Linux; the full Linux/macOS/Windows matrix is available via workflow_dispatch). Separate scheduled/PR-triggered workflows cover cargo audit, CodeQL, spell-checking (typos), unused-dependency detection (cargo-machete), and Actions-workflow security linting (zizmor).
Pushing a vX.Y.Z tag builds the minder binary for Linux (gnu/musl, x86_64/aarch64), macOS (aarch64), and Windows (x86_64) and attaches them — with checksums — to a draft GitHub Release (review and publish it manually), and publishes all crates to crates.io under the minder-* prefix. See release.yml / cargo-publish.yml.
- 🐛 Report a bug
- 💡 Request a feature
- ⭐ Star the project if you find it useful!
MIT