Multi-provider (Anthropic, OpenAI, Gemini, Ollama) coding-agent harness 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 five 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.
- Multi-provider — Anthropic, OpenAI, Gemini, and local Ollama (including gpt-oss) behind one interface, switched with a single env var.
- Extensible without forking — project-local skills, subagents, hooks, WASM tool plugins, and optional MCP servers all live under
.agent/in the target repo, not in minder itself. - Unattended runs —
minder loopworks through a Markdown checklist turn by turn, with retry/backoff on transient provider 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 |
Anthropic/OpenAI/Gemini/Ollama 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
- Subagents
- Hooks
- Tool plugins (WASM)
- MCP servers (optional)
- 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.
$ export ANTHROPIC_API_KEY=sk-ant-...
$ 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, @path attachments, 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. Every line here 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)), replaced by the
normal ✓/✗ line as soon as that step finishes.
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 (e.g. bash kills its child process rather
than orphaning it) before being force-aborted regardless — a second Ctrl-C forces this immediately.
The turn's partial messages are discarded from history; the rest of the conversation stays intact.
Every write_file/edit_file/delete_file call is checkpointed: the first time a turn touches a
given file, its prior content (or the fact that it didn't exist yet) is kept in memory before the
change lands. /undo restores every file the most recently completed turn touched back to that
state, including a delete_filed one. It only reaches back one turn, and deliberately isn't
git-based (no stash/commit/worktree) — it won't catch a file a bash command edited or deleted
directly, only the three dedicated file tools.
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 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 |
/clear |
Clears the conversation history (the session file stays, so --continue still works, just with nothing to continue) |
/plan <task> |
Investigates read-only and proposes a plan for <task> before touching anything — see below |
/thinking |
Toggles showing the model's extended-thinking output (Anthropic only, once thinking_budget is configured — see below) |
/todo |
Shows the model's current todo list (from todo_write) |
/undo |
Reverts the write_file/edit_file changes from the most recently completed turn — see above |
/plan runs <task> through a throwaway session that shares the real session's provider and
hooks but is only given read-only tools (read_file, grep, glob, ls, git_diff,
git_log, git_status, web_fetch, web_search), so the model can investigate but never actually
change anything:
❯ /plan add retry-with-backoff to the http client
Planning (read-only investigation, no changes will be made)...
1. Add a `max_retries` field to `HttpClient` in src/http.rs...
2. Wrap the request in a loop that retries on 5xx/timeout with exponential backoff...
3. Add a unit test that a 503 followed by a 200 succeeds after one retry...
Proceed with this plan? [y/N] yAnswering y/yes feeds the plan back into the real session (full tools, normal hooks) as the
task to implement; anything else discards 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 (plain, --continue, or --resume with a task); interactive chat/loop read
their own input from stdin and always stream live text, so piping into those has no effect.
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 with a note 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":"anthropic","model":"claude-sonnet-5","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 and parsing its result.
Selected via MINDER_PROVIDER; MINDER_MODEL overrides the default model for whichever provider
is active.
MINDER_PROVIDER |
Required env | Default model | Notes |
|---|---|---|---|
anthropic (default) |
ANTHROPIC_API_KEY |
claude-sonnet-5 |
|
openai |
OPENAI_API_KEY |
gpt-5.4-mini |
|
gemini |
GEMINI_API_KEY |
gemini-3.5-flash |
|
ollama |
none | llama3.2 |
needs a local ollama serve; override the endpoint with OLLAMA_BASE_URL |
# Anthropic (default)
ANTHROPIC_API_KEY=... minder "run the tests and summarize failures"
# OpenAI
MINDER_PROVIDER=openai OPENAI_API_KEY=... MINDER_MODEL=gpt-5.4 minder "..."
# Gemini
MINDER_PROVIDER=gemini GEMINI_API_KEY=... minder "..."
# Ollama (local, no key needed)
MINDER_PROVIDER=ollama MINDER_MODEL=llama3.2 minder "..."
OLLAMA_BASE_URL=http://localhost:11434 MINDER_PROVIDER=ollama minder "..."Set provider/model once per project instead of re-exporting env vars every session:
# .agent/config.toml
provider = "openai"
model = "gpt-5.4"
# ollama_base_url = "http://localhost:11434" # only read when provider = "ollama"
# thinking_budget = 4000 # Anthropic only, see belowEvery 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 from the table above.
thinking_budget (or MINDER_THINKING_BUDGET, same precedence as above) requests Anthropic
extended thinking with that
many tokens as the budget. Unset by default — no thinking requested, no extra cost or latency. Once
set, thinking is shown live (dimmed, above the final answer); toggle it per-session with /thinking
without losing the budget setting.
gpt-oss (OpenAI's open-weight models,
gpt-oss-20b/gpt-oss-120b) runs through the existing ollama provider — no minder changes
needed, since Ollama handles the gpt-oss-specific translation over its generic /api/chat endpoint.
Setup steps
-
Install Ollama v0.11.4+ (needed for correct gpt-oss tool-calling support): https://ollama.com/download, or
brew install ollama(macOS) /curl -fsSL https://ollama.com/install.sh | sh(Linux). -
Start the server (skip if your install already runs it as a background service):
ollama serve. -
Pull a gpt-oss model —
20bneeds ~16GB RAM/VRAM,120bneeds ~65GB+ (multi-GPU/datacenter):ollama pull gpt-oss:20b. -
Point minder at it:
MINDER_PROVIDER=ollama MINDER_MODEL=gpt-oss:20b minder "..." # remote/non-default Ollama host: OLLAMA_BASE_URL=http://your-ollama-host:11434 MINDER_PROVIDER=ollama MINDER_MODEL=gpt-oss:20b minder "..."
gpt-oss's reasoning effort (low/medium/high) isn't configurable through minder today — it runs at
Ollama's default. minder loop works the same way with gpt-oss as with any other provider.
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 |
worktree_add |
Creates a new git worktree (git worktree add), optionally on a new or existing branch |
worktree_list |
Lists every worktree linked to the repo (git worktree list) |
worktree_remove |
Removes a worktree and its directory (git worktree remove), leaving its branch intact |
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) |
agent |
Delegates a task to a named subagent — always available via a built-in general-purpose subagent, no project config required; see Subagents |
todo_write |
Replaces the current todo list with a full updated one, so the model can plan and track progress on a multi-step task |
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 |
Worktree tools let the agent check out a second branch into its own directory without disturbing
the current one — e.g. running main's test suite for comparison while mid-edit on a feature
branch. minder itself doesn't switch its own working directory between worktrees; point a fresh
minder process at the new directory to actually work inside it.
todo_write always replaces the whole list, never a partial patch. It's not offered to subagents
or /plan's read-only session. Run /todo any time to see the current list.
Additional tools can be supplied per-project as WASM plugins or from MCP servers (opt-in feature).
.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 for a runnable example (copy
skills/ to .agent/skills/ in a project to try it).
.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 — a project can override it or add more via
.agent/agents/<name>/AGENT.md.
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, rather than rebuilding any of those.
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).
.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 |
See hooks/security.mq for a runnable example (copy it to .agent/hooks/ to try it).
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"},
]);
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, since a display bug should never be able to affect what the agent actually does.
| 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/:
.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). 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 (no arbitrary process execution by design), 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 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 (by hand, or have
something else append to it) 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 (a custom "done" marker, a different bullet convention, ...) |
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 provider errors don't end the run — rate limits, 5xx responses, and network blips
are retried with backoff (honoring the provider's
Retry-Afterwhen it sends one) 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, a Ctrl-C, or a container restart resumes the conversation instead of starting over — at most the in-flight turn is lost. - A durable log, even off-terminal —
MINDER_LOG_FILE=path/to/logalso appends every turn/tool-call/tool-result/retry as a plain-text line to that file, useful for a loop launched undernohup,tmux, or systemd, where stderr isn't being watched live.
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)Each provider's live round-trip test needs real credentials and is #[ignore]d by default:
ANTHROPIC_API_KEY=... cargo test -p minder-providers -- --ignored anthropic
OPENAI_API_KEY=... cargo test -p minder-providers -- --ignored openai
GEMINI_API_KEY=... cargo test -p minder-providers -- --ignored gemini
cargo test -p minder-providers -- --ignored ollama # needs `ollama serve` running locallyEvery 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 (needs a CARGO_REGISTRY_TOKEN
secret). See release.yml / cargo-publish.yml.
- 🐛 Report a bug
- 💡 Request a feature
- ⭐ Star the project if you find it useful!
MIT