A small murder mystery where every suspect is an LLM agent that remembers, keeps secrets and lies. You are the detective.
Alibi is a learning project: one clean, working example of each core
LLM-engineering concept (structured outputs, RAG, MCP, agent orchestration,
evals, fine-tuning, observability) in a codebase small enough to read. The
roadmap and milestone checklists live in docs/PLAN.md.
| Milestone | State |
|---|---|
| M0 Skeleton | β |
| M1 The Case File | π§ (10/10 cases generate; paper-solving pending) |
| M2 One Suspect | β |
| M3 World + MCP | β |
| M4 Evals | β (20-game run, gates, 90% labeller agreement) |
| M5 Detector + Web + Homelab | π§ (code, web UI + deploy done; fine-tune, demo GIF and docker-stats pending) |
make install # uv sync
make up # postgres + phoenix (docker)
cp .env.example .env # add your OPENROUTER_API_KEY
make hello # one real LLM call: tokens, cost, latency (traced)
make case # generate + validate a case into cases/
uv run alibi interrogate --case cases/<id>.json --suspect <id> --debug
uv run alibi play --case cases/<id>.json --new
make test && make lintScreenshots use the test case with scripted answers. A recorded GIF of a real game and a live link are still to do.
Browser (HTMX) Terminal (Rich) Any MCP client
β β β
ββββββββ FastAPI / CLI βββββββ β
βΌ βΌ
MCP server (game tools)
β
Game loop (LangGraph): actions, turns, score
β
βββββββββββββββββββββββββΌββββββββββββββββββββββ
βΌ βΌ βΌ
Suspect agent (LangGraph) Truth labeller Lie detector
retrieve β think β speak claims β TRUE/FALSE (ONNX, CPU, M5)
β remember β
β βΌ
pgvector memory stored labels β evals + training data
β
βΌ
llm.py (LiteLLM) ββ hosted models, cost/latency/tokens, daily spend cap
β
OpenTelemetry ββ Phoenix
Offline (laptop / CI): case generator + validator, solver agent, evals,
lie-detector training (Colab) β model.onnx
Every choice is here to teach one thing. The "why" is the point.
| Concern | Choice | Why |
|---|---|---|
| Language / packaging | Python 3.12 + uv |
uv replaces pip/venv/pyenv wheel; one lockfile, fast installs, reproducible builds. |
| Schemas | Pydantic v2 | A schema is the contract for structured LLM output; parsing and validation come for free. |
| LLM access | LiteLLM, behind llm.py |
One interface over many providers (openrouter/β¦, openai/β¦); swap models in config/models.yaml, no code change. Also gives us token/cost accounting. |
| Agent orchestration | LangGraph | Agents as explicit state machines (retrieve β think β speak β remember). Edges and nodes make the loop inspectable and testable, unlike a hidden ReAct string loop. |
| Tools | MCP Python SDK | A standard tool protocol: the game is exposed once and used by the terminal, the solver, and external clients alike. Tool descriptions double as prompts. |
| Memory | Postgres 16 + pgvector | One database for both durable game state and vector search β no separate vector store to run on a 4 GB box. |
| DB access | SQLAlchemy 2.x, create_all |
Typed models and queries; migrations (Alembic) are overkill for a project this size. |
| Observability | OpenTelemetry β Arize Phoenix | Vendor-neutral spans for every node and LLM call, with cost/latency attached. Tracing is wired in from M0 and used everywhere. |
| API / UI | FastAPI + Jinja + HTMX | Server-rendered pages, no JS build step (M5). |
| CLI | Typer + Rich | Type-hint-driven commands; readable terminal output. |
| ML (training only) | PyTorch + HF, export to ONNX | Training happens on Colab/Kaggle; the server only runs ONNX Runtime on CPU β so torch never touches the homelab. |
| Infra | Docker Compose + Makefile + GitHub Actions | Same compose for dev and homelab; CI runs lint, tests and (for prompt changes) an eval gate. |
Each milestone adds one concept; see docs/results/ for the measured outcomes.
- M1 β structured output + validation. One LLM call fills a Pydantic
Casefromconfig/setting.yaml; a deterministic validator checks eight rules and a bounded repair loop feeds violations back to the model. Creativity is the model's job; correctness is the code's. (case.py,generator.py,validator.py) - M2 β RAG memory + a suspect agent. Each suspect stores memories in
pgvector and answers through a LangGraph. Information hiding is a code
guarantee: the prompt only ever sees the persona, the suspect's own secrets
and what
knowledge.pysays they know. (knowledge.py,memory.py,suspect.py) - M3 β the world and MCP. A
Gameowns the rules (20 actions, scoring, persistence); the terminal and an MCP server are thin adapters over it. State lives in Postgres, so a game survives a restart. (game.py,mcp_server.py) - M4 β evals. An automatic detective plays through the tools; a judge
labels every claim against the ground truth; metrics gate changes in CI.
(
solver.py,truth.py,evals.py,config/gates.yaml) - M5 β detector, web UI, homelab. A tiny DistilBERT fine-tuned on our own TRUE/FALSE claims, exported to ONNX, plus a browser UI and the deployment below.
Measured numbers (full write-ups in docs/results/).
Game quality β 20-game eval (uv run alibi eval --games 20 --gate):
| Metric | Value | Gate |
|---|---|---|
| case_valid_rate | 1.000 | β₯ 0.80 |
| solve_rate | 0.850 | 0.40β0.85 |
| contradiction_rate | 0.029 | < 0.08 |
| early_confession_rate | 0.000 | < 0.10 |
| cost_per_game | $0.030 | β€ $0.50 |
Honest caveat: the raw solve_rate counts timed-out games as losses; of the
games it finished, the auto-solver won 17/17. A capable model finds a
four-suspect mystery too easy β the number that matters for a game is the human
one, which the browser UI provides. See docs/results/m4.md.
Lie detector vs baselines (one statement, no case context; 30-statement sample):
| Model | F1 (lie) | Latency | Cost / prediction |
|---|---|---|---|
| always-TRUE | 0.000 | β | β |
| majority | 0.000 | β | β |
| LLM judge | 0.182 | 7.7 s | $0.00022 |
| fine-tuned ONNX | 0.333 | 40 ms | ~$0 |
Baselines: uv run alibi baselines --data ml/data/test.jsonl. Details and the
training run live in ml/MODEL_CARD.md.
The app is one small image (python:3.12-slim, no torch), run twice: as MCP over
streamable HTTP (port 8080) and as the browser game (port 8000), alongside
Postgres and Phoenix β sized to stay under ~2 GB on an Intel N150 with no GPU.
cp .env.example .env # set OPENROUTER_API_KEY (+ low budget)
docker compose -f compose.homelab.yml run --rm app alibi case --seed 1
make deploy # docker compose -f compose.homelab.yml up -d --buildOr build/run the image directly:
docker build -t alibi .
docker run --rm -p 8080:8080 --env-file .env -v alibi_cases:/app/cases alibi
# the browser game (same image, different command):
docker run --rm -p 8000:8000 --env-file .env -v alibi_cases:/app/cases alibi \
alibi web --host 0.0.0.0 --port 8000Full walkthrough β Cloudflare Tunnel (public access, no open ports), cost caps,
memory limits and Postgres backup β is in
docs/deploy-homelab.md.
The whole game is exposed as MCP tools. Point any client at the stdio server:
{
"mcpServers": {
"alibi": {
"command": "uv",
"args": ["run", "alibi", "mcp", "--case", "cases/<id>.json"],
"cwd": "/absolute/path/to/alibi"
}
}
}Or at the deployed HTTP endpoint: { "type": "http", "url": "http://<vm>:8080/mcp" }.
Tools: case_summary, status, list_rooms, search_room, inspect,
question, lie_detector, accuse.
uv run alibi web # http://127.0.0.1:8000FastAPI + Jinja + HTMX, no JS build step (htmx and the fonts are served from
src/alibi/static, so no CDN is needed). Generate a case (or pick one), search
rooms, question suspects β presenting evidence when you have it β test an answer
with the lie detector, then accuse and read "what really happened".
Every "Start a game" is a fresh game with its own URL, so a case can be replayed
and visitors of a public deploy never share one. The story clock turns your 20
actions into a night: you arrive an hour after the death, each action takes a
quarter of an hour, and the murderer leaves when the roads clear at dawn. The
reveal page ranks you (Constable to Great Detective) and lists the real clues you
missed. The same Game object backs the web, CLI and MCP server.
make eval # uv run alibi eval --games 20 --gate
uv run alibi eval --games 5 --seed 1Metrics (config/gates.yaml): case_valid_rate, solve_rate,
contradiction_rate, early_confession_rate, cost_per_game. A GitHub Action
runs a 5-game gate on PRs touching src/alibi/prompts/** or suspect.py and
comments the summary on the PR. The gate is exercised by deliberately breaking a
suspect prompt: the closed "broken prompt" PR.
alibi export-dataset turns the stored labels into fine-tuning JSONL (split by
case, no leakage); alibi baselines scores always-TRUE, majority and the LLM
judge for comparison with the fine-tuned detector.
src/alibi/ case Β· generator Β· validator Β· knowledge Β· llm Β· memory
suspect Β· game Β· mcp_server Β· solver Β· truth Β· evals
detector Β· export Β· baselines Β· web Β· templates Β· static Β· cli
config/ models.yaml (role β model) Β· setting.yaml Β· gates.yaml
tests/ deterministic tests only (no network; FakeLLM)
ml/ dataset export + training (M5; never installed on the server)
assets/ banner + demo screenshots
docs/PLAN.md roadmap Β· docs/adr/ decisions Β· docs/results/ measurements
Dockerfile small runtime image compose.homelab.yml the deploy
- Every LLM/embedding call goes through
src/alibi/llm.py, with a role. - Models come from
config/models.yaml; prompts fromsrc/alibi/prompts/*.jinja. - The validator, not the model, decides whether a case is sound.
MIT β see LICENSE.