The ctx API
A reference for every surface a loadable extension’s activate(ctx) receives: the React runtime, the registration calls, and the read/write workspace APIs.
When Undra loads your extension.js, it calls activate(ctx) and hands you a single ctx object. Everything your extension can do flows through it: there are no imports, no globals, no module resolution. This page documents each member of ctx with its exact signature and a tiny runnable example. For the format and lifecycle of an extension, start with the Overview.
The golden rule
Never import anything (not even react). The module is loaded by URL and cannot resolve packages. Take React from ctx.runtime, and bundle every other library inline. Importing react yourself loads a second copy and breaks hooks.
The surface at a glance
ctx.… |
Signature | What it does |
|---|---|---|
runtime |
{ createElement, Fragment, useState, useEffect, useRef, useMemo, useCallback, … } |
Undra’s React. Build all UI from this. |
registry.registerItemType |
(manifestId, def) => () => void |
Claim a first-class item type (file + route + template). See Item types. |
registerItemTabPresentations |
(contribs[]) => void |
The type’s icon and title. |
registerItemTabRenderers |
(contribs[]) => void |
The type’s editor component. |
registerCommands |
(contribs[]) => void |
Commands for the palette. See Commands & AI context. |
registerCanvasWidgets |
(contribs[]) => void |
Canvas widgets. See Canvas widgets. |
workspace.getDocument |
(itemId) => Promise<{ id, title, content }> |
Read an item’s body. |
workspace.update |
(itemId, { title?, content? }) => Promise<unknown> |
Save an item. |
workspace.create |
({ type, title?, folderPath?, content? }) => Promise<unknown> |
Create an item. |
query |
{ queryMetadata, searchKeyword, getChangesSince } |
Advanced read-only workspace introspection. |
exportApi / getExtensionApi |
(api) => void / (id) => T | undefined |
Publish / consume another extension’s API. See Packaging & distribution. |
Each call requires the matching capability in manifest.capabilities (itemTypes.registry, commands.registry, canvasWidgets.registry, contextProviders.registry). workspace, query, exportApi, and getExtensionApi need no capability; they are always present on the desktop host.
ctx.runtime
Undra’s own React. It carries createElement, Fragment, the common hooks (useState, useEffect, useRef, useMemo, useCallback), and the full React object for anything else you need. Destructure what you use at the top of activate and alias createElement to h.
export function activate(ctx) {
const { createElement: h, useState } = ctx.runtime
function Counter() {
const [n, setN] = useState(0)
return h('button', { onClick: () => setN(n + 1) }, 'Clicked ' + n)
}
// ...register Counter through one of the contribution calls below
}One React, always Undra’s
If you split your code across files (a bundled extension), do not import 'react' in any of them. Fill a small shared module from ctx.runtime at activation and import that instead. See Packaging & distribution for the pattern.
ctx.registry.registerItemType
Claims a new first-class item type: its own file extension, route, icon, and editor. Returns a disposer you can ignore (Undra tears it down on reload).
ctx.registry.registerItemType(manifest.id, {
id: 'book', // the TYPE: one lowercase token, no dots
label: 'Book',
pluralLabel: 'Books', // optional, defaults to `${label}s`
dockIconClassSuffix: 'book', // optional explorer/dock presentation suffix
fileExtension: '.ubook', // '.u' + the type id, by convention
routePrefix: '/books',
emptyBodyTemplateKind: 'json', // 'json' (body starts as {}) or 'markdown' (empty string)
})Provide fileExtension, routePrefix, and emptyBodyTemplateKind together to register a full first-class type the kernel can create, open, and persist like a built-in. The body is a string you own (usually a JSON blob). This is only the identity call; the icon, title, and editor come from the two registerItemTab* calls below. Requires capability itemTypes.registry. For the full lifecycle (templates, persistence, the body contract), see Item types.
ctx.registerItemTabPresentations
Gives the type its icon and title in tabs, the explorer, and the dock. One contribution per type, keyed by the type id.
function Icon(p) {
return ctx.runtime.createElement(
'span',
{ style: { fontSize: (p && p.size ? p.size : 14) + 'px' } },
'📘',
)
}
ctx.registerItemTabPresentations([
{ id: 'book', title: 'Book', icon: Icon, dockIconClassSuffix: 'book' },
])Each contribution is { id, title, icon, dockIconClassSuffix? }. id is the type id, icon is a component that receives a size prop, and dockIconClassSuffix is optional.
ctx.registerItemTabRenderers
Supplies the editor component for your type. Each contribution is { id, render }, where render(p) is called for every open tab and must return your element only when the tab is one of yours.
The render contract: inspect p.tab and return your editor only when it matches. p.tab.kind is 'item' for item tabs, p.tab.itemType is the type id, and p.tab.itemId is the id you pass to the workspace APIs. Return null for anything that is not yours.
const TYPE = 'book'
const { createElement: h } = ctx.runtime
ctx.registerItemTabRenderers([
{
id: TYPE,
render: function (p) {
if (p.tab && p.tab.kind === 'item' && p.tab.itemType === TYPE) {
return h(BookEditor, { itemId: p.tab.itemId })
}
return null
},
},
])Your editor component then loads and saves its body with ctx.workspace.getDocument and ctx.workspace.update (below). A complete editor lives in Item types.
ctx.registerCommands
Registers commands that appear in the command palette. Each contribution is { id, title, category, handler }; handler is called with a host context object when the user runs the command.
ctx.registerCommands([
{
id: 'community.example.book.new',
title: 'New Book',
category: 'Book',
handler: async () => {
await ctx.workspace.create({ type: 'book', title: 'Untitled Book' })
},
},
])Requires capability commands.registry. For palette behavior, categories, and AI context providers (the contextProviders.registry capability), see Commands & AI context.
ctx.registerCanvasWidgets
Registers a renderer for a canvas widget, keyed by widgetKind. The canvas owns node creation and mutation; your component renders the node’s data and writes changes back through setData.
ctx.registerCanvasWidgets([
{
widgetKind: 'community.example.counter',
title: 'Counter',
defaultData: { count: 0 },
defaultSize: { width: 220, height: 120 },
component: function (props) {
// props: { data, setData, width, height }
const { createElement: h } = ctx.runtime
const count = Number(props.data.count || 0)
return h(
'button',
{ onClick: () => props.setData({ count: count + 1 }) },
'Count: ' + count,
)
},
},
])Each contribution is { widgetKind, title, icon?, defaultData?, defaultSize?, component }. The component receives { data, setData, width, height }: data is the node’s persisted object, setData(next) writes it back (the renderer never touches the canvas document), and width/height are the node’s current pixel size. Requires capability canvasWidgets.registry. See Canvas widgets for the full widget model.
ctx.workspace
The sealed read/write API for item bodies. Your editor uses it to load and persist its content through a contract instead of reaching into renderer internals. The content field is the raw body string; your extension owns its meaning.
ctx.workspace.getDocument(itemId: string)
=> Promise<{ id: string; title: string; content: string }>
ctx.workspace.update(itemId: string, patch: { title?: string; content?: string })
=> Promise<unknown>
ctx.workspace.create(opts: {
type: string
title?: string
folderPath?: string | null
content?: string
}) => Promise<unknown>A load-then-save round trip inside an editor:
const { createElement: h, useState, useEffect } = ctx.runtime
function BookEditor(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
let v = { author: '', notes: '' }
try { Object.assign(v, JSON.parse(doc.content || '{}')) } catch (e) {}
setData(v)
})
return function () { cancelled = true }
}, [itemId])
if (!data) return h('div', null, 'Loading...')
const save = function (next) {
setData(next)
ctx.workspace.update(itemId, { content: JSON.stringify(next) })
}
return h('input', {
value: data.author,
onChange: function (e) { save(Object.assign({}, data, { author: e.target.value })) },
})
}Notes:
getDocumentreturns{ id, title, content }. Thecontentis exactly the string you last wrote (the empty-body template on a fresh item).updatetakes a partial patch. Pass onlycontentto save the body, onlytitleto rename, or both.createneeds at leasttype(your registered type id).folderPathofnullor omitted creates at a default location; pass a workspace-relative folder path to target a folder.updateandcreateresolve to an opaque value; treat them as fire-and-forget writes (await for ordering, ignore the result).
ctx.query
A read-only introspection API for the wider workspace, separate from ctx.workspace (which is scoped to single-item bodies). Use it to list items by metadata, run a keyword search, or follow a change feed. Every method is read-only; it cannot mutate anything.
ctx.query.version // '1'
ctx.query.queryMetadata(request: {
limit?: number
offset?: number
folderPath?: string
itemType?: string
location?: 'live' | 'trash'
}) => Promise<{
limit: number
offset: number
total: number
rows: Array<{
id: string
relPath: string
type: string
format?: string | null
title: string
folderPath: string
tags: string[]
dueDate?: string | null
createdAt: string
updatedAt: string
location: string
deletedAt?: string | null
originalPath?: string | null
metadataRev: number
}>
}>
ctx.query.searchKeyword(request: { query: string; limit?: number })
=> Promise<{
query: string
hits: Array<{ itemId: string; score: number }>
}>
ctx.query.getChangesSince(seq: number, options?: { limit?: number })
=> Promise<{
fromSeq: number
latestSeq: number
events: Array<{
seq: number
kind: string
itemId?: string | null
metadataRev?: number | null
createdAtMs: number
payload: Record<string, unknown>
}>
hasGap: boolean
}>List the titles of every item of your type in a folder:
const page = await ctx.query.queryMetadata({
itemType: 'book',
folderPath: 'Library',
limit: 50,
})
const titles = page.rows.map(function (r) { return r.title })
// page.total tells you whether to fetch another page with offsetKeyword-search the workspace, then load the top hit’s body:
const res = await ctx.query.searchKeyword({ query: 'tolkien', limit: 5 })
if (res.hits.length) {
const doc = await ctx.workspace.getDocument(res.hits[0].itemId)
// doc.content is the matched item's body
}Follow the change feed (poll incrementally, watch hasGap):
let cursor = 0
async function poll() {
const res = await ctx.query.getChangesSince(cursor)
if (res.hasGap) {
// you fell behind the retained window; re-read from queryMetadata instead
}
for (const ev of res.events) {
// ev.kind, ev.itemId, ev.metadataRev, ev.payload
}
cursor = res.latestSeq
}Paging and cursors
queryMetadata returns { limit, offset, total, rows }: page by advancing offset until you have seen total rows. getChangesSince returns latestSeq as your next cursor and hasGap: true when the requested seq is older than the retained event window (re-snapshot with queryMetadata when that happens).
ctx.query may be absent on hosts that do not provide it, so null-check ctx.query before use if you want to be defensive. On the desktop host it is present.
ctx.exportApi and ctx.getExtensionApi
The inter-extension bridge. An extension publishes a public API with exportApi, and a dependent consumes it with getExtensionApi.
ctx.exportApi(api: unknown) => void
ctx.getExtensionApi<T = unknown>(id: string) => T | undefined// In the provider extension's activate:
ctx.exportApi({ greet: (name) => 'Hello, ' + name })
// In a dependent extension's activate (declare the dep in manifest.dependencies):
const provider = ctx.getExtensionApi('community.example.greeter')
if (provider) {
// null-check: a dependency may activate and export nothing
console.log(provider.greet('world'))
}To consume another extension you must declare it in manifest.dependencies (a bare id string means “any version, required”; an object adds a semver version range and/or optional: true). Undra activates dependencies before dependents, so a required dependency has already exported by the time your activate runs. A satisfied dependency still gates activation only, not API presence, so always null-check the result. See Packaging & distribution for declaring dependencies and shipping a multi-extension bundle.
Where to go next
- Item types: the full lifecycle behind
registerItemTypeand the tab renderer contract. - Canvas widgets: the complete widget model behind
registerCanvasWidgets. - Commands & AI context: commands and the
contextProviders.registrycapability.