Authoring an addon
Scripts, the manifest, scopes, external entities, and the capability proxy.
You author an addon in the app at /addons/{id} (Edit → Build & Run → Save
version), or over the API: POST /api/v1/addons to create, then edit its
scripts, build, and map its entities. AI assistants author through the same
surface with the addons_read / addons_write MCP tools. This page is the
concept tour; the API reference at /api-docs is the exhaustive,
always-current catalogue — including the full list of dalea.* methods.
If you haven't yet, read Addons first — it explains the sandbox and the eleven scopes this page builds on.
The contract
An addon's content is a source tree of .ts / .tsx files. Paths may nest
up to 3 directories deep (src/ui/panel.tsx), and directories are implicit, so
there is nothing to create. Import a sibling as ./its-name (no extension),
across folders with ordinary relative paths (../lib/util), or from the addon
root with the @/ alias (@/lib/util). The limits: ≤ 512 KB per script,
≤ 128 scripts, ≤ 2 MB combined.
The entry script (manifest.sourceEntry, default index.tsx) must
default-export a mount function that renders into the given element and
returns an unmount cleanup:
import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import type { AddonMount, AddonProps } from '@dalea/addon-sdk';
function App({ dalea }: AddonProps) {
const [envs, setEnvs] = useState<{ id: string; name: string }[]>();
const [error, setError] = useState<string>();
useEffect(() => {
let alive = true;
dalea.data.environments.list()
.then((r) => alive && setEnvs(r.environments))
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
return () => { alive = false; };
}, [dalea]);
if (error) return <div style={{ color: 'hsl(var(--destructive))' }}>Failed: {error}</div>;
if (!envs) return <div>Loading…</div>;
return <ul>{envs.map((e) => <li key={e.id}>{e.name}</li>)}</ul>;
}
const mount: AddonMount = (el, props) => {
const root = createRoot(el);
root.render(<App {...props} />);
return () => root.unmount();
};
export default mount;
The mount function receives four props:
- dalea
- The capability proxy. Every call is async (it crosses a postMessage bridge) and mirrors the public SDK.
- config
- The per-instance configuration a user filled in, driven by
manifest.configSchema. Read keys defensively —config.environmentIdmay be undefined. - grantedScopes
- The scopes the user actually consented to (may be narrower than the manifest). Degrade gracefully when one is missing rather than crashing.
- state
- Host-persisted per-instance UI state: what the user did, such as selections, filters and sort, never fetched rows. It exposes
get(),set(),subscribe()and areadonlyflag. No scope is required; the host persists it wherever the addon runs, debounced, up to 64 KiB of serialised JSON.
Allowed imports — nothing else exists
There is no npm install. The build rejects any import outside this
allowlist, and specifiers are exact — a subpath the import map does not
list (bare react-dom, @dalea/ui/components, three/addons/*) fails the
build.
react, andreact/jsx-runtime(JSX resolves it for you)react-dom/client@dalea/ui/primitives— declare"ui": ["primitives"]in the manifest. Prefer these over hand-rolled markup: they carry the platform's theme, spacing and accessibility, and follow the host's light/dark/brand theme automatically.@dalea/addon-sdk: theAddonMount,AddonPropsandAddonDaleaClienttypes, plus runtime helpers:AddonImage,useFilePreviewanduseFileTextfor reading stored files,createAddonState/useAddonState/useSharedState, and theAddonErrorclass.three— three.js core only, for WebGL rendering into a canvas you create.@dalea/units— the UCUM unit registry:parseUnit,canonicalizeUnit,convert,formatMeasure,suggestUnits. Convert through it rather than hardcoding factors; column units and inventory quantities are canonical UCUM codes.
The iframe's CSP blocks all fetch/XHR, and URL / data:
/ node: imports, filesystem traversal, and eval /
new Function are all rejected. If the addon needs data it comes
through dalea; if it needs an outside resource, it can't have it.
The capability proxy
dalea is the entire callable surface. Its methods mirror the public SDK —
arguments are positional (id, body, params) and every call returns parsed
JSON. Anything not on the surface throws ADDON_SCOPE_UNKNOWN; a listed call
without its scope granted throws ADDON_SCOPE_DENIED; bursts are throttled with
ADDON_RATE_LIMITED.
// data.read
const { environments } = await dalea.data.environments.list();
const { objects } = await dalea.data.tables.listObjects(tableId, { limit: 50 });
// data.write — note the deliberate asymmetry
await dalea.data.tables.createObject(tableId, { data: { name: 'S-001' } });
await dalea.data.objects.update(objectId, { data: { status: 'passed' } });
await dalea.data.objects.archive(objectId); // there is no updateObject / deleteObject
Create a row with tables.createObject, update with objects.update,
remove with objects.archive (undo via restore). The asymmetry is
intentional and consistent across domains. The full method list — every path
with its required scope — is in the API reference, and an assistant
can fetch it live via GET /api/v1/addons/capabilities.
External entities — the process.ext contract
An addon must never hardcode a workspace id. Instead it references entities through manifest-declared variables, substituted at build time:
const tableId: string = process.ext.SAMPLES_TABLE; // a real id, injected at build
const { objects } = await dalea.data.tables.listObjects(tableId);
Every process.ext.NAME you use must be declared under
manifest.externalEntities. The build errors on an undeclared name, on a raw
UUID literal anywhere in source, and on computed access
(process.ext[whatever]). This is exactly what makes an addon portable: when
it's shared to another workspace, the installer re-maps each variable to their
entities — and the description you write is the only context they get, so
phrase it as an instruction. entityId: null means "declared but not mapped
here" — the standard state right after installing a shared addon. It blocks the
build (issue code UNMAPPED), and the entity's domain must be covered by a
declared scope (a data_table needs a data.* scope).
The manifest
{
"apiVersion": "addon/v1",
"sourceEntry": "index.tsx",
"scopes": ["data.read"],
"externalEntities": {
"SAMPLES_TABLE": {
"entityId": null,
"entityType": "data_table",
"description": "The table holding the samples this addon tracks"
}
},
"ui": ["primitives"],
"surfaces": ["page", "block"],
"configSchema": {
"environmentId": { "type": "string", "title": "Data environment", "description": "Optional — defaults to the first environment" }
},
"defaultHeight": 360,
"react": "19"
}
- sourceEntry
- Which script the build starts from (default
index.tsx). - scopes
- The subset of the eleven scopes the code calls. Declare the minimum: the build warns on undeclared usage, and every extra scope is one more consent the user must grant.
- externalEntities
- The
process.extdeclarations described above. - ui
- A subset of
["primitives", "components", "blocks"], covering every@dalea/ui/*subpath you import. - surfaces
pageand/orblock— where the addon may render.- configSchema
- Powers the per-instance config form; whatever the user fills arrives as
config.
You don't have to write all of this by hand. POST /api/v1/addons/{id}/suggest-manifest
statically analyses your code and proposes the scopes and external entities
it actually uses — review it, flesh out the descriptions, and apply.
Building & enablement
Two build endpoints: POST /api/v1/addons/{id}/build is the persisted build
(scripts + manifest + your entity mappings) that must be green for the addon to
render, and POST /api/v1/addons/build is a stateless compile check for a
quick "does this compile?" without saving. Read the errors literally:
- UNMAPPED
- Not a code bug — an external entity has no
entityIdin this workspace. Map it. - hardcoded UUID
- Move the literal id into an
externalEntitiesvariable. - scope errors
- The code calls a domain the manifest doesn't declare (or declares one it never calls). Adjust
scopes. - compile errors
- Ordinary TypeScript/JSX errors — fix and re-save.
Remember the enablement gate: an addon created over the
API or by an assistant starts disabled and won't run until a person clicks
Enable on /addons/{id}. Browser-authored addons are enabled at creation.
Widening the scope set later revokes enablement and requires a fresh approval.
- Always render loading and error states. Every
daleacall can reject — an addon that white-screens on the first rejection is undebuggable for its users. - Pace loops over
dalea. The per-instance rate limit is a token bucket; batch or sequence large fan-outs rather than firing hundreds of calls at once. - Type diagnostics are advisory, with one exception. esbuild strips types, so type errors don't block the bundle. The exception is a value import of a name that does not exist in
react,react-dom,@dalea/uior@dalea/addon-sdk: that is a guaranteed load-time crash, so it fails the build. Fix the rest anyway; they usually predict runtime bugs. - Installed marketplace addons are read-only until you detach them — see Publishing & installing addons.