Writing Extensions¶
Extensions add new functionality to ScriptO Studio through the JS Configurator pattern - a JavaScript class that provides UI tabs and device-side Python libraries.
Extension Structure¶
Extensions use a modular source structure that gets bundled for distribution:
MyExtension/
├── extension.json # Metadata and configuration
├── src/
│ └── index.js # Entry point (exports class)
└── device/ # Device-side files (optional)
└── lib/
└── my_helpers.py
extension.json¶
The manifest file defines your extension:
{
"name": "My Extension",
"id": "my-extension",
"version": [1, 0, 0],
"author": "Your Name",
"description": "What your extension does",
"icon": "settings",
"menu": [
{ "id": "config", "label": "Configuration" },
{ "id": "status", "label": "Status" }
],
"styles": ".my-class { color: blue; }",
"devicePaths": {
"lib/my_helpers.py": "/lib/ext/my-extension/my_helpers.py"
}
}
Fields¶
| Field | Required | Description |
|---|---|---|
name |
✓ | Display name |
id |
✓ | Unique identifier (lowercase, hyphens) |
version |
✓ | Semver array [major, minor, patch] |
author |
✓ | Your name or organization |
description |
✓ | Brief description |
icon |
Tabler icon name (e.g., bolt, battery-charging) |
|
iconSvg |
Custom SVG icon (overrides icon) |
|
menu |
Array of menu items with id and label |
|
styles |
CSS styles injected when extension loads | |
devicePaths |
Map of source → device paths for bundler |
Extension Class¶
Create src/index.js with a class that extends the base pattern:
class MyExtension {
constructor(deviceAPI, emit, state, html) {
this.device = deviceAPI // Device communication
this.emit = emit // Trigger re-render
this.state = state // Shared state object
this.html = html // Tagged template literal for rendering
// Initialize your extension's state
if (!this.state.myExtension) {
this.state.myExtension = {
config: {},
status: 'idle'
}
}
}
// Render each menu tab
renderConfig() {
return this.html`
<div class="system-panel">
<div class="panel-header">
<h2>Configuration</h2>
</div>
<div style="padding: 20px;">
<p>Your configuration UI here</p>
</div>
</div>
`
}
renderStatus() {
return this.html`
<div class="system-panel">
<div class="panel-header">
<h2>Status</h2>
</div>
<div style="padding: 20px;">
<p>Status: ${this.state.myExtension.status}</p>
</div>
</div>
`
}
}
export { MyExtension as default }
Render Methods¶
For each menu item with id: "foo", implement renderFoo() (capitalized). The method should return a template literal using this.html.
State Management¶
- Use
this.statefor persistent data shared across renders - Call
this.emit('render')to trigger a re-render after state changes - State persists across panel switches
Device Files¶
Device-side Python files live in device/ and get deployed during installation.
devicePaths Mapping¶
In extension.json, map source files to device destinations:
{
"devicePaths": {
"lib/helpers.py": "/lib/ext/my-extension/helpers.py",
"lib/utils.py": "/lib/ext/my-extension/utils.py"
}
}
Convention: Device files go to /lib/ext/<extension-id>/
onInstall Hook¶
Implement onInstall() to deploy device files:
async onInstall() {
if (!this.state.isConnected) return false
try {
// Create directories
await this.device.mkdir('/lib/ext/my-extension')
// Write all device files (injected by loader)
for (const [path, content] of Object.entries(this.deviceFiles)) {
await this.device.saveFile(path, content)
}
return true
} catch (e) {
console.error('Installation failed:', e)
return false
}
}
The this.deviceFiles object is injected by the loader and contains base64-decoded file contents keyed by target path.
Device API¶
Use this.device to communicate with the connected device:
| Method | Description |
|---|---|
device.execute(code) |
Run Python code, returns output |
device.saveFile(path, content) |
Write file to device |
device.readFile(path) |
Read file from device |
device.mkdir(path) |
Create directory |
device.listDir(path) |
List directory contents |
// Example: Get config from device
async getConfig() {
const result = await this.device.execute(`
from lib.ext.my_extension.helpers import get_config
import json
print(json.dumps(get_config()))
`)
return JSON.parse(result)
}
Bundling¶
Use the bundler tool to create a distributable bundle:
# In-place mode (default) - writes bundle to extension directory
node tools/bundle_extensions.js --extensions-dir /path/to/your/extensions
# Or central output mode
node tools/bundle_extensions.js \
--extensions-dir /path/to/extensions \
--output-dir /path/to/bundles
The bundler:
1. Reads extension.json for metadata
2. Bundles JS from src/index.js using esbuild
3. Base64-encodes device files from device/
4. Produces a single {id}.bundle.js file
ScriptoHub Structure¶
For distribution, place bundles in the extension directory:
ScriptoHub (see scriptohub.com)
├── dbe.bundle.js # Bundled extension
├── package.json # Minimal metadata
└── README.md # Documentation
Best Practices¶
- State initialization - Always check if state exists before accessing
- Error handling - Wrap device calls in try/catch
- Responsive UI - Use async/await, show loading states
- Minimal device code - Keep Python files focused and small
- Avoid import verification - Device files may have dependencies; just write files in onInstall
Examples¶
See these extensions for reference patterns:
- DBE - Battery emulator with multiple panels
- OpenInverter - Device discovery with dynamic menus
Next Steps¶
- Extension API - Full API reference
- Device Libraries - Python library patterns
- Built-in Extensions - More examples