UI Automation¶
Enumerate and drive Lumiverse's built-in navigation surfaces — drawer tabs, settings tabs, and the command palette — from your backend worker. Same primitives the Command Palette uses, but exposed to extensions so agents and onboarding flows can steer the user to the right surface.
No permission is required. This is a free-tier utility like Toast Notifications.
Quick Start¶
// List what's available
const drawerTabs = await spindle.ui.getDrawerTabs({ userId })
const settingsTabs = await spindle.ui.getSettingsTabs({ userId })
// Take the user straight to a tab
await spindle.ui.openDrawerTab('connections', { userId })
// Open settings on a specific view
await spindle.ui.openSettings('webSearch', { userId })
// Surface the command palette
await spindle.ui.openCommandPalette({ userId })
When to use¶
- Onboarding flows — an extension that needs an API key can open the Connections drawer for the user instead of telling them to find it.
- AI agents — a Lumia member can navigate the user to a setting it's about to ask them to change, or surface a relevant panel for context.
- Custom command palettes — pair
getDrawerTabs()+getSettingsTabs()withspindle.modal.open()to build your own searchable picker that targets the same surfaces.
For adding command palette entries (rather than navigating to existing ones), see Commands.
Methods¶
spindle.ui.getDrawerTabs(options?)¶
Read the drawer tabs visible to the resolved user. Returns built-in tabs and any extension-contributed tabs the user's frontend has registered.
const tabs = await spindle.ui.getDrawerTabs({ userId })
// [
// { id: 'profile', shortName: 'Profile', tabName: 'Profile', ..., source: 'builtin' },
// { id: 'connections', shortName: 'Connect', tabName: 'Connections', ..., source: 'builtin' },
// { id: 'my-ext-tab', tabName: 'My Panel', ..., source: 'extension', extensionId: 'com.acme.tools' },
// ...
// ]
| Field | Type | Description |
|---|---|---|
id |
string |
Stable id used by openDrawerTab(id) |
shortName |
string |
Short label under the sidebar icon (max ~8 chars) |
tabName |
string |
Full title shown in menus and the command palette |
tabDescription |
string |
One-line description |
keywords |
string[] |
Search keywords used by the command palette |
source |
'builtin' \| 'extension' |
Origin of the tab |
extensionId |
string? |
Set when source === 'extension' |
Extension tabs may lag
Extension drawer tabs are synced from the user's frontend over the WebSocket. While a fresh tab loads, the snapshot may briefly show only built-ins. Built-in tabs are always present.
spindle.ui.getSettingsTabs(options?)¶
Read the settings tabs visible to the resolved user. Restricted tabs (role: 'admin' or 'owner') are filtered to match the user's role.
const tabs = await spindle.ui.getSettingsTabs({ userId })
// [
// { id: 'account', tabName: 'Account Settings', ... },
// { id: 'display', tabName: 'Display & Layout', ... },
// { id: 'webSearch', tabName: 'Web Search', ... },
// { id: 'operator', tabName: 'Operator Panel', role: 'owner', ... }, // owners only
// ...
// ]
| Field | Type | Description |
|---|---|---|
id |
string |
Stable id used by openSettings(id) |
shortName |
string |
Short label shown in the settings sidebar |
tabName |
string |
Full title shown in the header and command palette |
tabDescription |
string |
One-line description |
keywords |
string[] |
Search keywords used by the command palette |
role |
'admin' \| 'owner'? |
Present when the tab is role-gated |
spindle.ui.openDrawerTab(tabId, options?)¶
Open the drawer to a specific tab. The id is forwarded verbatim, so extension-contributed tab ids (anything reported by getDrawerTabs()) work just as well as built-ins.
await spindle.ui.openDrawerTab('lorebook', { userId })
If the drawer is already open it just switches tabs. Resolves once the navigation event is dispatched — the frontend applies it asynchronously.
spindle.ui.closeDrawer(options?)¶
Close the drawer if it is currently open.
await spindle.ui.closeDrawer({ userId })
spindle.ui.openSettings(viewId?, options?)¶
Open the settings modal and switch to the specified tab in a single step.
// Land on the Connections settings tab
await spindle.ui.openSettings('webSearch', { userId })
// Open settings (falls back to the 'display' tab when no view id is supplied)
await spindle.ui.openSettings(undefined, { userId })
If you pass an unknown id, the frontend still flips the modal open with that id as the active view; the picker will simply not match a known tab.
spindle.ui.closeSettings(options?)¶
Close the settings modal if it is currently open.
await spindle.ui.closeSettings({ userId })
spindle.ui.openCommandPalette(options?) / spindle.ui.closeCommandPalette(options?)¶
Show or hide the command palette overlay (the same surface as Cmd/Ctrl+K).
await spindle.ui.openCommandPalette({ userId })
User scoping¶
options.userId follows the standard Spindle scoping rules — same as Toast Notifications and Modal:
- User-scoped extensions:
userIdis inferred from the installer and the option is ignored. - Operator-scoped extensions: pass the
userIdfrom the event/handler that triggered the navigation (e.g. theuserIdargument on anonFrontendMessagecallback). OmittinguserIdon an operator-scoped extension is allowed but broadcasts navigation events to every connected user, which is rarely what you want.
For getDrawerTabs() / getSettingsTabs(), omitting userId returns:
- Drawer tabs: built-ins only (no per-user extension tab data is loaded).
- Settings tabs: every tab including role-gated entries, since role gating cannot be applied without a user.
Patterns¶
Onboarding nudge¶
spindle.on('CHAT_CHANGED', async (_, userId) => {
const connections = await spindle.connections.list(userId)
if (connections.length === 0) {
spindle.toast.info('No API connection configured yet — opening Connections.', { userId })
await spindle.ui.openDrawerTab('connections', { userId })
}
})
Build a custom picker¶
const tabs = await spindle.ui.getDrawerTabs({ userId })
const { dismissedBy, openRequestId } = await spindle.modal.open({
title: 'Jump to…',
items: tabs.map((t) => ({
type: 'key_value',
label: t.tabName,
value: t.tabDescription,
})),
userId,
})
if (dismissedBy === 'user') {
// (Real implementations would render selectable buttons via a custom widget.)
}
Agent-driven setting walkthrough¶
async function walkUserTo(viewId: string, userId: string, hint: string) {
spindle.toast.info(hint, { userId, duration: 8_000 })
await spindle.ui.openSettings(viewId, { userId })
}
await walkUserTo(
'webSearch',
userId,
'Set your SearXNG URL here, then re-run the council tool.',
)
Behavior notes¶
- Navigation calls are fire-and-forget from the frontend's perspective. The returned promise resolves once the host has dispatched the event, not when the UI animation completes.
- Unknown
tabId/viewIdvalues won't throw. The drawer/settings modal still toggles open and the unknown id sits as the active view — useful while iterating, but worth guarding against in production code. - Navigation events are scoped through the WebSocket bus, so an open palette / drawer / settings modal on another device for the same user will also react.
- There is no rate limit on navigation calls, but stacking many opens in quick succession will simply land the user on whatever tab the last call requested.