Connects a large language model to a Minecraft server through mineflayer, so the bot can accompany a player: it reads requests in chat, carries them out as multi-step tasks, and answers in chat.
The bot joins a Minecraft 26.1 server, reads chat, asks a model for a decision, runs tools against the live world, and answers in chat. It can walk after a player, walk to a place, pick up a dropped item, hold and use an item, kill a creature, eat food it carries, be told to stand still, keep a plan for a request that spans several turns, and be stopped mid-request. A trusted operator can have it run a server command.
Still missing: the bot says nothing unless spoken to, so it never comments on what it sees; it remembers nothing between requests beyond the current task list; and it cannot find a player who is out of entity range, so "come to me" only works while the player is in view.
- Deno 2.9.7 or newer.
- A Minecraft server at version 26.1 with
online-mode=false, because the bot uses offline authentication. - Java 25 or newer, for the dev server that the integration scenarios run against.
- An OpenAI-compatible chat completions endpoint.
deno task start
| Task | Purpose |
|---|---|
deno task start |
run the bot |
deno task dev |
run with the file watcher |
deno task check |
type check |
deno task test |
unit tests |
deno task lint |
lint |
deno task fmt |
format |
deno task demo:01 |
connectivity scenario, against the dev server |
deno task demo:02 |
task runner and turn budgets, no server needed |
deno task stub:model |
stand-in OpenAI-compatible endpoint, for runs without an API key |
deno task probe:chat |
joins as a second player and drives the bot through chat |
deno task compile |
build a self-contained binary into dist/ |
Configuration is read from $MCLLM_CONFIG_DIR/config.json and overridden by
MCLLM_* environment variables. Data is written under $MCLLM_DATA_DIR. Every
setting, its environment variable and its default are declared in one table in
src/config.ts, which is the only place a tunable is written down; the sections
below name the ones an operator is likely to change.
The model is reached at MCLLM_LLM_BASE_URL with MCLLM_LLM_MODEL, and the key
is read from MCLLM_API_KEY and never written to a config file. Without a key
the bot still joins and answers its chat commands.
A full run without a real model:
scripts/dev-server.sh start
MCLLM_LLM_BASE_URL=http://127.0.0.1:18080/v1 MCLLM_LLM_MODEL=stub \
MCLLM_API_KEY=stub deno task start &
deno task stub:model &
deno task probe:chat
A run against Ollama's hosted models, which speak the OpenAI wire format at
https://ollama.com/v1 and need no local weights:
export MCLLM_LLM_BASE_URL=https://ollama.com/v1
# Any model the account may use. Tool calling is required.
export MCLLM_LLM_MODEL=gemma4:cloud
export MCLLM_API_KEY=... # from ollama.com/settings
MCLLM_SERVER_PORT=25567 deno task start
A model outside the account's plan answers HTTP 402 with a link to the pricing
page. deno task start logs the model it selected at startup, so a wrong name
shows up before the first question.
The system message carries a persona read from personas/, chosen per world:
MCLLM_PERSONA=companion deno task start
personas/companion.md ships with the repository. A name with no matching file
is not an error: the bot runs without a persona, and the startup line reports
"persona":"none". MCLLM_PERSONA_DIR moves the lookup to another directory,
which is how a compiled binary finds them.
| Command | Effect |
|---|---|
!state |
read the bot's own state out loud |
!tasks |
read the plan it is working through |
!phase |
report which stage of the build the code is at |
!stop |
stop everything it is doing, without waiting for a model turn |
!help |
list the commands |
Everything else is a request for the model.
A request that asks the bot to follow a player starts the pathfinder, which
keeps running after the turn ends. !stop or "stop following me" ends it.
While it follows, the bot keeps its head on the player, so it looks at whoever it is walking behind rather than at the horizon. It looks along its path while it walks, because mineflayer steers the bot with the same rotation it uses for the head. If the followed player leaves the world, the follow ends there instead of walking on to where they last stood.
A follow keeps up rather than trailing behind. Where the ground between the bot
and the player is walkable and the player is within thirty-two blocks, it runs
straight at them, sprinting and jumping, which is about a quarter faster than
sprinting alone; the pathfinder cannot sprint-jump, because it decides its gait
against the next path node and those are one block apart on a straight run.
Everywhere else the pathfinder walks the bot as before, and it takes over again
the moment the straight line stops being walkable. MCLLM_FOLLOW_RUN_STRAIGHT=false
turns that off.
The bot keeps a task list for a request that spans more than one turn. It writes
the plan with mc_setTaskList, the plan is carried into every later request, and
!tasks reads it back:
follow me home and eat something when we arrive
> Follow Alex home and eat something. | 0 of 2 steps done | next: Follow Alex
!stop cancels whatever is running and drops the plan, and says what it reached:
!stop
> stopped follow Alex; dropped the task list
A stop reaches a turn that is already running, including a model request still
waiting on the server, so it does not have to wait for the current step to end.
The plan is capped by MCLLM_TASKS_MAX_ITEMS, MCLLM_TASKS_MAX_ITEM_CHARS and
MCLLM_TASKS_MAX_GOAL_CHARS, because it is paid for on every request.
A request that runs past MCLLM_MAX_STEPS_PER_TURN or
MCLLM_TURN_WALL_CLOCK_MS stops there, says which limit it hit, and cancels
what it started. The plan survives that, so the next request can pick it up.
The pathfinder never breaks blocks or builds a tower to reach a player. It reports that it found no path instead, so a follow across terrain the bot cannot walk stops rather than edits the world.
| Tool | Effect |
|---|---|
mc_goTo |
walk to a player or a coordinate |
mc_pickupItems |
walk to a dropped item and pick it up |
mc_equipItem |
hold a named item in the main hand |
mc_useItem |
use the held item; eating waits for the meal |
mc_attack |
chase a creature and swing until it dies |
There is no tool for reading the inventory, because every state snapshot already carries the whole inventory and the held item. Such a tool would only repeat what the model has, and every extra choice is a chance to choose wrong.
mc_attack refuses a player whatever the request says, and refuses before it
starts chasing when health is already at the floor it fights down to
(MCLLM_FLEE_HEALTH, default 6). Both are policy, not capability: bot.attack
would send the packet either way.
A walk or a fight replaces a follow that is still running, because the bot walks
one way at a time. Each of those tools closes the follow's cancellation scope
first, so a request that wants both asks for the follow again afterwards. A
fight is also reachable by !stop, like any other long-running work.
The bot can run a server command when a player asks it to. Two gates must both be open:
MCLLM_CHEATS=true— the world allows it, and the bot holds operator status.- The player who asked is on
MCLLM_CHEAT_WHITELIST, a comma-separated list of names or UUIDs.
MCLLM_CHEATS=true MCLLM_CHEAT_WHITELIST=Alex deno task start
An empty whitelist trusts nobody, so the default is a bot that refuses every
command. A refused request is logged as chat.untrusted and answered in chat,
and the refusal reaches the model as not permitted: <reason> so it cannot
mistake it for a success.
mineflayer does not expose whether the bot is an operator, so MCLLM_CHEATS is
the operator's assertion and the server has the last word: a command the bot has
no permission for comes back as "Unknown or incomplete command", and the tool
reports that as a failure instead of a result.
One command runs at a time, and a command carrying a second line is refused, so a command cannot smuggle a second one past the model.
mineflayer speaks up to Minecraft 26.1. For a 26.2 or 26.3 server, put ViaProxy in front of it and point the bot at the proxy:
java -jar ViaProxy.jar cli \
--bind-address 127.0.0.1:25568 \
--target-address 127.0.0.1:25565 \
--auth-method NONE \
--proxy-online-mode false
MCLLM_SERVER_HOST=127.0.0.1 MCLLM_SERVER_PORT=25568 deno task start
The server must have online-mode=false: the bot authenticates offline, and a
server that requires a real account rejects it during login.
ViaProxy translates the protocol only. It does not add mod registries, so it
cannot make a modded Fabric server reachable. A Fabric server running
fabric-registry-sync-v0 disconnects a client that cannot speak Fabric
networking unless every registry the mods want to sync is marked optional by the
mod that adds it. A vanilla-protocol client such as mineflayer is such a client,
and no server-side setting lifts that.
scripts/dev-server.sh downloads and runs a vanilla server for the integration
scenarios. Everything it writes stays outside the repository, under
$MCLLM_DEV_SERVER_DIR.
scripts/dev-server.sh start # download if needed, start, wait until ready
scripts/dev-server.sh stop # stop the server, letting it save the world
scripts/dev-server.sh status # report whether it is running
| Variable | Default | Meaning |
|---|---|---|
MCLLM_MC_VERSION |
26.1 |
Minecraft version |
MCLLM_SERVER_PORT |
25565 |
listening port |
MCLLM_DEV_SERVER_DIR |
~/.cache/minecraft-llm/dev-server |
data directory |
MCLLM_DEV_SERVER_JAVA |
java |
java binary, needed when the default is older than 25 |
MCLLM_DEV_SERVER_MEMORY |
1G |
heap size |
MCLLM_LEVEL_TYPE |
normal |
vanilla level type |
Starting the server accepts the Minecraft EULA. An example run:
MCLLM_DEV_SERVER_JAVA=/usr/lib/jvm/java-25-openjdk/bin/java \
scripts/dev-server.sh start
MCLLM_SERVER_PORT=25565 deno task demo:01
deno task compile
Writes dist/mcllm, an executable that needs no Deno install, no node_modules
and no source tree beside it. Permissions are baked in at build time, so it runs
with no flags. personas/ is copied to dist/personas/, and
MCLLM_PERSONA_DIR moves it.
minecraft-data carries the world data for every version it knows, which is
430 MB of JSON a bot speaking one version can never read, and a plain
deno compile produces a 507 MB binary. scripts/compile.ts keeps the files the
shipped version's entry in data.js reaches and hands the rest to --exclude,
which brings the binary to 133 MB. That entry is not self-contained — the 26.1
entry reuses files from five other version directories — so what is dropped is a
closure over the entry rather than a list of versions. MCLLM_MC_VERSION
selects the version the binary is built for.
scripts/README.md covers the pre-commit hook. Commit subjects follow the
scoped convention: <scope>: <description>, with the rationale in the body.
ISC. See LICENSE.
The code, this document and the test suite are written with AI assistance, under
human review. Claims about behaviour are backed by a check that can be run
rather than asserted: deno task test covers the units, deno task demo:01 and
deno task demo:02 cover the integration paths without a model, and the figures
quoted above were measured on a live server.