Skip to content

Rool Extension

v0.3.12

An extension is a feature package that adds capabilities to a Rool Space. Extensions are Svelte 5 components hosted in sandboxed iframes, communicating with the host via a postMessage bridge. Each extension gets a reactive channel as its interface to the Space’s objects, schema, AI, and real-time events.

Developers build extensions to create custom experiences on top of a Space — productivity tools, dashboards, data views, games, or anything else. Multiple extensions can be installed into the same Space, letting users and teams assemble an AI-powered interface that fits exactly how they work.

An extension project is just two files:

  • App.svelte — Your UI component (receives a reactive channel as a prop)
  • manifest.json — Manifest with id, name, icon, visibility, and collection access

Everything else (Vite config, entry point, HTML, Tailwind CSS) is provided by the CLI.

Quick Start

Terminal window
npx @rool-dev/cli extension create my-extension
cd my-extension
pnpm install
pnpm dev

This opens a dev host at /__rool-host/ that loads your extension in a sandboxed iframe, connected to a real Rool Space.

Manifest

manifest.json declares your extension’s identity and collection access:

{
"id": "my-extension",
"name": "My Extension",
"public": false,
"icon": "icon.png",
"description": "What this extension does",
"collections": {
"write": {
"task": [
{ "name": "title", "type": { "kind": "string" } },
{ "name": "done", "type": { "kind": "boolean" } }
]
},
"read": "*"
},
"systemInstruction": "Optional system instruction for the AI"
}
FieldRequiredDescription
idYesUnique identifier (lowercase, hyphens)
nameYesDisplay name
publicYesWhether the extension is listed in the public extension directory
iconNoPath to an icon image file relative to the project root (e.g. "icon.png")
descriptionNoShort description
collectionsYesCollection access declarations — can be {} (see below)
systemInstructionNoDefault system instruction for the AI channel

Collection Access

The collections field declares what collections the extension works with, grouped by access level:

  • write — Collections the extension can create, update, and delete objects in. An object with field definitions creates the collection in the space. "*" grants write access to all collections.
  • read — Collections the extension can read from. An object with field definitions declares the expected shape. "*" grants read access to all collections.

write implies read — no need to list a collection under both.

// Extension with its own collections + read access to everything else
"collections": {
"write": {
"card": [
{ "name": "front", "type": { "kind": "string" } },
{ "name": "back", "type": { "kind": "string" } }
]
},
"read": "*"
}
// Full access to all collections (chat, SQL interface, etc.)
"collections": {
"write": "*"
}
// Read-only access to all collections
"collections": {
"read": "*"
}

Extension Component

App.svelte receives a single prop — a ReactiveChannel:

<script lang="ts">
import type { ReactiveChannel } from '@rool-dev/extension';
interface Props {
channel: ReactiveChannel;
}
let { channel }: Props = $props();
</script>
<div>
<p>Connected to: {channel.spaceName}</p>
<p>Objects: {channel.objectIds.length}</p>
<button onclick={() => channel.prompt('Hello')}>Send</button>
</div>

The component can import other .svelte components and .ts files — standard Svelte/TypeScript conventions apply. Tailwind CSS v4 is available out of the box. Add an app.css file to include custom styles.

Example: Task List

A complete extension that lets users add tasks, mark them done, and ask the AI to generate tasks from a description. The watch primitive keeps the list in sync with the Space in real-time.

<script lang="ts">
import type { ReactiveChannel } from '@rool-dev/extension';
interface Props { channel: ReactiveChannel }
let { channel }: Props = $props();
const tasks = channel.watch({ collection: 'task' });
let input = $state('');
async function addTask() {
if (!input.trim()) return;
await channel.createObject({ data: { title: input, done: false } });
input = '';
}
async function generate() {
if (!input.trim()) return;
await channel.prompt(`Create tasks for: ${input}`);
input = '';
}
</script>
<div class="flex gap-2 mb-4">
<input bind:value={input} placeholder="New task or describe what you need…"
class="flex-1 border rounded px-2 py-1" onkeydown={(e) => e.key === 'Enter' && addTask()} />
<button onclick={addTask} class="px-3 py-1 bg-blue-600 text-white rounded">Add</button>
<button onclick={generate} class="px-3 py-1 bg-violet-600 text-white rounded">AI Generate</button>
</div>
{#each tasks.objects as task (task.id)}
<label class="flex items-center gap-2 py-1">
<input type="checkbox" checked={task.done}
onchange={() => channel.updateObject(task.id, { data: { done: !task.done } })} />
<span class:line-through={task.done}>{task.title}</span>
</label>
{/each}

This example covers the main patterns you’ll use in most extensions: watch for a live query, createObject for direct mutations, updateObject for edits, and prompt to let the AI create or modify objects on the user’s behalf.

Dark Mode

Extensions automatically receive the host’s color scheme. The dark class is toggled on the extension’s <html> element, so Tailwind’s dark: variants work out of the box — no configuration needed.

Use the neutral color scale for dark mode backgrounds and borders to match the host app:

SurfaceLightDark
Page backgroundbg-slate-50dark:bg-neutral-950
Panels / cardsbg-whitedark:bg-neutral-900
Inset surfacesbg-slate-50dark:bg-neutral-800
Bordersborder-slate-200dark:border-neutral-700
Primary texttext-slate-800dark:text-neutral-100
Secondary texttext-slate-500dark:text-neutral-400
Inputsbg-slate-50dark:bg-neutral-800

Accent colors (teal, violet, emerald, etc.) should shift to the 400 weight in dark mode for better contrast against dark backgrounds — e.g. text-emerald-600 dark:text-emerald-400.

The reactive channel.colorScheme property ('light' or 'dark') is available if you need to branch in code rather than CSS.

ReactiveChannel

The channel is the extension’s interface to the host Space — objects, schema, AI, metadata, undo/redo, and real-time events.

Reactive State

These are Svelte 5 $state properties — use them directly in templates or $effect blocks:

PropertyTypeDescription
interactionsInteraction[]Channel interaction history (auto-updates)
objectIdsstring[]All object IDs in the space (auto-updates on create/delete)
collectionsstring[]Collection names from the schema (auto-updates)
conversationsConversationInfo[]Conversations in this channel (auto-updates on create/delete/rename)
colorSchemeColorSchemeHost’s color scheme: 'light' or 'dark' (auto-updates on toggle)

Properties

PropertyTypeDescription
channelIdstringChannel ID
spaceIdstringSpace ID
spaceNamestringSpace name
roleRoolUserRoleUser’s role (owner, admin, editor, viewer)
linkAccessLinkAccessURL sharing level
userIdstringCurrent user’s ID
userBridgeUserCurrent user info ({ id, name, email })
isReadOnlybooleanTrue if viewer role

Object Operations

Objects are plain key/value records. id is reserved; everything else is application-defined. References between objects are data fields whose values are object IDs.

MethodDescription
getObject(id)Get object data, or undefined if not found
findObjects(options)Find objects using filters and/or natural language (see below)
getObjectIds(options?)Get all object IDs. Options: { limit?, order? }
createObject(options)Create a new object. Returns { object, message }
updateObject(id, options)Update an existing object. Returns { object, message }
deleteObjects(ids)Delete objects by ID
stat(id)Get audit info (modifiedAt, modifiedBy) from local cache

createObject / updateObject

// Create with literal data
await channel.createObject({ data: { title: 'Hello', status: 'draft' } })
// Use {{placeholders}} for AI-generated content
await channel.createObject({ data: { headline: '{{catchy headline about coffee}}' } })
// Update fields directly
await channel.updateObject(id, { data: { status: 'published' } })
// Update via AI instruction
await channel.updateObject(id, { prompt: 'Make it shorter and more casual' })
// Delete a field by setting it to null
await channel.updateObject(id, { data: { subtitle: null } })

Placeholders are resolved by the AI during the mutation and replaced with concrete values. The {{...}} syntax is never stored.

createObject options: data (required, include id for a custom ID), ephemeral. updateObject options: data, prompt, ephemeral.

findObjects

  • where only — exact-match filtering, no AI, no credits
  • collection only — filter by collection name, no AI, no credits
  • prompt only — AI-powered semantic query over all objects
  • where + promptwhere narrows the set first, then AI queries within it
await channel.findObjects({ collection: 'note' })
await channel.findObjects({ where: { status: 'active' } })
await channel.findObjects({ collection: 'note', where: { status: 'active' } })
await channel.findObjects({ prompt: 'notes about climate solutions' })
await channel.findObjects({ collection: 'note', prompt: 'most urgent', limit: 5 })

Options: where, collection, prompt, limit, objectIds, order ('asc' | 'desc'), ephemeral.

Hidden Fields

Fields starting with _ (e.g., _ui) are hidden from AI and ignored by the schema. Use them for UI state, positions, or other data the AI shouldn’t see:

await channel.createObject({ data: { title: 'Note', _ui: { x: 100, y: 200 } } })

AI

const { message, objects } = await channel.prompt('Create three tasks');
const { message } = await channel.prompt('Summarize', { readOnly: true, effort: 'QUICK' });
OptionDescription
objectIdsFocus on specific objects
responseSchemaRequest structured JSON response
effort'QUICK', 'STANDARD', 'REASONING', or 'RESEARCH'
ephemeralDon’t record in interaction history
readOnlyDisable mutation tools
attachmentsFiles to attach (File, Blob, or { data, contentType })

The AI automatically receives interaction history, recently modified objects, and any objects passed via objectIds as context.

Schema

channel.getSchema()
await channel.createCollection('task', [
{ name: 'title', type: { kind: 'string' } },
{ name: 'done', type: { kind: 'boolean' } },
])
await channel.alterCollection('task', [...updatedFields])
await channel.dropCollection('task')

Undo/Redo

await channel.checkpoint('Before delete')
await channel.deleteObjects([id])
await channel.undo() // restores deleted object
await channel.redo() // deletes again

Metadata

Arbitrary key-value storage on the Space (not visible to AI):

channel.setMetadata('viewport', { zoom: 1.5 })
channel.getMetadata('viewport')
channel.getAllMetadata()

Interaction History & Conversations

channel.getInteractions()
channel.getSystemInstruction()
await channel.setSystemInstruction('Respond in haiku')
// List all conversations in this channel
channel.getConversations()
// Delete or rename a conversation
await channel.deleteConversation('old-thread')
await channel.renameConversation('Research')

Conversation Handles

For extensions that need multiple independent interaction threads (e.g., chat with multiple threads), use channel.conversation() to get a reactive handle scoped to a specific conversation:

<script>
const thread = channel.conversation('thread-42');
</script>
<!-- thread.interactions is reactive $state — auto-updates via SSE -->
{#each thread.interactions as interaction}
<div>{interaction.output}</div>
{/each}
<button onclick={() => thread.prompt('Hello')}>Send</button>
// Reactive state
thread.interactions // $state<Interaction[]> — auto-updates
// All conversation-scoped methods
await thread.prompt('Hello');
await thread.createObject({ data: { text: 'Hello' } });
await thread.setSystemInstruction('Respond in haiku');
await thread.rename('Research Thread');
// Cleanup
thread.close(); // Stop listening for updates

Conversations are auto-created on first interaction — no explicit create step needed. All conversations share one bridge connection. The 200-interaction cap applies per conversation.

Events

channel.on('objectCreated', ({ objectId, object, source }) => { ... })
channel.on('objectUpdated', ({ objectId, object, source }) => { ... })
channel.on('objectDeleted', ({ objectId, source }) => { ... })
channel.on('metadataUpdated', ({ metadata, source }) => { ... })
channel.on('channelUpdated', ({ channelId, source }) => { ... })
channel.on('conversationUpdated', ({ conversationId, channelId, source }) => { ... })
channel.on('reset', ({ source }) => { ... })

source is 'local_user', 'remote_user', 'remote_agent', or 'system'.

Reactive Primitives

channel.watch(options)

Auto-updating filtered object list:

<script>
const tasks = channel.watch({ collection: 'task' });
</script>
{#each tasks.objects as task}
<div>{task.title}</div>
{/each}
StateDescription
watch.objects$state<RoolObject[]> — matching objects
watch.loading$state<boolean> — loading state

Methods: watch.refresh(), watch.close().

channel.object(id)

Single reactive object subscription:

<script>
const item = channel.object('abc123');
</script>
{#if item.data}
<div>{item.data.title}</div>
{/if}
StateDescription
object.data`$state<RoolObject
object.loading$state<boolean> — loading state

Methods: object.refresh(), object.close().

Hosting

Extensions run in a sandboxed iframe (allow-scripts allow-same-origin). The host creates the iframe, establishes a postMessage bridge, and proxies all channel operations to a real Rool Space. The extension never authenticates directly — the host handles auth and forwards operations.

The bridge protocol:

  1. Extension sends rool:ready
  2. Host responds with rool:init (channel metadata, schema, space info, user identity)
  3. Extension calls channel methods → rool:request → host executes → rool:response
  4. Host pushes real-time events → rool:event → extension updates reactive state

When creating a bridge host, pass user so the extension can display the current user’s name:

const host = createBridgeHost({
channel,
iframe,
user: { id: currentUser.id, name: currentUser.name, email: currentUser.email },
});

CLI Commands

CommandDescription
rool extension create [name]Scaffold a new extension project
rool extension devStart the dev server with host shell
rool extension buildBuild the extension
rool extension publishBuild and publish the extension

Exported Types

import type {
ReactiveChannel,
ReactiveConversationHandle,
ReactiveObject,
ReactiveWatch,
WatchOptions,
BridgeUser,
ColorScheme,
RoolObject,
RoolObjectStat,
SpaceSchema,
CollectionDef,
FieldDef,
FieldType,
Interaction,
InteractionStatus,
ConversationInfo,
ToolCall,
PromptOptions,
PromptEffort,
FindObjectsOptions,
CreateObjectOptions,
UpdateObjectOptions,
ChangeSource,
RoolUserRole,
LinkAccess,
ChannelEvents,
} from '@rool-dev/extension';

License

MIT - see LICENSE for details.