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
- Write one
extension.jsfollowing the format (copy the example and change it). - Put it at
<workspace>/.undra/extensions/<name>/extension.js. - 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.
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(aliasedhbelow). 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.jsUndra 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).
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.
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.jsonShare Undra’s React across files
Keep the runtime in one small module that activate fills, so every file can use it:
// Filled once at activate; import { R } from './runtime' anywhere.
export const R = {} // R.h, R.Fragment, R.useState, R.useEffect, ...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
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
- Save your
extension.jsin the folder above. - Restart or reload Undra.
- Open Settings → Extensions, your extension should be active, or it shows an error with the reason if the code threw.
- 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:
- The ctx API, a reference for every member of
ctx, with signatures. - Item types, the full first-class item-type contract and editor pattern.
- Canvas widgets, build a widget that lives on the canvas.
- Commands & AI context, palette commands and feeding context to agents.
- Packaging & distribution, dev-mode live reload, signing, sideloading, and dependencies.