Patterns for big apps
How the large DeepBot apps (a presentation studio, a tutor with lessons and simulations, a site builder that publishes, a video clipper) are built. Use these when the app is more than one card.
#1. Shape of a big app
ui/
lib/ shared ES modules: the data model, rendering, helpers
model.js read, normalise, save the app's files (the one place that knows the format)
render.js turning data into HTML/SVG (used by cards AND pages)
files.js downloads, base64, exports
vendor/ third-party libraries, as files, with their LICENSE files
fonts/ .woff2 files and their licences
card-thing.html/.js one HTML + one JS per card
main.html/.js one HTML + one JS per page
app.css the app's own design on top of the kit variables
skill/
SKILL.md the workflow
format.md the full file format, read by the Bot when it needs it
scripts/ the heavy work
tests/ unit tests for ui/lib (bun test + happy-dom)Cards and pages are separate documents: share code through ui/lib/*.js modules
(<script type="module" src="main.js">, import { readDeck } from "./lib/model.js").
#2. A data format the Bot writes and the app trusts
The Bot writes JSON; the app shows and edits it. One module owns the format: every read goes
through normalise, so a missing field or a wrong type never breaks the UI, and an older version
of the format still opens.
// ui/lib/model.js
export const ROOT = "recipes";
export const FORMAT = "recipe-box.recipe/v1";
export function normalise(raw, slug) {
const r = raw && typeof raw === "object" ? structuredClone(raw) : {};
return {
format: FORMAT,
slug: r.slug || slug,
title: String(r.title || "Untitled recipe"),
servings: Math.max(1, Math.round(Number(r.servings) || 4)),
ingredients: (Array.isArray(r.ingredients) ? r.ingredients : [])
.filter((i) => i && i.item)
.map((i) => ({ item: String(i.item), amount: Number(i.amount) || null, unit: String(i.unit || "") })),
steps: (Array.isArray(r.steps) ? r.steps : []).map(String),
tags: (Array.isArray(r.tags) ? r.tags : []).map(String),
};
}
export async function readRecipe(slug) {
return normalise(await deepbot.files.read(`${ROOT}/${slug}.json`, "json"), slug);
}
export async function saveRecipe(recipe) {
await deepbot.files.write(`${ROOT}/${recipe.slug}.json`, JSON.stringify(recipe, null, 2));
}
export async function listRecipes() {
const { items } = await deepbot.files.list(ROOT);
const out = [];
for (const f of items.filter((i) => !i.dir && i.name.endsWith(".json"))) {
try { out.push({ ...(await readRecipe(f.name.slice(0, -5))), modified: f.modified }); }
catch { /* a broken file does not hide the others */ }
}
return out.sort((a, b) => b.modified - a.modified);
}Put the same format, with an example, in the skill (skill/format.md), and keep the two in step.
One folder per item when an item has more than one file (decks/<slug>/deck.json +
decks/<slug>/images/), one file per item otherwise.
#3. Editing: autosave and undo
let doc = await readRecipe(deepbot.args.slug);
const history = [JSON.stringify(doc)];
let at = 0;
let timer = null;
function change(mutate) {
mutate(doc);
history.splice(at + 1);
history.push(JSON.stringify(doc));
at = history.length - 1;
draw();
clearTimeout(timer);
timer = setTimeout(() => saveRecipe(doc), 600); // autosave, debounced
}
function undo() { if (at > 0) { doc = JSON.parse(history[--at]); draw(); saveRecipe(doc); } }
function redo() { if (at < history.length - 1) { doc = JSON.parse(history[++at]); draw(); saveRecipe(doc); } }
document.addEventListener("keydown", (e) => {
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === "z") { e.preventDefault(); e.shiftKey ? redo() : undo(); }
});The Bot may change the same file while the page is open. Watch it and reload when it changed and you have no unsaved edits:
let seen = 0;
setInterval(async () => {
if (document.hidden) return;
const { items } = await deepbot.files.list("recipes");
const f = items.find((i) => i.name === `${doc.slug}.json`);
if (f && f.modified > seen + 1500 && !timer) { seen = f.modified; doc = await readRecipe(doc.slug); draw(); }
}, 2000);(Set seen = Date.now() after your own saves.)
#4. The person's own files: upload, drop, paste
async function fileToBase64(file) {
const buf = new Uint8Array(await file.arrayBuffer());
let s = "";
for (let i = 0; i < buf.length; i += 0x8000) s += String.fromCharCode(...buf.subarray(i, i + 0x8000));
return btoa(s);
}
// shrink big photos before saving (keeps the workspace and the Bot's context small)
async function shrink(file, max = 2000) {
const img = await createImageBitmap(file);
const k = Math.min(1, max / Math.max(img.width, img.height));
const c = new OffscreenCanvas(Math.round(img.width * k), Math.round(img.height * k));
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
return c.convertToBlob({ type: "image/jpeg", quality: 0.86 });
}
async function addPicture(file) {
const blob = file.size > 1.5e6 ? await shrink(file) : file;
const name = `${Date.now().toString(36)}.${blob.type === "image/png" ? "png" : "jpg"}`;
await deepbot.files.write(`recipes/images/${name}`, await fileToBase64(blob), { encoding: "base64" });
return `recipes/images/${name}`;
}
input.addEventListener("change", () => [...input.files].forEach(addPicture));
zone.addEventListener("dragover", (e) => e.preventDefault());
zone.addEventListener("drop", (e) => { e.preventDefault(); [...e.dataTransfer.files].forEach(addPicture); });
document.addEventListener("paste", (e) => [...e.clipboardData.files].forEach(addPicture));Show pictures with img.src = deepbot.files.url(path), never as base64 in the page.
#5. Exports and downloads
Downloads work from the frame:
export function download(blob, name) {
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), { href: url, download: name });
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 30000);
}
download(new Blob([csv], { type: "text/csv" }), "shopping-list.csv");Also save exports into the workspace (files.write with base64) when the Bot should be able to
send or attach them. For rich formats, bundle a library in ui/vendor/ (for example a PPTX or
PDF writer, an HTML-to-image renderer, a ZIP writer) and keep its licence file next to it.
#6. Rich rendering
All of this works inside the frame:
SVG and Canvas 2D for charts, diagrams, covers, editors;
WebGL / WebGL2 (three.js or similar, as a file in
ui/vendor/), loaded as an ES module;WebAssembly:
WebAssembly.instantiateStreaming(fetch("./lib/engine.wasm"))(served asapplication/wasm);Web Workers from a Blob URL (a worker from a file URL is refused in the locked frame):
const code = await (await fetch(new URL("./lib/worker.js", import.meta.url))).text(); const worker = new Worker(URL.createObjectURL(new Blob([code], { type: "text/javascript" })));fonts as files with
@font-faceand relative URLs;<video>and<audio>withdeepbot.files.url(path)(seeking works).
Not available: localStorage, sessionStorage, IndexedDB (they throw), alert/confirm/prompt,
popups, the clipboard API (navigator.clipboard is refused: select the text for the person, or
offer a download), camera and microphone.
#7. Talking with the Bot from the app
- A card's button that needs the Bot:
deepbot.ask(text, { instructions }). Writetextas the person would say it (it shows in the conversation); put the exact steps ininstructions("Rewrite decks/q3/deck.json in place, keep the theme, then call showDeckStudioDeck"). - A page's "Ask the Bot" starts a new conversation: give enough context in the text ("In my Recipe box, plan dinners for next week from the recipes tagged quick").
- The Bot to the app: files, and card arguments. A card can open a page on exactly the right item
with
deepbot.openPage(page, params). - Let the Bot know what happened in the card:
deepbot.done("The person chose option B")is read by the Bot before it answers.
#8. Long jobs
The script writes progress.json in the job folder:
{ "state": "running", "percent": 42, "doing": "Rendering clip 3 of 8", "updated": "2026-10-10T12:00:00Z" }and the page shows every job it finds, every second or two, while it is visible:
async function poll() {
if (!document.hidden) {
const { items } = await deepbot.files.list("clips");
for (const job of items.filter((i) => i.dir)) {
const p = await deepbot.files.read(`${job.path}/progress.json`, "json").catch(() => null);
if (p) drawJob(job, p);
}
}
setTimeout(poll, 1500);
}
poll();A Stop button writes cancel into the job folder; the script checks for it between steps.
#9. Web services and keys
- Hosts in
permissions.network; every call throughdeepbot.net.fetch(requests from the page straight to other hosts are not allowed and are rejected in review). - Keys: a settings panel saves them with
deepbot.secrets.set; requests use{{secret:name}}. Show which keys are set (secrets.list), never their values. - Handle 401/403 (key missing or wrong: open the settings), 429 (wait and retry with backoff), pagination, and timeouts with clear messages.
- Sign-in flows that need a browser:
deepbot.open(url)to the provider's page where the person creates a token, then paste it into your settings.
#10. Per-person state
deepbot.storage for what belongs to the person and not to the shared files: the open tab,
filters, a draft, progress through a course, stars. It is per app and per person, up to 2 MB.
Shared content (what the Bot writes and reads) goes in files.
#11. Testing
Keep logic in ui/lib/*.js pure (no deepbot calls in the parts you test), and test it with
bun test (add happy-dom for DOM code). For the parts that call the bridge, pass a fake:
globalThis.deepbot = {
files: { read: async (p, as) => fixtures[p], write: async () => ({ ok: true }), list: async () => ({ items: [] }) },
storage: { get: async () => null, set: async () => ({ ok: true }) },
};Then test the whole app by hand in developer mode (publish.md has the checklist).
#12. Performance
- Draw first, then load the rest: a card shows a skeleton at once, then the data.
- Lazy-load heavy libraries (
await import("./vendor/three.module.js")) only when needed. - Images through
files.url, sized to their box; thumbnails for grids. - Do not poll when
document.hidden.