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 nine scopes this page builds on.
The contract
An addon's content is a flat set of scripts — .ts / .tsx files, all in
one simulated directory. Import a sibling as ./its-name (no extension, no
subdirectories). The limits: ≤ 512 KB per script, ≤ 32 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 three 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.
Allowed imports — nothing else exists
There is no npm install. The build rejects any import outside this
allowlist:
reactreact-dom/client@dalea/ui/primitives,@dalea/ui/components,@dalea/ui/blocks— each subpath you use must be declared inmanifest.ui. 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— types only (AddonMount,AddonProps,AddonDaleaClient).
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, { values: { name: 'S-001' } });
await dalea.data.objects.update(objectId, { values: { 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 nine 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. esbuild strips types, so type errors don't block the bundle — but they usually predict runtime bugs, so fix them.
- Installed marketplace addons are read-only until you detach them — see Publishing & installing addons.