Last updated

Building an Undra extension

An extension is a single file that adds new functionality to Undra, a new kind of item with its own editor, a command, or a canvas widget, with no rebuild. This guide is written so an AI agent or a person can build a working extension from it alone.

Building one with an AI?

You only need The format and The ctx API below. Write one extension.js, drop it in the extensions folder, restart Undra. That’s it.

Quick start

  1. Write one extension.js following the format (copy the example and change it).
  2. Put it at <workspace>/.undra/extensions/<name>/extension.js.
  3. Restart (or reload) Undra. Your extension is live, its type appears in the explorer’s right-click New menu, opens in your editor, and saves.

The format

An extension is a pure ES module named extension.js with two exports: a manifest and an activate(ctx) function.

javascript
export const manifest = {
  id: 'publisher.name',          // dot-namespaced, at least 2 segments
  version: '0.1.0',
  displayName: 'My Extension',
  publisher: 'yourname',          // lowercase
  capabilities: ['itemTypes.registry'],
}

export function activate(ctx) {
  // register your contributions here using ctx (see "The ctx API")
  return function deactivate() {}  // optional cleanup, called on unload/reload
}

Hard rules

No import statements.

The module is loaded by URL and can’t resolve react or any package. Everything comes through ctx, React is ctx.runtime. This is the #1 mistake; don’t import anything.

  • Build UI with ctx.runtime.createElement (aliased h below). No JSX.
  • Style with inline styles + Undra’s CSS variables so it matches the theme.

Where it loads

Put your extension in the workspace’s hidden extensions folder, one folder per extension:

<your-workspace>/.undra/extensions/<your-extension>/extension.js

Undra scans this folder on startup and loads everything it finds. Drop the file, restart, and it’s live. (Two other paths you usually don’t need: the dev workbench search-paths in Settings → Extensions, with hot-reload while you iterate; and the signed community catalog for published extensions.)

The ctx API

activate(ctx) receives everything through ctx:

ctx.… what it does
runtime React, createElement, Fragment, the common hooks (useState, useEffect, useRef, useMemo, …), and the full React object
registry.registerItemType(manifestId, def) claim a new item type (file + route + template)
registerItemTabPresentations([…]) the type’s icon + title
registerItemTabRenderers([…]) the type’s editor component
registerCommands([…]) a command (appears in the palette)
registerCanvasWidgets([…]) a widget for the canvas
workspace.getDocument(itemId) read an item’s body → { id, title, content }
workspace.update(itemId, patch) save an item → { title?, content? }
workspace.create(opts) create an item → { type, title?, folderPath?, content? }
exportApi(api) / getExtensionApi(id) publish / consume another extension’s API (declare it in manifest.dependencies)

Item types

The most common contribution. One call claims a first-class type, its own file extension, route, icon, and editor. Undra persists the file; you own the body (a string, usually JSON).

javascript
ctx.registry.registerItemType(manifest.id, {
  id: 'book',                 // the TYPE, one lowercase token, no dots
  label: 'Book',
  pluralLabel: 'Books',
  dockIconClassSuffix: 'book',
  fileExtension: '.ubook',    // '.u' + the type id, by convention
  routePrefix: '/books',
  emptyBodyTemplateKind: 'json', // 'json' (you own the body) or 'markdown'
})

Read the body with ctx.workspace.getDocument(itemId).content; save it with ctx.workspace.update(itemId, { content }). For structured types, store a JSON string.

Capabilities

Declare in manifest.capabilities what your extension contributes:

  • itemTypes.registry, register a new item type (most common).
  • commands.registry, register commands.
  • contextProviders.registry, register AI context providers.
  • canvasWidgets.registry, register canvas widgets.

Full example, a Book type

A first-class Book item: author, rating, and notes, with its own editor. Save it at <workspace>/.undra/extensions/book/extension.js, restart, then right-click a folder → New → Book.

javascript
export const manifest = {
  id: 'community.example.book',
  version: '0.1.0',
  displayName: 'Book',
  publisher: 'example',
  capabilities: ['itemTypes.registry'],
}

export function activate(ctx) {
  const { createElement: h, useState, useEffect } = ctx.runtime
  const TYPE = 'book'

  ctx.registry.registerItemType(manifest.id, {
    id: TYPE,
    label: 'Book',
    pluralLabel: 'Books',
    dockIconClassSuffix: TYPE,
    fileExtension: '.u' + TYPE,
    routePrefix: '/' + TYPE + 's',
    emptyBodyTemplateKind: 'json',
  })

  function Icon(p) {
    return h('span', { style: { fontSize: (p && p.size ? p.size : 14) + 'px' } }, '📘')
  }
  ctx.registerItemTabPresentations([{ id: TYPE, title: 'Book', icon: Icon, dockIconClassSuffix: TYPE }])

  function Editor(props) {
    const itemId = props.itemId
    const [data, setData] = useState(null)
    useEffect(function () {
      let cancelled = false
      ctx.workspace.getDocument(itemId).then(function (doc) {
        if (cancelled) return
        var v = { author: '', rating: 0, notes: '' }
        try { Object.assign(v, JSON.parse(doc.content || '{}')) } catch (e) {}
        setData(v)
      }).catch(function () { if (!cancelled) setData({ author: '', rating: 0, notes: '' }) })
      return function () { cancelled = true }
    }, [itemId])
    if (!data) return h('div', { style: { padding: '24px', color: 'var(--u-text2,#8c969f)' } }, 'Loading...')

    var save = function (next) { setData(next); ctx.workspace.update(itemId, { content: JSON.stringify(next) }) }
    var field = { width: '100%', boxSizing: 'border-box', padding: '9px 12px', borderRadius: '9px', font: 'inherit', fontSize: '13.5px', border: '1px solid var(--u-border0,rgba(255,255,255,0.12))', background: 'var(--u-bgPanel,#16191c)', color: 'var(--u-text0,#f2f4f5)', outline: 'none', marginBottom: '10px' }

    return h('div', { style: { height: '100%', overflow: 'auto', padding: '32px', background: 'var(--u-bgEditor,#111315)', color: 'var(--u-text0,#f2f4f5)' } },
      h('div', { style: { maxWidth: '520px' } },
        h('div', { style: { fontSize: '18px', fontWeight: 650, marginBottom: '14px' } }, '📘 Book'),
        h('label', { style: { fontSize: '12px', color: 'var(--u-text2,#8c969f)' } }, 'Author'),
        h('input', { value: data.author, onChange: function (e) { save(Object.assign({}, data, { author: e.target.value })) }, style: field }),
        h('label', { style: { fontSize: '12px', color: 'var(--u-text2,#8c969f)' } }, 'Rating (0-5)'),
        h('input', { type: 'number', min: 0, max: 5, value: data.rating, onChange: function (e) { save(Object.assign({}, data, { rating: Number(e.target.value) })) }, style: field }),
        h('label', { style: { fontSize: '12px', color: 'var(--u-text2,#8c969f)' } }, 'Notes'),
        h('textarea', { value: data.notes, rows: 8, onChange: function (e) { save(Object.assign({}, data, { notes: e.target.value })) }, style: Object.assign({}, field, { resize: 'vertical' }) }),
      ),
    )
  }

  ctx.registerItemTabRenderers([
    { id: TYPE, render: function (p) { return (p.tab && p.tab.kind === 'item' && p.tab.itemType === TYPE) ? h(Editor, { itemId: p.tab.itemId }) : null } },
  ])
}

Scaling up, powerful extensions

Everything above hand-writes a single file, ideal for adding a type, a widget, or a command. For something larger (many files, TypeScript, npm libraries), write it like normal software and bundle it into one extension.js with a build step. The loader only needs one file with no runtime imports, and a bundler produces exactly that, every module and library inlined. This is the standard plugin model: ship a built bundle. There is no practical size limit, a large, library-heavy extension is completely fine.

Use Undra’s React, not your own.

Take createElement and the hooks from ctx.runtime, never bundle react yourself (two copies break hooks). ctx.runtime gives you createElement, Fragment, the common hooks (useState, useEffect, useRef, useMemo, useCallback, …) and the full React. Bundle every other library freely.

Project layout

my-extension/
  src/
    index.ts        // exports manifest + activate
    runtime.ts      // holds Undra's React, filled at activate
    BookEditor.tsx  // components, npm libraries, anything
  build.mjs         // esbuild -> extension.js
  package.json

Share Undra’s React across files

Keep the runtime in one small module that activate fills, so every file can use it:

javascript
// Filled once at activate; import { R } from './runtime' anywhere.
export const R = {}  // R.h, R.Fragment, R.useState, R.useEffect, ...
javascript
import { R } from './runtime'
import { BookEditor } from './BookEditor'

export const manifest = {
  id: 'community.example.book', version: '0.1.0',
  displayName: 'Book', publisher: 'example',
  capabilities: ['itemTypes.registry'],
}

export function activate(ctx) {
  Object.assign(R, {
    h: ctx.runtime.createElement, Fragment: ctx.runtime.Fragment,
    useState: ctx.runtime.useState, useEffect: ctx.runtime.useEffect, useRef: ctx.runtime.useRef,
  })
  ctx.registry.registerItemType(manifest.id, {
    id: 'book', label: 'Book', pluralLabel: 'Books', dockIconClassSuffix: 'book',
    fileExtension: '.ubook', routePrefix: '/books', emptyBodyTemplateKind: 'json',
  })
  ctx.registerItemTabRenderers([
    { id: 'book', render: (p) => (p.tab && p.tab.itemType === 'book') ? R.h(BookEditor, { ctx, itemId: p.tab.itemId }) : null },
  ])
}

Components import { R } from './runtime' and call R.h(...) / R.useState(...). Prefer JSX? Point your bundler’s JSX factory at R.h (below) and write markup as usual.

The build

javascript
import { build } from 'esbuild'

await build({
  entryPoints: ['src/index.ts'],
  outfile: 'extension.js',
  bundle: true,           // inline every import into one file
  format: 'esm',          // an ES module, as the loader expects
  target: 'es2020',
  jsxFactory: 'R.h',      // only if you use JSX
  jsxFragment: 'R.Fragment',
})

Run node build.mjs (wire it to npm run build), then drop the produced extension.js in <workspace>/.undra/extensions/<name>/ like any other. That one file can be tens of thousands of lines and pull in whatever libraries you need.

Theme variables

Use these in inline styles so your extension matches light and dark themes. Always pass a fallback, e.g. var(--u-accent, #5b8def).

  • Text, --u-text0 (primary), --u-text1, --u-text2 (muted)
  • Surfaces, --u-bgEditor, --u-bgPanel, --u-bg0, --u-bgHover
  • Borders, --u-border0, --u-border1
  • Accent, --u-accent (+ --u-accentSubtle), --u-danger, --u-warning

Testing

  1. Save your extension.js in the folder above.
  2. Restart or reload Undra.
  3. Open Settings → Extensions, your extension should be active, or it shows an error with the reason if the code threw.
  4. For item types: right-click a folder → New → Your Type, edit it, restart, and confirm it reloads with your data intact.

Common mistakes

Most errors come from one of three things: an import statement (not allowed, use ctx), a manifest.id that isn’t dot-namespaced, or a TYPE with a dot in it (it must be a single token).

Trust & sharing

A loadable extension runs with full access, it can read and write files and reach the network. Only run extensions you trust. Published, signed extensions go through the community catalog and show a Verified badge; anything you drop in the folder yourself or sideload is Unverified, fine for your own, and worth a careful look for anyone else’s.

Where to go next

This page is the quick start. The rest of the section goes deeper: