Documents as components.
DOCX as output.
Compose documents from typed, reusable nodes. Preview them in the
browser, pack a real .docx
locally, and hand the rest to the engine when you need one.
npx docxcelerate init my-letters v0.1.3
{
"schemaVersion": "docxcelerate.config/v0",
"activePreset": "local",
"presets": {
"local": {
"build": { "outDir": "build" },
"upload": {
"endpoint": "",
"method": "POST",
"headers": {},
"body": "letter"
}
},
"staging": {
"build": { "outDir": "build" },
"upload": {
"endpoint": "https://letters.staging.example.com/api/letters",
"method": "POST",
"headers": { "Authorization": "Bearer ${LETTERS_TOKEN}" },
"body": "letter"
}
}
}
} import { paragraph } from "docxcelerate";
import type { OfferData } from "../types.ts";
/**
* Formatting is ordinary TypeScript — no template language, so an Oxford comma
* and a singular/plural switch cost one line each.
*/
export const Conditions = paragraph<OfferData>({
id: "conditions",
render: (data) => {
const list = formatList(data.conditions);
const verb = data.conditions.length === 1 ? "condition is" : "conditions are";
return `This offer is conditional. The ${verb} ${list}. We will confirm ` +
`your place automatically once your results reach us.`;
},
});
function formatList(items: string[]): string {
if (items.length <= 1) return items[0] ?? "";
return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
} import { paragraph } from "docxcelerate";
import type { OfferData } from "../types.ts";
/**
* An optional field changes the paragraph rather than leaving a gap — the kind
* of branch that is awkward in a mail-merge and trivial here.
*/
export const Fees = paragraph<OfferData>({
id: "fees",
render: (data) => {
const base = `You have been assessed as a ${data.feeStatus} fee payer, so ` +
`tuition for your first year will be ${data.tuitionFee}.`;
if (!data.scholarship) {
return `${base} Details of loans, bursaries and hardship funding are in ` +
`the enclosed funding guide.`;
}
return `${base} We are also pleased to award you the ` +
`${data.scholarship.name}, worth ${data.scholarship.amount} for the ` +
`duration of your course. No separate application is needed.`;
},
}); import { paragraph } from "docxcelerate";
import type { OfferData } from "../types.ts";
export const Greeting = paragraph<OfferData>({
id: "greeting",
render: (data) => `Dear ${data.applicantName},`,
}); export { Conditions } from "./conditions.node.ts";
export { Fees } from "./fees.node.ts";
export { Greeting } from "./greeting.node.ts";
export { NextSteps } from "./next-steps.node.ts";
export { Offer } from "./offer.node.ts";
export { SignOff } from "./signoff.node.ts";
export { TutorNote } from "./tutor-note.node.ts"; import { paragraph } from "docxcelerate";
import type { OfferData } from "../types.ts";
export const NextSteps = paragraph<OfferData>({
id: "next-steps",
render: (data) =>
`To accept, reply through the applicant portal by ${data.replyBy}. ` +
`If you would like to visit ${data.college} before deciding, our open ` +
`afternoons run every Thursday through April and you are very welcome.`,
}); import { paragraph } from "docxcelerate";
import type { OfferData } from "../types.ts";
export const Offer = paragraph<OfferData>({
id: "offer",
render: (data) =>
`I am delighted to offer you a place on the ${data.programme} at ` +
`${data.college}, beginning ${data.startDate}. Your application reference ` +
`is ${data.offerRef}; please quote it in any correspondence with us.`,
}); import { paragraph } from "docxcelerate";
import type { OfferData } from "../types.ts";
export const SignOff = paragraph<OfferData>({
id: "sign-off",
render: (data) =>
`Yours sincerely, ${data.signatory.name}, ${data.signatory.title}, ${data.college}.`,
}); import { paragraph } from "docxcelerate";
import type { OfferData } from "../types.ts";
/**
* The one paragraph worth generating. Everything else in this letter is
* deterministic, so this is the only node that needs an endpoint at all.
*/
export const TutorNote = paragraph<OfferData>({
id: "tutor-note",
placeholder: (data) =>
`A short personal note from ${data.interviewer} about ${data.applicantName}'s interview.`,
generalPrompt: (data) =>
`Write two warm, specific sentences from ${data.interviewer} to ` +
`${data.applicantName}, referring to their interest in ${data.portfolioTheme}. ` +
`Mention one thing they should read before term starts.`,
systemPrompt: () =>
"You are an admissions tutor. Be warm but never effusive, and never promise outcomes.",
negativePrompt: () =>
"Do not restate the offer, the conditions, or the reply deadline.",
}); import { cleanMinimalDocumentStyle } from "docxcelerate";
import { defineDocumentProject } from "docxcelerate/document";
import { letterTemplate } from "./letter.tsx";
import { previewData } from "./preview-data.ts";
import type { OfferData } from "./types.ts";
export default defineDocumentProject<OfferData>({
id: "offer-of-admission",
name: "Offer of Admission",
version: "1.0.0",
template: letterTemplate,
style: cleanMinimalDocumentStyle,
previewData,
}); import { Document, Section, template } from "docxcelerate/template";
import {
Conditions,
Fees,
Greeting,
NextSteps,
Offer,
SignOff,
TutorNote,
} from "./nodes/index.ts";
import type { OfferData } from "./types.ts";
export const letterTemplate = template<OfferData>(
<Document id="offer-of-admission" title="Offer of Admission">
<Section id="your-offer" title="Your offer">
<Greeting />
<Offer />
<TutorNote />
</Section>
<Section id="conditions" title="Conditions">
<Conditions />
</Section>
<Section id="fees-and-funding" title="Fees and funding">
<Fees />
</Section>
<Section id="next-steps" title="Next steps">
<NextSteps />
<SignOff />
</Section>
</Document>,
); import type { OfferData } from "./types.ts";
export const previewData: OfferData = {
applicantName: "Maya Oyelaran",
programme: "MEng Structural Engineering",
college: "Ashcroft College",
startDate: "28 September 2026",
offerRef: "ADM-2026-4417",
conditions: [
"grades A*AA at A-level, including Mathematics and Physics",
"IELTS 7.0 overall, with no component below 6.5",
"a satisfactory enhanced DBS check",
],
replyBy: "15 May 2026",
portfolioTheme: "timber gridshell roofs and low-carbon structural systems",
interviewer: "Dr Priya Raman",
tuitionFee: "£9,535",
feeStatus: "Home",
scholarship: { name: "Ashcroft Engineering Scholarship", amount: "£3,000 per year" },
signatory: { name: "Dr Priya Raman", title: "Director of Undergraduate Admissions" },
}; export interface OfferData {
applicantName: string;
programme: string;
college: string;
startDate: string;
offerRef: string;
conditions: string[];
replyBy: string;
/** Context for the tutor's note — never printed verbatim. */
portfolioTheme: string;
interviewer: string;
tuitionFee: string;
feeStatus: "Home" | "International";
scholarship?: { name: string; amount: string };
signatory: { name: string; title: string };
} import { paragraph } from "docxcelerate";
import type { RepairsData } from "../types.ts";
/**
* An absent optional field should change the sentence, not print "undefined"
* or leave a blank line.
*/
export const Access = paragraph<RepairsData>({
id: "access",
render: (data) =>
data.accessNotes
? `We hold the following access note for your home: ${data.accessNotes} ` +
`Please let us know if this is out of date.`
: `Someone aged 18 or over needs to be home for the visit. If that is ` +
`not possible, call us and we will arrange access another way.`,
}); import { paragraph } from "docxcelerate";
import type { RepairsData } from "../types.ts";
export const Appointment = paragraph<RepairsData>({
id: "appointment",
render: (data) =>
`We have booked a ${data.trade} to visit ${data.address} on ` +
`${data.visitDate}, ${data.visitWindow}. Your job reference is ` +
`${data.jobRef}.`,
}); import { paragraph } from "docxcelerate";
import type { RepairsData } from "../types.ts";
export const Greeting = paragraph<RepairsData>({
id: "greeting",
render: (data) => `Dear ${data.residentName},`,
}); export { Access } from "./access.node.ts";
export { Appointment } from "./appointment.node.ts";
export { Greeting } from "./greeting.node.ts";
export { WhatToExpect } from "./what-to-expect.node.ts"; import { paragraph } from "docxcelerate";
import type { RepairsData } from "../types.ts";
export const WhatToExpect = paragraph<RepairsData>({
id: "what-to-expect",
placeholder: (data) => `What to expect during a ${data.trade}'s visit.`,
generalPrompt: (data) =>
`In three short sentences, explain what a ${data.trade} will do during a ` +
`routine repair visit, how long it usually takes, and what the resident ` +
`should move out of the way beforehand.`,
systemPrompt: () =>
"You write for social housing residents. Use plain English and short sentences.",
negativePrompt: () => "Do not repeat the appointment date or the job reference.",
}); import { cleanMinimalDocumentStyle } from "docxcelerate";
import { defineDocumentProject } from "docxcelerate/document";
import { letterTemplate } from "./letter.tsx";
import { previewData } from "./preview-data.ts";
import type { RepairsData } from "./types.ts";
export default defineDocumentProject<RepairsData>({
id: "repairs-appointment",
name: "Repair Appointment",
version: "2.1.0",
template: letterTemplate,
style: cleanMinimalDocumentStyle,
previewData,
}); import { Document, Section, template } from "docxcelerate/template";
import { Access, Appointment, Greeting, WhatToExpect } from "./nodes/index.ts";
import type { RepairsData } from "./types.ts";
export const letterTemplate = template<RepairsData>(
<Document id="repairs-appointment" title="Your repair appointment">
<Section id="appointment" title="Your appointment">
<Greeting />
<Appointment />
</Section>
<Section id="on-the-day" title="On the day">
<WhatToExpect />
<Access />
</Section>
</Document>,
); import type { RepairsData } from "./types.ts";
export const previewData: RepairsData = {
residentName: "Tomasz Wójcik",
address: "12 Bracken Road, Leeds LS6 3QT",
jobRef: "RPR-88213",
trade: "plumber",
visitDate: "Tuesday 25 August",
visitWindow: "between 8am and 12pm",
accessNotes: "Key safe at the side gate; the code is with the scheme manager.",
}; export interface RepairsData {
residentName: string;
address: string;
jobRef: string;
trade: "plumber" | "electrician" | "joiner";
visitDate: string;
visitWindow: string;
accessNotes?: string;
} import { paragraph } from "docxcelerate";
import type { PolicyData } from "../types.ts";
export const Greeting = paragraph<PolicyData>({
id: "greeting",
render: (data) => `Dear ${data.holderName},`,
}); export { Greeting } from "./greeting.node.ts";
export { PremiumChange } from "./premium-change.node.ts";
export { Renewal } from "./renewal.node.ts";
export { Shopping } from "./shopping.node.ts"; import { paragraph } from "docxcelerate";
import type { PolicyData } from "../types.ts";
/**
* A price rise is the sentence customers actually read. Computing the
* direction and the percentage locally means it can never contradict the
* figures printed beside it.
*/
export const PremiumChange = paragraph<PolicyData>({
id: "premium-change",
render: (data) => {
const delta = data.newPremium - data.lastPremium;
const percent = Math.abs((delta / data.lastPremium) * 100).toFixed(1);
if (Math.abs(delta) < 0.01) {
return `Your premium is unchanged at ${money(data.newPremium)} a year.`;
}
const direction = delta > 0 ? "risen" : "fallen";
return `Your premium has ${direction} by ${percent}%, from ` +
`${money(data.lastPremium)} to ${money(data.newPremium)} a year. ` +
`That works out at ${money(data.newPremium / 12)} a month.`;
},
});
function money(value: number): string {
return value.toLocaleString("en-GB", { style: "currency", currency: "GBP" });
} import { paragraph } from "docxcelerate";
import type { PolicyData } from "../types.ts";
export const Renewal = paragraph<PolicyData>({
id: "renewal",
render: (data) =>
`Your ${data.cover.toLowerCase()} policy ${data.policyNumber} renews on ` +
`${data.renewalDate}. Unless you tell us otherwise, cover continues ` +
`automatically with an excess of ${money(data.excess)}.`,
});
function money(value: number): string {
return value.toLocaleString("en-GB", { style: "currency", currency: "GBP" });
} import { paragraph } from "docxcelerate";
import type { PolicyData } from "../types.ts";
export const Shopping = paragraph<PolicyData>({
id: "shopping-around",
placeholder: () => "A short note on comparing this renewal with other quotes.",
generalPrompt: (data) =>
`Write two sentences reminding ${data.holderName} that they can compare ` +
`this renewal against other quotes, and that doing so will not affect ` +
`their existing cover.`,
systemPrompt: () =>
"You write regulated insurance correspondence. Be neutral and never discourage switching.",
negativePrompt: () => "Do not quote a price, a percentage, or a competitor name.",
}); import { cleanMinimalDocumentStyle } from "docxcelerate";
import { defineDocumentProject } from "docxcelerate/document";
import { letterTemplate } from "./letter.tsx";
import { previewData } from "./preview-data.ts";
import type { PolicyData } from "./types.ts";
export default defineDocumentProject<PolicyData>({
id: "policy-renewal",
name: "Policy Renewal",
version: "0.4.2",
template: letterTemplate,
style: cleanMinimalDocumentStyle,
previewData,
}); import { Document, Section, template } from "docxcelerate/template";
import { Greeting, PremiumChange, Renewal, Shopping } from "./nodes/index.ts";
import type { PolicyData } from "./types.ts";
export const letterTemplate = template<PolicyData>(
<Document id="policy-renewal" title="Your renewal">
<Section id="renewal" title="Your renewal">
<Greeting />
<Renewal />
<PremiumChange />
</Section>
<Section id="your-options" title="Your options">
<Shopping />
</Section>
</Document>,
); import type { PolicyData } from "./types.ts";
export const previewData: PolicyData = {
holderName: "Eleanor Whitfield",
policyNumber: "HC-2291-8840",
cover: "Contents and buildings",
renewalDate: "1 October 2026",
lastPremium: 284.4,
newPremium: 311.16,
excess: 250,
}; export interface PolicyData {
holderName: string;
policyNumber: string;
cover: "Contents" | "Buildings" | "Contents and buildings";
renewalDate: string;
lastPremium: number;
newPremium: number;
excess: number;
} Not screenshots. Every page is rendered at build time by the same renderer the CLI ships, from the files beside it.
Write documents like websites
A document is a tree of typed components. If you have written React you already know the shape of this — props, composition, small files — so a frontend engineer is productive on the first afternoon rather than learning a template language first.
Designed for AI
Generated prose is a node type, not a bolt-on. Mark the one paragraph that genuinely needs a model, give it prompts and a placeholder, and leave the rest deterministic — so AI solves the hard part without putting the whole document at its mercy.
Documents live in your repo
Because a document is source, changing a sentence is a pull request — diffed, reviewed, and still attributable a year later when someone asks who altered the arrears wording. Tests assert a document renders what you expect, so CI catches the mistake before a recipient does.
Docxcelerate treats a document the way a UI framework treats a screen: small components, composed into a tree, rendered by something that knows about paper. You get the ergonomics of a component model, and the recipient gets a Word document.
The engine
The engine is where documents are actually written. It fills in your data, runs the AI, and returns the finished document. Any kind of node can use AI, not only paragraphs. The model's answer either becomes the text, written from the information you give it, or makes a decision the document depends on.
You publish a template to the engine once. After that, any system can call its API with a set of data and get a document back. A free engine is available to self-host. The managed cloud runs the complete one, with a lot the free version does not have, and is coming soon.
- 01 Build The framework turns your document into a package. This step runs on your machine.
- 02 Publish You send the package to an engine. The engine stores it and gives it a name.
- 03 Write Your application sends a set of data. The engine returns the finished document.
Read the whole thing
Every node type, every CLI flag, and every file a build writes — with previews rendered by the real renderer, so nothing on the page can describe a helper that no longer exists.