Skip to content

Plugin development

The fastest way to start a new plugin project is the @racona/cli CLI:

Terminál
bunx @racona/cli

The wizard walks you through the setup:

  1. App ID — kebab-case identifier (e.g. my-app)
  2. Display Name — human-readable name shown in Racona
  3. Description — short description
  4. Author — your name and email
  5. Features — pick the features you need
  6. Install dependencies? — automatically runs bun install
FeatureWhat it adds
sidebarSidebar navigation (menu.json, AppLayout mode, multiple page components)
databaseSQL migrations, sdk.data.query() support, local dev database via Docker
remote_functionsserver/functions.ts, sdk.remote.call(), local dev server
notificationssdk.notifications.send() support
i18nlocales/hu.json + locales/en.json, sdk.i18n.t() support
datatableDataTable component with insert form, row actions (duplicate/delete), full i18n

The structure depends on the selected features. Full example (all features enabled):

my-app/
├── manifest.json # App metadata and permissions
├── package.json
├── vite.config.ts
├── tsconfig.json
├── menu.json # (if sidebar)
├── build-all.js # (if sidebar)
├── dev-server.ts # (if remote_functions)
├── docker-compose.dev.yml # (if database)
├── .env.example # (if database)
├── src/
│ ├── App.svelte
│ ├── main.ts
│ ├── plugin.ts
│ └── components/ # (if sidebar)
│ ├── Overview.svelte
│ ├── Settings.svelte
│ ├── Datatable.svelte # (if datatable)
│ ├── Notifications.svelte # (if notifications)
│ └── Remote.svelte # (if remote_functions)
├── server/ # (if remote_functions)
│ └── functions.ts
├── migrations/ # (if database)
│ ├── 001_init.sql
│ └── dev/
│ └── 000_auth_seed.sql
├── locales/ # (if i18n)
│ ├── hu.json
│ └── en.json
└── assets/
└── icon.svg

The available scripts depend on the selected features.

Terminál
bun dev # Vite dev server (standalone, Mock SDK) — http://localhost:5174
bun run build # Build IIFE bundle (dist/index.iife.js)
bun run build:watch # Build in watch mode
bun run package # Create .elyospkg package
Terminál
bun run dev:server # Start dev server — http://localhost:5175

dev:server starts a Bun HTTP server that:

  • Serves static files from dist/ and the project root (with CORS headers)
  • Exposes a POST /api/remote/:functionName endpoint for calling functions from server/functions.ts
Terminál
bun db:up # Start Docker Postgres container
bun db:down # Stop Docker Postgres container
bun run dev:full # dev:server + dev in parallel (single terminal)

dev:full starts both the Vite dev server (5174) and the dev server (5175) at the same time, so you don’t need two terminals.

Terminál
cp .env.example .env # Set environment variables
bun db:up # Start Postgres container (Docker required)
bun run dev:full # Dev server + Vite together

.env.example contains the default connection URL for the database started by Docker Compose:

DATABASE_URL=postgresql://postgres:postgres@localhost:5433/{plugin_id}_dev
PORT=5175
DEV_USER_ID=dev-user

Your app can be developed without a running Racona instance. The @racona/sdk/dev package provides a Mock SDK that simulates all SDK services:

SDK serviceMock behavior
ui.toast()Writes to console.log
ui.dialog()Uses window.confirm / window.prompt
data.set/get/delete()Uses localStorage (under devapp:{appId}: key prefix)
data.query()Returns an empty array
remote.call()Configurable mock handler
i18n.t()Reads from the provided translations map
notifications.send()Writes to console.log
Terminál
bun dev

The Vite dev server starts at http://localhost:5174. Hot reload automatically refreshes the browser on every save.

If remote_functions is also enabled, you need to run bun run dev:server in parallel alongside bun dev (or use bun run dev:full if database is also enabled).

The Mock SDK is initialized automatically in src/main.ts:

src/main.ts
import { MockWebOSSDK } from '@racona/sdk/dev';
import App from './App.svelte';
import { mount } from 'svelte';
// Only runs when NOT inside Racona
if (typeof window !== 'undefined' && !window.webOS) {
MockWebOSSDK.initialize({
i18n: {
locale: 'en',
translations: {
en: { title: 'My App', welcome: 'Welcome!' },
hu: { title: 'Alkalmazás', welcome: 'Üdvözöljük!' }
}
},
context: {
pluginId: 'my-app',
user: {
id: 'dev-user',
name: 'Developer',
email: 'dev@localhost',
roles: ['admin'],
groups: []
},
permissions: ['database', 'notifications', 'remote_functions']
}
});
}
const target = document.getElementById('app');
if (target) mount(App, { target });

All initialize() configuration options:

OptionTypeDescription
i18n.localestringDefault locale (e.g. 'en')
i18n.translationsRecord<string, Record<string, string>>Translation keys per locale
context.pluginIdstringSimulated app ID
context.userUserInfoSimulated logged-in user
context.permissionsstring[]Simulated permissions
data.initialDataRecord<string, unknown>Pre-populated localStorage data
remote.handlersRecord<string, Function>Mock server function handlers
assets.baseUrlstringAsset URL prefix

When Racona loads the app in production, window.webOS already exists, so the if (!window.webOS) guard prevents the Mock SDK from running.

To test server functions in standalone mode:

MockWebOSSDK.initialize({
remote: {
handlers: {
getServerTime: async () => ({
iso: new Date().toISOString(),
locale: new Date().toLocaleString('en-US')
}),
calculate: async ({ a, b, operation }) => {
if (operation === 'add') return { result: a + b };
throw new Error('Unsupported operation');
}
}
}
});

dev:server defaults to port 5175 (the Vite dev server uses 5174). If you’re developing multiple apps at the same time, the port can be overridden via the PORT environment variable in .env or directly:

Terminál
PORT=5176 bun run dev:server

Enter the corresponding URL in the Racona Dev Apps loader: http://localhost:5176.


Standalone dev mode (Mock SDK) only tests the UI. To test real SDK calls, database access, or server functions, you need to load the app into a running Racona instance.

The idea: build the app, start a static HTTP server (dev:server), then load it into Racona by URL. There is no automatic hot reload — if you change the code, you need to rebuild and reopen the app window.

In the elyos-core monorepo root:

Terminál
# Enable dev app loading in .env.local:
# DEV_MODE=true
bun app:dev

Racona is available at http://localhost:5173 by default. Log in with an admin account.

In the app project directory:

Terminál
bun run build

This creates dist/index.iife.js — the file Racona loads.

Terminál
bun run dev:server

This starts the dev-server.ts Bun HTTP server at http://localhost:5175. It serves files from dist/ and the project root with CORS headers.

If database is also enabled, the server automatically runs migrations on startup, and server/functions.ts functions are accessible via POST /api/remote/:functionName.

  1. Open Racona in the browser
  2. Start menu → App Manager
  3. Click “Dev Apps” in the left sidebar
  4. A URL input field appears with http://localhost:5175 as the default value
  5. Click “Load”

Racona fetches manifest.json from the dev server, then loads the IIFE bundle and registers the app as a Web Component.

Terminál
# 1. Rebuild
bun run build
# 2. In Racona: close the app window, then reopen it
# (no need to click "Load" again — the app is already in the list)

Basic (without remote_functions):

Terminál
# Terminal 1 — Racona core
cd elyos-core && bun app:dev
# Terminal 2 — App build + server
cd my-app
bun run build # Build IIFE bundle
bun run dev:server # Start static server (http://localhost:5175)
# In Racona: App Manager → Dev Apps → Load → http://localhost:5175

With database (database + remote_functions):

Terminál
# Terminal 1 — Racona core
cd elyos-core && bun app:dev
# Terminal 2 — App (first time)
cd my-app
cp .env.example .env # Set DATABASE_URL and PORT
bun db:up # Start Postgres container
# Terminal 2 — App (every time)
bun run build # Build IIFE bundle
bun run dev:server # Dev server + migrations + remote endpoint (http://localhost:5175)
# In Racona: App Manager → Dev Apps → Load → http://localhost:5175

Once your app is ready, package it and install it into Racona.

Terminál
bun run build # Build IIFE bundle
bun run package # Create .elyospkg file

This creates a {id}-{version}.elyospkg file (e.g. my-app-1.0.0.elyospkg). The package is a ZIP archive containing:

  • manifest.json
  • dist/ — build output (IIFE bundle)
  • locales/ — translations (if present)
  • assets/ — static files (if present)
  • menu.json — sidebar configuration (if present)
  • server/ — server-side functions (if present)
  • migrations/ — database migrations (if present, without dev seed files)
  1. Start menu → App Manager → Plugin Upload
  2. Drag and drop the .elyospkg file, or click the browse button
  3. Racona validates the package and shows a preview
  4. Click Install

During installation, Racona:

  • Extracts files to the plugin storage directory
  • Registers the app in the app registry
  • Imports translations (if locales/ is present)
  • Creates the plugin database schema (if database permission is declared)
  • Registers email templates (if notifications permission is declared)

manifest.json contains the app metadata. Required and optional fields:

{
"id": "my-app",
"name": { "hu": "Alkalmazásom", "en": "My App" },
"version": "1.0.0",
"description": { "hu": "Rövid leírás", "en": "Short description" },
"author": "Your Name <email@example.com>",
"entry": "dist/index.iife.js",
"icon": "assets/icon.svg",
"iconStyle": "cover",
"category": "utilities",
"permissions": ["database", "notifications", "remote_functions"],
"multiInstance": false,
"defaultSize": { "width": 800, "height": 600 },
"minSize": { "width": 400, "height": 300 },
"maxSize": { "width": 1920, "height": 1080 },
"keywords": ["example", "demo"],
"isPublic": false,
"sortOrder": 100,
"dependencies": {
"svelte": "^5.0.0",
"@lucide/svelte": "^1.0.0"
},
"minWebOSVersion": "2.0.0",
"locales": ["hu", "en"]
}
PermissionDescriptionSDK functions
databaseDatabase accessdata.set(), data.get(), data.query()
notificationsSend notificationsnotifications.send()
remote_functionsServer-side functionsremote.call()
file_accessFile access(planned)
user_dataUser data(planned)
  • Lowercase letters, numbers and hyphens only (kebab-case)
  • Minimum 3, maximum 50 characters
  • Regex: ^[a-z0-9-]+$
"id": "my-app" // ✅ Valid
"id": "MyApp" // ❌ Invalid
"id": "my_app" // ❌ Invalid

The SDK is available via the window.webOS global object:

const sdk = window.webOS!;
// Toast notification
sdk.ui.toast('Message', 'success');
// type: 'info' | 'success' | 'warning' | 'error'
// Dialog
const result = await sdk.ui.dialog({
title: 'Title',
message: 'Message',
type: 'confirm' // 'info' | 'confirm' | 'prompt'
});
// Call a server function
const result = await sdk.remote.call('functionName', { param: 'value' });
// With a generic return type
const result = await sdk.remote.call<MyResult>('functionName', params);
// Key-value storage
await sdk.data.set('key', { value: 123 });
const value = await sdk.data.get('key');
await sdk.data.delete('key');
// SQL query (plugin's own schema only!)
const rows = await sdk.data.query('SELECT * FROM my_table WHERE id = $1', [123]);
// Transaction
await sdk.data.transaction(async (tx) => {
await tx.query('INSERT INTO ...');
await tx.query('UPDATE ...');
await tx.commit();
});
// Translation
const text = sdk.i18n.t('key');
// With parameters
const text = sdk.i18n.t('welcome', { name: 'John' });
// Current locale
const locale = sdk.i18n.locale; // 'hu' | 'en'
// Switch locale
await sdk.i18n.setLocale('en');
await sdk.notifications.send({
userId: 'user-123',
title: 'Title',
message: 'Message',
type: 'info' // 'info' | 'success' | 'warning' | 'error'
});
const pluginId = sdk.context.pluginId;
const user = sdk.context.user;
const permissions = sdk.context.permissions;
// Window controls
sdk.context.window.close();
sdk.context.window.setTitle('New title');
const iconUrl = sdk.assets.getUrl('icon.svg');
const imageUrl = sdk.assets.getUrl('images/logo.png');

@racona/sdk ships with full TypeScript type definitions. The window.webOS type is available automatically:

// Automatic type — no import needed
const sdk = window.webOS!;
sdk.ui.toast('Hello!', 'success'); // ✅ autocomplete
sdk.data.set('key', { value: 123 }); // ✅ type checking
sdk.remote.call<MyResult>('fn', params); // ✅ generic return type

Explicit type import when needed:

import type { WebOSSDKInterface, UserInfo } from '@racona/sdk/types';
const user: UserInfo = sdk.context.user;

Plugins use Svelte 5 runes-based reactivity. The runes: true compiler option is enabled in vite.config.ts:

<script lang="ts">
const sdk = window.webOS!;
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => {
sdk.ui.toast(`Count: ${count}`, 'info');
});
</script>
<button onclick={() => count++}>
{count} (doubled: {doubled})
</button>

When remote_functions is enabled, server-side functions are defined in server/functions.ts:

server/functions.ts
import type { PluginFunctionContext } from '@racona/sdk/types';
export async function getItems(
params: { page: number; pageSize: number },
context: PluginFunctionContext
) {
const { db, pluginId } = context;
const schema = `app__${pluginId.replace(/-/g, '_')}`;
const rows = await db.query(
`SELECT * FROM ${schema}.items LIMIT $1 OFFSET $2`,
[params.pageSize, (params.page - 1) * params.pageSize]
);
return { success: true, data: rows };
}

Calling from the client:

const result = await sdk.remote.call('getItems', { page: 1, pageSize: 20 });

When database is enabled, SQL files in the migrations/ directory define the plugin’s own database schema. Files run in alphabetical order (e.g. 001_init.sql, 002_add_column.sql).

-- migrations/001_init.sql
CREATE TABLE items (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
value JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

Files in migrations/dev/ are for development only (e.g. seed data) and are excluded from the .elyospkg package.


Plugin CSS is automatically bundled into the JS output during the IIFE build via vite-plugin-css-injected-by-js. This plugin is already included in the vite.config.ts generated by @racona/cli — no manual setup needed.

The core app’s Tailwind styles (base layer resets) can override plugin styles. Svelte scoped CSS generates button.svelte-xxxx selectors, but Tailwind’s button { ... } reset loads with higher specificity.

The fix: always scope your styles inside a container class:

<!-- ❌ Bad — core styles will override -->
<style>
button { border: 1px solid #ccc; }
</style>
<!-- ✅ Good — scoped inside a container class -->
<style>
.my-plugin button { border: 1px solid #ccc; }
</style>

If core styles override an element, all: revert restores the browser’s native style:

<style>
.my-plugin button {
all: revert;
cursor: pointer;
border: 1px solid #ccc;
border-radius: 0.25rem;
padding: 0.5rem 1rem;
}
</style>
RuleWhy
Scope inside a container class (.my-plugin button)Core Tailwind styles override bare tag selectors
Use all: revert when neededRestores the browser’s native style
Give the root container a unique class nameAvoids conflicts with other plugins’ styles

  • eval() and Function() constructor
  • innerHTML and document.write()
  • fetch/XHR to external domains
  • Dynamic import from external URLs
  • Accessing other plugins’ schemas
  • Accessing system schemas (platform, auth, public)

Only whitelisted packages may appear in the manifest dependencies field:

  • svelte (^5.x.x)
  • @lucide/svelte / lucide-svelte
  • phosphor-svelte
  • @elyos/* and @elyos-dev/* (any version) — deprecated, use @racona/* instead
  • @racona/* (any version)

ErrorSolution
"Invalid plugin ID format"Use kebab-case: my-plugin
"Permission denied"Add the required permission to manifest.json
"Module not found"Run bun run build first
"Plugin already exists"A plugin with that ID is already installed — uninstall it first
"Plugin is inactive"The plugin is in inactive state — activate it in the App Manager
Dev app not showing upCheck that DEV_MODE=true is set in the Racona .env.local