mini-me from Kotlin: minime.Agent
A Kotlin (JVM) client that uses mini-me as a library by driving mm -acp over ACP (ticket 187). It is the Kotlin twin of the Node.js and Go clients. It is built on the official ACP Kotlin SDK, com.agentclientprotocol:acp 0.34.0 (Maven Central), and on coroutines. See ../README.en.md for the ACP messages behind it.
Agent.start(Options(config = "agent.yaml")).use { agent ->
val result = agent.complete("Explain this repository") // not streamed
println(result.text)
agent.stream("And the tests?").collect { event -> // streamed
if (event is Event.Text) print(event.text)
}
launch { delay(3.seconds); agent.cancel() } // cancelled after 3 s
agent.complete("A long task") // stopReason == "cancelled"
}
Run the examples
llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
llmman serve
cd demo.acp.sdk/kotlin
./gradlew runComplete # not streamed
./gradlew runStream # streamed, with the tool calls
./gradlew runCancel # cancelled after 3 seconds
./gradlew runPermission # a command refused, then allowed
Only a JDK is needed (21 or later): the Gradle wrapper downloads Gradle, Kotlin and the dependencies on the first run. 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. Their sources are in src/examples/kotlin/.
To use the library from another Gradle project, until it is published, include this build in the project's settings.gradle.kts:
includeBuild("../path/to/demo.acp.sdk/kotlin")
and depend on it in build.gradle.kts:
dependencies {
implementation("minime:minime:0.1.0")
runtimeOnly("org.slf4j:slf4j-nop:2.0.20") // or any SLF4J backend, for the SDK's logs
}
The API
Agent.start(Options(...)): Agent |
(suspend) starts mm -acp -config <config> (made absolute) in cwd, then initialize and session/new {cwd}, within 60 s. Throws MinimeException when mm cannot be started or does not answer. |
Options.config |
the agent file. Required. |
Options.cwd |
the session's working directory, where the tools run. Default: the current directory. |
Options.mm |
the mm binary. Default: $MM_BIN, then mm on the PATH. |
Options.onPermission: suspend (ToolCall) -> Decision |
asked when the agent wants to run a tool that needs a confirmation (yolo: false). Without it, every such call is refused. |
Options.onStderr: (String) -> Unit |
mm's stderr, line by line (banner, warnings, [acp] lines). Otherwise it is read and dropped; its last line is added to errors. |
agent.complete(prompt, onEvent = null): Result |
(suspend) one turn; returns Result(text, stopReason, usage) once it is over. onEvent sees every event as it comes. |
agent.stream(prompt): Flow<Event> |
one turn, run when the flow is collected; the last event is Event.Done. Cancelling the collecting coroutine, or leaving the flow early (first(), take(n)…), cancels the turn: mm receives session/cancel. |
agent.cancel() |
(suspend) cancels the turn in progress, if any: it ends with stopReason = "cancelled" and the text so far, returned as usual by complete / stream. |
agent.reset() |
(suspend) a new session on the same process: the conversation starts over. |
agent.close() |
closes mm's stdin, waits for it to exit (kills it after 5 s). Agent is AutoCloseable: use { } closes it. Calling it twice is harmless. |
agent.sessionId |
the ACP session id. |
AgentBusyException |
thrown by a turn or a reset while another turn runs on the same Agent. |
MinimeException |
a turn mm ended with an error, or mm gone: the message carries mm's words and its last stderr line. |
Decision, the answer of onPermission:
Decision.Allow |
run the tool this once (allow_once) |
Decision.Reject |
refuse it this once (reject_once) |
Decision.Option(id) |
one of the options mm offers by its id: "allow", "allow_always" (this tool, for the session), "reject", "never" (this tool, for the session). An id mm did not offer counts as Reject. |
A callback that throws counts as Reject.
The events (sealed interface Event):
| Event | Fields |
|---|---|
Event.Text |
text: a chunk of the answer |
Event.Thinking |
text: a chunk of a thinking model's reasoning |
Event.ToolCallStarted |
call: ToolCall(id, title, kind, input) — title e.g. bash: ls, kind execute/read/edit…, input the arguments as JSON text |
Event.ToolResult |
id, status (completed or failed), output |
Event.Done |
stopReason, usage (null when mm sent none), text: the whole answer |
Rules worth knowing:
- One Agent = one mm process = one conversation. Calls continue it, as in an editor. One turn at a time per Agent (
AgentBusyException); start several Agents to work in parallel. - A turn mm ends with an error throws
MinimeExceptionwith mm's own words and the last line of its stderr, e.g.minime: turn failed: Internal error (-32603): the model repeated the same bash call 6 times…. If mm crashed, the message saysmm exited: exit status N. - A cancelled question is dropped from the conversation by mm: the next question does not know about it.
cancel()returns a result; cancelling the coroutine throws.agent.cancel()lets the turn end normally withstopReason = "cancelled". Cancelling the collecting coroutine (withTimeout,job.cancel()) also stops mm's turn, but follows the coroutine rules:completethrows theCancellationException.- The client advertises no file-system, terminal or form capability: mm needs none, and its
ask_usertool is not offered. - The SDK logs through SLF4J: add the backend you want (
slf4j-nopkeeps it silent); without one, SLF4J prints a short warning at start-up and then stays silent.
Tests
cd demo.acp.sdk/kotlin
./gradlew test # 20 tests, no model, no mm, ~5 s once Gradle is warm
src/test/kotlin/minime/FakeMm.kt is a fake mm, started as a real child process on the test classpath. It writes and reads the JSON lines itself, with the message shapes mm v1.0.3 sends (four permission options, rawOutput.output, usage on the prompt's answer, available_commands_update after session/new): answers, a thought, a tool call with its permission, a slow turn to cancel, an error, a crash.
Measured (2026-11-01, llmman on the author's host, mm v1.0.3 built from the working tree, JDK 25)
| Example | Result |
|---|---|
runComplete |
one-sentence answer, end_turn, 663 tokens, 9.3 s (cold model) |
runStream |
find src/examples -name "*.kt" | wc -l allowed by the callback (read-only), output 4, answer streamed chunk by chunk, end_turn |
runCancel |
story cut after 3.0 s (cancelled, 332 characters kept); the next question on the same Agent answered at once, without knowing the story, which mm dropped |
runPermission |
pwd refused: the model invented an answer (/root); allowed: the real directory. A refusal does not stop a small model from guessing. |
Not done
- Not published to Maven Central: 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.- No timeout on a cancel mm never answers (the Go client has one, 30 s): wrap the call in
withTimeoutif you need one.
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 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
|