Skip to content

Building Your Own Tool

Michael Dohmen edited this page Aug 13, 2026 · 16 revisions

Building Your Own Tool

Everything domain-specific lives in one file: src/domain.js. Swap it, rebuild, done. This page is the detailed version of AGENTS.md — read that file if you're an AI assistant doing the swap; read this page if you want to understand the shape yourself.

The mental model

You are not building an app from scratch. You are swapping one file and rebuilding. Everything else — the table, the edit form, the sidebar filters, CSV export, the AI's instructions and the validation of what it proposes back — is generic and reads its shape from a single SCHEMA object.

What src/domain.js exports

Export Purpose
SCHEMA Field definitions plus presentation hints (see below)
uid() Generates a record id. Use a readable prefix (A- for action items, R- for risks, …)
emptyRecord() A blank record with sensible defaults, used when the user clicks "New …"
seed() 8–12 realistic demo records so the file isn't empty on first open
isDone(r) Record no longer counts towards the open total in the overview tile
isOverdue(r) Record is flagged in red in the table. Return false if the concept doesn't apply to your domain
formatDate(s) ISO date string (YYYY-MM-DD) to a display string

The SCHEMA shape

export const SCHEMA = {
  idField: 'id',
  singular: 'risk',            // used in buttons: "New risk"
  plural: 'risks',             // used in the overview tile
  titleField: 'name',          // leading column, rendered with emphasis
  subField: 'category',        // second line under the title, or null
  list: ['name', 'owner', 'review', 'likelihood', 'impact'],   // table columns, in order
  facets: ['likelihood', 'category'],   // enum fields that become sidebar filters
  search: ['id', 'name', 'owner'],      // fields the search box looks at
  totalField: 'impact',                 // number summed in the overview, or null
  fields: [
    { key: 'name', label: 'Risk', type: 'text', required: true },
    { key: 'category', label: 'Category', type: 'enum', values: ['Operational', 'Legal', 'IT'] },
    { key: 'review', label: 'Review date', short: 'Review', type: 'date' },
    { key: 'impact', label: 'Impact score', short: 'Score', type: 'number' },
    { key: 'mitigation', label: 'Mitigation', type: 'text', long: true },
  ],
}

Notes on the less obvious fields:

  • short is the table column header used when the full label would be too wide (e.g. label: 'Effort in days', short: 'D').
  • long: true renders a <textarea> in the edit drawer instead of a single-line input.
  • Field types are enforced when the AI proposes changes. An enum value outside values is rejected and reported in the proposal review, not silently coerced — so keep values accurate; it's also what gets sent to the model as the allowed set.
  • facets must be enum-type fields; they become the sidebar filter groups with live counts.
  • totalField must be a number-type field; it drives the "Open effort in days"-style tile in the overview and is excluded from the total once isDone(r) is true.

A complete worked example

examples/risk-register.domain.js in the repo is a full, working alternative domain (a risk register instead of action items) — copy it over src/domain.js and rebuild to watch the entire app change: different columns, different filters, different enum values, different seed data.

The five questions to ask before writing a domain

If you're an AI assistant (or a human) doing this for someone else, don't guess — ask, in one round:

  1. What is being tracked? One record — what is it called, singular and plural?
  2. Which fields? Name, type (text, enum, date, number), and for enums the allowed values.
  3. Which field is the headline in the list, and which fields belong in the table at all?
  4. Any "overdue" or "done" logic? A date that can lapse, a status that closes an item.
  5. Should the AI assistant be part of it? It ships switched off; the user can enable it later.

If the request already gave enough detail, skip the questions and state the assumptions made instead.

When you need more than domain.js

src/app.jsx is the only other file most tools should ever need to touch — and only for something the schema genuinely can't express: an extra sidebar section, a computed column, a different empty state. Everything else is already generic; resist the urge to touch it for anything schema can do.

Two things worth knowing if you do:

  • Default title, subtitle, file name, colours and product name live in DEFAULT_SETTINGS, DEFAULT_COLORS and DEFAULT_BRAND near the top of src/app.jsx. Set them to match the tool you're building — the user can change all of them later in Settings.
  • The interface language dictionary (src/i18n.js) is a separate, unrelated layer — see Interface Languages. It translates app chrome, never your schema's field labels or enum values, which stay exactly as you wrote them.

When the shape doesn't fit

openToolbox is a records-with-fields tool: a list, a form, filters. If the request is a calculator, a canvas, a diagram editor or a multi-step wizard, say so plainly rather than forcing an unrelated shape into the table. Two honest options: a stripped-down variant that keeps the file persistence, encryption and AI plumbing but replaces the list view, or a different starting point entirely.

Build and deliver

npm install
npm run build          # produces dist/index.html — one file, self-contained
npm test                # optional: headless smoke test, needs a Chromium download

Deliver dist/index.html, renamed to something meaningful. Worth telling whoever receives it:

  • double-click to open, no server needed
  • Ctrl/Cmd+S or the Save button writes a new HTML file containing the data
  • nothing is auto-saved; the amber dot in the top bar means unsaved changes
  • many mail gateways strip .html attachments — send it zipped

Rules that are not negotiable

These exist because they're exactly the mistakes that quietly break a single-file build, so an assistant (or you, six months later) doesn't have to rediscover them the hard way:

  • Never add a runtime dependency on the network. No CDN links, no web fonts, no external images. The file must work with the network cable pulled.
  • Never use localStorage or IndexedDB for the data. Both are unreliable under file:// — the embedded payload is the storage mechanism, on purpose.
  • Keep the build single-file. Don't add a second entry point; vite-plugin-singlefile supports exactly one.
  • Don't set removeViteModuleLoader: true in vite.config.js. It silently empties the inlined script and produces a ~9 KB file that renders nothing — see Limits and Troubleshooting.
  • Don't weaken the SVG sanitiser or the AI-action validation. Both exist because the output file gets passed around to people who didn't build it.

Clone this wiki locally