A TypeScript library for composing signable legal-style .docx documents from
markdown. Out of the box it knows about the structures legal documents tend to
need — defined-term shorthand, field/value tables, side-by-side or single-party
signature blocks, and schedule/inventory grids — and renders them in a
consistent house style (Times New Roman, US Letter, full-grid tables).
It also ships as a Claude skill so Claude
can draft and fill in legal documents end-to-end on top of the library, without
hand-rolling docx-js boilerplate.
The skill teaches Claude how to draft legal documents using this library —
picking an appropriate template, filling values, and producing the .docx.
-
Build the plugin:
bun install bun run pack # → plugin.zipOr grab
plugin.zipfrom the latest GitHub release. -
Open https://claude.ai/customize/skills → Create skill → Upload skill → upload
plugin.zip.
Once installed, Claude reads SKILL.md and uses the bundled CLI
to produce documents from any conversational request ("draft an exclusive
songwriter agreement between …").
Prerequisite: pandoc must be on your
PATH — the markdown frontend shells out to it. (The Claude skill path doesn't
need this; Claude's environment already has pandoc.)
# macOS
brew install pandoc
# Debian/Ubuntu
sudo apt-get install -y pandoc
bun add legalese # or: npm i legaleseimport { build, h2, p, dt, fieldTable, signatureTable } from 'legalese';
await build({
title: 'EXCLUSIVE SONGWRITER AGREEMENT',
output: './Songwriter_Agreement.docx',
body: [
p('This Agreement', dt('Agreement'),
' is entered into as of the Effective Date stated below.'),
h2('1. Parties'),
fieldTable({ effective_date: '', writer: '' }, [
['Effective Date', 'effective_date'],
['Writer (legal name)', 'writer'],
]),
],
});The full public surface is re-exported from src/index.ts.
The core idea is that defined terms are variables, not strings.
In a typical legal document, "the Customer" appears thirty times across the
body, schedules, and signature block — and changing the customer's name means
thirty hand-edits with thirty chances to miss one. Here you declare the term
once in schema:, refer to it everywhere by {{the_Customer}} /
{{The_Customer}} / {{$the_Customer}}, and a single edit at the top ripples
through the whole document.
Plurals derive automatically; the correct article (a vs an) flips with the
term it precedes; sentence- start capitalization rides on the marker case. Same
idea for static expansions — monthly_fee.def: "$1,850.00 per Location" lives
in one place and renders consistently every time it's introduced.
A template is a markdown file with three pieces: YAML front-matter (schema +
optional values + style), prose with {{marker}} substitutions, and fenced
blocks for the structural elements legal documents need (form fields, signature
blocks, schedule grids).
---
title: LANDSCAPING SERVICES AGREEMENT
output: ./Landscaping_Agreement.docx # optional; CLI flag wins
schema:
agreement:
def: "Landscaping Services Agreement"
# `customer` / `contractor` need no `def:` — the auto-derived label
# (snake → Title Case) already gives "Customer" / "Contractor". The
# values file supplies the per-deal expansion.
customer:
contractor:
effective_date:
type: date
required: true
services:
term: "Services"
def: "certain landscaping and grounds-maintenance
services described in this Agreement"
monthly_fee:
term: "Monthly Fee"
required: true # value supplied per deal
governing_law:
type: string
default: "State of Delaware"
values: # or pass --values-file foo.yml
effective_date: "June 1, 2026"
customer: "McDonald's USA, LLC"
contractor: "Greenline Landscaping, Inc."
monthly_fee: "$1,850.00 per Location"
style:
font: EB Garamond # bundled; embeds into the .docx
size: 12
margin: 1440 # 1" all sides (twips)
---schema declares every defined term and form field. values (or
--values-file) supplies per-deal data. Run legalese my.md --schema to dump
values: / schema: / required: / missing: without rendering.
The article rides in the marker prefix; the term comes from schema.
| Marker | Renders |
|---|---|
{{key}} |
Key |
{{the_key}} |
the Key |
{{The_key}} |
The Key (sentence-start) |
{{a_key}} |
a Key / an Key (auto by vowel) |
{{$key}} |
<expansion> ***"Key"*** |
{{$the_key}} |
<expansion> (the ***"Key"***) |
{{!Term}} |
***"Term"*** (literal, inline-styled) |
{{^WHEREAS}} |
small-caps run |
{{KEY}} |
uppercased — useful in titles |
The $ forms introduce a term (with its expansion + parenthetical
definition); plain forms reference it after introduction. Lookups are
case-insensitive; plurals auto-derive (location → Locations); a_ / an_
auto-flips by the first letter of the resolved term.
This {{$the_Agreement}}, dated {{$the_Effective_date}}, is between
{{$the_Customer}} and {{$the_Contractor}}, individually {{$a_Party}} and
collectively {{$the_Parties}}. {{The_Contractor}} shall provide
{{$the_Services}} at each {{Location}} listed in **{{Schedule_A}}** for
{{$the_Monthly_fee}}.
Renders (with the schema and values from the front-matter example above):
This Landscaping Services Agreement (the "Agreement"), dated June 1, 2026 (the "Effective Date"), is between McDonald's USA, LLC (the "Customer") and Greenline Landscaping, Inc. (the "Contractor"), individually a "Party" and collectively the "Parties". The Contractor shall provide certain landscaping and grounds-maintenance services described in this Agreement (the "Services") at each Location listed in Schedule A for $1,850.00 per Location (the "Monthly Fee").
Now rename customer to client in schema: once — every reference
above flips to "Client" / "the Client" / "Clients" / "a Client" without
touching the body.
fields — labelled form rows for blanks the parties fill in:
```fields
effective_date
writer_name
Licensing Fee | fee | prefix=$
Spotify URL | spotify | sub=if credit required
```
Renders as a two-column field table:
| Effective Date | |
| Writer Name | |
| Licensing Fee | $ |
| Spotify URL | if credit required |
sig — signature block. First line is LEFT_HEADER || RIGHT_HEADER; omit
|| for single-party. [tall] = a roomy line for handwritten signature.
```sig
WRITER || COMPANY
Name | sig_writer_name || Entity | sig_company_entity
Signature [tall] || Signature [tall]
Date || Date
```
Renders as two stacked blocks side-by-side. Each block is its own table with a centered header spanning both columns, bold labels on the left, and a roomy signature row sized for ink:
WRITER | |
|---|---|
| Name | |
| Title | |
| Signature | |
| Date | |
COMPANY | |
|---|---|
| Entity | |
| By (name) | |
| Signature | |
| Date | |
grid — styled table (full-grid borders, scaled column widths). Schedules,
inventories, deliverable lists. Rows can be literal, pulled from values[key]
via rows: $key, or repeated per-entry via from:.
```grid
columns:
- label: '#'
key: '#'
width: 600
- label: 'Service'
key: service
width: 3500
- label: 'Frequency'
key: frequency
width: 1800
rows:
- service: "Mowing"
frequency: "Weekly"
- service: "Snow Removal"
frequency: "As-needed"
```
Renders as a numbered, full-grid table:
| # | Service | Frequency |
|---|---|---|
| 1 | Mowing | Weekly |
| 2 | Snow Removal | As-needed |
Don't use raw markdown tables. The renderer ignores them —
gridis the only supported tabular form.
Nested lists aren't supported. Use a flat lettered list (
a.,b.,c.with blank lines between) or agridblock.
The full DSL — every marker form, schema field, grid from: repeater, and style
override — is documented in SKILL.md.
Two flavours of every entry point: one that writes to a file, one that returns the bytes in memory. Use the buffer variants when you don't have (or don't want) a filesystem — browsers, serverless handlers, anywhere you'd rather hold the document and stream it back to a caller.
import { convertMarkdown, convertMarkdownToBuffer } from 'legalese';
const src = `---
title: NDA
schema:
# No `def:` needed — auto-derived "Disclosing Party" / "Receiving Party"
# is exactly what we want; values supply the actual party identifiers.
disclosing_party:
receiving_party:
values:
disclosing_party: "Acme Inc."
receiving_party: "Beta LLC"
---
This NDA is between {{$the_Disclosing_party}} and {{$the_Receiving_party}}.
`;
// Write to disk:
await convertMarkdown(src, { output: './nda.docx' });
// Or hold the bytes in memory:
const buf: Buffer = await convertMarkdownToBuffer(src);Both functions accept caller-supplied values (which override the values:
block in the source), title, baseDir, and a strict flag that throws if any
required: true schema fields are missing.
import { convertMarkdownToBuffer } from 'legalese/browser';
// pandoc-wasm is an optional peer dep — install it explicitly:
// npm i pandoc-wasm (~56 MB on disk, ~15 MB gzipped over the wire)
const bytes = await convertMarkdownToBuffer(src);
const blob = new Blob([bytes], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
});
// Hand to <a download>, fetch upload, FileSystemAccess API, etc.The legalese/browser subpath is byte-identical to legalese except its
convertMarkdown* defaults to the WASM engine. It deliberately never imports
node:child_process, so bundlers (Vite, esbuild, Rollup, etc.) don't end up
trying to polyfill the system-pandoc shell-out.
If you want to walk the parsed AST, render block-by-block, or stop short of producing a docx:
import {
splitFrontMatter, // string → { meta, body }
runPandoc, // body → PandocAst (Node)
runPandocWasm, // body → PandocAst (browser/Node)
blockToDocBuilder, // AST block → BodyEntry[]
buildToBuffer, // BodyEntry[] → Buffer
} from 'legalese';
const { meta, body } = splitFrontMatter(src);
const ast = await runPandocWasm(body);
const entries = ast.blocks.flatMap((b) =>
blockToDocBuilder(b, meta.values ?? {}, { baseDir: '/' }),
);
const docx = await buildToBuffer({ title: meta.title, body: entries });You can also inject any parser into convertMarkdown* via the parse option —
useful for caching the AST, plugging in a custom markdown flavour, or running
tests against a synthetic AST without spinning up pandoc at all.
bun add -g legalese # or: npm i -g legalese
legalese my-agreement.md
legalese my-agreement.md --output ./out/agreement.docx
OUTPUT_DIR=./out legalese my-agreement.mdThe CLI takes a markdown file with front-matter (title, output, values)
and the three supported fenced blocks (fields, sig, grid). See
examples/ for ready-to-edit templates and
SKILL.md for the full markdown DSL reference.
src/ pure TypeScript library
index.ts public barrel
types.ts cross-cutting types (BodyEntry)
lib/ build, runs, defaults, internal
blocks/ things that produce a docx Paragraph or Table
paragraphs/ p, h1, h2, list, spacer, raw
field-table/
grid-table/
signature-table/
md/ markdown frontend (pandoc → AST → builder)
scripts/legalese.ts CLI entry (bundled to dist/)
examples/ ready-to-edit markdown templates
tests/ end-to-end generation tests
SKILL.md skill manifest — instructions Claude reads at load time
bun install
bun test # end-to-end generation tests
bun run typecheck # tsc --noEmit
bun run build # bundle CLI → dist/legalese.js
bun run pack # build + zip → plugin.zipStrict TypeScript, bundler-mode module resolution, @/* path aliases (@/foo →
src/foo). Tagging vX.Y.Z and pushing triggers the release
workflow, which builds plugin.zip and
attaches it to a GitHub release.
MIT — see LICENSE.