Photon Development Guide
Photons are single-file TypeScript MCP servers. No compilation — runs directly with tsx.
Quick Start
npm install -g @portel/photon # Install runtime
photon maker new my-weather # Create photon
photon beam # Launch Beam UI
photon mcp my-weather # Run as MCP server
photon cli my-weather current --city London # CLI
Files go in ~/.photon/. See Directory Structure for the full layout. Connect to Claude Desktop:
{ "mcpServers": { "my-weather": { "command": "photon", "args": ["mcp", "my-weather"] } } }
Minimal Photon
import { Photon } from '@portel/photon-core';
/**
* Weather API
* @version 1.0.0
* @dependencies axios@^1.0.0
* @icon 🌤️
*/
export default class Weather extends Photon {
constructor(private apiKey: string) { super(); }
/**
* Get current weather for a city
* @param city City name {@example London}
* @readOnly
* @title Current Weather
* @format markdown
*/
async current({ city }: { city: string }): Promise<string> {
const res = await fetch(`https://api.weather.com/v1/current?q=${city}&key=${this.apiKey}`);
if (!res.ok) throw new Error(`API error: ${res.statusText}`);
const data = await res.json();
return `**${data.name}** — ${data.temp}°C, ${data.description}`;
}
}
Core Principles
- Return values directly — no
{ success: true, data }wrappers. If it returns, it succeeded. - Throw on errors — let the runtime handle them. Don't catch and wrap.
- Single-word method names — they read as CLI commands:
weather current, notweather getCurrentWeather. - Constructor = config — constructor params auto-map to env vars:
PHOTON_WEATHER_APIKEY.
Class Structure
/**
* Brief description (becomes photon description)
*
* @version 1.0.0
* @runtime ^1.5.0
* @dependencies package1, package2@^2.0.0
* @icon 🔧
* @tags api, utility
*/
export default class MyTool extends Photon {
constructor(private apiKey: string) { super(); }
/**
* Method description (becomes tool description)
* @param query Search query {@example "typescript"} {@min 1}
* @readOnly
* @title Search Items
* @format table
*/
async search({ query }: { query: string }) { ... }
}
MCP Annotations
JSDoc tags map to MCP protocol annotations (spec 2025-11-25):
/**
* @readOnly — no side effects, safe to auto-approve
* @destructive — requires confirmation
* @idempotent — safe to retry
* @openWorld — calls external systems
* @closedWorld — local data only
* @title My Tool — human-readable display name
* @audience user — who sees results: user, assistant, or both
* @priority 0.9 — content importance (0.0–1.0)
*/
UI-only methods: Combine @internal + @audience user for methods the dashboard can call but the LLM never sees:
/**
* Dashboard-only admin panel data.
* @internal
* @audience user
* @readOnly
*/
async metrics() { return { cpu: 42 }; }
@internal hides from tools/list. @audience user marks results as human-only. The UI still calls via window.photon.callTool('metrics', {}).
Structured Output
Auto-generated from TypeScript return types — no tags needed:
async create(params: { title: string }): Promise<{ id: string; done: boolean }> { ... }
For field descriptions, use an interface with JSDoc:
interface Task {
/** Unique identifier */
id: string;
/** Whether complete */
done: boolean;
}
async create(params: { title: string }): Promise<Task> { ... }
Output Formats
Use @format to control rendering. Common values:
| Format | Use For |
|---|---|
table | Array of objects |
list | Styled list with {@title name, @subtitle email} |
markdown | Rich text, diagrams |
chart:bar / chart:line / chart:pie | Data visualization |
json | Raw JSON |
dashboard | Composite panels (auto-detected) |
For complete format reference with layout hints, containers, and auto-detection rules, see references/output-formats.md.
Lifecycle & Hot-Reload
// Lifecycle hooks — receive optional context for hot-reload support
async onInitialize(ctx?: { reason?: string; oldInstance?: any }) {
if (ctx?.reason === 'hot-reload' && ctx.oldInstance) {
// Transfer non-serializable resources (sockets, timers, connections)
this.socket = ctx.oldInstance.socket;
ctx.oldInstance.socket = null; // prevent old instance from using it
return;
}
// Normal first-time initialization
this.socket = await createConnection();
}
async onShutdown(ctx?: { reason?: string }) {
if (ctx?.reason === 'hot-reload') {
return; // DON'T close resources — new instance will take them
}
// Real shutdown: clean up everything
this.socket?.close();
}
Hot-reload rules:
onShutdown({ reason: 'hot-reload' })→ skip resource cleanuponInitialize({ reason: 'hot-reload', oldInstance })→ transfer resources from old instance- Normal shutdown/init (no context) → full cleanup/setup
- Backward compatible — photons without context param still work
Events & Channels
// Simple emit (local only — goes to current caller's UI)
this.emit({ status: 'processing', progress: 50 });
// Channel emit (cross-photon pub/sub via daemon broker)
// Framework auto-prefixes with photon name: 'messages' → 'whatsapp:messages'
this.emit({ channel: 'messages', type: 'message', data: msg });
// Subscribe to another photon's events (use the full prefixed name)
const broker = getBroker();
const sub = await broker.subscribe('whatsapp:messages', (msg) => {
// handle message
});
sub.unsubscribe(); // when done
Channel naming: Emit with simple names (channel: 'messages'). The framework auto-prefixes with the photon name. Channels with a colon are left as-is.
Generator Workflows
// Multi-step with user interaction
async *deploy({ env }: { env: string }) {
yield { emit: 'status', message: 'Deploying...' };
const ok = yield { ask: 'confirm', message: `Deploy to ${env}?` };
if (!ok) return 'Cancelled';
return 'Done';
}
// Waiting for external async events (WebSocket, library callbacks, etc.)
async *connect() {
yield { emit: 'status', message: 'Connecting...' };
let resolve: (v: any) => void;
const promise = new Promise(r => { resolve = r; });
this.socket.on('ready', (data) => resolve(data));
await this.initSocket();
const event = await promise; // blocks until external event fires
yield { emit: 'toast', message: 'Connected!', type: 'success' };
return event;
}
Scoped Memory
Zero-config persistent storage via this.memory:
await this.memory.set('key', value); // photon scope (default)
const val = await this.memory.get<T>('key');
await this.memory.set('shared', data, 'global'); // cross-photon
Three scopes: photon (private), session (per-user), global (shared). Full API: get, set, delete, has, keys, clear, getAll, update.
Runtime Scheduling
Dynamic task scheduling via this.schedule — complements static @scheduled/@cron tags:
// Create a scheduled task at runtime
await this.schedule.create({
name: 'nightly-cleanup',
schedule: '0 0 * * *', // 5-field cron or @daily, @hourly, @weekly, @monthly
method: 'purge',
params: { olderThan: 30 },
});
// Manage tasks
await this.schedule.pause(id);
await this.schedule.resume(id);
await this.schedule.cancel(id);
const tasks = await this.schedule.list('active');
Full API: create, get, getByName, list, update, pause, resume, cancel, cancelByName, cancelAll, has. Tasks persist to disk; the daemon executes them.
Use @scheduled/@cron for fixed schedules known at build time. Use this.schedule for dynamic schedules created at runtime (user-configured intervals, conditional jobs, etc.).
User Settings
Expose configurable options via protected settings. Runtime auto-generates a settings tool and persists to disk:
export default class MyAgent extends Photon {
protected settings = {
/** Polling interval in ms */
pollIntervalMs: 5000,
/** Max concurrent operations */
maxConcurrent: 3,
/** Auto-resume after restart */
autoResume: true,
};
async doWork() {
const interval = this.settings.pollIntervalMs; // read-only Proxy
}
}
Users change settings via CLI (photon cli my-agent settings). Values persist to ~/.photon/state/<name>/<instance>-settings.json.
Live Rendering (v1.14+)
this.render(format, value) pushes formatted output that replaces the previous render zone (instead of appending):
export default class Monitor {
async status() {
while (true) {
const metrics = await this.collectMetrics();
this.render('table', metrics); // Replaces previous output
await new Promise(r => setTimeout(r, 5000));
}
}
}
Accepts the same format values as @format tags. Call this.render() with no arguments to clear the render zone.
Compile to Binary (v1.13+)
Build standalone executables from any photon — no Node.js required on the target machine:
photon build my-tool # Binary for current platform
photon build my-tool -t bun-linux-x64 # Cross-compile for Linux
photon build my-tool --with-app # Embed Beam UI as a desktop app
Uses Bun's compiler. The binary bundles the photon, its @dependencies, and transitive @photon deps.
Install from GitHub (v1.14+)
Use qualified refs to install and run photons directly from any GitHub repository:
photon beam Arul-/photons/claw # Install from GitHub, open in Beam
photon cli Arul-/photons/todo add # Install from GitHub, run method
Format: owner/repo/photon-name. Transitive @photon dependencies from the same repo are resolved automatically.
Custom UIs
Link HTML files as interactive result renderers:
/** @ui dashboard ./ui/dashboard.html */
export default class MyApp extends Photon {
/** @ui dashboard */
async getData({ range }: { range: string }) { return { metrics: 42 }; }
}
The UI gets a photon-named global proxy: window.myApp.getData(...), window.myApp.onResult(...).
For full MCP Apps guide, see references/mcp-apps.md.
Directory Structure
Photon separates source/assets from runtime data under ~/.photon/:
~/.photon/
├── <name>.photon.ts # Source (or symlink)
├── <name>/ # Assets (@ui templates, images)
├── state/<name>/ # Runtime: this.memory, settings (automatic)
├── data/<name>/ # Runtime: photon-written files (manual)
├── cache/ # Runtime: compiled .mjs cache
└── logs/<name>/ # Runtime: execution logs
Key rule: Photons that write runtime files (auth tokens, downloaded media, databases) MUST use ~/.photon/data/<name>/, never ~/.photon/<name>/. The asset folder is watched for hot-reload — writing there causes reload loops.
// Correct: runtime data goes in data/<name>/
const dataDir = path.join(os.homedir(), '.photon', 'data', 'my-app');
// Wrong: asset folder triggers hot-reload!
const dataDir = path.join(os.homedir(), '.photon', 'my-app', 'downloads');
For the full convention, see Directory Structure.
References
| Topic | When to Read |
|---|---|
| Directory Structure | Need the ~/.photon/ layout rules for assets vs runtime data |
| Docblock Tags | Need the complete tag reference (class, method, inline, daemon, MCP) |
| Output Formats | Need layout hints, chart mapping, containers, or auto-detection rules |
| Dependency Injection | Using @mcp, @photon, or this.call() for cross-photon communication |
| Daemon Features | Setting up webhooks, cron jobs, or distributed locks |
| User Settings | protected settings, persistence, auto-resume patterns |
| MCP Apps | Building custom HTML UIs with the photon bridge |
| Visualization | Generating Mermaid diagrams from photons |
| Mermaid Syntax | Flowchart shapes, arrows, subgraphs |
| Photon Patterns | Common emit/ask/yield patterns with Mermaid equivalents |
| Examples | Complete Photon-to-Mermaid conversion examples |