DeepBotDownload

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 as application/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-face and relative URLs;

  • <video> and <audio> with deepbot.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 }). Write text as the person would say it (it shows in the conversation); put the exact steps in instructions ("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 through deepbot.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.