The .alab syntax
.alab is a readable, Mermaid-like text form of the same model .archlab.json stores — lossless in both directions, so you can edit whichever you prefer and nothing is dropped. Text → model → text is byte-identical, and so is JSON → text → JSON; pnpm check:archtext proves the round trip on every run. Every snippet on this page is checked against the real parser by pnpm check:syntax-docs.
A complete example
A whole small model first, so the shape is obvious before the details: header lines, then one @context diagram and one @container diagram, each with nodes and edges. Geometry is omitted throughout — omitted positions get deterministic grid defaults, so terse files stay lossless.
archlab 1.0
title "ShopFlow Platform"
@context ctx-root "ShopFlow Platform"
customer:person "Customer" #shopper
shop:system "ShopFlow Platform" @nextjs >cnt-shop
stripe:external "Stripe"
customer -> shop : "Places an order" [HTTPS]
shop <-> stripe : "Authorises payment" [HTTPS/JSON]
@container cnt-shop owner=shop
web:container "Web App" @nextjs
db:database "Orders DB" @postgresql
web -> db : "Reads and writes" [SQL/TCP]
Indentation & comments
The format is line-structured with significant indentation — spaces only, never tabs — and the parser accepts exactly three depths:
| Indent | What lives there |
|---|---|
0 | Header lines and @level diagram headers |
2 | Diagram body: desc, view, ! lines, node lines, edge lines |
4 | Node/edge continuations: desc and ! lines |
- Any other indentation (3 spaces, a tab, …) is a parse error naming the line and column. The VS Code extension pins spaces-only indentation for
.alabfiles, so this is one mistake you can stop making. - Blank lines are ignored.
//starts a full-line comment — at any indentation, even above thearchlabline. Trailing comments after content are a parse error (see Errors). Comments are the one thing a round trip does not preserve — they are text-only sugar, not model data.- All quoted strings are JSON string literals, so
\",\nand\uXXXXescapes work exactly as in the JSON file. Ids, tags and icon slugs may be bare ([A-Za-z0-9_][A-Za-z0-9_.-]*) or JSON-quoted when they contain anything else:"weird id":person "Name".
// Full-line comments start with // — at any indentation, even line 1.
archlab 1.0
title "Layout rules"
// Blank lines are ignored. Comments are text-only sugar: they are the one
// thing a round trip does not preserve.
@context ctx-root "Layout rules"
api:system "API"
desc "Indent 4: a continuation of the node line above."
Header lines
Header lines sit at indent 0, before the first @ diagram (a header line after a diagram is a parse error). Only archlab and title are required. Each line maps to one field of the JSON model:
| Line | Example | Maps to | Notes |
|---|---|---|---|
archlab <version> | archlab 1.0 | version | Required; must be the first content line of the file. |
schema "<url>" | schema "https://arch-lab.dev/schema/v1/diagram.schema.json" | $schema | Optional JSON-schema URL. |
title "<text>" | title "ShopFlow Platform" | metadata.title | Required — a file without a title is refused. Keep it to 120 characters: longer still parses, but the checkers raise a review note, since the title becomes the export filename too. |
description "<text>" | description "Customer-facing commerce platform." | metadata.description | Optional. |
owner "<text>" | owner "Platform Team" | metadata.owner | Optional. |
direction tb|lr|fit | direction fit | direction | Optional; the default layout direction for every diagram that does not set its own. Omitted means `tb` — top-down, as every document laid out before this line existed. `lr` runs layers along the long axis and folds a long flow into bands, so a deep chain lands near the shape of a screen instead of a column. `fit` names no direction: the diagram is laid out every way — top-down, left-to-right, and top-down wrapped into columns — and whichever lands nearest the shape of a screen is kept. Only affects nodes whose position the text omits. |
tags #a #b | tags #commerce #payments | metadata.tags | At least one tag; quote odd names: #"needs review". |
created <timestamp> | created 2026-07-01T00:00:00Z | metadata.createdAt | Defaults to the fixed sentinel 1970-01-01T00:00:00Z when omitted. |
updated <timestamp> | updated 2026-07-27T00:00:00Z | metadata.updatedAt | Same default sentinel as created. |
reviewed <timestamp> | reviewed 2026-07-20T00:00:00Z | metadata.lastReviewedAt | Optional. |
tagcolor <tag> "<colour>" | tagcolor payments "#e11d48" | metadata.tagColors | One line per tag; repeat for more tags. |
customicon <slug> "<name>" "<svg>" | customicon warehouse "Warehouse" "<svg viewBox=\"0 0 24 24\"/>" | metadata.customIcons | One line per icon; the SVG is a JSON string, so quotes escape. |
generator "<name>" "<version>" | generator "arch-lab" "0.1.0" | metadata.generator | Optional tool fingerprint. |
root <diagram-id> | root ctx-root | rootDiagramId | May be omitted when exactly one parentless @context diagram exists — it is then the root. |
archlab 1.0
schema "https://arch-lab.dev/schema/v1/diagram.schema.json"
title "ShopFlow Platform"
description "Customer-facing commerce platform."
owner "Platform Team"
tags #commerce #payments
created 2026-07-01T00:00:00Z
updated 2026-07-27T00:00:00Z
reviewed 2026-07-20T00:00:00Z
tagcolor payments "#e11d48"
customicon warehouse "Warehouse" "<svg viewBox=\"0 0 24 24\"/>"
generator "arch-lab" "0.1.0"
root ctx-root
@context ctx-root "ShopFlow Platform"
customer:person "Customer"
Diagrams
A diagram opens with an @level line at indent 0 — @context, @container, @component or @code — followed by the diagram id, an optional quoted title, and optional attributes:
| Part | Maps to | Notes |
|---|---|---|
@container cnt-shop "Title" | id, level, title | The title may be omitted — it is then inferred from the owner node's name. |
owner=<node-id> | ownerNodeId | The node this diagram details. |
in=<diagram-id> | parentDiagramId | May be omitted when it equals the diagram containing the owner node; in=nullforces a parent-less diagram that still has an owner. Each level must sit exactly one level below its parent's. |
desc "…" | description | A body line at indent 2. |
view <zoom> <x> <y> | viewport | A body line at indent 2; three numbers. |
frame <id> "Label" | frames[] | A body line at indent 2, before the nodes. Add in=<frame> to nest one frame in another. |
archlab 1.0
title "ShopFlow Platform"
@context ctx-root "ShopFlow Platform"
shop:system "ShopFlow Platform" >cnt-shop
@container cnt-shop "ShopFlow — Containers" owner=shop in=ctx-root
desc "Deployable units inside the platform boundary."
view 0.75 -120 64
web:container "Web App"
A frame is a labelled rectangle drawn behind a group of nodes — the C4 boundary. Declare it in the diagram body, then put nodes in it with in=<frame> on the node line. Frame ids are unique within their diagram, not across the file.
Frames carry no geometry: the rectangle is derived from its members' bounding box, so it follows them when they move. A frame with no members is not drawn.
archlab 1.0
title "ShopFlow Platform"
@context ctx-root "ShopFlow Platform"
shop:system "ShopFlow Platform" >cnt-shop
@container cnt-shop "ShopFlow — Containers" owner=shop
frame internal "Internal"
frame storage "Data Layer" in=internal
web:container "Web App" in=internal
orders:container "Orders Service" [Go 1.22] in=internal
orders-db:database "Orders DB" [PostgreSQL 16] in=storage
ext-pay:external "Payment Provider"
web -> orders : "Submits the order"
orders -> orders-db : "Reads and writes orders"
orders -> ext-pay : "Authorises payment"
Nodes
One line per node at indent 2: <id>:<type> "Name" — no space around the : — followed by attributes in any order. Each node type keyword is only legal at certain diagram levels; the parser checks this at parse time:
| Keyword | Model type | Legal at levels |
|---|---|---|
person | person | @context, @container |
system | softwareSystem | @context |
external | externalSystem | @context, @container, @component |
container | container | @container |
database | database | @container, @component |
queue | queue | @container, @component |
component | component | @component |
code | codeElement | @code |
| Attribute | Example | Maps to | Notes |
|---|---|---|---|
@slug | api:container "API" @golang | icon | Icon slug. No marker: the model carries no iconSource. |
@slug! / @slug~ | api:container "API" @golang! | icon + iconSource | "!" = explicit, "~" = inferred iconSource. |
[technology] | api:container "API" [Go 1.22] | technology | Free text up to "]". Quote when it contains one: ["odd ] tech"]. |
#tag / #"weird tag" | api:container "API" #critical-path #"needs review" | tags | Repeat for more tags. |
>diagram-id | api:container "API" >cmp-api | childDiagramId | Drill-down to a child diagram declared in the same file. |
>null | api:container "API" >null | childDiagramId: null | An explicit null in the model (distinct from the key being absent). |
>>"file" | billing:container "Billing" >>"./billing.archlab.json" | childRef | Reference to another model file; mutually exclusive with >child. |
^diagram/node | shop-ref:container ^ctx-root/shop | externalRef | Boundary placeholder for a node that lives in another diagram. The name is omitted here because it is derived from the referenced node; give one ("Shop (boundary)") only to override it locally. |
in=<frame> | frame internal "Internal"
api:container "API" in=internal | frameId | Puts the node inside a frame declared on the same diagram. Name the INNERMOST frame; nesting is recorded on the frame itself. |
pin / pin=false | api:container "API" pin | pinned | Bare pin means pinned: true. |
(x,y wxh) | api:container "API" (656,616 176x88) | position + size | Omit it and the node gets a deterministic grid position and per-type default size. |
desc "…" (indent 4) | api:container "API"
desc "Order lifecycle." | description | A continuation line under the node, indented four spaces. |
The geometry separator between width and height is the ASCII letter x, as in (656,616 176x88). A node line with everything on it:
archlab 1.0
title "Orders"
@context ctx-root "Orders"
shop:system "Shop" >cnt-shop
@container cnt-shop owner=shop
orders:container "Orders Service" @golang! [Go 1.22] #critical-path >cmp-orders pin (656,616 176x88)
desc "Order lifecycle: create, pay, cancel, fulfil."
@component cmp-orders owner=orders
api:component "HTTP API"
Edges
One line per relationship at indent 2: source <arrow> target, then attributes in any order. Both endpoints must be nodes of the same diagram — cross-diagram edges are a parse error. Solid arrows write no style key at all; dashed arrows write "style": "dashed"; the rare explicit "style": "solid" is spelled style=solid so absent-vs-solid survives the round trip.
| Arrow | Direction | Style | Example |
|---|---|---|---|
-> | forward | solid (no style key) | web -> db : "Writes" |
<-> | bidirectional | solid (no style key) | web <-> db : "Syncs" |
-- | none | solid (no style key) | web -- db : "Peers with" |
..> | forward | dashed | web ..> db : "Writes, async" |
<..> | bidirectional | dashed | web <..> db : "Syncs, async" |
.. | none | dashed | web .. db |
| Attribute | Example | Maps to | Notes |
|---|---|---|---|
: "label" | web -> db : "Reads and writes orders" | label | The relationship's label. |
[technology] | web -> db [SQL/TCP (pgx)] | technology | Same quoting rule as on nodes. |
#tag | web -> db #system-of-record | tags | Repeat for more tags. |
~edge-id | web -> db ~e-cust-shop | realizes | Traceability to the parent-level edge this one realizes. |
id=<edge-id> | web -> db id=e-orders-write | id | Omitted when the id is the conventional e-<source>-<target>. |
style=solid | web -> db style=solid | style: "solid" | Carries the rare explicit "style": "solid" — a plain solid arrow writes no style key at all. |
via (x,y) (x,y) | web -> db via (640,700) (600,760) | waypoints | One or more routing points. |
! key : json (indent 4) | web -> db : "Writes"
! x-edge-meta after label : true | unknown fields | Forward-compatible fields — see the ! lines section. |
An edge line with everything on it:
archlab 1.0
title "Orders"
@context ctx-root "Orders"
cust:person "Customer"
shop:system "Shop" >cnt-shop
cust -> shop : "Places orders" [HTTPS]
@container cnt-shop owner=shop
web:container "Web App"
db:database "Orders DB"
web -> db : "Reads and writes orders" [SQL/TCP (pgx)] #system-of-record ~e-cust-shop id=e-ord-db via (640,700) (600,760)
Paths — authored walks
A path is an ordered walk through one diagram that a reader steps through beat by beat. It is a pure overlay: the viewer dims everything off the walk and lights the current beat, and nothing about the model changes. Paths are written last in a diagram, after the edges.
path <id> "Title" sits at indent 2 and its ids are unique within the diagram; beat "One sentence"at indent 4, at least one per path; and each beat’s chain lines at indent 6, at least one per beat. A beat may carry several chain lines, and its elements are the union of them — so a branching step is one beat, not two.
The arrow orders the telling, not the traffic. Only -> is legal in a chain, and a hop matches every relationship joining its pair in either direction — a request and its response point opposite ways, and a walk has to be able to go against an arrow. Where two relationships join one pair the hop lights both; append ~<edge-id>to pin the line’s last hop to one of them.
Every id a beat names must exist on the same diagram, and every hop must be joined by a relationship that is actually written down. Both are parse errors rather than silent omissions: a walk that lights the wrong thing is worse than one that refuses to load. Paths keep the order they were written in — it is the order the reader walks — so they are never sorted by id the way frames, nodes and edges are.
archlab 1.0
title "ShopFlow Platform"
@context ctx-root "ShopFlow Platform"
shop:system "ShopFlow Platform" >cnt-shop
@container cnt-shop "ShopFlow — Containers" owner=shop
web:container "Web App" [Next.js 15]
orders:container "Orders Service" [Go 1.22]
orders-db:database "Orders DB" [PostgreSQL 16]
ext-pay:external "Payment Provider"
web -> orders : "Submits the order"
orders -> orders-db : "Reads and writes orders"
orders -> ext-pay : "Authorises payment"
path checkout "Checkout path"
beat "The storefront hands the order to the service"
web -> orders
beat "The service records it, then authorises the payment"
orders -> orders-db
orders -> ext-pay
Unknown fields — ! lines
Any model field the grammar has no sugar for — unknown keys from newer minor versions, or known optional keys carrying an unexpected shape — is written as a ! escape line, valid at every scope: ! <path> [after <key>] : <json>. The JSON value and the key's position (via the after anchor) both survive the round trip byte-for-byte. Bare path segments match [A-Za-z0-9_-]+; anything else is JSON-quoted. A field that has dedicated syntax (like title) cannot be set with a ! line — the parser refuses it.
archlab 1.0
title "Forward compatible"
! meta.x-review after updatedAt : {"cycle":30}
! x-pipeline : {"stage":"prod"}
@context ctx-root "Forward compatible"
! x-diagram-flag : true
api:system "API"
! x-node-meta after name : [1,2]
Sequence diagrams
Everything above describes a C4 model, opened by archlab 1.0. .alab reads a second, separate document kind: a sequence diagram — participants and messages over time — opened by archlab 1.0 sequence and parsed by its own grammar. The two never mix. A sequence document has no @contextlevels, a C4 model has no messages, and handing one to the other's parser fails on line 1. The body sits under a single @sequence block: participants first, then the flow in order.
archlab 1.0 sequence
title "Checkout"
@sequence
autonumber
cust:actor "Customer" @person
web "Storefront" @nextjs [Next.js]
api:participant "Order API" @golang [Go]
cust -> web : "Clicks Place order"
web ->+ api : "POST /orders" [HTTPS]
api ..>- web : "201 Created"
The label is introduced by : — a -> b : "Label" — and a message without one does not parse. An arrow is two choices, not one name: the line is solid or dotted, and the head is one of five — none, an arrowhead, a cross, an open async head, or a head at each end. Ten arrows in all, one token each, and every one converts to and from its Mermaid equivalent without loss. The line says which way the step runs — solid is a call outward, dotted is a return or a callback — and the head says what happens when it arrives. Activation rides the arrow rather than sitting on its own line: ->+ opens the receiver's bar and ..>-closes the sender's, so a call and its return read web ->+ api … api ..>- web. A participant's kind is optional and only two exist — a bare web "Storefront" is a participant, cust:actor draws the stick figure. A message from a participant to itself draws a self-loop, and autonumber numbers every step.
| Arrow | Line | Head | The head means | Mermaid |
|---|---|---|---|---|
-- | solid | none | no direction claimed | -> |
-> | solid | arrow | the sender waits on it | ->> |
x> | solid | cross | lost — it never arrives | -x |
~> | solid | open | fire and forget | -) |
<-> | solid | bidirectional | both ways at once | <<->> |
.. | dotted | none | no direction claimed | --> |
..> | dotted | arrow | the sender waits on it | -->> |
..x> | dotted | cross | lost — it never arrives | --x |
..~> | dotted | open | fire and forget | --) |
<..> | dotted | bidirectional | both ways at once | <<-->> |
archlab 1.0 sequence
title "Message kinds"
@sequence
web "Storefront"
api:participant "Order API"
queue:participant "Events" [Kafka]
web -> api : "Call login API" [HTTPS]
desc "POST /api/v1/basic/verify\nbody { email, password }\n200 → { token } (15 min)\n401 → bad credentials"
api -> api : "Validates the cart"
api ~> queue : "order.created" [Avro]
queue ..> api : "ack"
api x> queue : "stale.event (dropped)"
api <-> queue : "Health handshake"
queue ..~> api : "replay.offer"
web -- api : "Shares a session cookie"
note right api : "Retries are idempotent"
note over api queue : "Both sides are at-least-once"
Keep the label short and put the rest in a desc. Indented two spaces under its message — the same continuation a node or participant takes — it carries the endpoint, the payload, the failure modes: whatever would turn the arrow into a paragraph. Only the label is ever drawn on the wire, so a desc costs no width and widens no column; the playground marks a message that has one with a small dot and shows the text when you click the message. Notes are the exception — a note already is its text, so it takes no desc.
A desc is a JSON string, so \n puts the detail on separate lines — and the viewer renders it as a monospace block that keeps them. That is what makes a request worth reading: the method and path, the body, then one line per status code, instead of all of it welded into a paragraph. The escape keeps the source one line per desc, so the file stays canonical.
Because it is a JSON string, a desc can hold a whole runnable request— quotes, curl's line-continuation backslashes and a JSON body included. Escape " as \" and \ as \\, and the dock gives it back exactly as written:
archlab 1.0 sequence
title "Order intake"
@sequence
web "Storefront" [Next.js]
api:participant "Order API" [Go]
web ->+ api : "Place the order" [HTTPS]
desc "curl https://api.shopflow.dev/v1/orders \\\n --request POST \\\n --header 'Content-Type: application/json' \\\n --header 'Authorization: Bearer $SHOPFLOW_TOKEN' \\\n --data '{\n \"cart_id\": \"cart_8f21c3\",\n \"address_id\": 4102,\n \"coupon\": null\n }'\n\n201 → { order_id } 409 → the cart changed under us"
api ..>- web : "201 Created"
Escaping that by hand is miserable, so don't: get your editor or an agent to JSON.stringify the command and paste the result after desc. The desc budget is 500 characters, which is a request and its responses — not a tutorial.
Fragments — alt/else, par/and, critical/option, opt, loop, break — nest by indentation, with no end keyword. This is the one place the format departs sharply from Mermaid: what belongs to a fragment is whatever is indented under it.
archlab 1.0 sequence
title "Branching"
@sequence
web "Storefront"
api:participant "Order API"
pay:participant "Payments"
alt "card accepted"
api ->+ pay : "Create charge" [REST]
pay ..>- api : "charge.succeeded"
par "receipt"
api ~> web : "Emails the receipt"
and "audit"
api -> api : "Writes audit row"
else "card declined"
api ..> web : "402 Payment Required"
opt "first purchase"
web -> web : "Shows onboarding tips"
A participant takes the same @icon a C4 node does, in the same place on the line — after the name, before [technology] — and from the same set of slugs. One vocabulary across both document kinds, because a participant and a container are usually the same system drawn twice, and two icon namespaces would let them disagree about what to call one. There is no !/~ suffix here: nothing infers icons for a sequence document, so there is no inference to override.
Two more constructs say these belong together without saying anything about control flow. box brackets a run of lifelines and takes its members as the participant lines nested inside it — nesting is what keeps a box a contiguous run, which is the only shape a bracket can honestly be drawn around. rect highlights a run of steps instead, and takes a colour where the other fragments take a guard. Both accept tint=, in #rrggbb, rgb(…) or a common colour name; whichever you write is stored as one canonical spelling, and drawn as a wash so it reads in both themes.
archlab 1.0 sequence
title "Grouped and highlighted"
@sequence
box "Front of house" tint=#bfdfff
cust:actor "Customer"
web "Storefront"
box "Payments" tint=#ffe4e1
pay:participant "Payments"
ledger:participant "Ledger"
cust -> web : "Places the order"
rect tint=#bfdfff
web -> pay : "Create charge" [REST]
pay -> ledger : "Post entry"
critical "Capture the funds"
pay ..> web : "charge.succeeded"
option "gateway timeout"
pay ..> web : "retry scheduled"
break "card declined"
pay ..> web : "402 Payment Required"
Render any of these in the sequence playground, which also imports pasted Mermaid sequenceDiagram code one-way. These snippets carry no Open in view mode button on purpose: that button encodes a snippet into a share link, and share links exist only for C4 models so far — a sequence one would hand the text to the C4 playground, which would reject it.
Gantt charts
A third document kind, opened by archlab 1.0 gantt: a plan — what the work is, how long each piece takes, and what cannot start until something else is done. The body sits under a single @gantt block, and every item belongs to a section, which is a named band of rows.
archlab 1.0 gantt
title "Order store migration"
starts 2026-09-07
@gantt
section "Prepare"
task audit "Schema audit" 5d done at 0
desc "Read every column, write down what actually moves."
task shadow "Shadow writes" 13d active after audit
task verify "Verify parity" 6d at-risk after shadow
milestone parity "Parity signed off" after verify
section "Cut over"
task cutover "Point traffic over" 3d after parity
An item is one line. task takes a duration in whole days and draws a bar; milestone takes none and draws a diamond. The d suffix on a duration is required, and it is what keeps 5d("five days long") from being read as at 5("starts on day five"). Both parts are optional after the name except the duration on a task: a status word — planned, active, done, at-risk — and one start. planned is the default and is written back as absence, so typing it is idempotent rather than sticky.
A start is either at or after, never both. at 0pins an item to a day counted from the document's origin; after audit, shadow says it begins when every named item has finished. A file carrying both makes two claims about one number, so the parser refuses it rather than picking a winner and drawing a start that disagrees with a line the author can still see.
The drawing is solved, not declared.A row's vertical place is the topological order of the dependencies, its horizontal place is the start that arithmetic gives it, and the critical path — the chain with no slack in it — is computed. There is deliberately no critkeyword, the one construct this grammar refuses to mirror from Mermaid's gantt: a hand-declared critical path can contradict the arithmetic, and when it does the diagram is simply wrong.
The one header line the other kinds do not have is starts, an ISO date with no time and no zone. It gives day 0 a calendar date. It is optional, and deleting it is the entire difference between a calendar axis and a relative one reading W1, W2, W3 — the same plan, drawn the same way:
archlab 1.0 gantt
title "Order store migration"
@gantt
section "Prepare"
task audit "Schema audit" 5d done at 0
task shadow "Shadow writes" 13d active after audit
task backfill "Historical backfill" 12d after audit
milestone parity "Parity signed off" after shadow, backfill
Render either in the gantt playground, which also converts Mermaid gantt code in both directions — at-risk travels as Mermaid’s crit, since both are an author saying a bar is in trouble. Two things do not travel. The critical path this format COMPUTES is never written out, because a derived chain typed as a crit tag is indistinguishable from one somebody asserted. And a plan with no startsline cannot become Mermaid at all: Mermaid’s axis is always a calendar, and no date is invented for it.
Milestone timelines
A fourth document kind, opened by archlab 1.0 timeline: what happened when, and which period it happened in. The body sits under a single @timeline block, and every event belongs to a period, which is a named band of points.
archlab 1.0 timeline
title "How the platform grew"
@timeline
period "2016"
event "Two people and a prototype"
desc "One Rails app on one box, deployed by hand on Friday afternoons."
period "2018"
event "First paying customer"
event "Split the monolith into an API and a web app"
period "2024"
event "Opened the public API" #platform
event "First region outside Europe"
An event is one line, and it is one quoted string. No id, because nothing in this grammar refers to anything; no bare tokens, so this is the only .alab grammar with no quoting rule to learn. A #tag may follow the label, and desc nests under it exactly as it does everywhere else in the format.
Nothing here measures.A period's label is a string — "2024", "Before the rewrite" — and is never read as a date, which is what stops the layout being asked where between two labels a third belongs. The order is the order you wrote, and nothing is sorted.
What it refuses, and where to go instead. There is no duration, no after, no at and no status word. Each is refused by name and each points at archlab 1.0 gantt, because each is that notation's subject: a gantt draws how long work takes and what cannot start until it is done, and computes the critical path from both. A timeline is what already happened.
archlab 1.0 timeline
title "What a timeline will not hold"
@timeline
period "Any label — a year, a quarter, a phrase"
// An event is a POINT. It carries its label and "#tag"s, nothing else.
event "What happened"
desc "The one nested slot: a note, drawn under the label."
// Each of these is refused by name, and each points at the gantt:
// event "Migration" 5d — no duration; a point has no length
// event "Cutover" after freeze — no dependency; nothing waits here
// event "Rewrite" at 12 — no start; nothing is measured
// event "Rollout" active — no state; this is what already happened
// If the work has lengths and prerequisites, write "archlab 1.0 gantt".
event "What happened next"
The drawing runs down the page, because the label is the whole element and a horizontal timeline would give each one the gap to its neighbour to be read in. A period's band is as tall as its events need, so the bands' relative sizes say how much happened in each.
Render one in the timeline playground, which also reads pasted Mermaid timeline code — both ways, unlike the gantt above: a timeline has no status vocabulary and computes nothing, so Mermaid holds everything it says. Mermaid's section, which groups periods one level further up, is refused by name rather than flattened.
Lifecycles
A fifth document kind, opened by archlab 1.0 lifecycle: what one thing went through, and where it can end up. The body sits under a single @lifecycle block, which opens with one subject — the thing the whole document follows — and then the state lines it passes through.
archlab 1.0 lifecycle
title "An order, from checkout to the doormat"
@lifecycle
subject "Order"
desc "One customer order, followed from checkout until it stops."
state placed "Placed"
exit "Cancelled" ends
when "the customer changes their mind before paying"
state paid "Paid"
state packed "Packed"
state shipped "Shipped"
exit "Returned" rejoins packed
when "the parcel comes back unopened"
state delivered "Delivered" ends
There is no line between two states, and that is the notation. The main track is the order you wrote the states in — nothing else decides it, and there is no to, next or then to write. A state carries an id, a quoted label, #tags and optionally ends, which says the subject stops there. The id has exactly one reader: rejoins.
A branch leaves the track; it is not a peer. An exit nests under the state it departs from, takes an optional when line saying what causes it, and lands in one of exactly two places — it ends, or it rejoins a state declared earlier. A forward rejoin is refused: that would be a shortcut along the track. An exit cannot open inside another, so branch depth is always one.
What it refuses, and where to go instead. Every construct above is refused by name and each points at archlab 1.0 flowchart, because each would turn this into an arbitrary graph — which is that notation's subject. A flowchart draws steps, decisions and edges that can go anywhere; a lifecycle draws one thing being somewhere, in order.
archlab 1.0 lifecycle
title "What a lifecycle will not hold"
@lifecycle
subject "The thing"
state first "First"
// A branch belongs to the state it leaves, and lands in one of two
// places: it "ends", or it "rejoins" a state declared EARLIER.
exit "Gave up" ends
when "nothing happens for a week"
state second "Second"
// Each of these is refused by name, and each points at the flowchart:
// state third "Third" to second — no edge; the track IS the order
// exit "Skip" rejoins last — no forward rejoin; that is a shortcut
// exit "Sent back" rejoins first — this one is FINE: first comes earlier
// exit "And then" — no branch off a branch; depth is one
// subject "Something else" — one subject; two would be a graph
// If the picture is really steps that can go anywhere, write
// "archlab 1.0 flowchart".
exit "Sent back" rejoins first
when "it needs redoing"
state last "Last" ends
The drawing runs down the page: states are dots on one spine with their text to the right, departures hang in their own lane to the left, and a returning branch travels in a reserved channel back into the track. A final state and a terminal branch carry a stop bar rather than a colour, so the distinction survives greyscale and a screenshot.
Render one in the lifecycle playground. There is no Mermaid dialect for this notation and none was invented: stateDiagram-v2is a state machine — every transition that could happen, from anywhere to anywhere — rather than one subject's ordered history, and journey scores satisfaction.
Errors
Parsing is all-or-nothing: a broken file throws one error and applies nothing. Every message reads line <n>, column <n>: <what is wrong> — in the two-pane editor the offending line is quoted with a caret at that column. These snippets are deliberately broken; the check script asserts each one fails with exactly the message shown:
A node type the format does not know
.alab archlab 1.0 title "Broken" @context ctx-root "Broken" api:blob "API"Error:
line 5, column 7: "blob" is not a node type — expected person, system, external, container, database, queue, component or codeA node type that is real, but illegal at this level
.alab archlab 1.0 title "Broken" @context ctx-root "Broken" db:database "Orders DB"Error:
line 5, column 6: "database" is not valid at level "context" — valid types here: person, system, externalIndentation that is not 0, 2 or 4 spaces
.alab archlab 1.0 title "Broken" @context ctx-root "Broken" api:person "API"Error:
line 5, column 4: inconsistent indentation of 3 spaces — expected 0 (header or "@" diagram), 2 (diagram body), 4 (node/edge continuation or "beat") or 6 (a beat's chain line)An edge whose endpoint is not a node in this diagram
.alab archlab 1.0 title "Broken" @context ctx-root "Broken" cust:person "Customer" cust -> ghost : "Uses"Error:
line 7, column 11: the target "ghost" does not resolve to a node in this diagramA trailing comment — comments must be full lines
.alab archlab 1.0 title "Broken" // not allowed hereError:
line 2, column 16: unexpected text after the "title" lineA string that is never closed
.alab archlab 1.0 title "Broken" @context ctx-root "BrokenError:
line 4, column 19: the string for the diagram title opened here is never closed — expected a closing '"'A file without a title
.alab archlab 1.0 @context ctx-root "Untitled"Error:
line 1, column 1: the file has no title — add a line like: title "My System"
Editor support
Out of the box an editor sees .alab as an unknown extension and renders it as plain text — unhelpful for a format with significant indentation. The repo ships a VS Code extension in editors/vscode that highlights every construct on this page and, more usefully, makes the indentation rules impossible to break: spaces only, two at a time, with indent guides on and any tab shown as an error before you save.
It is not on the Marketplace yet. Symlink it into your extensions folder from a clone of the repo and reload the window:
git clone https://github.com/raksitnongbua/arch-lab.git
ln -s "$PWD/arch-lab/editors/vscode" ~/.vscode/extensions/alab-syntaxPrefer a package? Run npx @vscode/vsce package --out alab-syntax.vsix inside editors/vscode, then code --install-extension alab-syntax.vsix.
One workaround to avoid: associating .alab with YAML ("files.associations": { "*.alab": "yaml" }) is worse than plain text. YAML treats # as a comment, so tags like #critical-path grey out as comments while real // comments colour as content — wrong in both directions.
No extension for your editor yet? The grammar is a portable TextMate grammar (editors/vscode/syntaxes/alab.tmLanguage.json), which Neovim, Zed and Sublime can all consume.
Where to use it
The fastest way to learn the format is to write it live: the playground is a two-pane editor with .alab on one side and .archlab.jsonon the other — each pane regenerates the other as you type, and parse errors appear inline with the caret format above. The "Open in view mode" buttons on this page's complete examples carry the snippet there inside the link itself (nothing is uploaded); they only appear while the encoded link stays under the share codec's honest ~2000-character limit. For finished, read-only models, see the live demo.