mini-me from Node.js: minime.js
A Node.js client that uses mini-me as a library by driving mm -acp over ACP (ticket 167). One file, minime.js, no dependency (Node ≥ 20: child_process and readline). See ../README.en.md for the ACP messages behind it.
import { Agent } from "./minime.js";
const agent = await Agent.start({ config: "agent.yaml" });
const { text } = await agent.complete("Explain this repository"); // not streamed
for await (const ev of agent.stream("And the tests?")) { // streamed
if (ev.kind === "text") process.stdout.write(ev.text);
}
const ac = new AbortController();
setTimeout(() => ac.abort(), 3000);
await agent.complete("A long task", { signal: ac.signal }); // cancelled
await agent.close();
Run the examples
llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
llmman serve
cd demo.acp.sdk/nodejs
node examples/complete.js # not streamed
node examples/stream.js # streamed, with the tool calls
node examples/cancel.js # cancelled after 3 seconds
node examples/permission.js # a command refused, then allowed
mm must be on the PATH, or named by MM_BIN. The examples use agent.yaml (Qwen3.5 4B on llmman, bash only, yolo: false); any agent file works.
The API
await Agent.start({ config, cwd?, mm?, onPermission?, onStderr? }) |
starts mm -acp -config <config> (made absolute), then initialize and session/new {cwd}. mm defaults to $MM_BIN, then mm. |
await agent.complete(prompt, { signal?, onEvent? }) |
one turn; resolves to { text, stopReason, usage } once it is over. onEvent sees every event as it comes. |
agent.stream(prompt, { signal? }) |
one turn as an async iterator of events; the last one is done. Leaving the loop early cancels the turn. |
signal (an AbortSignal) |
aborting it sends session/cancel; the turn ends with stopReason: "cancelled" and the text so far. |
onPermission(call) |
asked when the agent wants to run a tool that needs a confirmation (yolo: false). Return true (allow once), false (reject once) or an ACP optionId ("allow_always", "never"). Without it, every such call is refused. |
onStderr(line) |
mm's stderr (banner, warnings, [acp] lines). Otherwise it is read and dropped; its last line is added to the error when mm exits. |
await agent.reset() |
a new session on the same process: the conversation starts over. |
await agent.close() |
closes mm's stdin and waits for it to exit. |
agent.sessionId |
the ACP session id. |
The events: {kind: "text", text}, {kind: "thinking", text}, {kind: "tool_call", id, title, toolKind, input}, {kind: "tool_result", id, status, output}, {kind: "done", stopReason, usage, text}.
Rules worth knowing:
- One Agent = one mm process = one conversation. Calls continue it, as in an editor. One turn at a time per Agent (a second one throws
already running); start several Agents to work in parallel. - A turn mm ends with an error rejects with mm's own words, e.g.
minime: Internal error (-32603): {"error":"the model repeated the same bash call 6 times…"}. - A cancelled question is dropped from the conversation by mm: the next question does not know about it.
- The client advertises no file-system, terminal or form capability: mm needs none, and its
ask_usertool is not offered.
Tests
npm test # node --test test/minime.test.js — 11 tests, no model, no mm
test/fake-mm.js plays mm's side of ACP with the message shapes captured from mm v1.0.1: answers, a tool call with its permission, a slow turn to cancel, a crash. Not a bare node --test: it would also run fake-mm.js as a test and wait forever.
Measured (2026-10-26, llmman on the author's host, mm v1.0.1 built from the working tree)
| Example | Result |
|---|---|
complete.js |
one-sentence answer, end_turn, 663 tokens, 2.9 s |
stream.js |
find examples -name "*.js" | wc -l allowed by the callback (read-only), output 4, answer streamed chunk by chunk, end_turn |
cancel.js |
story cut after 3.0 s (cancelled, 383 characters kept); the next question on the same Agent answered at once — without knowing the story, which mm dropped |
permission.js |
pwd refused: the model invented an answer (/root); allowed: the real directory. A refusal does not stop a small model from guessing. |
Seen in three runs before the callback allowed find: the model retried the same refused command until mm's loop detector stopped the turn (6 identical calls); stream() rejected with that message — hence the try/catch in stream.js.
Not done
- The official ACP TypeScript library was not tried (no network in the sandbox where this was written): raw JSON-RPC keeps the client dependency-free.
- Not a published npm package: the
mmbinary would have to ship with it, per platform. session/load/session/resume(stored sessions), images and file attachments in prompts,ask_userforms: not exposed.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
|