Item types
Register a first-class item kind, its own file extension, route, icon, and editor, and Undra treats it like a built-in note or canvas: it persists the file, you own the body and the renderer.
This is the deepest contribution an extension can make. One registerItemType call, plus a presentation and a renderer, gives you a new kind of document that lives in the explorer, opens in a tab, and shows up in the right-click New menu. This page covers every field, the two registration modes, and exactly how persistence works.
If you have not read the extension overview yet, start there: the format (manifest + activate(ctx)), the no-import rule, and the ctx API shape are assumed here.
The three calls
A working item type is three registrations inside activate(ctx):
ctx.registry.registerItemType(manifest.id, def), claim the type (identity, file, route, template).ctx.registerItemTabPresentations([...]), give it an icon and a title in tabs and the dock.ctx.registerItemTabRenderers([...]), supply the React component that renders the editor.
The type id (a single lowercase token, no dots) is the thread that ties all three together. The same id you pass to registerItemType is the id you use in the presentation and renderer entries.
The type id is not the manifest id
manifest.id is dot-namespaced (community.example.book). The type id is one lowercase token with no dots (book). A dot in the type id is one of the three most common reasons an extension fails to load.
ItemTypeContribution field by field
This is the object you pass as the second argument to ctx.registry.registerItemType(manifest.id, def).
ctx.registry.registerItemType(manifest.id, {
id: 'book', // the TYPE: one lowercase token, no dots
label: 'Book', // singular human label
pluralLabel: 'Books', // optional; defaults to `${label}s`
dockIconClassSuffix: 'book', // optional; presentation/dock suffix
fileExtension: '.ubook', // full mode: leading-dot, lowercase
routePrefix: '/books', // full mode: route under which items open
emptyBodyTemplateKind: 'json', // full mode: 'json' or 'markdown'
})| Field | Required | What it does |
|---|---|---|
id |
yes | The type identity. One lowercase token, no dots. Reused verbatim in the presentation and renderer entries, and is the type you pass to ctx.workspace.create. |
label |
yes | Singular human-readable name (Book). Shown in menus and surfaces. |
pluralLabel |
no | Plural for list and “New X” surfaces. Defaults to `${label}s`, so Book gives Books for free. Set it only when the default is wrong (Person to People, not Persons). |
dockIconClassSuffix |
no | Presentation suffix used by the explorer and dock. Match it to the same suffix in your tab presentation so the icon is consistent everywhere. By convention this is just the type id. |
fileExtension |
full mode | The file extension Undra owns for this type. Leading dot, lowercase (.ubook). Convention is .u + the type id. |
routePrefix |
full mode | The route prefix under which items of this type open (/books). Convention is / + the plural. |
emptyBodyTemplateKind |
full mode | The body the kernel writes to a brand-new file: 'json' writes {}, 'markdown' writes an empty string. Pick 'json' for structured types, 'markdown' for prose. |
editor |
no | Reserved. A future kernel-routing hook. Omit it. You supply the editor through registerItemTabRenderers, not here. |
Full first-class mode vs metadata-only
The same call has two modes, decided entirely by whether you set the three “file lifecycle” fields.
Full first-class mode
Set all three of fileExtension, routePrefix, and emptyBodyTemplateKind. You are declaring a type that behaves like a built-in. The split of responsibility:
- The host owns the file lifecycle. When the user picks New to Book, the kernel creates a real
.ubookfile, seeds it with the empty template ({}forjson), opens it under/books, persists it, and reloads it on restart. You do not touch files. - You own the body and the renderer. The body is an opaque string whose meaning is yours. You read it with
ctx.workspace.getDocument, write it withctx.workspace.update, and render it with the component you registered. This is a custom-editor model: the host handles the document, you handle its contents and its UI.
This is what almost every item-type extension wants. The Book example below is full mode.
Metadata-only mode
Omit all three of fileExtension, routePrefix, and emptyBodyTemplateKind. You register only identity (id, label, dockIconClassSuffix). There is no host-managed file, no route, no “New” entry that creates a document. Use this when you only need the type to exist for labelling and presentation, not as a createable, persisted document.
It is all-or-nothing
The three full-mode fields go together. Set them all for a real document type, or set none of them for metadata-only. A partial set (for example fileExtension without routePrefix) is not a supported configuration.
Presentations
ctx.registerItemTabPresentations([...]) gives the type its visual identity in tabs, the explorer, and the dock. Each entry:
ctx.registerItemTabPresentations([
{
id: 'book', // MUST match the item type id
title: 'Book', // label shown on the tab
icon: Icon, // a component: (props) => element
dockIconClassSuffix: 'book', // match the registerItemType suffix
},
])| Field | What it does |
|---|---|
id |
The item type id. This is how the presentation is bound to your type. |
title |
The title shown on the tab for this type. |
icon |
A component called with a props object. Read props.size (a pixel number) and render an element. Build it with ctx.runtime.createElement. |
dockIconClassSuffix |
Optional. The dock/explorer icon suffix; keep it equal to the suffix on the type itself. |
The icon is a plain function component. A minimal one:
function Icon(p) {
return h('span', { style: { fontSize: (p && p.size ? p.size : 14) + 'px' } }, '📘')
}The renderer contract
ctx.registerItemTabRenderers([...]) supplies the actual editor. Each entry is { id, render }, where render(p) is called for every tab Undra opens, not just yours. The guard is mandatory: return your component only when the tab is an item of your type, and null otherwise.
ctx.registerItemTabRenderers([
{
id: 'book',
render: function (p) {
return (p.tab && p.tab.kind === 'item' && p.tab.itemType === 'book')
? h(Editor, { itemId: p.tab.itemId })
: null
},
},
])What render receives in p:
p.tab.kind, the tab kind. Check it equals'item'.p.tab.itemType, the registered type id. Check it equals yourid.p.tab.itemId, the id of the open item. Pass this to your component so it can load and save the body.
Always guard, always return null
render runs for tabs that are not yours (notes, canvases, other extensions’ types). If you do not check p.tab.itemType and return null for non-matches, you will try to render your editor over unrelated tabs. The (p.tab && p.tab.kind === 'item' && p.tab.itemType === TYPE) ? ... : null pattern is the contract, not a suggestion.
Persistence: you own the body string
In full mode the host creates and tracks the file, but the body is an opaque string you control. The contract is two methods on ctx.workspace:
ctx.workspace.getDocument(itemId)resolves to{ id, title, content }.contentis the raw body string.ctx.workspace.update(itemId, patch)writes it back.patchis{ title?, content? }; passcontentto save the body,titleto rename the item.
There is no schema and no validation. If you stored JSON, you parse it; if you stored markdown, you read it as text. For a structured type the standard pattern is: keep a JSON string on disk, parse on load into a defaulted object, stringify on every save.
// LOAD: parse defensively, always fall back to a default shape.
const doc = await ctx.workspace.getDocument(itemId)
const data = { author: '', rating: 0, notes: '' }
try { Object.assign(data, JSON.parse(doc.content || '{}')) } catch (e) { /* keep defaults */ }
// SAVE: stringify the whole object back into content.
ctx.workspace.update(itemId, { content: JSON.stringify(data) })Three things to get right:
- Tolerate an empty body. A freshly created
jsonitem arrives as{}(the empty template). Parsing it gives an empty object, so your defaults fill in the missing fields. Always merge onto a default shape rather than trusting the parsed result. - Wrap the parse in try/catch. The body is a string you (or a past version of your code) wrote. Never assume it parses.
- Save the whole object.
contentis the entire body.updatereplaces it; there is no field-level patch on the body. Read-modify-write the full object each time.
getDocument is the body, not just metadata
getDocument returns the item’s title plus its raw body content. That is everything your editor needs. You do not read files yourself and you do not reach into renderer internals; the ctx.workspace contract is the whole surface.
Complete runnable example
A first-class Book type: author, rating, and notes, persisted as JSON, with its own editor. Save it at <workspace>/.undra/extensions/book/extension.js, restart Undra, then right-click a folder to get New to 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'
// 1. Claim the type. All three file-lifecycle fields set = full first-class.
ctx.registry.registerItemType(manifest.id, {
id: TYPE,
label: 'Book',
pluralLabel: 'Books',
dockIconClassSuffix: TYPE,
fileExtension: '.u' + TYPE, // '.ubook'
routePrefix: '/' + TYPE + 's', // '/books'
emptyBodyTemplateKind: 'json', // new files start as '{}'
})
// 2. Icon + title for tabs, explorer, and the dock.
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 },
])
// 3. The editor component. Owns load (parse) and save (stringify) of the body.
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...')
}
// Read-modify-write the whole object, then persist the full body string.
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,var(--u-bg0,#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' }),
}),
),
)
}
// 4. Bind the renderer to the type. Guard: only render OUR item tabs.
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
},
},
])
}The item’s title is the book’s name (the host owns it; rename it through ctx.workspace.update(itemId, { title }) if you want an in-editor rename). The body (author, rating, notes) is the JSON string you persist to the .ubook file. Restart Undra and the data reloads intact.
Verify the round-trip
After editing a Book, restart Undra and reopen it. If your fields are gone, you almost certainly saved partially or forgot to merge onto defaults on load. The getDocument to parse to default-merge to update to stringify loop is what makes the round-trip survive a restart.
It appears in the New menu
Once a full-mode type is registered, Undra wires it into the explorer automatically. Right-click a folder to get New to (your label) and the kernel creates a new file with your extension, seeds it with the empty-body template, opens it under your route prefix, and hands it to your renderer. You wrote a type, an icon, and an editor; the create, open, persist, and reload path is the host’s. That is the whole payoff of full first-class mode.
Where to go next
- Canvas widgets, embed an interactive widget on the canvas with the same
ctx.runtimeReact. - Commands & AI context, add palette commands and feed your item’s data into AI prompts.
- Packaging & distribution, bundle a multi-file TypeScript item type into one
extension.jsand ship it.