Skip to content
This repository was archived by the owner on Oct 27, 2026. It is now read-only.
hareharePublic archive

Repository files navigation

minder

A coding-agent harness in Rust.

CI Security audit License: MIT

Website

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.

Why minder?

  • 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-cli builds 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 loop works through a Markdown checklist turn by turn, with retry/backoff on transient errors and a session that survives a restart.

Packages

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)

Contents

Install

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-cli

Run in place without installing with cargo run -p minder-cli -- "...". The rest of this README uses minder "..." for brevity.

Shell completion

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.fish

Also 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.

Quick start

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

Live execution display

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 (for write_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.

Interrupting or steering a turn

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.

Markdown in, Markdown out

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/.

Undoing a turn's file changes

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.

Attaching files with @path

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.

Running a shell command with !

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 clean

The 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.

REPL commands

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.

Scripting and automation

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.

Piped input

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).

Structured output (--output json)

$ 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.

Providers

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

Project defaults (.agent/config.toml)

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 above

Every 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.

Running with gpt-oss

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.

Other local servers (openai-compat)

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.

Tools

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.

Skills

.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).

Memory

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.

Subagents

.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).

Background subagents

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.

Hooks

.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

The agent module

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

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, .git

The 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.

Overriding a tool's result

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};

Customizing the display

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.

Tool plugins (WASM)

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 wasm

Built 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_000

Plugins 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 servers (optional)

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 mcp

With 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.

Plugins

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).

Autonomous loop mode

minder loop TODO.md
minder loop TODO.md "ship the v2 pagination rewrite"   # optional overall-goal hint

minder 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:

  1. Query <file> for GFM checklist lines that are still unchecked, entirely inside mq-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.

  2. If nothing comes back, the file is done: minder logs that it's idle and polls <file> on an interval for new items.

  3. 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.

  4. 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.

Resilience for unattended runs

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/log appends every turn/tool-call/tool-result/retry as a plain-text line, useful under nohup/tmux/systemd. Set MINDER_LOG_FORMAT=json for JSON Lines instead.
  • Checking status from another terminal — MINDER_STATUS_FILE=path/to/status.json keeps a small JSON snapshot of what the agent is doing right now, rewritten atomically on every state change.

Development

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 ollama

CI and releases

Every 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.

Support

License

MIT

Releases

Used by

Contributors

Languages