본문으로 건너뛰기

xl3

The deterministic runtime for AI-generated Excel reports. An LLM writes the template, xl3 renders the workbook — same template, same data, same bytes, every time.

Status: alpha · XTL spec 0.1 (draft) · breaking changes possible until 1.0

xl3 is a small TypeScript engine that turns a pair of .xlsx files — a template (the workflow contract) and raw data — into a finished, formatted workbook. The template is itself an .xlsx, authored in Excel with familiar formulas plus a tiny embedded expression language (XTL) for the things that must be known before the workbook is written: filters, groups, aggregates, filename patterns.

It's a good fit when the template is generated, edited, or reviewed by an LLM (Claude, GPT, Gemini, Cursor, Codex, …) and you need the execution layer to stay deterministic, inspectable, and verifiable — not "AI guessing at the output cells."

English · 한국어 · 日本語 · 简体中文 · 繁體中文 · Español · Website · Spec · LLM authoring guide · Implementations · Roadmap · Governance


The split: model writes, runtime renders

┌──────────────────────────┐ ┌──────────────────────────┐
│ LLM (Claude / GPT / │ │ xl3 │
│ Gemini / Cursor / …) │ │ (deterministic runtime) │
│ │ │ │
│ natural language │ │ template.xlsx │
│ + sample report ───► │ emits │ + raw.xlsx │
│ │ │ → result.xlsx │
│ "monthly settlement │ │ │
│ by region, with │ │ same inputs │
│ per-region subtotals" │ │ → same bytes, always │
└──────────────────────────┘ └──────────────────────────┘
creative, stochastic boring, reproducible

LLMs are good at drafting a report shape from a prompt and a sample. They are bad at producing the same .xlsx twice, preserving cell styles, or honoring "this column must always be SUM-aggregated." xl3 fills that gap: the model emits an .xlsx template once; every subsequent render is a pure function of (template, data, inputs).

This split is what docs/llm-template-authoring.md, the 154-fixture conformance corpus, and the intentionally small XTL surface are designed for.

Quick example

A template can contain ordinary Excel content, __config__, and xl3 expressions:

__config__ keyValue
source_sheetRaw
source_table1
output_file_patterncustomer-renewal-report.xlsx
CellTemplate value
A5{{ [Account] }}
B5{{ [Region] }}
C5{{ [Renewal] }}
E5{{ IF([Renewal] > 10000, "Priority", "Standard") }}

Given this data workbook:

AccountRegionRenewalOwner
Acme LogisticsSeoul18400Mina
Beta WorksBusan7200Joon

xl3 renders:

AccountRegionRenewalOwnerTier
Acme LogisticsSeoul18400MinaPriority
Beta WorksBusan7200JoonStandard

…with the template's number formats, fills, borders, merged headers, and footer rows preserved verbatim. The output is an .xlsx you can open in Excel, Numbers, or Google Sheets without conversion.

See spec/ for the language draft and conformance/ for the implementation-neutral fixture corpus and runner protocol.

Why the runtime needs to be boring

The pitch in one paragraph: anything an LLM emits as Excel is one bad token away from a broken report. Cell formulas drift, a merge moves by one row, a currency symbol becomes a literal $ instead of a number format. xl3's job is to make the execution of that template predictable so the model only has to be right once.

Concretely:

  • A small, auditable XTL surface (ADR-0043). A function lives in XTL only when its value must be known before the workbook is written. Everything else is a normal Excel cell formula and Excel evaluates it at open time. The smaller the language, the smaller the surface an LLM has to learn — and the smaller the surface to verify. See Cookbook 16 for the side-by-side guide.
  • Conformance corpus. 154 fixtures, all green, across 70 ADRs. This is the test bed an LLM's template can be checked against before it ever touches user data.
  • One implementation, one spec. The spec/ directory defines XTL independently of this TypeScript reference. Ports to other runtimes are welcome; the corpus is the contract.
  • No macros, no vendor cloud. A template is an ordinary .xlsx. You can diff it, review it in a pull request, and hand it to a human reviewer who has never heard of xl3.

The same properties make xl3 useful even without an LLM in the loop — operators and analysts can read and edit templates directly, because expressions are written with the same IF, SUM, and column references they already use day to day. The AI angle is the wedge; the human-readability is the long tail.

How it compares

ApproachBest atTradeoff for AI-driven Excel
xl3The execution half of an LLM-authored Excel pipeline. The model writes the template once; xl3 renders deterministically every run.Alpha; one maintainer; the XTL surface is intentionally small and still evolving until 1.0.
Direct LLM → xlsx (function-call to a spreadsheet SDK)Quick exploratory drafting, one-off charts.Each render is non-deterministic; styles, number formats, and totals drift between runs even with temperature 0.
SheetJS / ExcelJS / openpyxlLow-level workbook generation.The model has to learn the entire SDK surface and re-emit it every render; the "template" is application code, not a portable file.
Power Query / Office Scripts / Power AutomateMicrosoft 365 workflows, data shaping, and action automation inside the Excel ecosystem.Tenant-bound; the workflow rules don't travel with the workbook.
JXLS / xltpl / jsreport xlsx recipeServer-side report generation from spreadsheet-like templates.Useful, but predate the LLM-as-author model; their template DSLs are larger and not designed to be model-emittable.
Document-generation SaaS (Plumsail, Conga, Formstack)Managed document workflows, integrations, approvals, and delivery.Rules live in a vendor service, not a portable workbook you can hand an LLM to edit.

Install

npm install @jinyoung4478/xl3

Optional acceleration (rc):

npm install @jinyoung4478/xl3@rc xl3-wasm

Usage

import { convert } from '@jinyoung4478/xl3';

const templateBuffer = await fetch('./template.xlsx').then((r) => r.arrayBuffer());
const dataBuffer = await fetch('./data.xlsx').then((r) => r.arrayBuffer());

const outputs = await convert(templateBuffer, dataBuffer);
// outputs: OutputFile[] — one or more .xlsx, depending on grouping rules in the template

Runs in browsers and Node (≥20.12).

Acceleration (opt-in)

convert and preview accept an engine option that selects the rendering backend:

await convert(templateBuffer, dataBuffer, { engine: 'auto' });
// 'auto' (default): try xl3-wasm if installed, fall back to JS silently
// 'wasm' : require xl3-wasm; throw if missing or unsupported feature
// 'js' : force the ExcelJS path

Install xl3-wasm alongside xl3 to activate the auto path; the JS engine remains the canonical reference, and the wasm engine falls back to it for any template it can't yet handle. Available in 0.9.0-rc.1 onwards.

Browser via <script> (no bundler)

For projects that don't use a bundler, a self-contained IIFE bundle exposes window.xl3:

<script src="https://cdn.jsdelivr.net/npm/@jinyoung4478/xl3@0.8.0/dist/xl3.bundle.iife.min.js"></script>
<script>
const tpl = await fetch('./template.xlsx').then((r) => r.arrayBuffer());
const data = await fetch('./data.xlsx').then((r) => r.arrayBuffer());
const outputs = await xl3.convert(tpl, data);
</script>

Bundle is ~1 MB minified (~300 KB gzipped). ExcelJS + JSZip are inlined; no other dependencies are needed.

You can try the browser flow on xl3.io: run the attached sample files as-is, download the raw/template workbooks, or replace either file with your own.

Excel version compatibility

xl3 reads .xlsx files via OOXML and is largely version-agnostic by design — it reads cached formula results, normalizes dates in UTC, and ignores OOXML serialization differences at the cell-value layer. See ADR-0022 for the full matrix; the short form is: stick to XTL's {{ ... }} syntax for anything dynamic, avoid charts/pivots/native formulas inside data blocks, and pick one date system (1900) per organization.

Templates choose the source table in the hidden __config__ sheet:

KeyExampleMeaning
source_sheetRawsource worksheet name, or prefix pattern ending with *
source_table1row 1 contains column names; rows below are data
source_tableA1:DA1-D1 contain column names; rows below are data
source_tableA1:D200A1-D1 contain column names; A2-D200 are data

Use source_table = N for the common case where row N contains the raw column names. Use a range form when the table starts in a later column or needs a bounded end row.

Reserved sheets

Templates use four reserved dunder-wrapped sheets (per ADR-0011):

SheetPurpose
__config__author-defined configuration and value dictionary; access via {{ __config__[name] }}
__inputs__per-run host-supplied values (ADR-0010); declared with name/type/default/label/description/options columns
__sources__additional named data sources beyond the default source_sheet (ADR-0012); declared with name/sheet/table/description columns
__lists__membership lists for @filter [field] in __lists__[name]

Author sheets matching ^__[a-z]+__$ are reserved and rejected at parse time.

Multi-source data

Beyond the default source_sheet, templates can declare named sources in __sources__ and reference them with the Excel structured-ref form:

{{ Customers[Account] }}
{{ SUM(Renewals[Amount]) }}
{{ XLOOKUP([Account], Customers[Account], Customers[Name]) }}

@source <Name> scopes a data block so the bare bracket shorthand ([Column]) resolves against <Name> instead of the default. @join pairs primary rows with rows from a second source by key (inner-join, first-match). See spec/language.md for full directive syntax.

Runtime inputs

Templates that need per-run values (a target month, a customer filter, a label) declare them in __inputs__ and the host passes them to convert(...):

await convert(templateBuffer, dataBuffer, {
inputs: { month: '2026-05', region: 'Seoul' },
});

Inputs flow into cells ({{ __inputs__[month] }}), filename patterns, and group keys.

Examples

Four production-shaped templates live in examples/: basic renewal report, sheet-per-region with list-filter, a multi-source join with runtime inputs, and a cafe weekly report showcasing @group + @subtotal per-category subtotals. Run them with npm run examples:build && npm run examples:run.

Guides

Short, copy-paste recipes for common workflows live in docs/guides/. Eighteen recipes covering getting started, conditionals, aggregates, file/sheet grouping, runtime inputs, joins, XLOOKUP, sort/top, styling, multi-line text, empty values, error handling, __config__ values, directive composition, XTL vs Excel-formula, template-authoring display, and @group / @subtotal.

Spec

The XTL spec is language-neutral and lives in spec/. This repo provides the TypeScript reference implementation. Other-language ports are welcome — see IMPLEMENTATIONS.md.

Run the conformance corpus locally:

npm run conformance
node dist/bin/conformance.js --fixture-dir=conformance/fixtures --comparison-stage=2

A summary of the latest reference-impl run — plus columns for any external port reports dropped under conformance/reports/ — lives in conformance/DASHBOARD.md. Regenerate with npm run conformance:dashboard.

Project structure

  • spec/ — normative XTL language draft.
  • conformance/ — implementation-neutral fixture corpus and runner protocol.
  • src/ — TypeScript reference implementation.

The spec is the source of truth. Conformance fixtures make spec behavior executable. The reference implementation is useful, but not normative.

License

  • Code (src/, conformance/): MIT
  • XTL spec (spec/): CC-BY-4.0

Microsoft and Excel are trademarks of Microsoft Corporation. xl3 is not affiliated with Microsoft. The Office Open XML format (.xlsx) is published as ISO/IEC 29500.