Canvas

Exports

Every canvas can leave the platform. PNG for sharing in a deck or a chat thread. PDF for paper or attaching to an email. Save to Brain for keeping the artefact alongside the user's other memories where future skills can read it. The exporter renders the same JSX the chat surface renders, then serialises it through the right pipeline.

Updated today

Overview

At a glance
Picker component
src/components/canvas/CanvasExportPicker.tsx
Render API
POST /api/canvas/export
Targets
PNG, PDF, Save to Brain
Render engine
Headless Chromium via Playwright in the sandbox
PNG DPR
2 by default (retina sharp)
Brain ingest
PDF → POST /api/brain/files

Exports use the same renderer the chat surface uses. The JSX that draws the canvas inline is the JSX that gets rasterised when you press export. There is no parallel rendering path that could drift; if the canvas looks right inline, it exports right.

Three targets

One picker, three destinations.

TargetWhat you getWhere it goes
PNGA 2x-DPR raster image of the canvas at its native rendered sizeDownloaded to the user's machine
PDFA single-page PDF with the canvas rendered at full width on letter paperDownloaded to the user's machine
Save to BrainSame PDF, pushed straight into the user's brain files surfaceBecomes searchable memory after chunking and embedding

PNG export

The picker calls the export endpoint with target=png. The endpoint renders into an offscreen container in the sandbox and takes a Playwright screenshot.

src/app/api/canvas/export/route.tsts
1// target=png
2const browser = await playwright.chromium.launch()
3const ctx = await browser.newContext({ deviceScaleFactor: 2, viewport: { width: 1280, height: 720 } })
4const page = await ctx.newPage()
5await page.setContent(htmlForCanvas(spec))
6await page.evaluate(() => document.fonts.ready)
7const buffer = await page.screenshot({ type: "png", fullPage: true })
8await browser.close()
Note
The screenshot waits for document.fonts.ready before firing so custom fonts have actually loaded. Without that wait the screenshot occasionally captures a fallback font.

PDF export

Same render, different serialisation. PDF goes through Playwright's print API.

src/app/api/canvas/export/route.tsts
1// target=pdf
2const buffer = await page.pdf({
3 format: "Letter",
4 printBackground: true,
5 margin: { top: "0.5in", right: "0.5in", bottom: "0.5in", left: "0.5in" },
6})

Backgrounds are forced on because canvases use surface colours to communicate (status pills, table zebra stripes). The default Letter format with half-inch margins fits a single canvas comfortably without forced shrinking; the renderer chooses the layout that fits the page.

Save to Brain

The PDF gets pushed into the user's brain files so future skills can ground on it.

  1. 01
    Render PDF
    Same path as the PDF target. The buffer never lands on disk; it is streamed directly into the upload step.
  2. 02
    Upload to /api/brain/files
    The endpoint accepts the buffer plus a generated filename (canvas-<skill_id>-<timestamp>.pdf) and inserts a brain file row with status processing.
  3. 03
    Async chunker
    The chunker parses the PDF, splits it into passages, embeds each one, and writes rows into file_embeddings. The file moves to status ready.
  4. 04
    Searchable
    The canvas content is now retrievable through brain_search_files. Skills that ground on the user's documents will find it.
Tip
Save to Brain is the right export when the canvas is a working artefact, not a deliverable. A weekly briefing or a market scan ages well in Brain because future runs can quote from it. A one-time deck or a presentation usually wants PNG or PDF.

The picker UI

A small dropdown on the canvas toolbar.

src/components/canvas/CanvasExportPicker.tsxtsx
1export function CanvasExportPicker({ spec }: { spec: CanvasSpec }) {
2 const [busy, setBusy] = useState(false)
3
4 const onExport = async (target: "png" | "pdf" | "brain") => {
5 setBusy(true)
6 const res = await fetch("/api/canvas/export", {
7 method: "POST",
8 body: JSON.stringify({ spec, target }),
9 })
10 if (target === "brain") return
11 const blob = await res.blob()
12 triggerDownload(blob, suggestedFilename(spec, target))
13 setBusy(false)
14 }
15
16 return (
17 <DropdownMenu>
18 <Trigger disabled={busy}>Export</Trigger>
19 <Item onSelect={() => onExport("png")}>PNG</Item>
20 <Item onSelect={() => onExport("pdf")}>PDF</Item>
21 <Item onSelect={() => onExport("brain")}>Save to Brain</Item>
22 </DropdownMenu>
23 )
24}

Behind the scenes

The export endpoint runs inside the user's isolated per-conversation sandbox so heavy Playwright work does not block the containerized cloud runtime that serves chat.

Headless Chromium plus Playwright is heavyweight to spin up. Doing it inside the same containerized cloud runtime that serves chat requests would steal memory from interactive turns. Instead the export endpoint pushes the render job into the user's sandbox where Playwright is already installed (the sandbox has all the system libraries pre-baked from the workspace template) and the result streams back to the caller.

If the sandbox is not warm at export time, the endpoint spawns one for the render and tears it down afterwards. The first export of a fresh session pays the warm-boot cost; subsequent exports are fast.

File map

src/components/canvas
CanvasBlock.tsx
CanvasExportPicker.tsxthe dropdown on the canvas toolbar
src/app/api
canvas
export/route.tsPNG, PDF, Brain export endpoint
brain
files/route.tscalled by Save to Brain to upload the rendered PDF

Glossary

Export
The act of taking a canvas out of the chat surface, either by downloading it or by saving it into the user's Brain.
Target
Which of the three destinations an export goes to: PNG, PDF, Save to Brain.
DPR
Device pixel ratio. PNG exports render at 2x so the result is sharp on retina displays.
Save to Brain
An export that pushes the rendered PDF into the user's brain files where the chunker makes it searchable.
Headless Chromium
The browser engine the exporter uses to render the canvas JSX. Driven by Playwright inside the user's sandbox.