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.environmentId may 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 a readonly flag. 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, and react/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: the AddonMount, AddonProps and AddonDaleaClient types, plus runtime helpers: AddonImage, useFilePreview and useFileText for reading stored files, createAddonState / useAddonState / useSharedState, and the AddonError class.
  • 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.
No external network, ever

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.ext declarations described above.
ui
A subset of ["primitives", "components", "blocks"], covering every @dalea/ui/* subpath you import.
surfaces
page and/or block — 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 entityId in this workspace. Map it.
hardcoded UUID
Move the literal id into an externalEntities variable.
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.

Gotchas worth internalising
  • Always render loading and error states. Every dalea call 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/ui or @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.

What's next