Last updated

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.

javascript
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).

javascript
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.

javascript
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.

javascript
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.

javascript
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.

javascript
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.

typescript
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:

javascript
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:

  • getDocument returns { id, title, content }. The content is exactly the string you last wrote (the empty-body template on a fresh item).
  • update takes a partial patch. Pass only content to save the body, only title to rename, or both.
  • create needs at least type (your registered type id). folderPath of null or omitted creates at a default location; pass a workspace-relative folder path to target a folder.
  • update and create resolve 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.

typescript
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:

javascript
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 offset

Keyword-search the workspace, then load the top hit’s body:

javascript
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):

javascript
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.

typescript
ctx.exportApi(api: unknown) => void
ctx.getExtensionApi<T = unknown>(id: string) => T | undefined
javascript
// 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 registerItemType and the tab renderer contract.
  • Canvas widgets: the complete widget model behind registerCanvasWidgets.
  • Commands & AI context: commands and the contextProviders.registry capability.