Document Export
Generate PDF, DOCX, PPTX, CSV and XLSX documents from an agent's structured content. A second entry, ./transcript, writes a transcript as Markdown, plain text, JSON, CSV, SRT, VTT, DOCX, an HTML fragment or a zip; a third, ./archive, reads a ZIP one entry at a time under bounds; a fourth, ./calendar, writes one event as an iCalendar file and as Google Calendar's template address. The three run in a browser as well as in Node.
Installation
npm install @framers/agentos-ext-document-export
The extension pack
import { createExtensionPack } from '@framers/agentos-ext-document-export';
const pack = createExtensionPack({ options: { workspaceDir: '/srv/agent' } });
| Tool | What it does |
|---|---|
document_export | Renders structured content (sections with paragraphs, tables, charts and images) to pdf, docx, pptx, csv or xlsx, saves the file under <workspaceDir>/exports/ and answers its download and preview links |
document_suggest | Reads a reply's length, tables and sections and says whether to offer an export, and in which formats, with no model call |
Options: workspaceDir (default: the process's working folder), serverPort (default 3777), publicBaseUrl and priority (default 50). This entry runs in Node: it writes files to disk and renders PDF with pdfkit.
Transcript exports (browser-safe)
@framers/agentos-ext-document-export/transcript writes a transcript with its times, an after-session pack whose lines carry their quotes, and the checks shown beside the lines. It imports nothing but fflate and never touches a file system: each writer answers a string or bytes, and the caller saves them.
import type { TranscriptExport } from '@framers/agentos-ext-document-export/transcript';
const doc: TranscriptExport = {
title: 'Budget review',
startedAt: '2026-10-08T14:05:00.000Z',
durationSeconds: 754,
tags: ['q3'],
pack: {
summary: 'The team agreed the budget.',
decisions: [{ text: 'Budget grows ten percent.', quotes: [{ seq: 2, text: 'we grow the budget by ten percent' }] }],
actionItems: [{ text: 'Send the slides.', owner: 'Ann', quotes: [{ seq: 3, text: "I'll send the slides" }] }],
openQuestions: [],
},
checks: [{ seq: 2, label: 'Matches your notes', source: 'Note: Budget', sentence: 'Budget up ten percent.' }],
turns: [
{ seq: 1, startMs: 0, endMs: 2_000, text: 'Shall we start?', speaker: 'You' },
{ seq: 2, startMs: 65_000, endMs: 69_500, text: 'Yes, we grow the budget by ten percent.', speaker: 'Other' },
{ seq: 3, startMs: 70_000, endMs: null, text: "I'll send the slides." },
],
};
Times are milliseconds from the session's start. A quote names its turn by seq, and the writers print that turn's time beside it. A turn's speaker is optional and is printed when present.
| Writer | Answers |
|---|---|
toMarkdown(doc) | Markdown: the title, a line of facts, the summary, the decisions, action items and open questions with their quotes, the transcript with times and speakers, each check under its line, and the notes |
toPlainText(doc) | The same without Markdown marks |
toJson(doc) | Every record, with format: 'transcript-export' and v: 1 |
toHtml(doc) | An HTML fragment for a clipboard: the title, the pack with its quotes, the transcript and the notes, each line break written as <br> |
actionItemsCsv(doc) | The action items as CSV: the item, its owner, its first quote and that quote's time, after a UTF-8 byte order mark |
toSrt(doc) | SubRip cues, listed by their start and numbered from 1 |
toVtt(doc) | A WebVTT file, its cues listed by their start |
toDocx(doc) | The bytes of a Word document with headings, paragraphs and quotes, each line break written as <w:br/> and each tab as <w:tab/> |
writeArchive(entries, options?), readArchive(bytes, options?) | A zip of several files, and its files read back in their order, with folder entries left out and no more than maxInflatedBytes (50 MiB unless you pass another bound) inflated in all. With options (onEntry, signal, mtime), writeArchive answers a promise and writes entry by entry (below) |
In the Markdown, plain-text, HTML and Word writers a section with nothing in it is left out: the summary's heading needs a summary, a list its entries, the notes' heading some notes and the transcript's heading a turn, so { ...doc, turns: [] } writes the record without a transcript section.
In these four writers a text keeps its own line breaks, blank lines included. In Markdown and plain text one blank line separates two sections, and the line breaks at the start and end of a text are left out.
One example per writer, on the record above:
import {
actionItemsCsv,
readArchive,
toDocx,
toHtml,
toJson,
toMarkdown,
toPlainText,
toSrt,
toVtt,
writeArchive,
} from '@framers/agentos-ext-document-export/transcript';
toMarkdown(doc);
// # Budget review
//
// Thu, 08 Oct 2026 14:05:00 GMT | 13 minutes | q3
//
// ## Summary
//
// The team agreed the budget.
//
// ## Decisions
//
// - Budget grows ten percent.
// > "we grow the budget by ten percent" (01:05)
// ...
// **[01:05] Other:** Yes, we grow the budget by ten percent.
// - Matches your notes: Budget up ten percent. (Note: Budget)
toPlainText(doc);
// ...
// [01:05] Other: Yes, we grow the budget by ten percent.
// Matches your notes: Budget up ten percent. (Note: Budget)
JSON.parse(toJson(doc)).format;
// 'transcript-export'
toHtml(doc);
// '<h1>Budget review</h1><h2>Summary</h2><p>The team agreed the budget.</p><h2>Decisions</h2><ul><li>...'
actionItemsCsv(doc);
// U+FEFF, the byte order mark, then:
// "Action item","Owner","Quote","Time"
// "Send the slides.","Ann","I'll send the slides","01:10"
toSrt(doc);
// 1
// 00:00:00,000 --> 00:00:02,000
// You: Shall we start?
// ...
toVtt(doc);
// WEBVTT
//
// 00:00:00.000 --> 00:00:02.000
// You: Shall we start?
// ...
const docx: Uint8Array = toDocx(doc);
const zip = writeArchive([
{ path: 'sessions/budget-review.md', data: toMarkdown(doc) },
{ path: 'sessions/budget-review.docx', data: docx },
]);
readArchive(zip).map((entry) => entry.path);
// ['sessions/budget-review.md', 'sessions/budget-review.docx']
The CSV starts with a byte order mark (U+FEFF, the bytes EF BB BF once encoded as UTF-8): Excel opens a UTF-8 CSV file normally when it was saved with one, and without one it can misread text outside ASCII, a person's name included. A reader that does not strip the mark finds it in front of the first header.
The subtitles leave out a turn with no start and list the cues by their start, whatever the order of the turns: WebVTT requires each cue to start no earlier than the cues before it. Turns with the same start keep their order. A turn with no end ends at the next cue's start or two seconds after its own start, whichever comes first.
What each format is guarded against, with the helper the entry exports:
- XML and HTML text (
xmlText): characters outside XML 1.0'sCharproduction are dropped, and&,<,>,"and'are escaped. - A WebVTT cue (
vttText):&and<are escaped, every-->is broken, and no blank line is left inside the cue. An SRT cue (cueText) keeps no blank line inside. - A CSV field (
csvField): every field is quoted with its quotes doubled, and a field that starts with=,+,-,@, a tab, a carriage return, a line feed or the full-width=,+,-or@gets a leading single quote, so a spreadsheet does not read it as a formula. - Markdown body text (
markdownText): marks are escaped and GitHub's mentions and references broken, as Markdown body text describes. - An archive path: relative, with no empty part, no
..and no backslash.writeArchiverefuses any other path, andreadArchiverefuses an archive with a file under one. A folder entry (a name ending in/, which zip tools write for a folder) is left out of whatreadArchiveanswers. - An archive's size (
readArchive): each file's size is added to a running total before the file is inflated, and a total pastmaxInflatedBytesthrows aRangeError, so a small archive that declares or holds gigabytes is never inflated in full. The default is 52,428,800 bytes (50 MiB); pass a larger bound for an archive known to hold more, as inreadArchive(bytes, { maxInflatedBytes: 512 * 1024 * 1024 }). An archive whose files claim more compressed bytes than it holds, as files that share one stretch of data do, throws aRangeErrortoo. The bound is on the bytes held, not on the time a read takes.
Writing an archive entry by entry
With options, writeArchive(entries, options) answers a promise and writes the zip one entry at a time, so a long export can show its progress and be stopped:
import { ArchiveError } from '@framers/agentos-ext-document-export/archive';
import { writeArchive } from '@framers/agentos-ext-document-export/transcript';
const controller = new AbortController(); // controller.abort() from a Stop key
try {
const zip = await writeArchive(entries, {
onEntry: (index, path) => console.log(`${index + 1} of ${entries.length}: ${path}`),
signal: controller.signal,
});
} catch (error) {
if (!(error instanceof ArchiveError && error.reason === 'stopped')) throw error;
// Stopped after the entry in hand; no archive was handed back.
}
| Option | What it does |
|---|---|
onEntry(index, path) | Called once as each entry has been written, with its index in entries and its path, in order |
signal | Read before each entry and once after the last. Once it is aborted, the write stops after the entry in hand and the promise rejects with ArchiveError, reason stopped, handing back no archive |
mtime | The modification time written on every entry, milliseconds or a Date from 1980 to 2099, so the same entries give the same bytes. Default: the clock's, as writeArchive(entries) writes |
Between two entries the write waits for a timer (setTimeout(fn, 0)), so a message sent to the thread, a worker's stop among them, is handled after the entry in hand. Every path is checked before the first entry is written, and a bad one, or one given twice, rejects the promise before onEntry is called; writeArchive(entries) keeps the last entry's data for a path given twice. With no options, writeArchive(entries) is the synchronous call above, unchanged. The two forms write different bytes for the same entries: the entry-by-entry form gives each entry's sizes in a data descriptor after its data (APPNOTE 4.3.9), as a streaming writer does, and readArchive and openArchive read both alike.
Archives (browser-safe)
@framers/agentos-ext-document-export/archive reads a ZIP too large to inflate whole, such as a folder of notes chosen in a phone's browser. openArchive lists the entries from the archive's central directory and inflates none of their data; each entry's bytes() reads that entry alone from its own slice of the Blob.
import { ArchiveError, openArchive } from '@framers/agentos-ext-document-export/archive';
const archive = await openArchive(file, { maxEntries: 10_000, signal });
try {
for (const entry of archive.entries) {
if (entry.kind !== 'file' || entry.unsafe) continue;
const bytes = await entry.bytes({ maxBytes: 10 * 1024 * 1024, signal });
// one entry's bytes, checked against its recorded length and CRC-32
}
} catch (error) {
if (error instanceof ArchiveError && error.reason === 'size') {
// the entry's recorded size is more than maxBytes
}
throw error;
} finally {
archive.close();
}
| Option | Where | What it bounds |
|---|---|---|
maxEntries | openArchive | The entries the archive may list, checked on the end record before the directory is read. Default: none |
maxDirectoryBytes | openArchive | The directory's size, checked before it is read. Default: 64 MiB (MAX_DIRECTORY_BYTES) |
maxBytes | bytes() | The entry's bytes. A recorded size past it is refused as size before any read. Each 8 KiB step's output is then counted before it is kept, and the step that takes the output past the recorded size is refused as format, so the output never passes maxBytes. Default: none; the recorded size alone then bounds what a read holds |
worker | bytes() | Inflate in fflate's worker (default) or, with false, on the calling thread. The worker is given the next step only after it has taken the one before. fflate makes its worker from a blob: URL, so a page whose Content Security Policy does not allow blob: workers reads with false |
signal | both | Stops the open or the read, at once even while a read of the Blob is pending or a step is on its way to the worker |
What an open reads
The open reads the archive's end and its directory, and inflates and returns no entry's data:
- With no archive comment: the last 22 bytes (the end record), the 20 bytes before them, where a ZIP64 locator sits (not read when fewer than 20 bytes come before the end record, as in an empty archive), and the directory.
- With a comment: the last 22 bytes, then the last 65,557 bytes at most (the longest comment and the end record), the 20 bytes before the end record and the directory. An archive shorter than 65,557 bytes is read whole at that second read, entries' data included.
- A ZIP64 archive adds its ZIP64 end record's first 56 bytes.
The directory is read in one slice, after its size is checked against maxDirectoryBytes.
Names and kinds
Each listing has path, size, compressedSize, method (0 stored, 8 DEFLATE), encrypted, kind and unsafe.
pathis the entry's name as text: UTF-8 when the entry's general-purpose bit 11 is set. Without it, the name of an Info-ZIP Unicode Path extra field (0x7075) whose CRC-32 matches the stored name; else the stored name as UTF-8 when its bytes are valid UTF-8, as many writers store it without the flag; else IBM code page 437, the ZIP format's original character set.pathis display text, never a path to resolve.kindisdirectoryfrom a trailing slash or a folder attribute,linkfrom Unix symlink bits, elsefile.encryptedis true when the entry's directory record sets general-purpose bit 0 (encrypted) or bit 6 (strong encryption). Such an entry is refused asmethodwhen read. Traditional encryption, as Info-ZIP'szip -ewrites it, keeps the method of the data under it, so its entries list method 8 withencryptedtrue: read both to leave out, before any read, every entry this reader refuses for how it is written.unsafemarks a path with an empty part,.,.., a backslash, a null character or a drive prefix: listed, never read.
What an archive must hold
ZIP64 archives are read; archives on more than one disk are not. The ZIP64 end record is read when its locator sits right before the end record, and its own size must be at least 44 bytes and end where the locator begins or before. Without a locator, the end record's numbers are taken as written, so an archive of exactly 65,535 entries, which fflate and Python's zipfile record as 0xFFFF without ZIP64, opens.
The directory holds the records the end record counts, then nothing or one digital signature record; any other bytes in it refuse the archive. An entry's local header must repeat its directory record's compression method, encryption bits and name, byte for byte, or the entry is refused before any of its data is read.
Reads
One entry of an archive is read at a time. close() stops an active read, ends its worker and refuses every later read. However a read ends (its last step, a refusal, the signal, close() or a failed read of the Blob), its worker ends with it. A stop that lands after a step is read and before the worker is given it refuses the read there as stopped, and the ended worker is given nothing more. A read stopped by its signal lets the archive go, so the next read runs; after close(), every later read is refused as stopped.
On the calling thread a read stops at the step that ends the entry's DEFLATE stream: fflate's inflater shows the end after each step, and nothing after that step is read or inflated.
fflate's worker cannot show the end, so a worker read whose steps inflate to nothing 16 times in a row (128 KiB) feeds the worker no more of the entry, and the worker is given at most 16 steps and one byte past the step that ends a stream:
- When the output is then the entry's whole recorded length, the worker is given the entry's next byte as its last step. A stream that has ended takes it without reading it, and the read is done with the stream inflated once, whatever follows it inside the entry's compressed size. A stream still going refuses the byte as cut short, unless that byte finishes the block the worker holds and the stream with it: one byte holds no DEFLATE block whole. An empty last step would not tell the two apart: an inflater that holds no input takes an empty last step as the stream's end, ended or not, and it holds none whenever the steps before it end exactly where a block ends on a byte boundary.
- Otherwise, or when the worker refuses that byte because the stream is still going, the worker is ended and the entry is read again on the calling thread.
A valid stream is read whatever empty blocks it holds, and a stream that gives more than the recorded size is refused as format on either thread. If the installed fflate's inflater does not show its state as fflate 0.8.3's does, a stream whose steps inflate to nothing 16 times in a row is finished there on the calling thread too, with the entry's next byte as its last step, and one still going is refused as format.
Every refusal is an ArchiveError whose message is fixed and never carries an entry's name or the Blob's own error:
reason | When |
|---|---|
format | Not a ZIP this reader opens: no end record, a malformed ZIP64 end record, numbers that do not fit the archive, bytes in the directory past its records other than one digital signature, entries whose data overlap, a missing local header or one that does not repeat its directory record, invalid DEFLATE, bytes that do not match the recorded length or CRC-32, or a Blob that could not hand back its bytes |
method | A compression other than stored or DEFLATE, or an encrypted entry |
size | The entry's recorded size is more than maxBytes |
path | The entry is unsafe |
entries | More entries than maxEntries |
directory_size | A directory larger than maxDirectoryBytes |
busy | Another entry of the same archive is being read |
stopped | The signal fired, or the archive was closed, a read of the Blob still pending included; for writeArchive with options, its signal fired before an entry or after the last |
A bound that is negative or not a number throws RangeError.
Calendar events (browser-safe)
@framers/agentos-ext-document-export/calendar writes one event as an iCalendar file and as the address of Google Calendar's new-event page. It imports no Node built-in and no package, so a page builds both offline. Neither writes to anyone's calendar: the person opens the file, or saves the event on Google's page.
import { googleCalendarAddress, icsEvent } from '@framers/agentos-ext-document-export/calendar';
const ics = icsEvent({
uid: '[email protected]',
title: 'Send the slides',
description: 'From the budget review: "I\'ll send the slides"',
start: '2026-11-12',
allDay: true,
stamp: new Date(),
});
const href = URL.createObjectURL(new Blob([ics], { type: 'text/calendar' }));
// a link with this href and download="send-the-slides.ics" saves the file
window.open(googleCalendarAddress({ title: 'Send the slides', start: '2026-11-12', allDay: true }), '_blank', 'noopener');
// https://calendar.google.com/calendar/render?action=TEMPLATE&text=Send%20the%20slides&dates=20261112/20261113
| Field | icsEvent | googleCalendarAddress | What it takes |
|---|---|---|---|
uid | UID | The event's identifier. Keep it the same for every file made for one event, so a calendar that opens a second file updates the event instead of adding another | |
title | SUMMARY | text | The title |
description, details | DESCRIPTION | details | Notes, written when not empty, line breaks kept |
start | DTSTART | dates | For a timed event, an instant: a Date, or an ISO 8601 date-time with its offset (2026-11-12T10:00:00-05:00), a fraction of a second of any length cut to the second when written (2026-11-12T15:00:00.123456+00:00). For an all-day event, its first day as YYYY-MM-DD |
end | DTEND | dates | In the form start takes. A timed event ends at it, and ends when it starts without it or when it falls in the second the event starts in. An all-day event covers it as its last day, and covers its first day alone without it |
allDay | Whole days rather than a time of day. Default false | ||
stamp | DTSTAMP | When the file was made, an instant | |
productId | PRODID | The product that made the file. Default -//Framers//AgentOS Document Export//EN |
The file follows RFC 5545:
- Lines end in CRLF and fold at 75 octets, each continuation starting with a space (section 3.1). A fold falls between characters, never inside one's UTF-8 bytes, so every line is valid UTF-8 and unfolding gives back the text whole.
- Text escapes
\,;and,with a backslash and writes each line break as\n(section 3.3.11). The control characters TEXT does not allow are dropped; a tab is kept. - A timed event's times are UTC date-times (
DTSTART:20261112T150000Z, section 3.3.5). A timed event given no end, or an end in the second it starts in, has noDTEND, which RFC 5545 reads as ending when it starts (section 3.6.1), since aDTENDmust be later thanDTSTART(section 3.8.2.2). - An all-day event's times are dates (
DTSTART;VALUE=DATE:20261112, section 3.3.4), and itsDTENDis the day after its last day, sinceDTENDis not inclusive (section 3.6.1).
The template address writes action=TEMPLATE, text, dates and details, with text and details percent-encoded. dates is two UTC date-times (20261112T150000Z/20261112T153000Z), the start twice for a timed event given no end or an end in the second it starts in, or for an all-day event two dates, the second the day after its last day (20261112/20261113). Google publishes no reference for these parameters. They are written as the Add Event to Calendar docs record them, read on 10 November 2026; those docs also say Google renders basic HTML in details.
Each helper throws a RangeError naming the field when a time is not in the form the event takes, a time falls outside the years 0 to 9999 or end falls in a second before the one start falls in, and icsEvent throws one when uid or productId is empty or holds only white space and control characters, which a TEXT value drops. A lone surrogate in a text is written as U+FFFD.
Markdown body text (browser-safe)
markdownText(text), exported from the package and from ./transcript, makes plain text safe to place in a Markdown body, such as an issue's, where it reads as written:
import { markdownText } from '@framers/agentos-ext-document-export/transcript';
markdownText('# x [y](z) @a #12');
// '\\# x \\[y\\](z) @a #12'
// GitHub shows "# x [y](z) @a #12" with no heading, link, mention or reference
- CommonMark marks are escaped with a backslash:
\ ` * _ [ ] < & ~ |anywhere, and a line's first#,>,+,=or-and an ordered list marker (1.,12)). Nothing starts a heading, a list, a quote, a thematic break, a table, a link, an image, HTML, an entity, a code span or emphasis. - GitHub links mentions and references after it reads the Markdown, and neither a backslash nor an HTML entity stops it, as its Markdown API rendered on 10 November 2026. A zero-width space (U+200B), which shows nothing, goes after the
@of a mention or an address, the#of#12andowner/repo#12, theGH-ofGH-12, theissues/,pull/ordiscussions/before a number, the colon ofhttps://, thewwwofwww.and the opening colon of an emoji shortcode (:tada:), which GitHub draws as an emoji whatever comes before it. None of them then links or draws, and no one is notified. - Line breaks are written as LF, and each line's indentation is dropped, since four spaces start a code block, where escapes show.
Two things still render on GitHub. A bare commit hash links to its commit when the repository holds one. Text between two dollar signs can render as math, and a backslash does not stop it there: GitHub's math page asks for a <span> around such a dollar sign. A reader who copies the text copies the zero-width spaces with it.