View as Markdown

5. Building tools for LeavenIT

This chapter is for the AI that writes a tool, and for the person who asks it. A LeavenIT tool is one self-contained HTML file. When it runs inside LeavenIT, it gets an object window.leavenit to save data and to work with tables. Outside LeavenIT, the same file must still work.

5.1 The rules of the tool page

LeavenIT runs every tool in a sealed frame on another domain (leavenit-tools.com). What this means for the code:

Rule Detail
One HTML file HTML, CSS and JavaScript in the same file. No local files next to it.
No network fetch, XMLHttpRequest, WebSocket, EventSource and WebRTC don't work. The tool can't call any API. Data comes from the person, from window.leavenit, or from files they pick.
Libraries from 4 CDNs only <script src> from https://cdnjs.cloudflare.com, https://cdn.jsdelivr.net, https://unpkg.com, https://esm.sh. Styles from cdnjs, jsdelivr and https://fonts.googleapis.com (fonts from fonts.gstatic.com).
Images only data: and blob: (embed small images as data URLs, or draw them with SVG/canvas). Remote image URLs are blocked.
No dialogs alert() shows a small notice, confirm() always returns false, prompt() returns null. Show messages and confirmations inside the page instead.
No Web Workers new Worker() is blocked. Do the work on the main thread, in small steps if needed.
Forms <form action> is blocked; handle submit events in JavaScript.
Downloads a link <a download href="blob:…"> or a data URL works (20 MB maximum per file).
localStorage works, and LeavenIT saves it for the person automatically. Prefer window.leavenit for anything that matters (versions, sharing).

5.2 Saving data: window.leavenit

const lv = window.leavenit;   // undefined when the file is opened outside LeavenIT

All methods return Promises.

Method Does
lv.get(key, options?) the saved value, or null
lv.set(key, value, options?) saves a value (anything JSON can hold: 1 MB maximum for personal data, 256 KB for shared data)
lv.delete(key, options?) removes it
lv.list(prefix?, options?) every key starting with prefix: [{ key, value, updated_at }] (personal) or [{ key, value, version, updatedAt, updatedBy }] (shared)
  • Keys: 1 to 200 characters. Use prefixes to group them: notes/2026-10-27, settings.
  • Pace: about 20 requests per second at most; beyond, calls fail with rate_limited. Save on change with a short delay, not on every keystroke.
  • Never rename a key once people use the tool: their data stays under the old key. Add a schema_version field inside values to migrate formats.
  • lv.connected is true when the person is signed in and the data is saved on LeavenIT; false for a visitor (data then lives in memory only, until the page closes).
  • lv.version is the version of this interface (currently 1).

Personal and shared data

By default, data belongs to the person (scope: 'me'). When the tool is opened inside a shared folder, it can also use the data of that folder, shared by its members:

await lv.set('menu', items, { scope: 'shared' });
const menu = await lv.get('menu', { scope: 'shared' });

lv.context tells where the tool is opened:

// { type: 'me' }   or
// { type: 'group', group: { id, name, role, memberCount }, space: { id, name }, permission: 'read' | 'write', canWrite: true | false }
if (lv.context.type === 'group' && !lv.context.canWrite) hideEditButtons();

Not overwriting someone else's change

For shared values that several people edit (these options apply to scope: 'shared' only), read with withMeta, then write with ifVersion:

const cur = await lv.get('menu', { scope: 'shared', withMeta: true });   // { value, version, updatedAt, updatedBy }
try {
  await lv.set('menu', updated, { scope: 'shared', ifVersion: cur.version });   // returns { version }
} catch (e) {
  if (e.code === 'conflict') showMessage('Someone changed the menu. Reload to see their version.');
}

For shared data, lv.list(prefix, { scope: 'shared', limit, after }) pages through long lists (limit up to 1000, after = the last key received).

The fallback to use in every tool

const store = window.leavenit ?? {
  get: async (k) => JSON.parse(localStorage.getItem(k) ?? 'null'),
  set: async (k, v) => localStorage.setItem(k, JSON.stringify(v)),
  delete: async (k) => localStorage.removeItem(k),
  list: async (p = '') => Object.keys(localStorage).filter((k) => k.startsWith(p)).map((k) => ({ key: k, value: JSON.parse(localStorage.getItem(k)) })),
};

Errors (e.code, e.message)

Code Meaning
no_group_context scope: 'shared' used outside a shared folder
not_member, forbidden the person lost access to the folder
not_linked the tool isn't linked to this data area
read_only the person (or this tool) can only read here
space_suspended the folder is scheduled for deletion
conflict the value changed since it was read (ifVersion)
quota_exceeded the data area is full
rate_limited too many requests: wait a moment
invalid_key, invalid_value key not 1–200 characters, or value not savable (or over 1 MB)

5.3 Tables: window.leavenit.sql

For large data (thousands to millions of rows), tools use tables. window.leavenit.sql exists only when tables are available on the person's account: always check it.

const sql = window.leavenit?.sql;
if (!sql) showMessage('Tables are not available here.');
Method Returns
sql.tables() the tables of the data area: names, rows, columns, last update
sql.preview(table) the first 100 rows (fast, nothing to download)
sql.query(table, select, params?) runs one read-only SQLite query; inside it, the table is always called data
sql.importFile(file, { table, mode }) imports a CSV file the person picked; mode: 'replace' (default) or 'add'; allowDuplicate: true to import the same file twice
sql.export(table, { format, separator }) downloads the table: format 'csv' (default) or 'sqlite'; separator ',', ';' or '\t'
sql.undo(table) goes back to the previous version of the table
sql.aiContext({ inputs, output, sampleRows }) a text describing tables and rules, to give to an AI that writes SQL
sql.blocks.list() / get(id) / save(block) / history(id) / restore(id, version) / runs(id, limit) / run(id, { dryRun }) the SQL blocks of the data area
// A typical read: the 10 best sellers, with a parameter
const r = await sql.query('orders', 'select item, sum(qty) as total from data where shop = ? group by item order by total desc limit 10', [shop]);

Table names: lowercase letters, digits and _, starting with a letter (orders_2026).

The SQL dialect (SQLite 3.49)

  • The whole block script is one transaction: all or nothing.
  • Allowed: SELECT, WITH, VALUES, INSERT, UPDATE, DELETE, REPLACE, CREATE TABLE / INDEX / VIEW, DROP, ALTER, ANALYZE, EXPLAIN. Refused: PRAGMA, ATTACH, DETACH, VACUUM, transactions, triggers, virtual tables.
  • Imported columns are text, and empty cells are '', not NULL: use nullif(x, '').
  • Clean values with the functions LeavenIT adds: to_number(x) ('1 250,50 €' → 1250.5, 'n/a' → NULL), to_date(x) (dd/mm/yyyy, yyyy-mm-dd, '4 Mar 2026' → 'yyyy-mm-dd'), upper(x) and lower(x) (Unicode), regexp_replace(text, pattern, replacement) (JavaScript regular expressions).
  • Don't clean with CAST: CAST('n/a' AS INTEGER) gives 0 silently. Use to_number.
  • 7/2 gives 3: write 7*1.0/2. Always add ORDER BY after GROUP BY. No ILIKE (use lower(x) LIKE …), no ::, no QUALIFY, no try_cast. Window functions, WITH and FULL JOIN work.

Table errors

Code Message shown
sql_syntax The SQL query has an error.
sql_not_allowed This SQL statement is not allowed here.
sql_timeout Time limit reached. The query was stopped.
sql_out_of_memory, sql_too_large the table is too large for this device or plan
sql_conflict someone changed the table at the same time: try again
sql_duplicate_file this file was already imported into this table
sql_import_empty, sql_import_encoding empty file, or mixed encodings: save it as UTF-8
nothing_to_undo there is no earlier version
not_found the table or block doesn't exist in this data area
read_only, forbidden, not_linked the person (or the tool) can't write or can't reach this data area
table_expired, input_expired a temporary table expired: run the block that makes it
upload_failed, download_failed, checksum_mismatch transfer problem: check the connection and try again

5.4 The prompt to give your AI

People can paste this into the AI that writes their tool:

Write a single self-contained HTML file. It must work offline and without any network request. Libraries only from cdnjs, jsdelivr, unpkg or esm.sh. No remote images. Don't use alert(), confirm() or prompt(): show messages inside the page. To save data, use window.leavenit if it exists (async get(key, options), set(key, value, options), delete(key, options), list(prefix, options)), otherwise localStorage. Never rename existing data keys. For data shared with a group, use { scope: 'shared' }, check window.leavenit.context.canWrite before showing edit buttons, and pass { ifVersion } when updating a shared value. For large data, use window.leavenit.sql if it exists (tables, preview, query with the table always called data, importFile, export, undo); its SQL dialect is SQLite 3.49 with to_number, to_date and regexp_replace.