← atelier
architecture & onboarding

atelier: a pixel-art editor with no screen

Aseprite-as-an-API. One static Rust binary that speaks MCP; an agent creates layered, animated documents, draws into them, looks at the result, and exports engine-ready art. No network, no keys, fully deterministic.


The smallest unit — two atoms, one system

Understand these two and every file in the repo reads itself. Atelier has a data atom and a behavior atom; the whole product is the two of them composed upward.

The data atom: the cel

One RGBA image at one (layer, frame) coordinate.

  • A Document is just metadata plus a map: cels: HashMap<(layer, frame), (x, y, RgbaImage)>document/mod.rs
  • Everything you ever see is a fold over that map: flatten(frame) composites the visible layers’ cels; exports walk frames of flattens; frame_diff compares two flattens
  • On disk a cel is literally one PNG: documents/<id>/cels/L<layer>_F<frame>.png
  • The one invariant: when layers/frames reorder, the cel map must move in lock-step — guarded by exactly two choke points, remap_cel_layers and shift_cel_frames

The behavior atom: the op

One JSON object describing one edit to one cel.

  • {"op":"rect","x0":1,"y0":1,"x1":8,"y1":8,"color":[r,g,b]} — that shape is the entire mutation language
  • The registry says what each op takes (batch_op_keys), validate_batch_op rejects unknown keys and malformed colours loudly, apply_op executes — document/batch.rs
  • Every mutating tool bottoms out here: doc_draw and doc_fx are one op, doc_batch is many, and even the fancy generators (rigs, tiles, particles) are Rust that emits the same primitive edits
  • Because an op is data, it is recordable — which is what makes the next rung possible

The composition ladder

pixelan RGBA value in a cel’s grid — all math on it lives in raster/
cela grid of pixels at (layer, frame) — the unit ops edit and PNGs store
documentmeta + the cel map — layers stack per frame, frames sequence into animation
opone validated JSON edit applied to one cel — the mutation language
tool callparse params → Studio method → ops (+ a look back: inline PNG, stats, critique)
recipethe ordered tool calls of a session — recorded by the server, replayable by atelier replay; a document is the product of its recipe

So the full flow is not a separate design — it is this ladder read top to bottom. The see-and-critique loop (doc_look, doc_critique, doc_ref_compare) exists because the agent driving the ops is nearly blind: draw → look → fix, measured instead of remembered.

What that buys: replayable art

Every document is an ordered sequence of tool calls. Draw a sprite through the API and the session can be recorded as a recipe — a JSON list of steps — and replayed byte-for-byte later. The files in docs/examples/*.json are exactly that: they are the brand art and the integration tests (atelier replay runs them against a real server).

The second idea is the see-and-critique loop. The model drawing the art is nearly blind, so the API is full of eyes: doc_look returns the frame as an inline PNG plus value statistics, doc_critique names failure modes, doc_ref_compare scores the canvas against a reference image. Draw → look → fix, measured instead of remembered.

State lives on disk under ~/.atelier (override: ATELIER_HOME): one directory per document holding doc.json (structure) plus one PNG per cel — the image at one (layer, frame) coordinate.

The four crates — a strict dependency tower

Functional core, imperative shell. Everything below atelier-mcp is synchronous, deterministic, and knows nothing about MCP or tokio. Dependencies point one way only.

atelierthe binary — arg parsing, daemon installer, replay runner
main.rs · flags & transports service.rs · launchd / systemd install replay.rs · runs recipes against a child server
depends on
atelier-mcpthe imperative shell — rmcp #[tool] router over stdio + HTTP
server/mod.rs · 75 tools, parse → Studio → format server/params.rs · request schemas server/recorder.rs · session → recipe server/resources.rs · browse docs/renders server/prompts.rs · packaged prompts recipe.rs · the Recipe/Step contract
depends on
atelier-studiothe Studio facade — the library API, one method per tool
lib.rs · store, exports, draw/fx dispatch, selection craft.rs · checkpoints, import, generators view.rs · look / select_render / contact sheet rig.rs · figure / walk / pose_cycle tiles.rs · wang & blob autotiles analysis.rs · critique, audits, reports reference.rs · ref compare loop set.rs · multi-document families
depends on
atelier-corethe functional core — document model + raster math, no I/O beyond its own files
document/mod.rs · types, persistence, layers/frames document/draw · primitives document/fx · effects document/timeline · tween, keyframes document/region · clipboard, transform document/render · flatten + read-only analysis document/export · sheet / GIF / APNG document/batch · the op registry (DRAW_OPS / FX_OPS) raster/ · colour · noise · transform · blend

By default the server advertises a 30-tool core profile (ATELIER_PROFILE=full advertises all 75). That filter is discovery-only — every tool always executes, so replay always works. Both counts are pinned by tests; the docs cannot silently drift.

Life of one tool call

What happens when an agent calls doc_draw {"op":"line", …} — this path is the whole system in miniature, and it is the same for all 75 tools:

  1. 1
    Transport receives the call — stdio or streamable HTTP at /mcp, one shared router.atelier-mcp/src/server/mod.rs · run / run_http
  2. 2
    call_tool dispatches; if recording is on, the successful step is appended to the recipe file.server/mod.rs + server/recorder.rs
  3. 3
    The tool method parses params and calls exactly one Studio method. No business logic lives here.server/mod.rs · async fn doc_draw
  4. 4
    Studio::doc_draw checks the op against core's DRAW_OPS partition and routes into the batch pipeline, honouring any active selection mask.atelier-studio/src/lib.rs · doc_draw → doc_batch → edit_masked
  5. 5
    The document loads from disk, validate_batch_op rejects unknown keys/malformed colours loudly, then apply_op runs the edit.atelier-core/src/document/{mod,batch}.rs
  6. 6
    The op body is pixel math on the cel — lines, fills, blends, colour spaces.atelier-core/src/document/draw.rs → raster/
  7. 7
    The document saves (doc.json + cel PNGs) and a change ack — pixel count + bbox, often an inline PNG — travels back up unchanged.document/mod.rs · save → back through Studio → JSON out

Start here — fifteen minutes

Everything is a make target; never run ad-hoc scripts. make help lists them all.

# 1 · clone, wire the git hooks once, prove the tree is green
make hooks
make pre-commit-checks && make test

# 2 · watch it draw — replay a shipped recipe into an isolated home
make release
./target/release/atelier replay docs/examples/water-tile.json --home /tmp/atelier-demo

# 3 · run it as a real MCP server
make stdio            # stdio transport (what clients spawn)
make run              # or HTTP at 127.0.0.1:8765/mcp
claude mcp add --transport http atelier http://127.0.0.1:8765/mcp

# 4 · first session, from the client side
#     doc_create → doc_draw / doc_batch → doc_look → doc_export
#     look after every burst of edits; export op=sheet|anim|tileset

make targets

run / stdioHTTP / stdio server
testthe full suite (229 tests)
pre-commit-checksfmt + clippy gate (hooks run this)
brandingreplay every example recipe
release / cleanoptimized binary / wipe

env vars (all optional, all in .env.example)

ATELIER_HOMEwhere documents live (default ~/.atelier)
ATELIER_PROFILEfull advertises all 75 tools
ATELIER_HTTPHTTP bind address
ATELIER_RECORDrecord the session into a recipe
ATELIER_ALLOWED_HOSTSextra Host headers for LAN use

Reading order

Shortest path to a working mental model — top-down, following one call:

1. A recipe in docs/examples/ (pick water-tile.json, 18 steps) — it reads as a narrated session and shows what the tools feel like.
2. atelier-mcp/src/server/mod.rs — skim the #[tool] descriptions; they are the product spec.
3. atelier-studio/src/lib.rs — the Studio struct, open/edit_masked/change_ack: the shape every operation shares.
4. atelier-core/src/document/mod.rsDocument, DocMeta, the cel map, and the two invariant choke points (remap_cel_layers, shift_cel_frames).
5. docs/ARCHITECTURE.md last — it will now read as confirmation, not introduction.

Where to change what

Add a drawing / fx op

  • document/batch.rs — key table in batch_op_keys, arm in apply_op_raw, add to DRAW_OPS or FX_OPS
  • op body beside its kin in document/draw.rs or fx.rs
  • mention it in the doc_draw/doc_fx/doc_batch description strings
  • a drift test already asserts the partition names real ops

Add a whole tool

  • Studio method first (pick the right studio module), tests beside it
  • param struct in server/params.rs, thin #[tool] method in server/mod.rs
  • decide: core profile? add to CORE_TOOLS
  • the count-pin test fails until README / TOOLS.md / .env.example counts are updated — on purpose

Pixel math

  • raster/colour.rs — OKLab/HSL, ΔE, ramps, quantisation
  • raster/noise.rs — easing, fBm/perlin, dither patterns
  • raster/transform.rs — affine, scale2x, IK, distance fields
  • pure functions, no Document — test them directly

House rules

  • Result over panic; unknown strings error loudly, never default silently
  • tests inline as #[cfg(test)], next to the code
  • master only; PRs born draft; make pre-commit-checks && make test before gh pr ready
  • if it can be a deletion, it is the best change