The Lila project format: the folder and the .lila file
A Lila project is a folder (ADR-018). A .lila file is that same folder zipped, with the
same layout and the same file names (ADR-027). The two are the same project in two containers:
cd pedido && zip -r ../pedido.lila . # a valid .lila
unzip pedido.lila -d pedido # a valid project folder
Nothing converts, migrates or rewrites in between. The folder is what you keep in git — it diffs
and merges, which is the whole reason it is the primary form. The .lila is what you hand to
somebody: one file to attach, download or double-click.
A project can hold several processes (ADR-029, #498). With one, it is the version 1 layout
below, unchanged; with two or more, it is a version 2 repository where each process keeps that
same layout under processes/<slug>/ (see Version 2).
Layout
| Entry | What it is |
|---|---|
lila-project.json |
The manifest (below). Required in a .lila; optional in a folder, where it is reconstructed if missing. |
model.bpmn |
The BPMN XML, byte for byte. Required. |
<name>.scenario.json |
One raw scenario document per scenario, extends untouched — never resolved on the way in or out. The file name is the scenario's identity: that is what extends points at. |
runs/<id>.result.json |
One stored run: the engine's RunResult plus the inputs that produced it (docs/RESULTS_FORMAT.md). |
Entries are written in a canonical order — manifest, model, scenarios sorted by name, runs sorted by id — with a fixed timestamp, so saving the same project twice produces identical bytes.
The manifest
{
"version": 1,
"id": "e2a1…",
"name": "pedido",
"model": { "id": "Process_Pedido", "name": "model.bpmn", "revision": 3 },
"scenarioRevisions": { "as-is.scenario.json": 2, "to-be-3-cajeros.scenario.json": 1 },
"engine": "1.0.0-beta.1"
}
engine is the only field the folder layout does not have: the version of @lila-modeler/engine that
wrote the runs in this archive. It is informational — readers record it and move on — and it is
deliberately not part of the in-memory document, so an open/save round-trip re-stamps it rather
than carrying somebody else's version forward.
Version 2: the repository
A project with more than one process is written as version 2 (ADR-029). The manifest keeps its
name, lila-project.json, and lists the processes; each process is exactly the version 1 layout,
without a manifest of its own, in its folder:
lila-project.json manifest, "version": 2
processes/<slug>/model.bpmn
processes/<slug>/<name>.scenario.json
processes/<slug>/runs/<id>.result.json
{
"version": 2,
"id": "e2a1…",
"name": "pedido",
"processes": [
{ "slug": "pedido", "name": "Pedido",
"model": { "id": "Process_Pedido", "name": "model.bpmn", "revision": 3 },
"scenarioRevisions": { "as-is.scenario.json": 2 } },
{ "slug": "facturacion", "name": "Facturación",
"model": { "id": "Process_Facturacion", "name": "model.bpmn", "revision": 1 },
"scenarioRevisions": {} }
],
"engine": "1.0.0"
}
- The slug is the folder: lowercase
a-z, digits and hyphens, unique in the project, derived from the name when the process is created and never changed by a rename.nameis what the canvas tab shows. The order ofprocessesis the order of the tabs. - Version 2 is written only when it is needed. While a project has one process it is saved as
version 1, byte for byte, so the builds that only read version 1 keep opening it. Adding a second
process writes version 2; deleting back to one writes version 1 again.
A version 2 manifest that lists a single process (written by another tool, or edited by hand) is
still read as a repository: that process keeps its slug and name, and a process added later goes
next to it instead of moving it to a new folder — until it is saved as a one-process project
(version 1). From then on its slug and name come from the project's name again, so the slug can
change. Adding a process keeps it version 2; any other save counts, the engine's included: once
the CLI or the MCP server writes into such a
.lila(a scenario, a saved run, an edited process) it is version 1, and an agent that still passes the old slug as--process(processin the MCP tools) gets «has no process». With a single process, leave--processout. - A version 1 project reads as a one-process repository, folder and
.lilaalike. Nothing migrates: opening and saving it without changes gives back the same bytes. - The folder moves, it does not copy. The first version 2 save of a version 1 folder moves
the first process's
model.bpmn, scenarios and runs from the root intoprocesses/<slug>/, in the same all-or-nothing commit as the rest of the save. Going back to one process writes the root files again and leavesprocesses/on disk for you to remove. A deleted process's folder stays too, orphaned, and it never comes back: a new process never takes a slug that is listed on disk, that has a folder underprocesses/, or that was deleted in the session — «Cobro» becomescobro-2— and the writer refuses (E-CARPETA-OCUPADA, deleting nothing) a new process whose folder already holds another one's scenarios or runs. The manifest is written after the process files, so a crash in the middle of that first save leaves the version 1 project intact. - Scenarios and runs are per process. The simulation runs one process at a time — the one on
the canvas — and a call activity is still a task with a time of its own; double-clicking one opens
the process whose BPMN process id is its
calledElement. - In the
.lila, anything outside the listedprocesses/<slug>/folders is reported and dropped, exactly like a stray file in a version 1 archive; a listed process without itsmodel.bpmnis fatal (LILA-NO-MODEL).
Versioning
version is 1 or 2. Changes to this format may only add optional fields; anything that
would make a reader misread a file takes a new version, and the reader refuses it instead of
guessing — the current reader refuses a version 3 with LILA-MANIFEST (in the folder, E-MANIFEST).
There is no migration step and none is planned: the files are on the user's disk, not in a database
anybody controls.
A build that only reads version 1 (up to 1.0.0-beta.14) refuses a version 2 repository cleanly: it
looks for the root model.bpmn before it reads the manifest's version, so the refusal it gives is
«no model.bpmn» (E-NO-MODEL for a .lila, E-SIN-MODELO for a folder) — never a half-read project.
What is tolerated, and what is not
Opening is tolerant where the format is loose and strict where it is not:
- A
*.scenario.jsonmay hold a draft that does not validate yet — that is the point of editing one. It only has to be a JSON object. - An unreadable scenario or an invalid run is excluded and explained in the project's
problems; it never aborts the opening. Losing a broken run should not cost you the project. - A missing
lila-project.jsonor a missingmodel.bpmnis fatal: there is no project. - Entries with
..segments, absolute paths, drive letters or backslashes are refused outright — a zip that carries one is not a project that lost a file.
Anything not in the layout above is reported and dropped. A notes.md or an attachments/
folder inside a .lila shows up in problems when you open it and is not written back when you
save. Preserving it would mean carrying opaque bytes through structuredClone (the desktop IPC
boundary) and JSON.stringify (the browser's localStorage mirror), neither of which survives a
Uint8Array honestly. In the folder form those files are simply left alone, because the
writer only touches the files it owns — so the place to keep notes and attachments today is the
folder, not the archive.
MIME type and extension
The MIME type is application/vnd.lila-modeler+zip. It is not registered with IANA; it follows
the +zip structured-suffix convention (RFC 8081), so a client that knows nothing about Lila can
still tell it is a ZIP.
The .lila extension is not registered either. The only other use found is LARSIM "LiLa", a
hydrological modelling format from the German water agencies, which has no registered MIME type
and no desktop file association — the collision is nominal and no tooling of ours or theirs can
mistake one for the other, since a Lila project starts with the ZIP magic PK.
Where the code is
packages/engine/src/project/ — types.ts (the document contract), document.ts (structural
validation, with stable error codes), lila.ts (encodeLila/decodeLila over fflate, and the
version 2 manifest), repository.ts (processesOf/withProcesses, which fold the list of processes
into the document: its top-level fields are the first process, and process/processes carry the
rest).
Published as @lila-modeler/engine/project, which stays browser-safe. The disk half is
packages/engine/src/project-fs/, published as @lila-modeler/engine/project-fs (Node only, #466):
the folder reader/writer projectIO.ts, the .lila file lilaFile.ts, and input.ts, which opens
one process of a .lila for the CLI and the MCP server and writes a scenario back. The desktop, the
CLI and the MCP server all use it.
The engine's codes are LILA-ZIP, LILA-NO-MANIFEST, LILA-MANIFEST, LILA-NO-MODEL,
LILA-ENTRY-PATH (the container) and LILA-DOCUMENT, LILA-PROBLEMS, LILA-RUN,
LILA-RUN-INPUTS (the document). Each front end words them itself: the desktop maps them to its
own E-ZIP, E-NO-MANIFEST, E-MANIFEST, E-NO-MODEL, E-ENTRY-PATH, E-DOCUMENTO,
E-DIAGNOSTICO, E-CORRIDA, E-ENTRADAS-CORRIDA at the IPC boundary (an explicit map in
lilaFile.ts, listed in the bridge.ts header); the web app localises them in
apps/web/src/project.ts. A .lila save runs the same E-CARPETA-OCUPADA/E-CAMBIO-EXTERNO
guards as a folder save, holding a <file>.lila.lock next to the archive while it checks and
writes, so writers in different processes (the desktop, the CLI, MCP servers) take turns; a writer
that cannot get the lock within 3 seconds refuses with E-ARCHIVO-OCUPADO (nothing is written). The
holder touches the lock every 2 seconds, so a lock untouched for 10 seconds is removed as left by a
crash; a writer only removes a lock whose token (a random id written into it) it has checked.