LMML Language Reference
View Source 
This document is the authoritative syntax and semantics reference for
lmml, a Markdown-superset markup language for LLM conversations. It
has been expanded incrementally as each implementation phase landed.
1. File forms
Every lmml document -- called a narrative -- is written in the same
Markdown-superset text syntax (section 3). It can be stored in one of two
container forms, chosen only by how you need to distribute it:
.lmml-- a bare, self-contained text file. It is the narrative itself, nothing more. There is nowhere for an external file to live alongside it, so every embed it uses must be inline (section 3.3)..lmmlz-- a zip archive. Its canonical narrative entry is named by stripping the trailingzfrom the archive's own filename: a file namedconvo.lmmlzcontains an entry namedconvo.lmml. Every other entry in the archive is a file the narrative can reference by name (section 3.2).
Both forms are opened through the same API and expose the same narrative
and embed model -- see Lmml.Bundle. Detection of which form a file is
happens by sniffing its content (the zip local-file-header magic bytes),
not by trusting the extension: a zip archive named .lmml still opens as
a zip, and a bare text file named .lmmlz produces a clean error rather
than being misinterpreted.
2. The embed model
An embed is a named piece of content a narrative mentions. Every embed has exactly one of two forms of content:
- inline -- the content is written directly into the narrative text,
as an
@@@name ... @@@fenced block (section 3.3). Inline embeds resolve in every bundle kind, since their bytes travel with the text. - external -- the content lives elsewhere: a real entry inside a
.lmmlzarchive, referenced from the narrative by@name.ext(section 3.2). External references only resolve inside a.lmmlz; in a bare.lmml, an external-style reference is a dangling reference by definition, since there is no archive for it to point into.
@name.ext (by reference) and @@@name.ext ... @@@ (by value) are two
renderings of the exact same idea -- "this document contains a named
embedded entity" -- not two different concepts. Converting between them
(externalizing an inline embed into a real zip entry, or inlining a zip
entry's content back into the narrative text) is a lossless, mechanical
operation; see Lmml.Pack (section 7).
3. Narrative syntax
A narrative is valid Markdown (CommonMark, as implemented by the
md library's Md.Parser.Syntax's Default
syntax module) plus two additions. Every plain .md file with no lmml-specific syntax at
all is already a valid, trivial, embed-less lmml narrative -- this
superset property is deliberate and tested (see
test/lmml/narrative/parser_test.exs, "lmml is a superset of vanilla
Markdown").
3.1 Standard Markdown
Headings, emphasis (*bold*, _italic_), links ([text](url)), images
(), lists, blockquotes, tables, plain fenced code blocks
(```lang), and everything else Md.Parser.Syntax's Default syntax
module supports work exactly as in standard Markdown, completely
unaffected by the additions below. Two of Default's own magnets are
notable and are kept as-is:
https:///http://auto-linking -- plain URLs in prose become anchors, useful for narrative text that mentions external resources.- The
✇(YouTube) and♫(SoundCloud) magnets -- obscure Unicode trigger characters, harmless to leave in place since they cannot collide with ordinary prose or with@/@@@(disjoint prefixes).
3.2 References: @name.ext
A bare @ immediately followed by a name (letters, digits, and any
characters that are not ASCII punctuation or whitespace) is an external
reference to an embed named name.ext:
Please review @diagram.png before the meeting.The reference stops at the first ASCII punctuation character that
immediately precedes whitespace or end-of-input -- so @image.png),
@image.png,, @image.png., and @image.png* all correctly capture
just image.png, leaving the trailing punctuation as ordinary text (or,
for markdown-meaningful punctuation like *, as ordinary markdown). A
dot inside a name, like the one before png, is never mistaken for a
terminator, since only the single character immediately preceding a
whitespace/EOF boundary is ever checked.
A reference resolves by looking up name.ext among the bundle's zip
entries (.lmmlz only) or among the narrative's own inline embeds
(@@@name.ext ... @@@, any bundle kind) -- whichever exists. See
Lmml.Bundle.embed/2 / Lmml.Document.embed/2.
3.3 Inline embeds: @@@name.ext ... @@@
A block starting with @@@name.ext on its own line, followed by content,
and closed by a line containing only @@@, is an inline embed: the named
entity's content, carried directly in the narrative text.
@@@settings.yaml
theme: dark
retries: 3
@@@Like a standard ``` fenced code block, @@@...@@@ is a block-level
construct: it must begin at the start of the document or immediately
after a blank line, and content inside it is captured verbatim (including
any blank lines within the fence) rather than being re-parsed as
Markdown. This is the same limitation an ordinary backtick fence has when
attempted mid-sentence, and matches the same user expectation: embeds are
used as their own block, never spliced into running prose.
Multiple inline embeds may appear in the same document, each producing its own embed node.
@@@ was chosen over an alternative like $$$ specifically to avoid
colliding with the widely-used $$/$$$ LaTeX/KaTeX math-block
convention in other Markdown flavors -- reusing that marker here would
silently corrupt how a bare .lmml renders in a generic Markdown viewer,
undermining the superset-of-Markdown guarantee. @ has no meaning at all
in vanilla CommonMark, so an unrendered @@@name degrades to inert
paragraph text in a naive viewer, never to a misleading math block or a
broken fence.
3.4 The manifest convention
manifest.json is not a separate front-matter mechanism -- it is simply
an embed literally named manifest.json (inline via @@@manifest.json ... @@@, or external via @manifest.json in a .lmmlz). It is entirely
optional: a document with no such embed is still a perfectly valid,
manifest-less narrative. When present, Lmml.Manifest.load/1 decodes it
(using OTP's built-in :json, not Jason) and exposes it as a plain
map; Lmml.Manifest.load/1 returns {:ok, nil} when no manifest.json
embed is mentioned at all, which is the common case, not an error.
3.5 Content fidelity of inline embeds
An inline embed's content is guaranteed to match, byte for byte, exactly
what was written between its @@@name ... @@@ fences. The underlying
Md parser's block: category (what the @@@ fence is built on)
HTML-entity-escapes five characters -- ', ", &, <, > -- by
default while capturing a block's content, the same as Default's own
```lang code fence; the @@@ entry in Lmml.Narrative.Syntax sets
escape: false (a block: property added in Md 0.12.2) so its content
is captured verbatim instead, which is why manifest.json (or any other
embed whose content happens to contain a quote, ampersand, or angle
bracket) round-trips correctly. This opt-out applies only to lmml's own
@@@ entry -- an ordinary code fence elsewhere in a narrative keeps
Default's escaping, since it isn't part of the embed model.
4. Path safety
Every zip entry name a .lmmlz bundle resolves is validated against path
traversal (Lmml.Bundle.validate_entry_names/1): no absolute paths, no
.. path segments, no backslashes. This matters because a .lmmlz may
arrive from somewhere you didn't author.
Empirically, Erlang's own :zip.unzip/2 already provides a first line of
defense here: given an entry name containing a path-traversal or absolute
component, it does not propagate that name as-is -- it logs a warning and
silently collapses the entry down to its bare basename before
Lmml.Bundle ever sees it. validate_entry_names/1 is still real
defense-in-depth, and is the only line of defense for entries supplied
directly to the in-memory Lmml.Bundle.new_zip/3 constructor, which never
goes through :zip and so receives none of that free sanitization.
5. Validation
Lmml.Bundle.validate/1 is a distinct, opt-in cross-check beyond what
open/1/new_zip/3 already enforce (path safety alone). It returns
:ok, or {:error, issues} collecting every issue found in one pass:
{:missing_reference, name}-- an@name.extreference has no matching zip entry (every external reference in a bare.lmmlnecessarily counts, since it has no entries at all).{:orphaned_entry, name}-- a zip entry nothing in the narrative mentions.{:malformed_embed_name, name}-- an embed name (inline or external) that could not safely become a zip entry name, relevant to every embed sinceLmml.Pack.pack/2(section 7) can turn any inline embed into a real entry later.{:conflicting_embed, name}-- two or more embeds share a name but disagree on content, including disagreeing on inline-vs-external.
6. Rendering for an LLM call
Lmml.Narrative.Resolver.resolve/1 resolves every distinct embed a
bundle's narrative mentions against the bundle itself (inline embeds
trivially, external references via the zip's entries), failing fast if
any one of them cannot be resolved rather than sending a partially
resolved narrative to an LLM.
Lmml.Narrative.Renderer.render/1 turns that resolved structure into an
ordered list of typed content parts for a multimodal chat completion
API: the raw narrative text becomes one verbatim "text" part (its
@name.ext/@@@name.ext ... @@@ syntax is left as-is, since it already
clearly marks where each embed occurs), followed by one part per
resolved embed -- an "image_url" part (base64 data URI) for image
extensions, or an "attachment" part carrying the content directly for
everything else, typed by an extension-to-MIME-type mapping with an
"application/octet-stream" fallback.
7. Packing and inlining
Lmml.Pack.pack/2 converts a bundle into a :zip bundle: every inline
embed is externalized into a real zip entry and its @@@name ... @@@
occurrence rewritten to a plain @name reference (safe anywhere, since a
short reference is valid wherever prose is). Already-external references
are left as text but their bytes are still resolved and copied into the
result's entries when possible, so packing an already-.lmmlz bundle is
a safe, idempotent merge rather than a lossy re-creation.
Lmml.Pack.inline/2 converts a bundle into a :text bundle: every
external reference must resolve (it fails outright rather than producing
a partial result), and a zip entry nothing references is necessarily
dropped, since a bare .lmml cannot carry an unreferenced entry.
Critically, inline/2 does not substitute a fenced block in place of a
reference: a @@@name ... @@@ fence's opening marker is only valid at
the start of the document or after a blank line (section 3.3), but a
reference is normally used mid-sentence ("Please review @diagram.png before the meeting."), where substituting a fence in place would
produce unparseable output. Instead, inline/2 strips the bare @
sigil from each in-prose occurrence ("@notes.txt" becomes plain
"notes.txt") and appends one proper, blank-line-separated
"@@@name ... @@@" block per resolved reference at the end of the
narrative.
Both directions rewrite text via targeted substring surgery on exact
fence/reference occurrences already known from the parsed Document,
not by re-serializing the whole AST back into Markdown (Md has no
matching generator, and embeds are the only lmml-specific syntax
either direction touches). Packing then inlining (or vice versa) is a
lossless round trip in terms of every embed's resolvable content, though
not necessarily of the raw narrative text's exact prose wording around a
formerly-external reference (its @ sigil is stripped) or of orphaned
zip entries (dropped by inline/2, since they were never part of the
document model to begin with).
Only valid UTF-8 content can ever be inlined. inline/2 fails with
{:error, {name, :not_utf8}} for an external reference whose resolved
bytes aren't valid UTF-8 -- splicing genuinely binary content (a real
image, say) into the narrative's own UTF-8 text isn't just ugly, it
crashes the underlying parser outright, since it matches one UTF-8
codepoint at a time with no defined behavior for an invalid byte
sequence appearing mid-text. A binary asset should simply stay external
via @name.ext, which is exactly what .lmmlz archives are for.