tessel·la
Robo·Boy docs

Your robot.
Within reach.

Run a friendly control room for ROS 2, then extend it with panels that remain independent of the core application.

robo·boy / ros 2 connected
bridge wss://robot/websocket
panels05 + SDK
runtimeweb / desktop
statelocal first
APIv2.0.0
01 / Meet Robo·Boy

A web and native interface for controlling and visualizing ROS 2 robots.

Robo-Boy combines camera streaming, configurable control pads, 3D visualization, behavior-tree editing, TF inspection, and custom themes in one touch-friendly workspace. The web, desktop, and mobile apps share the same React codebase; the native packages are thin shells and do not bundle ROS.

A

Operate

Drive and monitor a robot from a phone, tablet, laptop, or desktop webview.

B

Visualize

Inspect cameras, TF, laser scans, point clouds, poses, odometry, and URDF models.

C

Compose

Build reusable control pads and behavior trees without making a one-off dashboard.

D

Extend

Ship trusted, standalone ESM panels against a small, versioned, framework-neutral SDK.

!
Choose the right extension path.

A custom gamepad is an in-app JSON layout made from the built-in joystick, button, D-pad, toggle, slider, camera, plot, and heartbeat components. An external panel is independently built JavaScript, loaded through the Panel SDK. New gamepad component types are internal core development; they are not external SDK panels.

02 / Install & run

Two routes: a native app, or the browser stack.

The native app is the shorter route. It needs only the ROS services, so there is no certificate to create and no proxy to run. Choose the browser stack when Robo-Boy should open directly from a browser.

Native app and ROS stack

  1. 1

    Install an app

    Linux.deb
    Linux.rpm
    macOS.dmg
    Windows.exe
    Android.apk
    iOS.ipa

    These stable links advance with each Robo-Boy application release. The macOS image is the Electron build for Apple silicon, the Linux packages are x86-64, and the iOS package is unsigned and must be signed before installation.

  2. 2

    Start the ROS services only

    git clone git@github.com:tessel-la/robo-boy.git
    cd robo-boy
    docker compose up -d --build ros-stack

    This one service supplies rosbridge, rosapi, and web_video_server. The web frontend, proxy, and certificate are not started, because the native app talks to these ports directly.

    If the robot publishes message, service, or action types of its own, mount that workspace as well, or those topics arrive without a definition to read them with. See robot-specific interfaces.

  3. 3

    Open the app and connect

    Use Quick Connect when ROS runs on the same computer. When it runs elsewhere, open the advanced options, choose Host or IP, and enter that computer's hostname, LAN address, or VPN name.

    docker compose logs -f ros-stack
    docker compose down
CAM
WebRTC panel 3.0.1 is installed from the official catalog.

Open Manage installations… to install WebRTC / RTSP Camera. Robo-Boy 0.7.0-alpha and later supply dedicated WHEP, discovery, and HLS gateway endpoints. Browsers negotiate WHEP; a Linux desktop webview without RTCPeerConnection automatically uses the gateway's low-latency HLS stream and reports Live · HLS. The robot or simulator deployment must run MediaMTX with WHEP on :8889, discovery on :9997, and HLS on :8888.

Robo-Boy never starts, stops, or bundles ROS or the media gateway. Run those services on the robot or simulator computer instead of locally and only step 2 changes.

Browser, with the full Docker stack

Serving Robo-Boy to a browser adds two requirements the native apps do not have: a browser needs HTTPS before it will grant a page a camera and other secure-context features, and the app and the robot services have to appear under one origin. Caddy supplies both, and a locally trusted certificate is what makes the first one work without warnings. You need Docker with Compose and mkcert.

  1. 1

    Clone and create a trusted local certificate

    git clone git@github.com:tessel-la/robo-boy.git
    cd robo-boy
    mkcert -install
    mkdir -p infra/caddy/certs
    mkcert \
      -key-file infra/caddy/certs/local-key.pem \
      -cert-file infra/caddy/certs/local-cert.pem \
      localhost 127.0.0.1 ::1 YOUR_HOST_IP

    Include the computer's LAN or VPN address as YOUR_HOST_IP when another device will open Robo-Boy.

  2. 2

    Start the stack

    docker compose up -d --build

    Open https://localhost, or https://YOUR_HOST_IP from a device on the same network. Every ROS and DDS setting has a default, so no environment file is needed for this configuration; add one only to select a robot workspace overlay.

  3. 3

    Stop or inspect the stack

    docker compose logs -f app
    docker compose logs -f caddy
    docker compose logs -f ros-stack
    docker compose down
Frontend only

UI development

Node.js 20+ and npm are required. ROS, video, and mesh routes need separate services or mocks.

npm ci
npm run dev
# http://localhost:5173
Desktop shell

Tauri development

Install Tauri v2 prerequisites and run the ROS stack separately. The desktop app connects directly to its host.

docker compose up -d --build ros-stack
npm ci
npm run desktop:dev
03 / Connect ROS 2

Robo-Boy talks to ROS through rosbridge.

The ROS stack supplies rosapi, rosbridge, and web_video_server. Robo-Boy discovers topics and interfaces after the shared ROSLIB WebSocket connects.

ConnectionWhat happensUse when
Quick Connect Uses the current web host's proxy routes. Desktop uses localhost. Robo-Boy and ROS services share a machine.
Domain ID Uses the current web host's proxy routes; DDS configuration belongs to the ROS container. The deployment owns ROS discovery settings.
Host or IP Uses the selected backend host for rosbridge, video, and mesh endpoints. The robot computer is elsewhere on the LAN or VPN.

Default service contract

rosbridgews://HOST:9090
videohttp://HOST:8080
mesheshttp://HOST:8000
Ollamahttp://HOST:11434

On the web, Quick Connect normally resolves these through same-origin /websocket, /video_stream, /mesh_resources, and /ollama routes. Advanced Ports override the first three service ports. Tauri connects directly.

DDS and robot-specific interfaces

The ros-stack service uses host networking for DDS discovery. Every container that must discover the same graph needs compatible ROS_DOMAIN_ID, ROS_LOCALHOST_ONLY, and RMW_IMPLEMENTATION settings.

Robo-Boy reads a topic through the interface definition that describes it, and it only has the definitions present inside ros-stack. A robot with message, service, or action types of its own therefore needs its built workspace mounted there, or those topics appear in the graph and cannot be decoded. Mount the workspace's install directory under a unique path in a Compose override:

services:
  ros-stack:
    volumes:
      - /path/to/robot_ws/install:/overlay_ws/robot:ro
COMPOSE_FILE=docker-compose.yml:infra/compose/robot.yml docker compose up -d --build

Overrides for the bundled Aerostack and manipulator workspaces already exist under infra/compose/. A workspace built with colcon --symlink-install also needs its build and source directories mounted, since its installed files point back at them.

ROS_DOMAIN_ID=0
ROS_AUTOMATIC_DISCOVERY_RANGE=SUBNET
ROS_STATIC_PEERS=
RMW_IMPLEMENTATION=rmw_fastrtps_cpp
FASTDDS_BUILTIN_TRANSPORTS=UDPv4

A robot workspace overlay is selected by pointing Compose at one of the bundled override files, either in .env or on the command line. After changing overlays, recreate the service:

COMPOSE_FILE=docker-compose.yml:infra/compose/aerostack.yml
# or infra/compose/manipulator.yml
docker compose up -d --build --force-recreate ros-stack
04 / Architecture

A frontend-first system with a narrow ROS boundary.

Robo-Boy has no application server or database. The browser or Tauri webview owns the interface, ROSLIB owns the active bridge connection, and browser storage holds serializable local state.

Client
React shellApp + MainControlView
Feature modulesPanels, BT, gamepads, themes
External panel hostSDK v2 + sandbox broker
ROSLIB WebSocket · HTTP media/resources
Services
rosapiGraph + schema discovery
rosbridgeROS over WebSocket
web_video_serverImage streams
ROS 2 DDS · host network
Robot
Robot nodesTopics, services, actions
SimulationOptional workspace overlays
01

App shell

App owns connection-screen transitions and global theme state. MainControlView coordinates the workspace and the single shared ROS connection.

02

Feature ownership

Behavior trees, custom gamepads, themes, TF, and 3D visualization keep their logic, persistence, and lifecycle code in focused modules.

03

Persistence

Layouts, themes, trees, visualization settings, panel sizing, and external-panel state use versioned browser storage. Live ROS clients and rendering objects are never serialized.

04

Deployment

Caddy gives the web app and robot services one origin. Tauri packages only the frontend and connects directly to services on the selected host.

05 / Concepts & built-ins

The workspace is a set of tiles backed by stable panel IDs.

Built-in and compatible external panels are merged into one catalog. The workspace stores panel instances and layout locally; an external tile retains its reverse-domain panel ID so Robo-Boy can show an actionable unavailable state if the deployment later removes that panel.

CAM

Camera

Discovers ROS image topics and renders the selected stream through web_video_server.

ros · network
3D

3D view

Visualizes TF, point clouds, laser scans, pose/odometry data, camera info, and URDF models.

ros · network · storage
BT

Behavior tree

Builds and runs sequence, selector, parallel, action, service, topic, and subscriber nodes.

ros · network · storage
TF

TF tree

Subscribes to live /tf and /tf_static data to inspect the transform hierarchy.

ros
PAD

Pad controls

Loads a configurable control surface with publish, subscribe, camera, plotting, and heartbeat components.

ros · storage
ESM

External panel

Loads a reviewed same-origin module lazily and gives each tile its own activation context and state namespace.

declared per manifest

Everyday workflows

Custom control pad

Open +, clone a template or start empty, place controls, assign ROS topics and mappings, then save or export the versioned JSON.

3D scene

Add visualizations, choose a fixed frame and topics, adjust render options, and rely on local storage for the serialized configuration.

Behavior tree

Discover ROS interfaces, configure nodes, connect the graph, then run in browser-owned mode or opt into a ROS-owned persistent execution.

Theme

Select a built-in palette or create a local custom theme. External panels can consume Robo-Boy CSS variables without importing theme internals.

06 / Panel SDK

Independent code. Small contract. Trusted boundary.

Panel SDK 2.2.0 is a type-only, additive update to the Panel API 2.0 contract. A panel exports one framework-neutral definition, runs in its own opaque-origin iframe, owns everything below the supplied DOM element, and receives only declared host services over a private channel. The release adds the webxr capability, which delegates XR spatial tracking and fullscreen for an immersive session, on top of the dedicated WHEP, discovery, and HLS host endpoints and binary arrayBuffer() responses introduced in 2.1.0; Robo-Boy does not scan node_modules or import panel source into its application build.

Robo-Boy core Catalog → verify → sandbox → broker

Owns metadata and integrity checks, workspace layout, iframe lifecycle, permission brokers, persistence, and tile-local errors.

+
External repository Source → ESM bundle → release

Owns dependencies, tests, manifest, immutable artifacts, and distribution policy.

=
Installed deployment Same-origin versioned assets

panels/installed.json lists the verified releases installed into that Robo-Boy deployment from its configured catalogs.

⚠
v2 minimizes access; it is not a hostile-code boundary.

Each panel runs without allow-same-origin, direct HTTP is blocked by sandbox CSP, and host services enforce manifest permissions. A panel can still consume CPU or misuse data explicitly granted to it, so install only reviewed artifacts.

The host owns tile creation, sizing, ordering, saved workspace layouts, and replacement. The panel owns only DOM below its mount element; v2 exposes no parent-page or layout-mutation API. Store panel configuration through context.storage, and keep framework/runtime dependencies inside the panel's own bundle.

Capability declarations

rosSupplies brokered discovery, topic selection, subscribe, publish, and service calls constrained by permissions.ros.
storageSupplies a 64 KiB, instance-owned JSON store.
networkSupplies credential-free brokered fetch and explicitly approved host endpoints constrained by permissions.network.
web-bluetoothDeclares Web Bluetooth use.
web-usbDeclares WebUSB use.
web-serialDeclares Web Serial use.
cameraDeclares camera/media capture.
microphoneDeclares microphone/media capture.
webxrDelegates XR spatial tracking and fullscreen for an immersive WebXR session.

Permission declarations

{
  "capabilities": ["ros", "network"],
  "permissions": {
    "ros": {
      "selectTopic": true,
      "publish": ["/cmd_vel"],
      "services": ["/robot/reset"]
    },
    "network": {
      "origins": ["https://telemetry.example.com"],
      "hostEndpoints": ["webrtcWhep", "webrtcDiscovery", "webrtcHls"]
    }
  }
}

ROS and network capabilities require matching permission blocks. Static topic/service patterns are checked for every operation. selectTopic opens a trusted Robo-Boy picker and grants only the chosen topic to that tile; panels never receive the unfiltered graph. Brokered network requests omit ambient browser credentials, revalidate redirects, and can return text, JSON, or binary bytes. videoStream remains available for older panels; new stream panels should request the dedicated gateway endpoints.

07 / Lifecycle

One imported module can create many isolated tile instances.

1Discover

Fetch and validate registry JSON. No panel code runs.

2Sandbox

Verify SHA-256, create an opaque iframe, and transfer source over a private channel.

3Activate

Import the ESM in the sandbox and create one instance with brokered services.

4Mount

Render below the supplied HTML element.

5Pause

React to effective visibility through setActive.

6Unmount

Release every owned resource, deterministically.

What remounts a panel

  • The tile or host disappears.
  • The panel manifest/version changes.
  • A required ROS instance changes.
  • The user retries after a lifecycle error.

What unmount() must release

  • DOM and browser event listeners.
  • Timers, animation frames, and observers.
  • Brokered ROS subscriptions and pending requests.
  • Media streams, WebRTC, WebGL, and device connections.

setActive(isActive) is optional; mount and unmount are mandatory. Effective active state combines workspace activity, tile intersection, and document visibility. Removing the iframe terminates the panel realm even if cleanup fails.

08 / Public API 2.0.0

The complete runtime surface fits in a few interfaces.

interface RoboBoyPanelDefinition {
  apiVersion: string;
  id: string;
  activate(
    context: RoboBoyPanelContext
  ): RoboBoyPanelInstance | Promise<RoboBoyPanelInstance>;
}

interface RoboBoyPanelInstance {
  mount(container: HTMLElement): void | Promise<void>;
  setActive?(isActive: boolean): void | Promise<void>;
  unmount(): void | Promise<void>;
}

interface RoboBoyPanelContext {
  readonly panelId: string;
  readonly instanceId: string;
  readonly capabilities: readonly RoboBoyPanelCapability[];
  readonly ros: RoboBoyPanelRos | null;
  readonly storage: RoboBoyPanelStorage | null;
  readonly network: RoboBoyPanelNetwork | null;
  readonly runtime: RoboBoyPanelRuntime;
  readonly connection: RoboBoyPanelConnection;
  readonly viewport: RoboBoyPanelViewport;
  readonly theme: RoboBoyPanelTheme;
  readonly logger: RoboBoyPanelLogger;
}
Context memberType / contractUse
panelIdstringStable manifest/definition ID.
instanceIdstringUnique workspace tile identity.
capabilitiesreadonly capability arrayValidated declarations from the manifest.
rosbroker or nullPermission-checked JSON ROS operations when ros is declared.
storagestore or nullPer-instance JSON state when storage is declared.
networkbroker or nullAllowlisted, credential-free fetch and approved named host endpoints.
runtimeweb | desktopCurrent host target; the complete endpoint map is intentionally private.
connectionsnapshot + subscribeStatus and monotonically increasing ROS generation.
viewportsnapshot + subscribe + fullscreenTile size, visibility, effective activity, fullscreen request.
themesnapshot + subscribeLight/dark state and the reviewed public CSS token set.
loggerdebug/info/warn/errorPanel- and instance-prefixed host logging.
Runtime, connection, and viewport shapes
interface RoboBoyPanelRuntime {
  readonly target: 'web' | 'desktop';
}

interface RoboBoyPanelConnectionSnapshot {
  readonly status: 'disconnected' | 'connecting' | 'connected';
  readonly generation: number;
}

interface RoboBoyPanelConnection {
  getSnapshot(): RoboBoyPanelConnectionSnapshot;
  subscribe(
    listener: (snapshot: RoboBoyPanelConnectionSnapshot) => void
  ): () => void;
}

interface RoboBoyPanelViewportSnapshot {
  readonly width: number;
  readonly height: number;
  readonly isIntersecting: boolean;
  readonly isDocumentVisible: boolean;
  readonly isActive: boolean;
}

interface RoboBoyPanelViewport {
  getSnapshot(): RoboBoyPanelViewportSnapshot;
  subscribe(
    listener: (snapshot: RoboBoyPanelViewportSnapshot) => void
  ): () => void;
  requestFullscreen(): Promise<void>;
}

interface RoboBoyPanelLogger {
  debug(message: string, ...details: unknown[]): void;
  info(message: string, ...details: unknown[]): void;
  warn(message: string, ...details: unknown[]): void;
  error(message: string, ...details: unknown[]): void;
}
Brokered ROS and network shapes
interface RoboBoyPanelRos {
  getTopics(): Promise<RoboBoyRosTopic[]>;
  selectTopic(options?: { currentTopic?: string }): Promise<RoboBoyRosTopic>;
  subscribe(
    options: RoboBoyRosSubscriptionOptions,
    listener: (message: RoboBoyJsonObject) => void
  ): Promise<RoboBoyRosSubscription>;
  publish(options: RoboBoyRosPublishOptions): Promise<void>;
  callService(options: RoboBoyRosServiceOptions): Promise<RoboBoyJsonObject>;
}

type RoboBoyHostEndpoint =
  | 'videoStream'
  | 'webrtcWhep'
  | 'webrtcDiscovery'
  | 'webrtcHls';

interface RoboBoyPanelNetworkResponse {
  readonly ok: boolean;
  readonly status: number;
  readonly statusText: string;
  readonly headers: { get(name: string): string | null };
  text(): Promise<string>;
  json<T extends RoboBoyJsonValue = RoboBoyJsonValue>(): Promise<T>;
  arrayBuffer(): Promise<ArrayBuffer>;
}

interface RoboBoyPanelNetwork {
  readonly endpoints: Partial<Record<RoboBoyHostEndpoint, string>>;
  fetch(
    url: string,
    request?: RoboBoyPanelNetworkRequest
  ): Promise<RoboBoyPanelNetworkResponse>;
}

ROS permissions can allow filtered discovery, a trusted host topic picker, subscription patterns, publish patterns, and services. Network permissions allow exact HTTPS origins, self, https:, or named host endpoints. The broker rechecks every operation and redirect; arrayBuffer() carries media segments and other non-text responses without bypassing the panel sandbox.

Theme contract
interface RoboBoyPanelThemeSnapshot {
  readonly colorScheme: 'light' | 'dark';
  readonly tokens: Readonly<
    Partial<Record<RoboBoyPanelThemeToken, string>>
  >;
}

interface RoboBoyPanelTheme {
  getSnapshot(): RoboBoyPanelThemeSnapshot;
  subscribe(
    listener: (snapshot: RoboBoyPanelThemeSnapshot) => void
  ): () => void;
}

Robo-Boy also writes the public tokens into the sandbox as CSS custom properties. Prefer variables such as --background-color, --card-bg, --text-color, --border-color, --primary-color, and --font-family-ui.

Storage contract and limits
interface RoboBoyPanelStorage {
  readonly schemaVersion: 1;
  readonly quotaBytes: number;
  get<T extends RoboBoyJsonValue>(key: string, fallback: T): T;
  set(key: string, value: RoboBoyJsonValue): void;
  remove(key: string): void;
  sizeBytes(): number;
}

Keys are 1–128 characters using letters, numbers, dots, underscores, or dashes. Values must be finite, acyclic, plain JSON no deeper than 20 levels. Each tile has a 64 KiB serialized quota.

09 / Complete panel tutorial

Build a persistent greeting panel from scratch.

This minimal standalone panel uses the v2 sandbox lifecycle and brokered storage API. It has no runtime framework dependency; TypeScript emits a browser-ready ESM file.

  1. 1

    Create a sibling repository

    mkdir my-roboboy-panel
    cd my-roboboy-panel
    mkdir -p src scripts

    For local staging, keep it next to robo-boy/. A local desired-state source points at the repository explicitly; it does not scan sibling projects.

  2. 2

    Add package.json

    {
      "name": "my-roboboy-panel",
      "version": "0.1.0",
      "type": "module",
      "scripts": {
        "build": "tsc -p tsconfig.json",
        "typecheck": "tsc -p tsconfig.json --noEmit",
        "integrity": "node scripts/print-integrity.mjs"
      },
      "devDependencies": {
        "@tessel-la/roboboy-panel-sdk": "https://github.com/tessel-la/robo-boy/releases/download/panel-sdk-v2.2.0/tessel-la-roboboy-panel-sdk-2.2.0.tgz",
        "typescript": "^5.2.2"
      }
    }
    npm install

    The SDK is distributed as a release tarball, not through the npm registry. Pin it and commit the lockfile.

  3. 3

    Configure TypeScript

    {
      "compilerOptions": {
        "target": "ES2020",
        "module": "ES2020",
        "moduleResolution": "bundler",
        "lib": ["ES2020", "DOM"],
        "strict": true,
        "declaration": false,
        "outDir": "dist",
        "skipLibCheck": true
      },
      "include": ["src"]
    }
  4. 4

    Implement src/index.ts

    import type {
      RoboBoyPanelContext,
      RoboBoyPanelDefinition,
    } from '@tessel-la/roboboy-panel-sdk';
    
    const activate = (context: RoboBoyPanelContext) => {
      let root: HTMLElement | null = null;
      let button: HTMLButtonElement | null = null;
      let count = context.storage?.get('greetings', 0) ?? 0;
    
      const renderCount = () => {
        if (button) button.textContent = `Send greeting (${count})`;
      };
    
      const sendGreeting = () => {
        count += 1;
        context.storage?.set('greetings', count);
        context.logger.info(`Greeting ${count} sent.`);
        renderCount();
      };
    
      return {
        mount(container: HTMLElement) {
          root = document.createElement('section');
          root.style.cssText = `
            height: 100%; display: grid; place-items: center;
            padding: 24px; box-sizing: border-box;
            color: var(--text-color, #f5f7fb);
            background: var(--background-secondary, #252b35);
          `;
    
          const content = document.createElement('div');
          content.style.textAlign = 'center';
    
          const heading = document.createElement('h2');
          heading.textContent = 'Hello from outside Robo-Boy';
    
          button = document.createElement('button');
          button.type = 'button';
          button.addEventListener('click', sendGreeting);
          renderCount();
    
          content.append(heading, button);
          root.append(content);
          container.replaceChildren(root);
        },
    
        setActive(isActive: boolean) {
          root?.toggleAttribute('data-inactive', !isActive);
        },
    
        unmount() {
          button?.removeEventListener('click', sendGreeting);
          root?.remove();
          button = null;
          root = null;
        },
      };
    };
    
    const definition: RoboBoyPanelDefinition = {
      apiVersion: '2.0.0',
      id: 'com.example.greeting',
      activate,
    };
    
    export default definition;
  5. 5

    Add roboboy.panel.json

    {
      "schemaVersion": 1,
      "id": "com.example.greeting",
      "name": "Greeting",
      "description": "A minimal persistent greeting panel.",
      "version": "0.1.0",
      "entryPoint": "./dist/index.js",
      "integrity": "sha256-REPLACE_WITH_THE_GENERATED_BASE64_DIGEST=",
      "compatibility": {
        "panelApi": "^2.0.0",
        "roboboy": ">=0.3.0-0 <1.0.0"
      },
      "capabilities": ["storage"],
      "author": { "name": "Your team" },
      "repository": "https://github.com/YOUR_ORG/my-roboboy-panel",
      "tags": ["example"]
    }

    Use a stable lowercase reverse-domain ID you control. The definition, manifest, desired-state selection, and published catalog entry must agree on identity and compatibility.

  6. 6

    Build and calculate integrity

    Create scripts/print-integrity.mjs:

    import { createHash } from 'node:crypto';
    import { readFile } from 'node:fs/promises';
    
    const bundle = await readFile(new URL('../dist/index.js', import.meta.url));
    console.log(`sha256-${createHash('sha256')
      .update(bundle)
      .digest('base64')}`);
    npm run typecheck
    npm run build
    npm run integrity

    Replace the placeholder in the manifest with the complete sha256-... output after every bundle change.

  7. 7

    Create the desired-state configuration

    Create an ignored config/panel-sources.json inside robo-boy/. Schema v2 uses typed sources and explicit selection; local development does not require an inventory entry.

    {
      "schemaVersion": 2,
      "sources": [
        {
          "type": "local",
          "name": "development",
          "root": "../..",
          "rootEnv": "ROBOBOY_PANEL_WORKSPACE",
          "repositories": ["my-roboboy-panel"]
        }
      ],
      "selection": {
        "mode": "include",
        "panelIds": ["com.example.greeting"]
      }
    }
  8. 8

    Stage, load, and use it

    cd ../robo-boy
    npm run panels:stage-local -- \
      --config config/panel-sources.json
    
    npm run dev:panels -- \
      --config config/panel-sources.json

    Open the workspace, choose Add panel, and select Greeting. Click the button, remove and re-add the tile, and confirm the instance-owned count returns. The normal npm run dev command intentionally keeps the tracked empty panel registry.

10 / Copy-and-adapt recipes

Patterns for real robot panels.

Panel API v2 brokers ROS as JSON operations. Panels do not receive ROSLIB, the rosbridge URL, or the complete graph; every request is checked against the manifest permissions.

ROS Subscribe to a topic
import type { RoboBoyRosSubscription } from
  '@tessel-la/roboboy-panel-sdk';

let subscription: RoboBoyRosSubscription | null = null;

if (context.ros) {
  const selected = await context.ros.selectTopic({
    currentTopic: '/battery_state',
  });
  subscription = await context.ros.subscribe({
    topic: selected.name,
    messageType: selected.messageType,
    queueLength: 1,
    throttleMs: 100,
    compression: 'none',
  }, (message) => {
    renderBattery(message);
  });
}

// In async unmount():
await subscription?.unsubscribe();
subscription = null;

Declare capabilities: ["ros"] and permissions.ros.selectTopic: true. The complete graph stays in trusted Robo-Boy UI; the panel receives and is granted only the selected topic and message type.

ROS Publish a message
await context.ros?.publish({
  topic: '/cmd_vel',
  messageType: 'geometry_msgs/msg/Twist',
  message: {
    linear: { x: 0.2, y: 0, z: 0 },
    angular: { x: 0, y: 0, z: 0 },
  },
});

Declare the exact publish path in permissions.ros.publish and match the robot's interface definition. For continuous controls, throttle publishing, cancel pending work, and deliberately publish a safe released/stopped state when the panel owns that responsibility.

STATE Expose settings and persist configuration
import type { RoboBoyJsonObject } from
  '@tessel-la/roboboy-panel-sdk';

type Settings = { topic: string; compact: boolean };

const fallback: Settings = {
  topic: '/battery_state',
  compact: false,
};

const stored = context.storage?.get(
  'settings',
  fallback as unknown as RoboBoyJsonObject,
);
let settings = (stored as unknown as Settings) ?? fallback;

function save(next: Settings) {
  settings = next;
  try {
    context.storage?.set(
      'settings',
      next as unknown as RoboBoyJsonObject,
    );
  } catch (error) {
    context.logger.warn('Unable to persist settings.', error);
  }
}

v2 has no host-rendered settings pane. Render controls inside your tile, sanitize loaded data, keep secrets out of storage, and handle quota errors.

LIVE Pause expensive rendering when hidden
let frame: number | null = null;
let active = context.viewport.getSnapshot().isActive;

const draw = () => {
  if (!active) return;
  renderLatestSample();
  frame = requestAnimationFrame(draw);
};

const stop = context.viewport.subscribe((snapshot) => {
  active = snapshot.isActive;
  if (active && frame === null) frame = requestAnimationFrame(draw);
  if (!active && frame !== null) cancelAnimationFrame(frame);
  if (!active) frame = null;
});

// In unmount():
stop();
if (frame !== null) cancelAnimationFrame(frame);

Use viewport width and height for compact layouts. Use setActive for coarse lifecycle changes and the viewport subscription when size or detailed visibility matters.

VIDEO Camera and WebRTC panels

For core ROS image topics, prefer the built-in Camera panel or a custom gamepad camera component. A transport-specific external panel can declare network with permissions.network.hostEndpoints: ["webrtcWhep", "webrtcDiscovery", "webrtcHls"], use the matching context.network?.endpoints, and own its media lifecycle. The official WebRTC panel 3.0.1:

  • Discovers ready gateway paths below the restricted /webrtc/_discovery/paths resource.
  • Negotiates WHEP with the browser's native WebRTC API.
  • Falls back to brokered low-latency HLS only when the desktop webview has no RTCPeerConnection; binary segments are read with arrayBuffer().
  • Stops statistics timers, aborts discovery, closes the remote WHEP session, and clears media on deactivate/unmount.
  • Keeps bearer credentials in memory; it never writes them to panel storage.
Study the WebRTC panel 3.0.1 release ↗
DEVICE Bluetooth or external devices
let device: BluetoothDevice | null = null;

async function connectFromUserGesture() {
  device = await navigator.bluetooth.requestDevice({
    filters: [{ services: ['YOUR_STANDARD_OR_VENDOR_SERVICE_UUID'] }],
  });
  const server = await device.gatt?.connect();
  // Resolve your documented GATT service and characteristics.
}

// In unmount():
device?.gatt?.disconnect();
device = null;

Declare web-bluetooth. The browser API normally requires HTTPS, compatible hardware/browser support, and a user gesture. Use the exact service and characteristic UUIDs from the device specification; the SDK does not broker or wrap Bluetooth. Apply the same ownership rule to WebUSB and Web Serial.

11 / Develop & test

Test the artifact the host will actually import.

01

Pure logic

Unit-test message normalization, field selection, sizing, state migration, and protocol helpers without a browser.

02

Lifecycle

Exercise activation, mount, active-state changes, repeated cleanup, missing services, quota errors, and failures.

03

Artifact

Import dist/index.js; verify its default ID/API, required methods, no unresolved bare imports, and SHA-256.

04

Host integration

Stage through schema-v2 desired state and test add, sandbox startup, permission denials, trusted topic selection, reload, reconnect, hide/show, resize, remove, and retry.

Local repository layout

workspace/
  robo-boy/                 # host application
  my-roboboy-panel/        # independent source + dist + manifest

The local source lists repository directories explicitly, then verifies bundle, manifest, permissions, compatibility, and integrity before generating an ignored .panel-stage/public/ tree. A separate inventory entry is needed only when publishing.

Robo-Boy project checks

npm run lint
npm run test:run
npm run build
npm run e2e

For Docker development with the known examples, add infra/compose/panels.yml. For a private sibling panel, mount its repository through an uncommitted deployment-local Compose override and select it in the schema-v2 source configuration.

12 / Package & distribute

Publish immutable ESM; install into the deployment.

Panel repoBuild + testroboboy.panel.json
dist/index.js
→
ReleaseImmutable assetsVersioned URLs
SHA-256 SRI
→
InventoryDiscoverable metadataPublic or private
no source/bundles
→
DeploymentInstalled registrySame origin
versioned path
  1. Bundle every runtime dependency. The release entry must be self-contained browser ESM with no unresolved bare imports or relative dynamic chunks. Frameworks such as React, Vue, and Three.js belong to the panel bundle; ROS access comes from the v2 broker.
  2. Generate and record SHA-256 SRI. The exact value must agree across bundle, panel manifest, and inventory.
  3. Publish a versioned release. Include the manifest and bundle. Treat assets as append-only.
  4. Register metadata separately. An inventory points at immutable release artifacts; it is not installed state and does not host source.
  5. Install for a deployment. The panel manager resolves explicit desired state, downloads selected releases, verifies identity and hashes, then atomically replaces installed.json. The browser transfers verified same-origin bytes into isolated panel sandboxes.
# Official remote source overlay
export ROBOBOY_PANEL_MANAGER_TOKEN='use-a-long-random-deployment-secret'

docker compose \
  -f docker-compose.yml \
  -f infra/compose/panels.remote.yml \
  build app panel-manager

docker compose \
  -f docker-compose.yml \
  -f infra/compose/panels.remote.yml \
  up -d

The Compose manager exposes authenticated preview/apply installation controls through Manage installations…. Private sources can use HTTPS release storage and authorization supplied through an approved named environment variable. Credentials stay on the manager side, redirects are revalidated, and duplicate IDs across configured sources are rejected.

13 / Best practices & stability

Build against the contract, not the host implementation.

Public / versioned

Safe dependencies

  • Panel SDK 2.2.0's panel-sdk/index.d.ts and API version 2.0.0.
  • The definition, lifecycle, brokered ROS/network, storage, runtime, connection, viewport, theme, and logger interfaces.
  • The manifest capabilities, permission blocks, and installed-registry contract documented for v2.
  • The reviewed CSS custom properties, used with your own fallbacks.
Internal / changeable

Do not couple to these

  • React components, hooks, application stores, and feature-module files.
  • MainControlView, ExternalPanelHost, loader/registry internals, or localStorage keys.
  • Built-in panel adapters and custom-gamepad implementation details.
  • Robo-Boy's bundled copy of ROSLIB, Three.js, or any other dependency, and its private endpoint map.

Own resources

Create clients inside the instance that cleans them up. Make unmount safe to call after partial startup and more than once.

Defend state

Version and sanitize your settings. Store only plain JSON. Keep live objects, handles, credentials, and large sample buffers in memory.

Respect activity

Stop decoding, polling, plotting, and device traffic while inactive. Design from a small tile upward using viewport snapshots.

Handle reconnects

Watch connection generation. Discard stale async results and re-establish brokered subscriptions after reconnects.

Keep releases lean

Bundle only what the panel needs. Shared host dependencies are deliberately not part of v2, and relative dynamic chunks are not supported.

Fail locally

Use the supplied logger, provide meaningful empty/error states, and allow exceptions to remain attributable to your versioned bundle.

14 / Troubleshooting

Start at the boundary that is failing.

Robo-Boy loads, but ROS never connects

Check docker compose logs ros-stack, confirm rosbridge listens on the selected/default port, and verify the web proxy or direct host is reachable. Every DDS participant must use compatible domain, discovery, and middleware settings.

The phone rejects HTTPS or browser device APIs

Regenerate the mkcert certificate with the machine's current LAN/VPN IP and trust the mkcert CA on the client device. Camera, Bluetooth, and similar browser APIs generally require a secure context.

Custom ROS messages are missing

Mount a built install workspace under /overlay_ws/<name>, recreate ros-stack, and inspect its logs for the activation line. Generated hooks may also require their original build/source paths.

A panel does not appear in Add panel

Normal dev and build use an empty tracked registry. Run a panel-aware command. Check ID uniqueness, manifest shape, compatibility ranges, same-origin versioned entry point, registry size, and the browser console for registry issues.

Local panel staging fails

Confirm the schema-v2 configuration names the local repository explicitly and its root resolves correctly on the host or through the approved root environment variable. Rebuild, regenerate the digest, and verify the manifest ID, API compatibility, capabilities, permissions, and integrity.

The tile says the panel could not start

Inspect the panel-prefixed console error. Common causes are an invalid default export, mismatched ID/API version, rejected integrity, unresolved runtime imports, sandbox startup failure, failure in activate/mount, or a denied capability/permission. Fix the artifact and publish/stage a new immutable version.

State fails to save

Declare storage; validate keys and plain JSON values; avoid cycles, non-finite numbers, deep nesting, and the 64 KiB per-instance limit. Browser storage is isolated by origin and can be unavailable or full.

Video, plots, or WebGL keep consuming resources

React to effective activity, unsubscribe high-rate topics, cancel animation frames, stop media tracks/timers, close peer/device connections, and dispose rendering resources in unmount.

15 / Known gaps

What the v2 SDK deliberately does not promise yet.

These are current platform boundaries found in the SDK, host loader, and project documentation. They are not hidden APIs to work around.

01

No automatic update or rollback

Compose provides authenticated preview/apply installation management, but there is no rollback UI, scheduled update policy, publisher revocation service, or garbage collector. CLI staging remains available for builds and Tauri.

02

Not a hostile-code boundary

The opaque-origin iframe prevents parent-page access and brokers host services, but a panel can still consume main-thread resources or misuse data explicitly granted to it. Install only reviewed artifacts.

03

No host settings surface

Panels render their own settings inside the tile. There is no standardized settings schema, secret store, notifications API, or application-level command surface.

04

Single-bundle releases

The installer rejects extra manifest assets until inventory metadata defines immutable URLs for every asset.

05

No shared runtime dependencies

Panel authors bundle any framework and runtime libraries themselves. This preserves independence but can duplicate code across panel sandboxes.

06

Integrity is not identity

SHA-256 detects drift but does not authenticate a publisher. Signing, inventory review policy, transactional rollback metadata, and revocation remain future hardening work.

07

No per-panel resource accounting

Lifecycle failures are tile-local and removing the iframe terminates its realm, but CPU-heavy synchronous code can still affect the main thread. Per-panel CPU and bandwidth limits remain future work.

08

ROS is a JSON broker

API v2 supports discovery, trusted topic selection, subscriptions, publishing, and service calls. Binary payloads, actions, and streaming service responses are not part of the contract.

Keep exploring

Read the implementation beside the contract.

The SDK definition, external-panel guide, and three standalone reference panels are the strongest source material for panel authors.