@saluzi/min
v1.6.0
Published
Min Agent: minimum agent for edge devices
Readme
min-agent
A minimal coding agent for edge devices. TypeScript, ESM, runs on Bun or Node.
@saluzi/min implements the smallest usable coding-agent core: a reasoning
loop, a handful of file/shell tools, opt-in context-window compaction, path-safe
permissions, a model/provider layer, and a plugin system that makes tools,
hooks, prompts, skills, and compaction replaceable or extendable — all without
hot-loading.
Install
bun installBuild
bun run build # tsc → dist/CLI
bun run dist/cli.js [options] [message]Or after bun link / global install:
min-agent [options] [message]
mina [options] [message]Options:
| Option | Description | Default |
| --- | --- | --- |
| --model <spec> | Model spec [provider:]model-id (see Models) | resolved (see Models) |
| --base-url <url> | Override the API base URL for this run (writes the provider's dedicated <PROVIDER>_BASE_URL) | — |
| --cwd <path> | Working directory | current directory |
| --project-root <path> | Project root (for path safety) | cwd |
| --max-turns <n> | Max LLM iterations per user turn | 100 |
| --permission <mode> | default | acceptWrite | bypass | default |
| --skills-dir <path> | Directory to scan for SKILL.md files | resolved (see Skills) |
| --skill <name> | Load only this skill (repeatable) | — |
| --workflows-dir <path> | Directory to scan for *.workflow.json files | resolved |
| --workflow <name> | Load only this workflow (repeatable) | — |
| --plugins-dir <path> | Directory to scan for file plugins (plugin.json) | resolved (see Plugins) |
| --trust-plugin <name> | Trust a named file plugin's code for this run (repeatable) | — |
| --trust-plugins | Trust all file plugins for this run (automation) | — |
| --compact | Enable context compaction (see Compaction) | off |
| --compact=<path> | Enable compaction with an external strategy module | — |
| --sandbox | Enable the OS-level bash sandbox (see Sandbox) | off |
| --debug | Enable debug logging to ~/.min/debug.log | — |
| --save-settings[=user\|project] | Persist the current configuration face to a settings file, then exit | user |
| --help | Show help | — |
Pass a positional message for single-shot mode; omit it for interactive mode.
Settings
min's configuration surface is environment variables, so its settings file is
not a parallel config system — it is a persistent layer of env defaults.
Shape is exactly { "env": { "KEY": "value" } }; every env var the agent
reads works there, with no schema to maintain:
// ~/.min/settings.json
{
"env": {
"MIN_DEFAULT_MODEL": "openai_compat:qwen2.5-coder:7b",
"OPENAI_COMPATIBLE_BASE_URL": "http://lan-host:11434/v1",
"OPENAI_COMPATIBLE_API_KEY": "ollama",
"MIN_MAX_CONTEXT_TOKENS": "32768",
"OPENAI_MAX_TOKENS": "4096"
}
}Two files load on every run — ~/.min/settings.json (user) then
<cwd>/.min/settings.json (project, later wins) — and each entry fills an
unset slot of the environment. That single rule yields the whole priority
chain for free:
CLI flags > exported env vars > project settings > user settingsSave the current run's flags instead of writing JSON by hand:
mina --model openai_compat:qwen2.5-coder:7b --base-url http://lan-host:11434/v1 \
--compact --save-settings # → ~/.min/settings.json
# thereafter: plain `mina` starts with the same setup--save-settings=project puts behavior keys into .min/settings.json while
credentials and *_BASE_URL routing are always split to the user file — a
committed project file never carries secrets. (This is also enforced on load:
a project file cannot set *_BASE_URL / *_API_KEY / *_AUTH_TOKEN or the
loader-path vars; those are refused with a warning. The user file is yours and
applies unconditionally.) MIN_SETTINGS=0 disables the layer before any file
is read.
Interactive mode
- Model banner — startup prints
▶ model <id> · provider <p>so a misrouted model is visible immediately (single-shot mode stays quiet). - Turn counter — a turn is one user input. The
─── Turn N ───banner appears once per message you send and grows across the session; tool calls and extra LLM round-trips within a run do not advance it. - Multi-line paste — pasting multi-line text inserts a compact tag into
the input line, e.g.
You: summarize this [Pasted text #1 +12 lines]. The full pasted content is stored and sent to the model when you press Enter (single-line pastes are inserted verbatim). Works via bracketed-paste mode on modern terminals, with a timing fallback elsewhere. - Ctrl+C — aborts the current run; press twice at the prompt to exit. During an approval prompt it denies the tool call and aborts.
Examples
# OpenAI — zero flags: the single provider with credentials is picked up
OPENAI_API_KEY=sk-... OPENAI_MODEL=gpt-4o min-agent
# Claude via Anthropic proxy
ANTHROPIC_BASE_URL=https://my-proxy.com ANTHROPIC_AUTH_TOKEN=sk-... \
min-agent --model claude-sonnet-4-5
# Local Ollama model (OpenAI-compatible) with a small context window.
# Model ids containing a colon need the openai_compat: prefix.
OPENAI_COMPATIBLE_BASE_URL=http://localhost:11434/v1 \
OPENAI_COMPATIBLE_API_KEY=ollama MIN_MAX_CONTEXT_TOKENS=32768 \
OPENAI_MAX_TOKENS=4096 min-agent --model openai_compat:qwen2.5-coder:7b
# DeepSeek (OpenAI-compatible endpoint; the base URL is required)
OPENAI_COMPATIBLE_API_KEY=sk-... \
OPENAI_COMPATIBLE_BASE_URL=https://api.deepseek.com/v1 \
min-agent --model deepseek-chat
# Enable auto-compaction, triggering at 50% of the context window
MIN_AUTOCOMPACT_PCT=50 min-agent --compact --model gpt-4o
# Enable compaction with an external strategy module
min-agent --compact=./my-compact.mjs --model gpt-4o
# Bypass permissions (sandboxed/automation only)
min-agent --permission bypass --model gpt-4o
# Sandbox bash at the OS level instead of bypassing permissions:
# sandboxed commands are auto-approved, so automation stays unattended
# while writes/network stay fenced (bubblewrap on Linux, sandbox-exec on macOS)
min-agent --sandbox --model gpt-4o
# Load file plugins and trust a code plugin
min-agent --trust-plugin web-tools --model gpt-4oEnvironment variables
Auth
| Var | Provider |
| --- | --- |
| OPENAI_API_KEY | openai |
| ANTHROPIC_API_KEY | anthropic |
| ANTHROPIC_AUTH_TOKEN | anthropic proxies (sent as Bearer) |
| OPENAI_COMPATIBLE_API_KEY | openai_compatible |
Model selection (when --model is omitted)
| Var | Effect |
| --- | --- |
| MIN_DEFAULT_MODEL | Default model spec, same syntax as --model (highest priority) |
| OPENAI_MODEL | Model id for the openai provider (requires OPENAI_API_KEY) |
| ANTHROPIC_MODEL | Model id for the anthropic provider (requires ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN) |
| OPENAI_COMPATIBLE_MODEL | Model id for the openai_compatible provider (requires OPENAI_COMPATIBLE_API_KEY) |
When --model is omitted, the agent picks the single builtin provider
that has credentials and uses its <PROVIDER>_MODEL. Zero or multiple
providers with credentials → an actionable startup error listing the three
configuration ways. Fail loud beats guessing.
Base URL
| Var | Effect |
| --- | --- |
| OPENAI_BASE_URL | Redirects the openai provider |
| ANTHROPIC_BASE_URL | Redirects the anthropic provider |
| OPENAI_COMPATIBLE_BASE_URL | Redirects the openai_compatible provider only |
| <PROVIDER>_BASE_URL | Redirects a plugin-registered provider |
Legacy: OPENAI_BASE_URL still redirects openai_compatible when
OPENAI_COMPATIBLE_BASE_URL is unset. --base-url always writes the
dedicated variable, so redirecting a compat provider never hijacks the
official openai endpoint.
Debug
| Var | Effect |
| --- | --- |
| MIN_DEBUG | 1 / true / verbose enables debug logging |
| MIN_DEBUG_FILE | Override log file path (default ~/.min/debug.log) |
| MIN_DEBUG=stderr | Mirror logs to stderr in human-readable form |
Context window & compaction (compaction is OFF by default — enable with
--compact / --compact=<path>; the compaction vars below only take effect
once it is on)
| Var | Effect |
| --- | --- |
| MIN_MAX_CONTEXT_TOKENS | Override the effective context window (any model). Highest priority. |
| MIN_AUTO_COMPACT_WINDOW | Secondary context window override, for local/custom models. |
| OPENAI_MAX_TOKENS | Override max output tokens for openai + openai_compatible. |
| MIN_MAX_OUTPUT_TOKENS | Generic max output tokens override (any provider). |
| MIN_AUTOCOMPACT_PCT | Percentage threshold (0–100) to trigger autocompact. Only makes it more aggressive. |
| MIN_KEEP_RECENT_TOKENS | Override keep-recent token budget (default 20000). |
| MIN_RESERVE_TOKENS | Override reserve token budget (default 16384). |
| DISABLE_COMPACT | Disable compaction entirely — even when --compact is passed. |
| DISABLE_AUTO_COMPACT | Disable auto-compact only. |
Sandbox (bash confinement, OFF by default — enable with --sandbox or
MIN_SANDBOX=1; see Sandbox)
| Var | Effect |
| --- | --- |
| MIN_SANDBOX | Truthy (1/true/yes/on) enables the sandbox with defaults, or with .min/sandbox.json when present. An explicit sandbox: false config beats this env var. |
Models
A model spec is [provider:]model-id, split at the first colon. There is
no per-model builtin table — snapshot-dated ids rot, and aliases silently
mapped to stale models. Resolution order:
- Explicit provider prefix —
anthropic:claude-sonnet-4-5,openai_compat:qwen2.5-coder:7b(openai_compatis an input alias foropenai_compatible; the rest of the string is the model id, colons included). The provider must be registered (builtin or plugin) — an unknown prefix fails at startup with the registered-provider list instead of silently routing your API key to a fallback host. - Plugin-registered models — a bare id matching some provider's
models: [...]entry (see Custom providers). - Brand inference (prefix-only, never version-aware):
claude*→ anthropic,gpt*/chatgpt*/o<digit>*→ openai. - Fallback — any other bare id resolves to
openai_compatible, so local models work by id:--model qwen2.5-coderwithOPENAI_COMPATIBLE_BASE_URLpointed at Ollama/vLLM.
Bare ids that contain a colon (Ollama-style qwen2.5-coder:7b) must be
prefixed explicitly (openai_compat:qwen2.5-coder:7b) — a colon always means
"provider prefix".
When --model is omitted, the default is MIN_DEFAULT_MODEL or the single
builtin provider with credentials (<PROVIDER>_MODEL + key, see
Environment variables).
Context window / max output tokens default to 128k / 16k for every model;
override via MIN_MAX_CONTEXT_TOKENS / OPENAI_MAX_TOKENS /
MIN_MAX_OUTPUT_TOKENS (see the env tables), or declare exact values on a
plugin provider's model entries.
Migration (v1.2)
- Aliases removed —
claude,sonnet,gpt4,deepseekno longer map to fixed snapshots. Use full ids (claude-sonnet-4-5) or specs. - Colon-containing ids need a prefix —
--model qwen2.5-coder:7b→--model openai_compat:qwen2.5-coder:7b. - DeepSeek is no longer a builtin endpoint — set
OPENAI_COMPATIBLE_BASE_URL=https://api.deepseek.com/v1(see the example above). - Metadata changes — claude models previously carried a 200k context
window from the builtin table; they now use the 128k default (auto-compact
triggers earlier). Override with
MIN_MAX_CONTEXT_TOKENSif you rely on the larger window. - Multiple keys — with credentials for 2+ providers set, the agent used
to silently default to
gpt-4o; it now fails at startup and asks you to pick explicitly.
Programmatic API
import { MinAgent } from "@saluzi/min";
const agent = new MinAgent({
// model is optional: omitted → MIN_DEFAULT_MODEL, or the single builtin
// provider with credentials. String specs use the [provider:]model-id
// syntax; a prebuilt ModelConfig object also works.
model: "gpt-4o",
cwd: process.cwd(),
permissionMode: "default", // requires askUser for default/acceptWrite
askUser: async (toolCall, reason) => true,
// plugins: [...], // configuration-time only
// skillsDir: "./.min/skills",
// compaction: true, // enable auto-compaction (off by default);
// // or "./my-compact.mjs" for an external strategy
});
const result = await agent.run("Add a vitest test for src/utils/env.ts");
console.log(result.totalTurns, result.stoppedReason);
agent.abort(); // stop the current run at the next safe point
agent.installSigintHandler(); // Ctrl+C → abortKey exports: MinAgent, createMinAgent, resolveModel, createDefaultTools
(and each tool factory), MinimalSession, plus the plugin/skills helpers shown
under Plugins and Skills.
Tools
Built-in tools: bash, read, write, edit, grep, ls, find.
- Concurrent writes to the same file are serialized automatically.
- File operations are confined to the working/project root (no traversal outside it).
- Tool output is truncated to protect the context window.
readshows at most the first 2000 lines (or first 50KB) of a file and includes the nextstartLineto continue from; files above 5MB are refused.
Permissions
Three modes:
default— ask beforebash/write/edit(requiresaskUser).acceptWrite— writes allowed, still asks for shell.bypass— no prompts (sandbox/automation only).
Approval is requested through your askUser callback; output is also guarded
against context overflow.
Sandbox
The sandbox confines bash commands and their child processes at the OS
level — the same shape as the permission system's guardrails, but enforced by
the kernel instead of by prompting. It is a replaceable plugin and off by
default: enable with --sandbox, sandbox: true in the programmatic API,
or MIN_SANDBOX=1.
min-agent --sandbox --model gpt-4oWhat the default policy enforces:
- Filesystem — writes confined to the working directory and the temp dir;
everything else is read-only. On Windows the writable temp is a dedicated
Low-integrity dir (
%TEMP%\min-sandbox-low, where TMP/TEMP point inside the sandbox) — the real%TEMP%is never labeled. Credential directories (~/.ssh,~/.aws,~/.gnupg) are unreadable. Inside the writable area,.min/and.git/hooks/stay read-only (a sandboxed command must not be able to grant itself plugin trust or a git hook). - Network — disabled entirely (
--unshare-neton Linux,(deny network*)on macOS). No proxy, no domain allowlist: for an edge-device agent a hard off-switch is the honest option. On Windows the network policy is not enforced — there is no unprivileged primitive that isolates networking without also destroying the read-everything filesystem semantics (AppContainer processes cannot read user files at all; WFP needs admin). - Environment — provider API keys (
OPENAI_API_KEY,ANTHROPIC_AUTH_TOKEN, …) are stripped from the bash environment; a shell command has no legitimate use for the agent's own credentials. - Approvals — sandboxed bash is auto-approved, so
--sandboxreplaces--permission bypassfor unattended automation. The dangerous-command blocklist (rm -rf /, fork bombs,curl | bash, …) still runs first and cannot be approved away; write/edit tools keep their normal permission flow.
Backends: bubblewrap on Linux/WSL2 (apt install bubblewrap), sandbox-exec
(Seatbelt) on macOS — both probed at startup by running a minimal sandboxed
no-op, because bwrap being on PATH says nothing (AppArmor on Ubuntu 24.04+
can block the user namespaces it needs). On Windows the backend is a
restricted-token / Low-integrity launcher built from the OS primitives:
commands run under a Low-IL token (the default NO_WRITE_UP policy makes every
unlabeled file — implicitly Medium — read-only), writable roots carry an
inheritable Low label, denyRead paths get a Medium + NO_READ_UP label (reads
denied for Low-IL processes, unaffected for you), and the whole process tree
lives in a kill-on-close job object that also fires when the agent dies.
Integrity labels are permanent — they outlive min itself — which is why the
default writable roots are the working directory and the dedicated Low temp
dir (%TEMP%\min-sandbox-low, where sandboxed commands' TMP/TEMP point);
the real %TEMP% is never labeled. (If an earlier release labeled your real
%TEMP% Low, restore the OS default with
icacls %TEMP% /setintegritylevel (OI)(CI)medium.) A
tiny C# helper implements this and is compiled on first use with the csc.exe
every Windows machine ships (cached under %LOCALAPPDATA%\min-sandbox, source
kept next to the exe for inspection) — no SDK, no downloads, no added
dependencies. Two caveats: the network policy is not enforced (see above), and
labeling requires NTFS — on FAT/exFAT volumes the probe fails and min warns
and continues unsandboxed. When the backend is unavailable, min warns and
continues unsandboxed; set failIfUnavailable: true to make that a hard error
instead.
Tune the policy with .min/sandbox.json (project) or ~/.min/sandbox.json
(global; project wins). Arrays replace the defaults:
{
"network": "off",
"allowWrite": [".", "/tmp"],
"denyWrite": [".env"],
"denyRead": ["~/.ssh", "~/.aws"],
"denyEnv": ["GITHUB_TOKEN"],
"autoApprove": true,
"failIfUnavailable": false
}A sandboxed command that hits the fence fails with the OS error, and min
appends a [sandbox] hint naming the allowed write roots so the model can
adjust instead of retrying blindly. File tools are unaffected — they are
already confined to the project root by path safety.
Compaction
Compaction is a replaceable plugin and is off by default — the core
loop never compacts unless a strategy is registered. Enable the builtin
auto-compaction with --compact (or compaction: true in the programmatic
API): when the context approaches the window limit, the loop finds a cut
point, summarizes the dropped messages through the main model, and keeps a
recent tail. Thresholds are tuned by the env vars above; DISABLE_COMPACT=1
is a kill switch that wins over the flag.
Point at an external compact with --compact=<path> (or
compaction: "./my-compact.mjs"). The module's default export is either a
CompactionStrategy object or a bare async function; both receive the current
entries, model config, and stream function, and return a CompactionResult
(or null when nothing should be compacted):
// my-compact.mjs — an external compaction strategy
export default async (entries, model, streamFn) => {
// …your own threshold / cut-point / summarizer logic…
return {
type: "compaction",
summary: "…",
tokensBefore: 12345,
retainedTail: [/* AgentMessage[] kept after the cut */],
};
};Any plugin can also replace compaction at setup time via
host.setCompactionStrategy(...) (last-writer-wins), and tune it with
host.setCompactionSettings(...) — including the "compaction" field of a
plugin.json manifest. A failed external-module load warns and leaves
compaction off (it never silently falls back to the builtin summarizer).
Plugins
A plugin runs at construction time and may replace or add capabilities — no
hot-loading. Through the PluginHost a plugin can:
- register/replace tools
- add
beforeToolCall/afterToolCallhooks - add prompt sections, guidelines, context files, or replace the prompt builder
- replace the stream function, session storage, or compaction strategy
- add context transforms, steering/follow-up message sources
- register custom model providers (
host.registerProvider) - publish capabilities on a
CapabilityBus(consumed by other plugins)
Programmatic plugins
import { MinAgent, type MinPlugin } from "@saluzi/min";
const myPlugin: MinPlugin = {
name: "style",
setup(host) {
host.addTool(myTool);
host.beforeToolCall(myGuard);
host.addPromptSection({ heading: "Style", body: "Prefer named exports." });
},
};
new MinAgent({ model: "gpt-4o", plugins: [myPlugin] });File plugins
Drop a plugin.json manifest into .min/plugins/<name>/ (project-local) or
~/.min/plugins/<name>/ (user-global). A manifest can declare tools,
providers, prompt sections, guidelines, and more — declaratively (no code) or
with code modules (tools[].module, module, provider.streamModule).
.min/plugins/
web-tools/
plugin.json # manifest: tool declarations + refs
web_search.mjs # tool execute implementation{
"name": "web-tools",
"tools": {
"items": [
{
"name": "web_search",
"description": "Search the web.",
"parameters": { "type": "object", "properties": { "q": { "type": "string" } }, "required": ["q"] },
"module": "./web_search.mjs"
}
]
}
}// web_search.mjs — export default a ToolExecutor
export default async (args, ctx) => {
ctx.onUpdate?.("searching...");
return { content: "results...", isError: false };
};Code modules require trust (--trust-plugin <name> or trust.json);
untrusted plugins apply only their declarative fields — with one exception:
the provider field also requires trust (it redirects LLM traffic and
attaches credentials). See ~/.min/plugins/plugins.md for the full guide
(auto-generated on first run).
Custom providers
Register a provider via host.registerProvider(def) or the provider field
in plugin.json. api: "openai" | "anthropic" reuses the builtin protocol;
streamModule gives a fully custom stream handler. Security rules: provider
registration requires trust; apiKeyEnv/authTokenEnv must stay in the
provider's namespace (<PROVIDER>_API_KEY / <PROVIDER>_AUTH_TOKEN); builtin
provider names (openai / anthropic / openai_compatible) are reserved —
redirect them with their dedicated *_BASE_URL vars or --base-url.
{
"name": "gemini-bridge",
"provider": {
"name": "gemini",
"api": "openai",
"baseUrl": "https://generativelanguage.googleapis.com/v1",
"apiKeyEnv": "GEMINI_API_KEY",
"models": [{ "modelId": "gemini-1.5-pro", "contextWindow": 1000000, "maxOutputTokens": 8192 }]
}
}Then min-agent --model gemini-1.5-pro resolves via the provider's model
list. ModelConfig.provider is a plain string, so any provider name works.
Skills
Agent-Skills convention: each skill is a SKILL.md file with YAML frontmatter
(name, description, optional disable-model-invocation). Only frontmatter is
loaded — the body is read on demand by the LLM via the read tool (lazy
loading). Skills are injected as a pure plugin that adds a catalog section to
the system prompt.
Resolution (first non-empty existing directory wins):
--skills-dir/MinAgentConfig.skillsDirMIN_SKILLS_DIRenv var<cwd>/.min/skills/(project-local)~/.min/skills/(user-global)
Selecting / disabling skills (allowlist / denylist)
An optional skills.config.json in the skills directory selects or excludes
skills by their frontmatter name (mirrors workflows.config.json):
{ "enabled": ["plan", "review"], "disabled": ["experimental"] }--skill <name> (repeatable) / MinAgentConfig.skills sets an explicit
allowlist that overrides enabled — only the named skills load:
min-agent --skill plan --skill review --model gpt-4omkdir -p .min/skills
cat > .min/skills/release.md <<'EOF'
---
name: release
description: Cut a new release bump and tag.
---
1. Run `bun run build`.
2. Bump version in package.json.
3. Commit and tag.
EOFScripts
bun run build # tsc
bun run test # vitest run
bun run test:watch # vitest
bun run typecheck # tsc --noEmit
bun run start # tsx src/index.tsLicense
MIT
