Imported from gastonmorixe/minimal-agent-core (
AGENTS.md). Install upstream withnpx skills add gastonmorixe/minimal-agent-core. Copyright stays with the author.
Orientation for agents working in this repo. User-facing usage lives in
README.md; deferred work in private/TODOS.md (untracked); per-change
write-ups in docs/changes/; deeper design notes and reverse
-engineering captures under private/research/.
Build, test, lint
bun run checkis the one gate that must stay green. It runs, in order: typecheck, lint (oxlint),format:check,biome:check(format + import sort),docs:check(typedoc), thenbun test. It stops at the first failure.bun testruns the suite (currently 4588 pass / 10 skip; the skips are live-network E2E behindE2E=1).bunfig.tomlscopes test discovery to first-party source:pathIgnorePatternsexcludes the gitignored, vendored trees underprivate/(research clones that ship their own suites and dependencies), so the gate never runs third-party tests.- Lint is
oxlint(config in.oxlintrc.json). Biome owns formatting and import-sort only; its linter is off.bun run formatdoes NOT sort imports, so runbun run biome:fix(orbun run check) before assuming a tree is clean.
Zero runtime dependencies
package.json ships an empty dependencies block, by policy, and stays that
way. The agent runs on Bun's standard library plus the TypeScript source in this
repo, with no npm packages resolved at runtime. The only devDependencies are
the toolchain (Bun types, Biome, oxlint, typedoc, TypeScript, Husky,
Commitlint); none of it ships in the running agent. scripts/install.sh is the
user installer: clone source, link minimal-agent and ma onto
~/.minimal-agent/bin plus ~/.local/bin, never bun install. Do not
document bunx github:... (it always installs, then dies on workspace:*). Before reaching for a package, write the small piece you
need as a readable file with a test. Optional external binaries (mdstream,
git) are fetched on demand and must degrade gracefully when missing, they are
never package dependencies. The extended-plugins repo follows the same policy:
every plugin is zero-dependency.
Conventions worth knowing
- Exhaustive switches over discriminated unions end with a compile-time
check, not a bare
throw:
If someone adds a union member, tsc fails at the switch instead of at runtime. Seedefault: { const _exhaustive: never = value throw new Error(`unhandled ...: ${_exhaustive}`) }src/cli/command-plan.tsandsrc/client/debug.tsfor the pattern. - Generators carry a JSDoc
@yields(oxlint's jsdocrequire-yieldsis on). - Capabilities are data, not branching. Code asks "does this model support
X?" by reading a
Capabilitiesrecord, never "is this opus-4-7?". - Prompts live in markdown, not string literals. Every model-facing prompt
(system prompt, tool descriptions, sub-prompts) is a
.md/.tmpl.mdfile loaded throughsrc/prompts/prompts.ts. Core prompts are undersrc/prompts/; plugin prompts sit next to the plugin (PROMPT.md, orplugins/<id>/prompts/*). Templates use%%name%%(required, throws if unwired) and%%name?%%(optional). The rule: prose in markdown, control flow (which fragment, what order) in TypeScript. SeebuildLoopSafetyParagraph(formerly in the now-removedsrc/headers.ts; the Anthropic header logic moved to../minimal-agent-plugins/ma-llm-anthropic-plugin/) for the worked example, anddocs/changes/2026-05-30-prompts-as-markdown.mdfor the rationale.
How the LLM layer is structured
The provider abstraction lives under src/llm/. It exists so the
agent loop, REPL, and live-area renderers depend on canonical types, never on a
specific vendor's wire format. Anthropic is the first (and currently only wired)
adapter; OpenAI Chat + Responses foundation files are present but not registered.
Full design rationale, the capability schema, and the phase plan are in
private/research/2026-05-28-llm-providers/01-architecture.md.
The shipped state and gotchas are in
docs/changes/2026-06-12-provider-decoupling-plugin-architecture.md.
The canonical core (src/llm/)
Vendor-neutral types and the orchestrator. Import everything from the barrel
src/llm/index.ts.
canonical-request.ts/canonical-messages.ts/canonical-tools.ts: what the agent sends.CanonicalRequestcarriessystem,messages,tools,effort,thinking,speed,outputFormat,metadata, plus per-vendor escape hatches (vendor.anthropic,vendor.openai).canonical-events.ts:CanonicalEvent, one discriminated union every provider's stream parses INTO (message_start,text_delta,thinking_delta,tool_use_*,message_deltawithstopDetails,stream_error, ...). The wire-event names never leave the adapter.capabilities.ts: theCapabilitiesrecord (context window, thinking modes, effort levels, caching, tools, modalities, speed, server tools).model-registry.ts:registerModel/resolveModel/findModelplus the provider registry. Module singletons withclearModelRegistry()/clearProviderRegistry()for test isolation.provider.ts: theProviderAdapterport :{ id, surfaces, validate(req, model), run(req, model, ctx) }.run.ts:run(req, { context, acceptDegrade? }): AsyncIterable<CanonicalEvent>. Resolves model → provider, validates, then delegates to the adapter. WithacceptDegrade, a validation failure that offersdegradeis retried with the fallback request instead of throwing.errors.ts: the provider-neutral error hierarchy (ProviderError,CapabilityViolation,UnsupportedCapabilityError, stream timeout/auth).pricing.ts:MTokRate,calculateUsageCost,mergeUsage, and the known Anthropic rate tables.streaming/sse-parser.ts: a generic line-buffered SSE parser the adapters reuse.
Provider plugins (plugins/llm-<id>/)
Each provider is a PLUGIN under plugins/llm-<id>/, not part of the core. A
plugin ships a provider.json descriptor (id / entry / export) and
exports a ProviderPlugin (id / displayName / shortCode / register()).
Inside, the adapter is the only place that knows a wire format. Shape:
validate.ts (capability gating), request-body.ts (canonical → wire),
response-stream.ts (wire SSE → CanonicalEvent), capabilities.ts +
models.ts (registry data), headers.ts, adapter.ts (implements the port +
bootstrap<Provider>() + the exported ProviderPlugin). Canonical-core imports
use ../../src/llm/*.
As of Wave G every provider plugin lives in the sibling
../minimal-agent-plugins/ repo (there is no in-core plugins/ dir):
ma-llm-anthropic-pluginis complete: full Messages mapping, live SSE fixtures, plugin-local model catalog + quota cache (no coresrc/imports).ma-llm-openai-pluginis complete: Chat + Responses surfaces, gpt-5.x / gpt-4 / o-series (gpt-5.5 registered dual-surface), live SSE fixtures.
src/llm/provider-discovery.ts is the EARLY provider loader: it scans each
plugin root for provider.json, dynamically imports each ProviderPlugin, and
registers it. Boot calls registerDiscoveredProviders([<repo>/plugins, ...resolveSiblingPluginRoots()]) then activateDiscoveredProviders() BEFORE any
model resolution, so src/index.ts names no provider. The embedded <repo>/plugins
root is now empty (providers come from the sibling clone at ~/.minimal-agent/plugins
in prod, or the sibling checkout at dev time). Separate from the TUI PluginLoader
(which runs later for tools / live-area slots; provider registration must happen earlier).
Coexistence with the legacy client (important)
The new canonical layer runs ALONGSIDE the legacy src/network/client.ts, it does not
replace it. Agent.send / Agent.run still call client.sendMessage, which
carries ~1500 lines of tuned cross-cutting infrastructure (idle/hard-timeout
watchdogs, retry coordinator, 401 store-first refresh with a multi-process
race fix, network observer, status bus). For Anthropic both paths emit identical
bytes.
src/llm/adapter-legacy.ts bridges the two directions (canonicalToSendOptions,
streamedResponseToCanonicalEvents, runLegacyAsCanonical). Migrating the
agent loop onto run() (and moving the transport infrastructure into
provider-neutral middleware) is "Phase 4-extended", a separate epic. Do not
refactor the client.ts watchdog/retry/observer/401-refresh code as a
side-effect of provider work.
Adding a model or provider
- New model for an existing provider: add a
Capabilitiesrecord in that plugin'scapabilities.tsand aregisterModel({...})entry in itsmodels.ts(e.g. under../minimal-agent-plugins/ma-llm-anthropic-plugin/). - New provider: create
../minimal-agent-plugins/ma-llm-<id>-plugin/mirroring an existing one, implement theProviderAdapterport + abootstrap<Id>(), export aProviderPlugin, and add aprovider.jsonpointing at it. Discovery registers it at startup. A provider that reuses another's wire spec (e.g. an OpenAI-compatible gateway) can import that plugin's translators/request-body (seeplugins/llm-openrouter, an OpenAI-compatible gateway that reusesllm-openai's wire layer). The CLI does not validate--modelagainst the registry (the server is the source of truth), so forward-compat ids pass through.
Lifecycle policy seams + AgentCore
- Policy veto/rewrite uses typed
PolicyDecision/LifecyclePort(src/sdk/lifecycle.ts). AgentCore never imports HookBus; the host wiresLifecyclePortAdapteroverloader.hooks(). - Catalog + decision contract:
docs/hooks.md. Channels such astool.willInvokeandmessage.willSendare wired emit sites (not catalog-only). Mode/CLI deny runs beforetool.willInvoke; silence ≠ allow. - Production interactive +
--prompthuman path:InteractiveSession(src/host/interactive-session.ts) overAgentCore. LegacyAgentremains for unit tests / compat and still sharesexecuteToolRound. - Reference policy fixture (plugins repo):
ma-policy-ref-plugin(disabled by default).
Slash commands + scheduling (the commands[] port)
Plugins contribute slash commands declaratively via a manifest commands[]
array ({name, summary, argHint?, handler}), mirroring tuis/modes/
liveAreaSlots. The loader collects them into a host-owned registry
(getCommands / hasCommand / listCommandInfo / dispatchCommand,
first-wins on cross-plugin name collision). runReplLiveArea.onSubmit
intercepts a registered /<name> (parsed by the pure src/cli/slash-command-parse.ts)
and acts on the handler's CommandResult union (expand → model turn,
notice/error → scrollback, none → nothing). Commands work headlessly; the
slash-menu plugin is just an autocomplete overlay over the registry (it reads
ctx.listCommands(), injected into hook/event contexts).
Out-of-band prompt injection rides the prompt.inject bus channel
({text, source?}): the REPL turns it into a normal queued submit that fires
BETWEEN turns. The live-area handler context gained emit so a periodic slot can
use it. The schedule plugin (cron engine + CronCreate/List/Delete + /loop
/schedule+ a 1s heartbeat) is built entirely on these ports. It imports no harness runtime, onlyimport typefromsrc/plugins/types.ts. Seedocs/changes/2026-05-30-schedule-plugin.md.
Git commits (Conventional Commits)
Commit subjects follow Conventional Commits
(feat:, fix:, chore:, …). Husky + Commitlint enforce this on commit-msg
(commitlint.config.js, .husky/commit-msg; wired by prepare → husky on
install). CI runs a commitlint job (bun run commitlint:last on push; range
lint on PRs). Local check: echo 'feat: ok' | bun run commitlint.
Git commits (agent Co-authored-by trailer)
Every commit made by an agent must include a Co-authored-by trailer for the
current session (name + short session id). This is a local audit trail
("which agent session authored this commit"). It is not a GitHub co-author,
not a second GitHub user, and not GitHub contributor attribution.
Format:
Co-authored-by: {Name} <{short-sid}@minimal-agent>
{Name}— session name (MINIMAL_AGENT_AGENT_NAME/SessionInfo/ TUI), e.g.Veronica{short-sid}— first 8 hex characters ofMINIMAL_AGENT_SESSION_ID, e.g.a26a1e75froma26a1e75-…
Example:
Co-authored-by: Veronica <a26a1e75@minimal-agent>
Put the trailer on its own line at the end of the commit message with a blank line before it. Prefer a HEREDOC so git keeps the trailer:
git commit -m "$(cat <<'EOF'
fix: explain the change briefly.
Co-authored-by: Veronica <a26a1e75@minimal-agent>
EOF
)"
Or append with Git's native flag:
git commit -m "fix: explain the change briefly." \
--trailer "Co-authored-by: Veronica <a26a1e75@minimal-agent>"
Enforcement: when MINIMAL_AGENT_SESSION_ID is set, .husky/commit-msg
runs scripts/check-agent-coauthor.sh after Commitlint. Missing or wrong
trailers (sid / name) fail the commit. Human commits (env unset) are not gated.
Agents must not bypass with --no-verify.