Skip to content

Custom UI Development Guide

Build rich interactive UIs for your photons. A global named after your photon file is auto-injected into the iframe — call methods and subscribe to events directly (e.g., kanban.onTaskMove(cb)).


Table of Contents


Overview

Photon custom UIs run in iframes and communicate with the host (BEAM, Claude Desktop, ChatGPT) via postMessage. The platform bridge automatically injects compatible APIs for:

  • MCP Apps Extension (SEP-1865) - Standard protocol for MCP UIs
  • ChatGPT Apps SDK - window.openai compatibility
  • Claude Artifacts - Theme synchronization

Sandbox Constraints

Photon UIs are loaded into a sandboxed blob: iframe so the same HTML works in every MCP client (Beam, Claude Desktop, ChatGPT, Cursor, future clients). Portability is the whole point — but the sandbox has real limits that matter if you try to run heavy browser features like client-side AI models, WebRTC, or WebGPU.

What doesn't work inside the iframe

  • Cross-origin fetch() — the iframe origin is null/opaque, so many CDNs reject CORS preflight. Loading model weights from HuggingFace, jsdelivr, unpkg often fails.
  • SharedArrayBuffer / threaded WASM — requires Cross-Origin-Isolated, which needs COOP/COEP headers the host client does not set. Rules out WebLLM and threaded ONNX Runtime.
  • WebGPU, camera, microphone — gated by Permissions-Policy on the parent iframe; not guaranteed across clients.
  • Dynamic import() of remote ESM / importScripts over http(s) — often blocked from blob: contexts.
  • Persistent IndexedDB / Cache Storage — scoped to the opaque origin, so models may re-download each session.

These are host-imposed constraints, not photon-runtime bugs. Changing them would either break portability or require every MCP client to adopt COOP/COEP, which is out of our control.

Choosing a strategy (photon author's call)

If your photon needs capabilities that bump into the sandbox, pick one of these up front:

  1. Run it on the backend (recommended default). Do the work in a photon method using Node/Bun libraries (onnxruntime-node, @xenova/transformers, sharp, etc.) and return results to the UI. Works in every MCP client, model cached on disk, no sandbox friction. Trade-off: no live webcam/audio stream without round-trips.

  2. Proxy assets through a photon method. Expose a method that returns model weights / remote resources as bytes. The UI calls it via the injected bridge instead of fetch(), sidestepping CORS from blob:. Portable, but slower first load.

  3. Inline small assets as data URIs. For models or datasets under a few MB (face/pose detection, small classifiers, fonts), base64-embed them in the UI HTML. Zero fetches, fully portable, ugly diff.

  4. Accept single-threaded WASM. Most detection-class models (MediaPipe Tasks, small ONNX via transformers.js) run fine single-threaded inside the sandbox. Slower than WebGPU/threads but fully portable.

  5. Beam-only enhancement. If and only if a feature genuinely cannot work under the sandbox and is acceptable as a Beam-only feature, document that clearly in the photon's README. Do not design the core experience around it — the photon must still work in other MCP clients.

Rule of thumb: if in doubt, do it on the backend. The @ui HTML is a renderer, not an application runtime.


MCP Apps Extension (SEP-1865)

The MCP Apps Extension defines a standard protocol for rendering UIs in MCP-compatible clients.

Initialization

When your UI loads, it receives a ui/initialize message:

json
{
  "jsonrpc": "2.0",
  "method": "ui/initialize",
  "params": {
    "hostContext": {
      "name": "beam",
      "version": "1.5.0"
    },
    "hostCapabilities": {
      "toolCalling": true,
      "resourceReading": true,
      "elicitation": true
    },
    "containerDimensions": {
      "mode": "responsive",
      "width": 800,
      "height": 600
    },
    "theme": {
      "--color-bg": "#0d0d0d",
      "--color-text": "#e6e6e6"
    }
  }
}

Ready Signal

Your UI must signal readiness:

javascript
window.parent.postMessage({
  jsonrpc: '2.0',
  method: 'ui/ready',
  params: {}
}, '*');

Platform Compatibility

BEAM injects APIs for multiple platforms, so your UI works everywhere:

PlatformAPIAuto-Injected
BEAM{photonName} global (e.g., kanban, chess)Yes
BEAMphoton low-level bridgeYes
BEAMopenai (ChatGPT compat)Yes
ChatGPTopenaiNative
ClaudepostMessageNative

Photon Bridge API

Two APIs are injected into your custom UI iframe:

  1. {photonName} (recommended) — A clean global named after your photon file. Call methods and subscribe to events directly: kanban.taskMove(args), kanban.onTaskMove(cb).
  2. photon — The low-level bridge with full control over tool I/O, progress, streaming, elicitation, and state.

No window. prefix needed — both are available as bare globals.

Low-level bridge (photon)

Properties

typescript
interface PhotonAPI {
  // Tool input/output
  readonly toolInput: Record<string, any>;   // Input parameters
  readonly toolOutput: any;                   // Last result
  readonly widgetState: any;                  // Persisted state

  // Context
  readonly theme: 'light' | 'dark';
  readonly locale: string;
  readonly photon: string;                    // Photon name
  readonly method: string;                    // Current method
  readonly isChatGPT: boolean;                // Running in ChatGPT?
}

Methods

typescript
// State persistence (survives page reload)
setWidgetState(state: any): void;

// Tool invocation
callTool(name: string, args: Record<string, any>): Promise<any>;
invoke(name: string, args: Record<string, any>): Promise<any>; // Alias

// Follow-up message
sendFollowUpMessage(message: string): void;

// Event subscriptions (each returns an unsubscribe function)
onProgress(cb: (event: { value: number; message?: string }) => void): () => void;
onStatus(cb: (event: { message: string }) => void): () => void;
onStream(cb: (event: { chunk: string }) => void): () => void;
onEmit(cb: (event: { emit: string; data?: any }) => void): () => void;
onResult(cb: (result: any) => void): () => void;
onError(cb: (error: any) => void): () => void;
onThemeChange(cb: (theme: 'light' | 'dark') => void): () => void;
onToolInputPartial(cb: (partial: any) => void): () => void;
onToolInput(cb: (input: any) => void): () => void;
onElicitation(handler: (event: any) => Promise<any>): () => void;
onTeardown(handler: () => void): () => void;

// Model context update (MCP Apps Extension)
updateModelContext(opts: { content?: string; structuredContent?: any }): Promise<void>;

// Toast notifications (displayed in host UI)
showToast(message: string, type?: 'info' | 'success' | 'warning' | 'error', duration?: number): void;

// Safe area insets (for mobile-aware layouts)
readonly safeAreaInsets: { top: number; bottom: number; left: number; right: number };

Note: State restoration uses a DOM event, not the window.photon API: window.addEventListener('photon:state-restored', (event) => { /* event.detail contains state */ })

Each photon gets a global named after the .photon.ts file. No window. prefix needed:

typescript
// For kanban.photon.ts:
kanban = {
  // onEventName → subscribes to 'eventName'
  onTaskMove(cb): () => void,    // subscribes to 'taskMove' event
  onTaskCreate(cb): () => void,  // subscribes to 'taskCreate' event
  // ... any event name works (convention: on + PascalCase)

  // methodName → calls server tool
  taskMove(args): Promise<any>,   // calls photon.callTool('taskMove', args)
  taskCreate(args): Promise<any>, // calls photon.callTool('taskCreate', args)
  // ... any method name works
};

Usage pattern:

javascript
// Server code:                    // Client code:
this.emit('taskMove', data);   →   kanban.onTaskMove(cb)
taskMove(params) { ... }       →   kanban.taskMove(params)

Event Subscriptions

typescript
// ═══════════════════════════════════════════════════════════════════════════
// DIRECT WINDOW API (Recommended for real-time sync)
// ═══════════════════════════════════════════════════════════════════════════

// Subscribe to specific events using the direct window API
// Server: this.emit('taskMove', data)
// Client: kanban.onTaskMove(callback)
kanban.onTaskMove((data) => {
  moveTaskInUI(data.taskId, data.column);
});

kanban.onTaskCreate((data) => {
  addTaskToUI(data.task);
});

// Call server methods
await kanban.taskMove({ id: 'task-1', column: 'Done' });

// ═══════════════════════════════════════════════════════════════════════════
// BUILT-IN EVENT TYPES
// ═══════════════════════════════════════════════════════════════════════════

// Progress updates (0-1 value)
photon.onProgress((event) => {
  console.log(`${event.value * 100}%: ${event.message}`);
});

// Status messages
photon.onStatus((event) => {
  console.log(`Status: ${event.message}`);
});

// Stream data
photon.onStream((event) => {
  console.log(`Chunk: ${event.chunk}`);
});

// All emit events (includes custom events)
photon.onEmit((event) => {
  console.log(`Event: ${event.event}`, event.data);
});

// Final result
photon.onResult((result) => {
  console.log('Complete:', result);
});

// Theme changes
photon.onThemeChange((theme) => {
  document.body.className = theme;
});

// ═══════════════════════════════════════════════════════════════════════════
// TOAST NOTIFICATIONS
// ═══════════════════════════════════════════════════════════════════════════

// Show a toast in the host Beam UI (not inside the iframe)
photon.showToast('Changes saved!', 'success');
photon.showToast('Upload failed', 'error', 5000);
photon.showToast('Processing...', 'info', 2000);

Theming

CSS Variables

The host injects CSS variables for consistent theming:

css
:root {
  /* Background */
  --color-bg: #0d0d0d;
  --color-bg-elevated: #1a1a1a;
  --color-bg-subtle: #262626;

  /* Text */
  --color-text: #e6e6e6;
  --color-text-muted: #999999;
  --color-text-subtle: #666666;

  /* Accent */
  --color-accent: #6366f1;
  --color-accent-hover: #818cf8;

  /* Status */
  --color-success: #22c55e;
  --color-warning: #eab308;
  --color-error: #ef4444;

  /* Borders */
  --color-border: #333333;
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 16px;
}

Theme Detection

typescript
// Listen for theme changes
photon.onThemeChange((theme) => {
  document.documentElement.classList.remove('light', 'dark');
  document.documentElement.classList.add(theme);
});

// Or use CSS
@media (prefers-color-scheme: dark) {
  :root { /* dark theme */ }
}

@media (prefers-color-scheme: light) {
  :root { /* light theme */ }
}

State Management

Persisted Widget State

State persists across page reloads and sessions:

typescript
// Save state
photon.setWidgetState({
  selectedTab: 'settings',
  filters: ['active', 'pending']
});

// Read current state
const state = photon.widgetState;

// Listen for state restoration
window.addEventListener('photon:state-restored', (event) => {
  const state = event.detail;
  renderWithState(state);
});

Tool Input

Access parameters passed to the tool:

typescript
// Read input
const { query, limit } = photon.toolInput;

// Use in UI
document.getElementById('search').value = query || '';

Tool Invocation

Basic Call

typescript
try {
  const result = await photon.callTool('search', {
    query: 'typescript',
    limit: 10
  });
  console.log('Results:', result);
} catch (error) {
  console.error('Tool failed:', error.message);
}

With Loading State

typescript
const searchBtn = document.getElementById('search-btn');

searchBtn.onclick = async () => {
  searchBtn.disabled = true;
  searchBtn.textContent = 'Searching...';

  try {
    const results = await photon.callTool('search', {
      query: document.getElementById('query').value
    });
    renderResults(results);
  } catch (error) {
    showError(error.message);
  } finally {
    searchBtn.disabled = false;
    searchBtn.textContent = 'Search';
  }
};

Real-time Updates

Cross-Client Sync

Photon enables real-time sync between Beam, Claude Desktop, and any MCP Apps-compatible client using standard MCP protocol.

How it works:

  1. Server emits: this.emit('taskMove', data)
  2. Photon sends standard ui/notifications/host-context-changed with embedded _photon data
  3. Claude Desktop (and other hosts) forward this standard notification
  4. Photon bridge extracts _photon and routes to your event handlers

The cleanest way to handle real-time events:

typescript
// Subscribe to specific events
// Pattern: {photonName}.on{EventName}(callback)
kanban.onTaskMove((data) => {
  moveTaskInUI(data.taskId, data.column);
});

kanban.onTaskCreate((data) => {
  addTaskToUI(data.task);
});

kanban.onBoardUpdate((data) => {
  refreshBoard(data);
});

// Call methods (same pattern)
await kanban.taskMove({ id: 'task-1', column: 'Done' });

Generic Event Subscription

For catching all events:

typescript
// Listen for ALL events
photon.onEmit((event) => {
  console.log(`Event: ${event.emit}`, event.data);

  switch (event.emit) {
    case 'taskMove':
      moveTaskInUI(event.data.taskId, event.data.column);
      break;
    case 'taskCreate':
      addTaskToUI(event.data.task);
      break;
  }
});

Notify Viewing (for subscription management)

Tell the host what resource you're viewing (enables ref-counted subscriptions):

typescript
window.parent.postMessage({
  type: 'photon:viewing',
  itemId: 'my-board'
}, '*');

Progress Visualization

html
<div class="progress-bar">
  <div class="progress-fill" id="progress"></div>
  <span class="progress-text" id="progress-text">0%</span>
</div>

<script>
photon.onProgress((event) => {
  const pct = Math.round(event.value * 100);
  document.getElementById('progress').style.width = pct + '%';
  document.getElementById('progress-text').textContent =
    event.message || `${pct}%`;
});
</script>

Sharing a UI Across Methods

Multiple methods can share the same HTML template by referencing the same @ui asset ID. The first method tagged becomes the primary (used for app detection); all tagged methods render their results in the same UI.

typescript
/**
 * @ui dashboard
 */
export default class Analytics {
  /** @ui dashboard */
  async overview() { return { visits: 1000, bounceRate: 0.3 }; }

  /** @ui dashboard */
  async realtime() { return { activeUsers: 42 }; }

  /** @ui dashboard */
  async funnel({ step }: { step: string }) { return { conversion: 0.12 }; }
}

All three methods render inside dashboard.html. The UI receives whichever method's result via onResult and can distinguish them by shape or by inspecting the data.

Pathless class-level @ui dashboard resolves the file by convention:

  1. ui/dashboard.photon.tsx
  2. ui/dashboard.tsx
  3. ui/dashboard.photon.html
  4. ui/dashboard.html

Use @ui dashboard ./some/path/index.html only when the UI is outside the conventional ui/ folder or when a prebuilt bundle needs sibling chunk serving from its own directory.


Examples

Minimal Custom UI

html
<!DOCTYPE html>
<html>
<head>
  <style>
    body {
      font-family: system-ui;
      background: var(--color-bg, #0d0d0d);
      color: var(--color-text, #e6e6e6);
      padding: 20px;
    }
    button {
      background: var(--color-accent, #6366f1);
      color: white;
      border: none;
      padding: 8px 16px;
      border-radius: 6px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <h1>My Custom UI</h1>
  <button id="action">Run Tool</button>
  <pre id="result"></pre>

  <script>
    // 'my-photon' global is auto-injected (named after your .photon.ts file)
    document.getElementById('action').onclick = async () => {
      const result = await window['my-photon'].myMethod();
      document.getElementById('result').textContent =
        JSON.stringify(result, null, 2);
    };
  </script>
</body>
</html>

React Integration

txt
import { useEffect, useState } from 'react';

declare global {
  interface Window {
    photon: {
      toolInput: Record<string, any>;
      widgetState: any;
      setWidgetState: (state: any) => void;
      callTool: (name: string, args: any) => Promise<any>;
      onProgress: (cb: (e: any) => void) => () => void;
      onEmit: (cb: (e: { emit: string; data?: any }) => void) => () => void;
      onResult: (cb: (r: any) => void) => () => void;
      onError: (cb: (err: any) => void) => () => void;
      onThemeChange: (cb: (theme: 'light' | 'dark') => void) => () => void;
      theme: 'light' | 'dark';
    };
    // Direct window API (e.g., window.kanban)
    [photonName: string]: any;
  }
}

export function usePhoton() {
  const [input] = useState(() => window.photon.toolInput);
  const [state, setState] = useState(() => window.photon.widgetState || {});
  const [theme] = useState(() => window.photon.theme);

  const updateState = (newState: any) => {
    setState(newState);
    window.photon.setWidgetState(newState);
  };

  return { input, state, updateState, theme, callTool: window.photon.callTool };
}

// Hook for real-time emit events
export function usePhotonEmit(callback: (event: { emit: string; data?: any }) => void) {
  useEffect(() => {
    const unsubscribe = window.photon.onEmit(callback);
    return unsubscribe;
  }, [callback]);
}

function KanbanApp() {
  const [tasks, setTasks] = useState<any[]>([]);

  // Subscribe to real-time events using direct window API
  useEffect(() => {
    const kanban = window.kanban;

    const unsub1 = kanban.onTaskMove((data: any) => {
      setTasks(prev => prev.map(t =>
        t.id === data.taskId ? { ...t, column: data.column } : t
      ));
    });

    const unsub2 = kanban.onTaskCreate((data: any) => {
      setTasks(prev => [...prev, data.task]);
    });

    return () => { unsub1(); unsub2(); };
  }, []);

  const moveTask = async (taskId: string, column: string) => {
    await window.kanban.taskMove({ id: taskId, column });
  };

  return (
    <div>
      {tasks.map(task => (
        <div key={task.id}>
          {task.title} ({task.column})
          <button onClick={() => moveTask(task.id, 'Done')}>Done</button>
        </div>
      ))}
    </div>
  );
}

Using Auto UI Renderers (photon.render)

Custom UIs don't have to build everything from scratch. photon.render() lets you use the same format renderers that auto UI uses — tables, charts, gauges, badges, and more — inside your own layout.

Quick Start

javascript
// 1. Get data from a method
const data = await showcase.cpu();

// 2. Render it using a format
photon.render(document.getElementById('gauge'), data, 'gauge');

That's it. The renderer handles theming, formatting, and interactivity automatically.

API

typescript
photon.render(container: HTMLElement, data: any, format: string, opts?: object): void
ParameterTypeDescription
containerHTMLElementDOM element to render into (innerHTML is replaced)
dataanyData to visualize — shape depends on format
formatstringFormat type (see table below)
optsobject?Optional overrides (columns, min/max, labels, etc.)

Available Formats

FormatData ShapeDescription
tableArray&lt;object&gt;Sortable table with auto-detected columns
gauge{ value, max, label?, unit? }SVG semicircular gauge with color gradient
metric{ value, label?, delta?, trend? }Large KPI display with trend arrow
stat-groupArray<{ label, value, delta?, trend?, prefix?, suffix? }>Row of KPI stat cards
progress{ value, max?, label? }Animated progress bar with percentage
chart:barArray&lt;object&gt;Bar chart (auto-detects label/value fields)
chart:hbarArray&lt;object&gt;Horizontal bar chart (same shape as chart:bar)
chart:lineArray&lt;object&gt;Line chart (auto-detects time series)
chart:pieArray&lt;object&gt;Pie chart
chart:areaArray&lt;object&gt;Area chart (line with fill)
chart:donutArray&lt;object&gt;Donut chart
chart:radarArray&lt;object&gt;Radar chart displaying multivariate data
sparklineArray&lt;number&gt;Minimalist inline line chart without axes
ring{ value, max?, label? }Circular progress indicator
timelineArray<{ time, event, details? }>Chronological event list with dots and lines
alert{ title?, description, variant?, icon? }Callout box for important information
badgestringColored status badge (auto-detects variant)
listArray<{ name, subtitle?, status? }>iOS-style list rows with optional badges
kv / cardobjectKey-value pairs in alternating rows
steps / stepperArray<{ label, status, detail? }>Step-by-step progress indicator
kanban{ columns: [{ title, items: [{ title, assignee?, priority? }] }] }Kanban board with columns and cards
comparison{ items: [{ name, ...props }], highlight? }Side-by-side property comparison
diffunified diff string or { before, after, filename? }Diff viewer with added/removed highlighting
logArray<{ level, message, timestamp?, source? }>Structured log viewer with level coloring
embedURL string or { url, title? }Embed an external URL in an iframe
heatmap{ rows, cols, values } or [{ rowKey, colKey, value }]Color-intensity activity heatmap
calendarArray<{ title, start, end?, allDay?, color? }>Monthly/weekly calendar view with events
mapArray<{ lat, lng, label?, popup? }>Interactive map with markers
network / graph{ nodes: [{ id, label, group? }], edges: [{ from, to, label? }] }Node-edge graph diagram
croncron string or { expression, description? }Human-readable cron expression display
imageURL string, { src, caption? }, or arraySingle image or image list with captions
carouselArray<{ src, caption? }>Horizontally scrolling image carousel
galleryArray<{ src, caption?, full? }>Thumbnail grid with lightbox expand
masonryArray<{ src, caption? }>Pinterest-style masonry image grid
hero{ title, subtitle?, image?, cta?, url? }Full-width hero section
banner{ message, type?, icon? }Dismissable notification banner
empty / empty-state{ title?, description?, icon?, action? }Centralized empty state placeholder
accordionArray<{ title, content }>Collapsible list of items
feedArray<{ user, action, target?, timestamp?, details? }>Rich activity stream with avatars and details
tabsArray<{ title, content }> or objectTabbed navigation panels
treeobject or ArrayCollapsible JSON-like structural tree viewer
datatableArray&lt;object&gt;Interactive table with search, sort, and pagination
quote{ text, author?, source?, avatar? }Styled pull-quote with attribution
profile{ name, avatar?, role?, bio?, stats? }User profile card with avatar and stats
feature-gridArray<{ icon, title, description }>Marketing feature grid
invoice / receipt{ items: [{ description, quantity, rate, amount }], total, ... }Itemized invoice with totals
markdownstringBasic markdown rendering (headings, bold, code, lists)
codestringSyntax-highlighted code (keywords, strings, numbers, comments)
jsonanyPretty-printed JSON in a <pre> block

Data Shape Examples

javascript
// Gauge — value within a range
photon.render(el, { value: 73, max: 100, label: 'CPU Usage', unit: '%' }, 'gauge');

// Metric — big number with trend
photon.render(el, { value: 14283, label: 'Active Users', delta: 842, trend: 'up' }, 'metric');

// Table — array of objects (columns auto-detected from keys)
photon.render(el, [
  { name: 'Alice', role: 'Admin', status: 'Active' },
  { name: 'Bob',   role: 'Editor', status: 'Offline' },
], 'table');

// Chart — array with string + numeric fields
photon.render(el, [
  { month: 'Jan', revenue: 12400, costs: 8200 },
  { month: 'Feb', revenue: 15800, costs: 9100 },
], 'chart:bar');

// Badge — auto-detects color from text
photon.render(el, 'Active', 'badge');    // green
photon.render(el, 'Degraded', 'badge');  // yellow
photon.render(el, 'Offline', 'badge');   // red

// Timeline — chronological events
photon.render(el, [
  { time: '2026-03-18T08:00:00Z', event: 'Deploy started', details: 'v2.4.1' },
  { time: '2026-03-18T08:05:00Z', event: 'Deploy live', details: 'All regions healthy' },
], 'timeline');

Options

Some renderers accept options to override auto-detection:

javascript
// Table — specify which columns to show
photon.render(el, data, 'table', { columns: ['name', 'status'] });

// Gauge — override min/max range
photon.render(el, { value: 4.2 }, 'gauge', { min: 0, max: 16, label: 'Memory', unit: 'GB' });

// Chart — specify axis fields
photon.render(el, data, 'chart:line', { x: 'timestamp', y: 'temperature' });

Full Dashboard Pattern

The typical pattern combines window[photonName] for data and photon.render() for visualization:

html
<div id="cpu-gauge"></div>
<div id="users-table"></div>

<script>
  // Reference your photon by name
  const monitor = window['system-monitor'];

  // Load data and render
  async function refresh() {
    const cpu = await monitor.cpu();
    photon.render(document.getElementById('cpu-gauge'), cpu, 'gauge');

    const users = await monitor.users();
    photon.render(document.getElementById('users-table'), users, 'table');
  }

  refresh();

  // Live updates via events
  monitor.onCpuUpdate((data) => {
    photon.render(document.getElementById('cpu-gauge'), data, 'gauge');
  });
</script>

Theme Awareness

Renderers auto-detect dark/light mode from the host theme. Colors, borders, and text adjust automatically — no extra configuration needed.

Lazy Loading

photon.render() lazy-loads the renderer library on first call. Chart formats further lazy-load Chart.js from CDN. The initial call may have a brief delay; subsequent calls are instant.

Example Photon

See render-showcase.photon.ts for a complete working example with all 11 format types rendered in a custom dashboard.


Declarative Templates (.photon.html)

For UIs that display method results, you can skip JavaScript entirely. Use the .photon.html file extension to opt into declarative mode — inspired by Datastar's SSE-first hypermedia approach, but with metadata-driven auto-inference.

Where Datastar uses explicit @get('/url') actions, photon auto-resolves method metadata from your docblock tags — format, reactivity, and refresh are inferred automatically.

Two Modes

ExtensionModeWhat happens
dashboard.htmlFull controlBridge injected, you write all JavaScript
dashboard.photon.htmlDeclarativeAuto-wrapped with base CSS, data attributes bind to methods
dashboard.tsxComponentTSX compiled with built-in JSX runtime, bundled into HTML
dashboard.photon.tsxDeclarative + TSXDeclarative mode with TSX components

Priority: .photon.html > .photon.tsx > .html > .tsx

Quick Start

html
<!-- ~/.photon/my-app/ui/dashboard.photon.html -->
<h1>System Monitor</h1>
<div data-method="cpu"></div>
<div data-method="memory"></div>
<div data-method="requests"></div>

That's it — no <html>, no <head>, no <script>. The runtime wraps the fragment with base styles, injects the bridge, and binds elements automatically. Each data-method element auto-resolves:

  • Format from the method's @format tag (table, gauge, chart:bar, etc.)
  • Live updates from @stateful on the class
  • Refresh interval from @scheduled / @cron on the method
  • Trigger from element type (buttons → click, divs → load)

How It Works

Given a photon like this:

typescript
/**
 * @stateful
 */
export default class Monitor {
  /** @format gauge */
  async cpu() { return { value: 73, max: 100, label: 'CPU', unit: '%' }; }

  /** @format table */
  async requests() { return [{ path: '/api', count: 1420 }]; }

  /** @scheduled "*/5 * * * *" */
  async health() { return { status: 'healthy', uptime: '14d' }; }

  async restart() { return { message: 'Restarted successfully' }; }
}

The declarative template only needs data-method:

html
<div data-method="cpu"></div>          <!-- gauge (from @format), live (from @stateful) -->
<div data-method="requests"></div>     <!-- table (from @format), live (from @stateful) -->
<div data-method="health"></div>       <!-- polls every 60s (from @scheduled) -->
<button data-method="restart" data-target="#status">Restart</button>  <!-- click trigger (button) -->
<span id="status"></span>

The data-method Attribute

data-method is the only required attribute. It specifies which photon method to call. Everything else is auto-inferred from metadata or element type:

WhatAuto-inferred fromManual override
Format@format tag on the methoddata-format="gauge"
Live updates@stateful tag on the classdata-live
Refresh@scheduled / @cron tagdata-refresh="5s"
TriggerElement type: <button> → click, <div> → loaddata-trigger="click"

Optional Override Attributes

Use these only when you need to deviate from the method's metadata:

AttributePurposeDefault
data-methodWhich method to call(required)
data-formatOverride format rendererFrom @format tag
data-targetCSS selector — where to render the resultSelf
data-swapHow to replace contentinnerHTML
data-triggerWhen to fire the method callAuto: button→click, div→load
data-argsJSON parameters to pass{}
data-fieldExtract a nested field from the result
data-liveForce live modeAuto from @stateful
data-refreshForce polling intervalAuto from @scheduled

Swap Modes

The data-swap attribute controls how results replace content (same as htmx swap):

ValueBehavior
innerHTMLReplace inner content of target (default)
outerHTMLReplace the entire target element
beforebeginInsert before the target element
afterbeginInsert at the start of the target
beforeendAppend to the end of the target
afterendInsert after the target element

Examples

Minimal — just the method name:

html
<div data-method="cpu"></div>

Button triggers a method, result renders elsewhere:

html
<button data-method="restart" data-target="#output">Restart Server</button>
<div id="output"></div>

Extract a specific field:

html
<span data-method="stats" data-field="users.active"></span>

Override format for a different visualization:

html
<div data-method="requests" data-format="chart:bar"></div>

Append results to a log:

html
<button data-method="generate" data-target="#log" data-swap="beforeend">Generate</button>
<div id="log"></div>

Pass arguments:

html
<button data-method="deploy" data-args='{"env":"production"}' data-target="#status">
  Deploy to Production
</button>
<span id="status"></span>

Full Dashboard Example

html
<!-- monitor/ui/dashboard.photon.html -->
<style>
  .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
  .full { grid-column: 1 / -1; }
</style>

<h1>System Dashboard</h1>

<div class="grid">
  <div data-method="cpu"></div>
  <div data-method="memory"></div>
  <div class="full" data-method="requests"></div>
</div>

<h2>Actions</h2>
<button data-method="restart" data-target="#action-result">Restart</button>
<button data-method="clearCache" data-target="#action-result">Clear Cache</button>
<div id="action-result"></div>

No JavaScript. The gauge renders because cpu() has @format gauge. The table renders because requests() has @format table. Live updates flow because the class is @stateful. Buttons trigger on click because they're <button> elements.

Loading State

Elements automatically get the photon-loading CSS class while a method call is in flight. Style it for visual feedback:

css
.photon-loading {
  opacity: 0.6;
  pointer-events: none;
}

When to Use Each Mode

  • .photon.html — Dashboards, status displays, simple data views, action buttons. No JavaScript needed.
  • .html — Interactive UIs, custom event handling, complex layouts.
  • .tsx — Component-based UIs with TypeScript, composition, and imports. Best for complex views.

TSX Views (.tsx)

Write view files as TSX components. A built-in JSX runtime (~1KB) maps h() calls directly to DOM elements — no React, no Preact, no virtual DOM.

Quick Start

my-app/
  my-app.photon.ts
  my-app/
    ui/
      dashboard.tsx      # ← TSX view
txt
// my-app/ui/dashboard.tsx

function Card({ title, value }: { title: string; value: number }) {
  return (
    <div style={{ padding: '16px', borderRadius: '8px', background: 'var(--color-surface, #1e1e2e)' }}>
      <div style={{ fontSize: '13px', color: 'var(--color-muted, #888)' }}>{title}</div>
      <div style={{ fontSize: '28px', fontWeight: 'bold' }}>{value.toLocaleString()}</div>
    </div>
  );
}

function Dashboard({ items }: { items: Array<{ title: string; value: number }> }) {
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '12px', padding: '24px' }}>
      {items.map(item => <Card title={item.title} value={item.value} />)}
    </div>
  );
}

// Mount to the auto-provided #root div
render(<Dashboard items={[{ title: 'Users', value: 42 }]} />, '#root');

Link it in your photon:

ts
/**
 * @ui dashboard
 */
export default class MyApp {
  /** @ui dashboard */
  async dashboard() {
    return { items: [{ title: 'Users', value: 42 }] };
  }
}

Built-in JSX Runtime

Available globally in every TSX view (no imports needed):

FunctionDescription
h(type, props, ...children)JSX factory — returns real DOM nodes
FragmentDocument fragment for <>...</> syntax
render(element, container)Mount element to a container (selector string or DOM node)

Supports: className, htmlFor, style objects, onClick/on* event handlers, dangerouslySetInnerHTML, boolean attributes.

Using React or Preact Instead

Add a tsconfig.json in your ui/ folder to override the built-in runtime:

json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "preact"
  }
}

Then install the framework in your photon's directory (bun add preact; pnpm users can run pnpm add preact). The TSX compiler will use it instead of the built-in runtime.

Bridge Integration

TSX views run in the same iframe sandbox as HTML views. The photon bridge is injected automatically:

txt
// Listen for tool results from the bridge
window.addEventListener('message', (event) => {
  if (event.data?.method === 'ui/notifications/tool-result') {
    const data = event.data.params.result;
    render(<Dashboard items={data.items} />, '#root');
  }
});

How It Works

TSX files are compiled on-demand via esbuild when first requested (not at startup). The result is cached by file mtime, so edits are picked up on the next request. The compiled output is a self-contained HTML document with all imports bundled inline — no external dependencies at runtime.


Auto-Form Input Widgets

When a photon method has @param tags, Beam renders an auto-form so users can fill in values before invoking the tool. The {@format} inline tag controls which input widget appears for each field.

Enhanced Basic Inputs

These are auto-detected from the parameter name or set explicitly with {@format}.

FormatWidgetAuto-detected names
password / secretMasked text + show/hide eye togglepassword, secret, token, apikey
emailtype="email"email
urltype="url" + live "open link" buttonurl, website, homepage
phone / teltype="tel"phone, tel, mobile
colorColor swatch + hex input side by sidecolor, colour
searchtype="search"search, query, q

Rich Input Components

These require explicit {@format} in the @param docblock.

FormatWidget
tagsChip/pill input — Enter or comma adds a chip, Backspace removes last, deduplicates. Also auto-detected for string[] params.
rating1–5 star picker with hover preview. Pair with {@multipleOf 0.5} for half-stars. Auto-detected: rating, stars.
segmentedHorizontal pill bar for enum params (2–4 choices). Pair with {@choice a,b,c}.
radioVertical radio buttons for enum params. Pair with {@choice a,b,c}.
code / code:langCode editor — line numbers, tab-indent (2 spaces), char/line count. Append language: code:typescript, code:python, code:css.
markdownSplit-pane editor with toolbar (Bold, Italic, Code, Link, Heading, List, Quote), Write/Split/Preview modes, word count.

Date & Time Pickers

Custom calendar replaces the native browser date input. Supports typed input ("2026-03-20", "Mar 20 2026", "03/20/2026"), Today and Clear buttons, and a 3-layer drill-down: click month name → month grid, click year → year grid with decade paging.

Smart positioning: params named birthday / dob open the year picker ~25 years back. Params named expiry / expires start 2 years forward.

FormatWidget
dateCalendar date picker
date-timeCalendar + hour:minute inputs
timeTime text input
date-rangeTwo date pickers side by side
datetime-rangeTwo date-time pickers side by side

Example

typescript
/**
 * Register a new user
 * @param name Full name
 * @param email Email address {@format email}
 * @param password Account password {@format password}
 * @param birthday Date of birth {@format date}
 * @param phone Phone number {@format phone}
 * @param website Personal website {@format url}
 * @param color Preferred color {@format color}
 * @param tags Interest tags {@format tags}
 * @param rating Experience level (1-5) {@format rating}
 * @param role User role {@choice admin,user,guest} {@format segmented}
 * @param bio About yourself {@format markdown}
 * @param code Custom CSS {@format code:css}
 */
async register(params: { ... }): Promise<User> { ... }

For the complete list of formats and their validation behavior, see DOCBLOCK-TAGS.md.


ChatGPT Apps SDK Compatibility

BEAM implements the ChatGPT Apps SDK for compatibility:

typescript
// These work in BEAM just like ChatGPT
window.openai.theme;          // 'light' | 'dark'
window.openai.toolInput;      // Tool parameters
window.openai.callTool(name, args);  // Invoke tool
window.openai.setWidgetState(state); // Persist state
window.openai.uploadFile(file);      // Upload file
window.openai.getFileDownloadUrl({ fileId }); // Get file URL
window.openai.requestModal({ template, params }); // Show modal
window.openai.openExternal({ href }); // Open link

Next Steps

Released under the MIT License.