Skip to content

Photon MCP Developer Guide

New to Photon? Start with Getting Started instead — you'll be running your first photon in 5 minutes. This page is the comprehensive reference for when you need to look something up.

Complete guide to creating .photon.ts files and understanding how Photon works.

Table of Contents

  1. Quick Start
  2. Creating Your First MCP
  3. Settings: User-Configurable Knobs
  4. Constructor Configuration (for secrets)
  5. Writing Tool Methods
  6. Docblock Tags
  7. Return Formatting
  8. Dependency Injection
  9. Assets and UI
  10. Advanced Workflows
  11. Lifecycle Hooks
  12. Configuration Convention
  13. Reactive Collections
  14. Real-Time Sync
  15. Sampling: this.sample()
  16. Scheduling: @scheduled, this.schedule, photon ps
  17. Common Patterns
  18. CLI Command Reference
  19. Testing and Development
  20. Deployment
  21. How Photon Works
  22. Best Practices
  23. Troubleshooting

Quick Start

The fastest way to use Photon is via the Beam, a visual dashboard for managing your MCPs.

1. Install & Launch

Install the global package and run the photon command to open the dashboard in the web browser:

bash
bun add -g @portel/photon
photon

2. Create an MCP

Ready to code? Create a new tool in seconds:

bash
# 1. Generate template
photon maker new my-tool

# 2. Edit ~/.photon/my-tool.photon.ts
export default class MyTool {
  async greet(params: { name: string }) {
    return `Hello, ${params.name}!`;
  }
}

# 3. Run in dev mode
photon mcp my-tool --dev

That's it! Your MCP is now running and ready to use.


Creating Your First MCP

File Structure

A Photon MCP is a single TypeScript file with this minimal structure:

typescript
export default class MyMCP {
  async toolName(params: { input: string }) {
    return `Result: ${params.input}`;
  }
}

Naming Conventions

The MCP name comes from:

  1. File name (preferred): calculator.photon.tscalculator
  2. Class name (fallback): class Calculatorcalculator

Complete Example

Here's a real-world example with all features:

typescript
/**
 * Calculator - Basic arithmetic operations
 *
 * Provides mathematical calculations: add, subtract, multiply, divide.
 * Useful for numerical computations and data processing.
 *
 * Dependencies: None
 *
 * @version 1.0.0
 * @author Your Name
 */

export default class Calculator {
  // Optional lifecycle hook
  async onInitialize() {
    console.error('Ready to calculate');
  }

  /**
   * Add two numbers together
   * @param a First number
   * @param b Second number
   */
  async add(params: { a: number; b: number }) {
    return params.a + params.b;
  }

  /**
   * Subtract b from a
   * @param a First number
   * @param b Number to subtract
   */
  async subtract(params: { a: number; b: number }) {
    return params.a - params.b;
  }

  // Private helper (not exposed as tool)
  private _validate(value: number) {
    if (isNaN(value)) throw new Error('Invalid number');
  }
}

Settings: User-Configurable Knobs

Whenever a value should be runtime-configurable, declare it on protected settings. Photon reads the property's JSDoc, generates an MCP settings tool with a typed input for each knob, and persists user changes to disk. Inside the photon, this.settings is a read-only Proxy.

This is the right pattern in almost every case. Reach for it first. Use the constructor pattern (next section) only when the value is a primitive secret that belongs in .env and never changes at runtime.

Basic Pattern

typescript
import { Photon } from '@portel/photon-core';

export default class Poller extends Photon {
  /** User-tunable knobs */
  protected settings = {
    /** Polling interval in seconds */
    intervalSec: 60,
    /** Endpoint to poll */
    endpoint: 'https://api.example.com/status',
    /** Pause polling without unloading the photon */
    paused: false,
  };

  async tick() {
    if (this.settings.paused) return { skipped: true };
    return await fetch(this.settings.endpoint);
  }
}

What you get for free:

  • An MCP tool named settings that lists current values, accepts updates, and validates types from the property declarations.
  • JSDoc on each property becomes the tool's parameter description.
  • Persistence to ~/.photon/state/<photon>/<instance>-settings.json. Persisted values win over the in-source defaults on the next load.
  • A writable Proxy on this.settings. You can read and write via this.settings.key = value from inside a photon method — the runtime persists the change and emits settings:changed, identical to what the settings MCP tool produces.

Changing settings

From the CLI:

bash
photon cli poller settings --intervalSec 30 --paused true

From an MCP client (Claude, Cursor, Beam): call the auto-generated settings tool with a partial object:

json
{ "intervalSec": 30, "paused": true }

When to use settings vs constructor

Use protected settings forUse the constructor for
Anything the user should change at runtimePrimitive secrets that belong in .env
Polling intervals, thresholds, modes, pathsAPI keys, tokens, passwords
Feature toggles, retry counts, defaultsService URLs that never change between deploys
Any value with a sensible defaultRequired boot-time configuration

If you find yourself writing process.env.SOMETHING in a method body, that is almost always a settings property in disguise — declare it on protected settings and Photon will surface it everywhere.


Constructor Configuration (for secrets)

Constructor parameters are the right tool when a value is a primitive secret that should be supplied by the environment and never exposed in the runtime UI. Photon captures the resolved constructor values it injects and stores them under the owning PHOTON_DIR, so daemon-hosted photons can be recreated after daemon restarts. For everything else, prefer Settings.

Basic Pattern

Constructor parameters automatically map to environment variables:

typescript
export default class Filesystem {
  constructor(
    private workdir: string = join(homedir(), 'Documents'),
    private maxFileSize: number = 10485760,
    private allowHidden: boolean = false
  ) {
    // Validate configuration
    if (!existsSync(workdir)) {
      throw new Error(`Working directory does not exist: ${workdir}`);
    }
  }
}

Environment Variable Mapping

Pattern: {MCP_NAME}_{PARAM_NAME} in SCREAMING_SNAKE_CASE

Constructor ParameterEnvironment Variable
workdirFILESYSTEM_WORKDIR
maxFileSizeFILESYSTEM_MAX_FILE_SIZE
allowHiddenFILESYSTEM_ALLOW_HIDDEN

Set these values normally in the environment before first loading the photon:

bash
export FILESYSTEM_WORKDIR=/Users/me/Documents
photon beam

When Photon injects that constructor value, it persists the resolved value to .data/... for the current PHOTON_DIR and photon namespace. If the daemon restarts later without the original shell environment, constructor injection replays the persisted value. photon config set remains available as a manual repair or override command, but it is not the normal setup path.

Scheduled methods can declare config that must exist before the daemon arms the schedule:

typescript
/**
 * Send the daily reminder.
 * @scheduled 0 9 * * *
 * @requiresConfig KITH_USER_EMAIL
 */
async remind() {
  const email = this.config.require('KITH_USER_EMAIL');
}

If KITH_USER_EMAIL is missing from Photon config, Photon refuses to enable the schedule and logs the missing key.

Type Conversion

Photon automatically converts environment variable strings:

typescript
constructor(
  private port: number = 3000,          // "8080" → 8080
  private enabled: boolean = false,     // "true" → true
  private tags: string[] = [],          // Not supported yet
) {}

Supported types:

  • string - No conversion
  • number - Parsed with Number()
  • boolean - "true"/"1" → true, "false"/"0" → false

Documentation

To provide descriptions for these parameters in the CLI and MCP help, use a Configuration: section in your class-level JSDoc:

typescript
/**
 * Filesystem MCP
 * 
 * Configuration:
 * - workdir: Path to the working directory
 * - maxFileSize: Maximum file size in bytes
 */
export default class Filesystem {
  constructor(private workdir: string, private maxFileSize: number) {}
}

NOTE

Arrays (string[], etc.) are not yet supported for direct environment variable mapping in the constructor. Use interactive elicitation in tool methods for complex user input.

Smart Defaults

Use platform-aware defaults:

typescript
import { homedir } from 'os';
import { join } from 'path';

constructor(
  // Cross-platform Documents folder
  private workdir: string = join(homedir(), 'Documents'),

  // Reasonable file size limit (10MB)
  private maxFileSize: number = 10485760,

  // Conservative security default
  private allowHidden: boolean = false
) {}

Required Parameters

For required config, omit defaults and throw clear errors:

typescript
constructor(
  private apiKey: string,
  private endpoint: string
) {
  if (!apiKey || !endpoint) {
    throw new Error('API key and endpoint are required');
  }
}

User experience: When users run photon my-tool --config, they see:

json
{
  "env": {
    "MY_TOOL_API_KEY": "<your-api-key>",
    "MY_TOOL_ENDPOINT": "<your-endpoint>"
  }
}

Configuration Examples

API Client:

typescript
constructor(
  private baseUrl: string = 'https://api.example.com',
  private timeout: number = 5000,
  private apiKey?: string  // Optional authentication
) {}

Database:

typescript
constructor(
  private dbPath: string = join(homedir(), '.myapp', 'data.db'),
  private readonly: boolean = false
) {
  if (!existsSync(dirname(dbPath))) {
    mkdirSync(dirname(dbPath), { recursive: true });
  }
}

Git Operations:

typescript
constructor(
  private repoPath: string = process.cwd(),
  private autoCommit: boolean = false
) {
  if (!existsSync(join(repoPath, '.git'))) {
    throw new Error(`Not a git repository: ${repoPath}`);
  }
}

Writing Tool Methods

Method Signature

Every tool is an async method with a single params object:

typescript
async methodName(params: {
  requiredParam: string;
  optionalParam?: number;
  arrayParam?: string[];
  objectParam: {
    nested: boolean;
  };
}) {
  return result;
}

JSDoc Documentation

JSDoc comments become tool descriptions in MCP:

typescript
/**
 * Read file contents from the filesystem
 * @param path Path to file (relative to working directory)
 * @param encoding File encoding (default: utf-8)
 */
async read(params: { path: string; encoding?: string }) {
  // Implementation
}

What MCP clients see:

  • Tool name: read
  • Description: "Read file contents from the filesystem"
  • Parameters:
    • path (required): "Path to file (relative to working directory)"
    • encoding (optional): "File encoding (default: utf-8)"

Return Values

Photon accepts multiple return formats:

typescript
// 1. Simple value (string, number, boolean)
async tool1(params: {}) {
  return "Success";
}

// 2. Object (auto-stringified to JSON)
async tool2(params: {}) {
  return { result: 42, status: "ok" };
}

// 3. Success/error format (recommended)
async tool3(params: {}) {
  try {
    // Do work
    return { success: true, result: data };
  } catch (error: any) {
    return { success: false, error: error.message };
  }
}

// 4. MCP content format
async tool4(params: {}) {
  return {
    content: [
      { type: "text", text: "Result data" }
    ]
  };
}

Error Handling

Handle errors gracefully:

typescript
async readFile(params: { path: string }) {
  try {
    // Validate input
    if (!params.path) {
      return { success: false, error: 'Path is required' };
    }

    // Resolve path safely
    const fullPath = this._resolvePath(params.path);

    // Perform operation
    const content = await readFile(fullPath, 'utf-8');

    return {
      success: true,
      content,
      path: fullPath
    };
  } catch (error: any) {
    return {
      success: false,
      error: error.message
    };
  }
}

TypeScript Type Support

Photon extracts JSON schemas from TypeScript types:

typescript
async process(params: {
  // Primitives
  name: string;
  age: number;
  active: boolean;

  // Optional
  nickname?: string;

  // Arrays
  tags: string[];
  scores?: number[];

  // Objects
  settings: {
    theme: string;
    notifications: boolean;
  };

  // Union types (as strings in schema)
  status: 'active' | 'inactive' | 'pending';
}) {
  return { processed: true };
}

Current limitations:

  • No support for complex union types beyond string literals
  • No support for generics or mapped types
  • Use interfaces/types for complex nested objects

Private Methods

Methods starting with _ or marked private are not exposed as tools:

typescript
export default class MyMCP {
  // Public tool
  async publicMethod(params: { input: string }) {
    return this._helper(params.input);
  }

  // Private helper (NOT a tool)
  private _helper(input: string) {
    return input.toUpperCase();
  }

  // Also private (NOT a tool)
  async _privateMethod() {
    return "Not exposed";
  }
}

Docblock Tags

Photon uses JSDoc-style docblock tags to extract metadata, configure tools, and generate documentation.

Class-Level Tags

Place these in the JSDoc comment at the top of your .photon.ts file.

TagUsageExample
@versionPhoton version@version 1.0.0
@authorAuthor name@author Jane Doe
@licenseLicense type@license MIT
@repositorySource repository URL@repository github.com/user/repo
@homepageProject homepage@homepage example.com
@runtimeRequired runtime version range@runtime ^1.5.0
@dependenciesNPM packages to auto-install@dependencies axios@^1.0.0
@mcpInject MCP dependency@mcp github anthropics/mcp
@photonInject Photon dependency@photon utils ./utils.photon.ts
@authConfigure MCP auth and populate this.caller@auth required
@statefulEnable stateful mode@stateful true
@idleTimeoutIdle timeout in ms@idleTimeout 300000
@uiDefine UI template asset@ui main ./ui/index.html
@promptDefine prompt asset@prompt system ./prompts/sys.txt
@resourceDefine resource asset@resource data ./data.json

Method-Level Tags

Place these immediately preceding a tool method.

TagUsageExample
@paramDescribe parameter@param name User name
@returnsDescribe return value@returns The result
@exampleProvide usage example@example await tool.run()
@formatOutput format hint@format table
@iconTool icon (emoji/name)@icon 🧮
@autorunAuto-run in UI@autorun
@uiLink to UI template@ui main
@scopeOverride the inferred OAuth scope for this tool@scope bookings:write

Daemon Tags (Advanced)

Enable background features handled by the Photon Daemon.

TagUsageExample
@webhookExpose as HTTP webhook@webhook stripe
@scheduledCron schedule@scheduled 0 0 * * *
@lockedDistributed lock@locked resource:write

Inline Parameter Tags

Use these within @param descriptions for validation and UI generation.

TagfunctionalityExample
{@min N}Minimum value@param age {@min 18}
{@max N}Maximum value@param score {@max 100}
{@pattern R}Regex pattern@param code {@pattern ^[A-Z]{3}$}
{@choice A,B}Enum/Choice@param color {@choice red,blue}
{@field T}HTML input type@param bio {@field textarea}
{@format T}Data format@param email {@format email}
{@example V}Example value@param city {@example London}
{@label L}Custom UI label@param id {@label User ID}

Return Formatting

Photon allows hinting the data shape and type of return values using the @format tag. This helps the CLI and Web interfaces render the data optimally.

Structural Formats

Structural hints tell Photon how to organize the data table or tree.

FormatDescriptionUsed For
primitiveFormats result as a single valueStrings, numbers, booleans
tableFormats results as a gridArrays of objects
listFormats results as a bulleted listArrays of primitives
gridFormats results as a visual gridArrays of objects/images
cardFormats result as a detailed cardSingle object
treeFormats results as a hierarchyNested objects/JSON
noneRaw JSON outputComplex data without specific shape

Content & Code Formats

Content hints specify the syntax for text coloring and highlighting.

  • Content Types: json, markdown, yaml, xml, html, mermaid
  • Code Blocks: code (generic) or code:language (e.g., code:typescript)

Advanced Layout Hints

For list, table, and grid formats, you can specify layout hints using nested syntax:

typescript
/**
 * @format list {@title name, @subtitle email, @icon avatar}
 */
HintDescription
@title fieldPrimary display text
@subtitle fieldSecondary display text
@icon fieldLeading icon/image
@badge fieldStatus badge
@columns NGrid column count
@style SStyle: plain, grouped, inset

Example:

typescript
/**
 * List files in directory
 * @format table
 */
async ls(params: { path: string }) {
  return await this._listFiles(params.path);
}

/**
 * Get system report
 * @format markdown
 */
async report() {
  return "# System Status\n- CPU: 10%\n- RAM: 4GB";
}

/**
 * Sales by region with formatted columns
 * @format table {@columnFormats revenue:currency,margin:percent,region:truncate(15)}
 */
async sales() {
  return [
    { region: "North America", revenue: 142830, margin: 0.23 },
    { region: "Europe", revenue: 98450, margin: 0.18 },
  ];
}

/**
 * Skill assessment
 * @format chart:radar
 */
async skills() {
  return [{ name: "Alice", communication: 8, coding: 9, design: 6, leadership: 7, testing: 8 }];
}

/**
 * Upload progress
 * @format ring
 */
async progress() {
  return { value: 73, max: 100, label: "Upload" };
}

Dependency Injection

Photon uses constructor parameters as the single injection point for all dependencies: environment variables, MCP clients, photon instances, and persisted state. See Constructor Injection for the complete reference.

Declaring Dependencies

Use @mcp and @photon tags at the class level to declare external dependencies.

typescript
/**
 * @mcp github anthropics/mcp-server-github
 * @mcp storage filesystem
 */
export default class Manager {
  constructor(
    private apiKey: string,  // Env var: MANAGER_API_KEY
    private github: any,     // Injected from @mcp github
    private storage: any     // Injected from @mcp storage
  ) {}
}

Injection Rules

  • Primitive parameters (string, number, boolean) → mapped to environment variables
  • Non-primitive parameters matching an @mcp declaration → MCP client proxy
  • Non-primitive parameters matching a @photon declaration → loaded photon instance
  • Non-primitive parameters with defaults on @stateful photons → restored from persisted state

Assets and UI

Photon supports "MCP Apps" by allowing you to bundle UI templates, prompts, and static resources directly with your Photon server.

Declaring Assets

Use @ui, @prompt, and @resource tags at the class level to link local files as assets.

typescript
/**
 * @ui dashboard
 * @prompt welcome ./prompts/welcome-message.txt
 * @resource data ./assets/data.json
 */
export default class MyApp {
  /**
   * Show the main dashboard
   * @ui dashboard
   */
  async showDashboard() {
    return { success: true };
  }
}

Pathless @ui dashboard resolves by convention from the photon UI folder:

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

Use the explicit path form only when the file lives outside the convention, for example @ui dashboard ./dashboard/dist/index.html for a prebuilt app. When the resolved UI is .tsx, Photon treats it as the client application shell in Beam: runtime paths such as /mcp and declared web routes still win, and otherwise GET routes fall through to the TSX app for client-side routing.

Linking UI to Tools

Use the method-level @ui tag to specify which UI template should be rendered when a tool is invoked in a compatible interface (like the Photon Playground or a custom web UI).

MCP Apps Compatibility (SEP-1865)

Photon implements the MCP Apps Extension (SEP-1865), the official standard for interactive UIs in MCP. This means:

  1. ui:// Resource URIs: Tools with linked UIs expose _meta.ui.resourceUri pointing to ui://photon-name/ui-id
  2. JSON-RPC Protocol: UI iframes communicate via standard ui/initialize, ui/ready, and tools/call messages
  3. Cross-Platform Support: UIs built for Claude, ChatGPT, or any MCP Apps-compatible host work in Photon

Protocol Messages

MessageDirectionPurpose
ui/initializeHost → AppInitialize with theme, capabilities, dimensions
ui/readyApp → HostApp is ready to receive data
tools/callApp → HostRequest tool execution from the UI
ui/notifications/tool-resultHost → AppPush tool result to UI

Client APIs

Photon injects APIs into UI iframes for maximum compatibility:

javascript
// 1. Photon global — named after your .photon.ts file (recommended)
// For search.photon.ts:
search.onResult(data => console.log(data));
search.query({ q: 'test' });  // calls the 'query' method

// 2. Low-level bridge — full control over tool I/O
photon.callTool('query', { q: 'test' });
photon.onResult(data => console.log(data));
photon.theme; // 'light' | 'dark'

// 3. OpenAI Apps SDK compatible
openai.callTool('query', { q: 'test' });

Building Compatible UIs

UIs that work with the official MCP Apps SDK (@modelcontextprotocol/ext-apps) will work in Photon without modification:

typescript
// Using official MCP Apps SDK
import { App } from '@modelcontextprotocol/ext-apps';

const app = new App({ name: 'My App', version: '1.0.0' });
app.connect();
app.ontoolresult = (result) => {
  // Handle result
};

Or use Photon's native API:

javascript
// Using the photon global (named after your .photon.ts file)
// For myTool.photon.ts:
myTool.onResult(result => {
  document.getElementById('output').textContent = result;
});

Resources


Advanced Workflows

Photon supports interactive and stateful workflows using async generators and the ask/emit pattern.

Interactive Tools (ask/emit)

Use the ask/emit pattern to create interactive CLI tools or conversational MCPs.

typescript
export default class InteractiveTool {
  async *survey() {
    // Emit progress
    yield { emit: 'progress', value: 0.2, message: 'Starting survey...' };

    // Ask for text
    const name = yield { ask: 'text', message: 'What is your name?' };

    // Ask for confirmation
    const confirm = yield { ask: 'confirm', message: `Is ${name} correct?` };

    if (!confirm) return "Aborted";

    // Ask for selection
    const color = yield { 
      ask: 'select', 
      message: 'Favorite color?', 
      options: ['Red', 'Green', 'Blue'] 
    };

    yield { emit: 'progress', value: 1.0, message: 'Done!' };
    return `Name: ${name}, Favorite Color: ${color}`;
  }
}

Stateful Workflows

Mark a class as @stateful and use checkpoint yields to persist state across sessions. This is ideal for long-running workflows or tasks that require manual approval.

typescript
/**
 * @stateful true
 */
export default class Workflow {
  async *execute(params: { task: string }) {
    console.error("Starting task:", params.task);

    // Initial work
    const step1 = await someAsyncWork();

    // Persist state here. If process restarts, it resumes from here.
    yield { checkpoint: 'step1_complete', data: { step1 } };

    // Next step
    const step2 = await nextWork(step1);

    return { step1, step2 };
  }
}

Stateful Persistence via Constructor

For simpler stateful photons that don't need generators or checkpoints, @stateful true combined with constructor defaults gives you automatic persistence across daemon restarts:

typescript
/**
 * @stateful true
 */
export default class List {
  items: string[];
  constructor(items: string[] = []) {
    this.items = items;
  }

  add(item: string): void {
    this.items.push(item);
    // Reactive array auto-persists state to disk
  }

  getAll(): string[] {
    return this.items;
  }
}

The runtime snapshots non-primitive constructor parameters on every mutation and restores them via constructor injection on restart. See Constructor Injection for the full design.


Lifecycle Hooks

Photon supports two optional lifecycle hooks:

onInitialize

Called once when the MCP server starts:

typescript
async onInitialize() {
  console.error('Starting up...');
  console.error(`Working directory: ${this.workdir}`);

  // Initialize resources
  await this._connectDatabase();
  await this._loadConfig();

  console.error('✅ Ready');
}

Use cases:

  • Log configuration
  • Validate environment
  • Initialize connections
  • Load resources

onShutdown

Called when the MCP server is shutting down:

typescript
async onShutdown() {
  console.error('Shutting down...');

  // Clean up resources
  await this.db?.close();
  await this.httpClient?.dispose();

  console.error('✅ Shutdown complete');
}

Use cases:

  • Close database connections
  • Clean up temp files
  • Flush caches
  • Save state

Complete Example

typescript
import Database from 'better-sqlite3';

export default class SqliteMCP {
  private db?: Database.Database;

  constructor(private dbPath: string = join(homedir(), 'data.db')) {}

  async onInitialize() {
    console.error(`Opening database: ${this.dbPath}`);
    this.db = new Database(this.dbPath);
    console.error('✅ Database ready');
  }

  async onShutdown() {
    console.error('Closing database...');
    this.db?.close();
    console.error('✅ Database closed');
  }

  async query(params: { sql: string }) {
    if (!this.db) throw new Error('Database not initialized');
    const result = this.db.prepare(params.sql).all();
    return { success: true, rows: result };
  }
}

Configuration Convention

Prefer protected settings for new photons. It auto-generates a typed settings MCP tool, persists changes to disk, and exposes a read-only Proxy on this.settings — no configure() plumbing required. The configure() pattern below is kept for compatibility with photons written before the settings system existed.

The configure() method is a by-convention pattern for photon configuration. Similar to how main() makes a photon a UI application, configure() makes it a configurable photon.

Why Use configure()?

When a photon needs persistent settings that:

  • Are shared across all instances (MCP, Beam UI, CLI)
  • Should be collected once during install/setup
  • Need to work alongside environment variables

Basic Pattern

typescript
import { PhotonMCP, loadPhotonConfig, savePhotonConfig } from '@portel/photon-core';

interface MyConfig {
  apiEndpoint: string;
  maxRetries?: number;
  enableCache?: boolean;
}

export default class MyService extends PhotonMCP {
  /**
   * Configure this photon
   *
   * Set your API endpoint and options. This is stored persistently
   * so all instances use the same configuration.
   */
  async configure(params: {
    /** The API endpoint URL */
    apiEndpoint: string;
    /** Max retry attempts (default: 3) */
    maxRetries?: number;
    /** Enable response caching */
    enableCache?: boolean;
  }): Promise<{ success: boolean; config: MyConfig }> {
    const config: MyConfig = {
      apiEndpoint: params.apiEndpoint,
      maxRetries: params.maxRetries ?? 3,
      enableCache: params.enableCache ?? true,
    };

    savePhotonConfig('my-service', config);
    return { success: true, config };
  }

  /**
   * Get current configuration
   */
  async getConfig(): Promise<MyConfig & { configPath: string }> {
    const config = loadPhotonConfig<MyConfig>('my-service', {
      apiEndpoint: '',
      maxRetries: 3,
      enableCache: true,
    });
    return {
      ...config,
      configPath: getPhotonConfigPath('my-service'),
    };
  }

  // Use config in your methods
  async fetchData() {
    const config = loadPhotonConfig<MyConfig>('my-service');
    const response = await fetch(config.apiEndpoint);
    // ...
  }
}

How It Works

AspectDescription
Storage~/.photon/{photonName}/config.json
ScopeShared across all instances
DetectionSchema extractor finds configure() method
ToolsNeither configure() nor getConfig() appear as MCP tools

Configuration Utilities

typescript
import {
  loadPhotonConfig,    // Load config with defaults
  savePhotonConfig,    // Save config
  hasPhotonConfig,     // Check if configured
  getPhotonConfigPath, // Get config file path
  deletePhotonConfig,  // Remove config
} from '@portel/photon-core';

// Load with defaults
const config = loadPhotonConfig('my-photon', { theme: 'dark' });

// Save config
savePhotonConfig('my-photon', { theme: 'light', fontSize: 14 });

// Check if configured
if (!hasPhotonConfig('my-photon')) {
  console.log('Please run configure() first');
}

Combined Setup: Environment Variables + Configuration

During install or reconfigure, both are collected together:

typescript
/**
 * My API Client
 *
 * @env API_KEY - Your secret API key
 */
export default class ApiClient extends PhotonMCP {
  constructor(private apiKey: string) {
    super();
  }

  /**
   * Configure additional settings
   */
  async configure(params: {
    /** API endpoint URL */
    endpoint: string;
    /** Request timeout in ms */
    timeout?: number;
  }) {
    savePhotonConfig('api-client', params);
    return { success: true };
  }
}

Setup flow:

  1. User installs photon
  2. Framework detects: apiKey (env var) + endpoint, timeout (config)
  3. Single setup UI collects all values
  4. Env vars saved to .env, config to ~/.photon/api-client/config.json

Convention Methods Summary

MethodPurposeAppears as Tool?
main()UI entry point✓ Yes
configure()Persistent configuration✗ No
getConfig()Read configuration✗ No
onInitialize()Startup lifecycle✗ No
onShutdown()Shutdown lifecycle✗ No

Reconfiguration

Already-configured photons can be reconfigured at any time. In Beam, users click the config icon on an installed photon to edit its settings. On the CLI, run the configure method again.

When you reconfigure, new values are merged with existing ones. If your config had { endpoint: "https://old.api.com", timeout: 5000 } and you only submit a new endpoint, the timeout stays put. No data lost, no surprises.

Under the hood, Beam calls reloadFile after saving the updated config, which recompiles the photon and picks up the new values without restarting the server. It is, genuinely, that simple.


Reactive Collections

Photon can make your plain arrays reactive, which means mutations like .push() and .splice() automatically emit events. No decorators, no wrapper functions, no "please remember to call notifyListeners()." You just write normal TypeScript.

There are three levels, depending on how much you want.

Truly Zero Effort

If your class extends PhotonMCP and has array properties, the compiler does everything:

typescript
import { PhotonMCP } from '@portel/photon-core';

interface Task {
  id: string;
  text: string;
}

export default class TodoList extends PhotonMCP {
  items: Task[] = [];  // Completely normal TypeScript

  async add(params: { text: string }) {
    this.items.push({ id: crypto.randomUUID(), text: params.text });
    // Auto-emits 'items:added'. You wrote zero extra code.
  }

  async remove(params: { id: string }) {
    const idx = this.items.findIndex(t => t.id === params.id);
    if (idx !== -1) this.items.splice(idx, 1);
    // Auto-emits 'items:removed'
  }
}

The compiler detects extends PhotonMCP plus array properties, and auto-injects the reactive wiring at build time. Your source stays clean.

Level 1: Explicit Import

If you prefer being explicit about what is happening (or your class does not extend PhotonMCP), import Array from photon-core. It shadows the global Array with a reactive version:

typescript
import { Array } from '@portel/photon-core';

interface Task {
  id: string;
  text: string;
}

export default class TodoList {
  items: Array<Task> = [];

  async add(params: { text: string }) {
    this.items.push({ id: crypto.randomUUID(), text: params.text });
    // Auto-emits 'items:added'
  }
}

Same result, just more visible. The = [] initializer gets transformed to = new Array() at compile time.

Level 2: Rich Collections

For cases where you need query methods (filtering, sorting, grouping), use Collection&lt;T&gt;. Think of it as a reactive array that also happens to have opinions about data access:

typescript
import { Collection } from '@portel/photon-core';

interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
  category: string;
}

export default class ProductCatalog extends PhotonMCP {
  products = new Collection<Product>();

  async inStock() {
    return this.products.where('stock', '>', 0);
  }

  async byCategory(params: { category: string }) {
    return this.products
      .where('category', params.category)
      .sortBy('price');
  }

  async summary() {
    return {
      total: this.products.count(),
      categories: this.products.groupBy('category'),
      avgPrice: this.products.avg('price'),
    };
  }
}

Collections give you convenience methods like .where(), .pluck(), .groupBy(), .sortBy(), .avg(), and .count(). They also provide rendering hints for auto-UI (tables, cards, charts). And they are still reactive, so mutations emit events just like plain arrays.

How It Works

The magic is split between compile time and runtime:

  1. Compile time: The Photon compiler sees = [] on class properties and transforms it to = new Array() (the reactive version, not the global one). For extends PhotonMCP classes, the import is auto-injected.

  2. Runtime: After the class is instantiated, the loader inspects instance properties. For any ReactiveArray, it auto-wires:

    • _propertyName to the property key (so events know their source)
    • _emitter bound to instance.emit.bind(instance) (so events flow through the photon's event system)
  3. You: Write this.items.push(thing) and move on with your life.


Real-Time Sync

Photon methods can push updates to all connected clients in real time. This is how a kanban board updated in Claude Desktop also updates in Beam, or how a long-running task can stream progress without anyone polling.

Emitting Events

Any method can call this.emit() to broadcast data:

typescript
export default class KanbanBoard extends PhotonMCP {
  async move(params: { taskId: string; column: string }) {
    await this.updateTask(params.taskId, { column: params.column });

    // Push update to every connected client
    this.emit('board:updated', {
      taskId: params.taskId,
      column: params.column,
    });

    return { success: true };
  }
}

Event Flow

The path from this.emit() to a browser tab is short but crosses a few boundaries:

this.emit('board:updated', data)


  Daemon pub/sub (Unix socket)


  SSE push to all connected clients


  Beam tab, Claude Desktop, other Beam tabs...

Every client subscribed to that photon's channel gets the event. If you have Beam open in two browser tabs and Claude Desktop running, all three update simultaneously.

Live Rendering with this.render()

For methods that produce incremental output (progress dashboards, streaming data, live metrics), use this.render() to push formatted content that replaces the previous output rather than appending:

typescript
export default class Monitor {
  async status() {
    while (true) {
      const metrics = await this.collectMetrics();
      // Replaces previous render output in CLI and Beam
      this.render('table', metrics);
      await new Promise(r => setTimeout(r, 5000));
    }
  }
}

In the CLI, this.render() manages a dedicated render zone that clears and repaints on each call. In Beam, it updates the result area in place. Call this.render() with no arguments to clear the render zone.

Runtime Helpers (UI Feedback Events)

Both async methods and generator methods can push transient UI events. Helpers on this are 1:1 wrappers around yield { emit: ... } so the two styles produce identical wire events — pick whichever reads better:

HelperGenerator equivalentEffect
this.toast(msg, { type?, duration? })yield { emit: 'toast', message, type, duration }Transient notification bubble (Beam toast-manager, CLI prefixed log)
this.status(msg)yield { emit: 'status', message }Ephemeral spinner / progress message without a value
this.progress(value, msg?)yield { emit: 'progress', value, message }Progress bar (0..1 or 0..100)
this.log(msg, { level?, data? })yield { emit: 'log', message, level, data }Structured log entry
this.thinking(active?)yield { emit: 'thinking', active }Indeterminate "thinking" indicator
this.render(format, value)yield { emit: 'render', format, value }Replace the live render zone with a formatted value
this.render()yield { emit: 'render:clear' }Clear the render zone

render('toast' \| 'status' \| 'progress', value) is sugar that routes through the dedicated emit event, so this.render('toast', 'Saved!') and this.toast('Saved!') are equivalent.

All of these are auto-injected on plain classes when the runtime detects their usage — no base class required. When extending the Photon base class they come from the mixin.

Custom formats from the server → custom UI

When you emit a format the auto-renderer doesn't recognize, Beam looks for a @ui format-&lt;name&gt; template. Inside that template you can reuse the auto-renderers via photon.render(element, data, 'table'|'gauge'|...) — see Using Auto UI Renderers (photon.render) for the client-side API and the full format list.

Receiving Events in Custom UIs

If your photon has a @ui template, use the auto-injected global named after your photon:

javascript
// Inside your @ui HTML template for kanban.photon.ts
// Subscribe to specific events using on + PascalCase convention
kanban.onBoardUpdated(data => renderBoard(data));
kanban.onTaskMoved(data => animateTask(data));

// Or use the low-level bridge for raw event access
photon.onEmit(event => console.log(event.emit, event.data));

That is the entire client-side setup. The bridge handles SSE subscription, reconnection, and message parsing.

Reactive Collections + Real-Time Sync

These two features compose naturally. When a reactive array emits items:added, that event flows through the same daemon pub/sub pipeline:

typescript
export default class TodoList extends PhotonMCP {
  items: Task[] = [];

  async add(params: { text: string }) {
    this.items.push({ id: crypto.randomUUID(), text: params.text });
    // 'items:added' auto-emits → daemon → SSE → all clients
  }
}

No explicit this.emit() needed. The reactive array handles it.


Sampling: this.sample()

this.sample() asks the calling agent's model to generate text on your photon's behalf. No API key, no SDK, the agent that invoked your photon pays for the tokens. Routes through MCP sampling on STDIO/Beam, through the SSE response stream on Cloudflare Workers, and through a hosted-LLM fallback on the local daemon if the client doesn't support sampling.

typescript
const summary = await this.sample({
  prompt: `Summarize this in one sentence:\n\n${article}`,
  maxTokens: 200,
});

Accepts prompt (single user turn) or messages (full multi-turn history), plus the standard sampling knobs: systemPrompt, temperature, maxTokens, stopSequences, modelPreferences, includeContext. Returns the model's text output as a string.

Augmenting this.sample() (v1.28.0+)

Three composable behaviors layer onto every this.sample() call without changing the call site. They make agent loops self-correcting and let photons accumulate persistent guidance.

1. Memory include convention. Memory keys with reserved prefixes auto-inject into every sample call:

PrefixWhere it lands
include_system_*Prepended to systemPrompt (in addition to whatever the caller passed).
include_transient_*Appended as a trailing user message after the conversation turn.

Persistent guidance pattern:

typescript
// Set once, e.g. during onInitialize:
await this.memory.set('include_system_voice', 'Always answer in plain English. No filler.');
await this.memory.set('include_system_format', 'Return JSON with keys: title, summary.');

// Now every this.sample() call inherits both system prompts:
const out = await this.sample({ prompt: userQuestion });

The convention is enforced by src/sample-augmenter.ts and the merged prompts ride through to the underlying sampling provider unchanged.

Filename safety: the FileMemoryBackend sanitizes key names — non-alphanumerics other than _ . - become _. Use underscores in the prefix (include_system_voice, not include:system:voice) so the stored filename and the prefix filter agree.

2. Transient context registry: this.context. Per-instance named sections with priority, assembled into the trailing message alongside memory transient includes.

typescript
this.context.add('user_intent', 'wants a one-paragraph reply', 'high');
this.context.add('recent_history', historyBlock, 'medium');
this.context.add('debug_state',  diagnosticsBlock, 'low');

// Trailing context is assembled under a char budget (default 8000).
// Whole sections are dropped when over budget — never truncated mid-content.
// Drop order: low → medium → high.

Useful when the relevant context is computed per call (history, search results, current task state) and you don't want to stringify it into every prompt manually.

3. Repeat-loop detection. The augmenter tracks the last 8 normalized responses per instance. On consecutive duplicates, it injects a graded signal into the next call's systemPrompt:

Consecutive duplicatesSignal injected
1INFO: repeat detected, consider varying your output
2WARN: still repeating, change approach
3+ERROR: stuck in a loop, abort the current path

The signal is inline feedback, not a meta-comment — the model sees it as system context for the next turn so it can self-correct. assembleSampleParams() orders augmentations as: repeat signal → memory system → caller's systemPrompt (the caller's prompt always wins on conflicts).

All three compose. None require any setup beyond writing memory keys and adding to this.context. To opt out of any single behavior, leave its inputs empty — there's no flag.


Scheduling: @scheduled, this.schedule, photon ps

Photon runs methods on a cron schedule via two surfaces:

  • @scheduled tag in source — declares intent. The cron is part of the code, version-controlled, and travels with the photon.
  • this.schedule.create() at runtime — for schedules whose cron, name, or method is dynamic (e.g. configured by the user through a UI).

Both are managed by the same daemon. photon ps is the operator surface.

The two-step model: DECLARED → ENABLED → ACTIVE

A @scheduled annotation does not automatically fire its cron. The daemon discovers the tag at boot and lists it as DECLARED. You enroll it with photon ps enable and it moves to ACTIVE. This split exists so a new annotation in source doesn't silently start running the moment someone pulls main — enrollment is an explicit operator decision per machine.

┌────────────────────────────────────────────────────────────────────┐
│  Source (@scheduled)  →  DECLARED  ──[photon ps enable]──→ ACTIVE  │
│                                          ↑                  │      │
│                                          └─[photon ps disable]     │
└────────────────────────────────────────────────────────────────────┘

this.schedule.create() skips DECLARED — it writes directly to the active schedule list and the timer arms immediately.

Declaring with @scheduled

Place the tag in the method's JSDoc. Five-field standard cron, plus the nicknames @hourly, @daily, @weekly, @monthly, @yearly.

typescript
export default class Newsletter {
  /**
   * Send the daily digest at 7:00 every morning.
   *
   * @scheduled 0 7 * * *
   */
  async sendDigest(): Promise<{ sent: number }> {
    // ... body runs once per cron tick
  }
}

After deploying the photon, enroll the schedule:

bash
photon ps enable newsletter:sendDigest
# Enabled newsletter:sendDigest (0 7 * * *) under ~/Projects/my-base

The cron now fires from the daemon. Source edits to the cron string are picked up live — the daemon's file watcher rescans on save and re-arms the timer. To stop firing without removing the source declaration:

bash
photon ps disable newsletter:sendDigest   # back to DECLARED, persists across restarts
photon ps pause   newsletter:sendDigest   # stays ACTIVE but doesn't fire until resumed

Programmatic schedules with this.schedule

Use this when the cron, name, or method is data — for example, a reminders photon where users add their own reminder times.

typescript
export default class Reminders {
  async addReminder(params: { name: string; cron: string; message: string }) {
    return await this.schedule.create({
      name:     params.name,
      schedule: params.cron,
      method:   'fire',
      params:   { message: params.message },
    });
  }

  async removeReminder(params: { name: string }) {
    return await this.schedule.cancelByName(params.name);
  }

  async list() {
    return await this.schedule.list();
  }

  // The method that actually runs each time the cron fires.
  async fire(params: { message: string }) {
    // ... send the reminder
  }
}

this.schedule.create() returns a ScheduledTask with a stable id. The daemon registers the timer immediately and persists the schedule to {base}/.data/{photon}/schedules/{uuid}.json. Cancellation (by id or name) unlinks the file and evicts the timer in one step.

For domain-owned jobs that must be cancelled or replaced later, treat name as the recomputable application key and keep id opaque. For example, a booking photon can create booking:&lt;bookingId&gt;:reminder:24h, then call cancelByName() when the booking is cancelled or moved. The local runtime and Cloudflare deploy surface both support getByName, cancelByName, has, pause, and resume.

Pre-1.27.0 gotcha (fixed): an enable_schedule method that called cancelByName followed by create could silently end up with no active timer if the daemon was in a recovery window. Fixed in v1.27.0. Regression tests live in tests/schedule-cancel-create-regression.test.ts and tests/schedule-ghost-cancel.test.ts.

photon ps command reference

photon ps is the daemon's process viewer. Without arguments it prints a four-section snapshot — ACTIVE schedules, DECLARED-but-not-enrolled, WEBHOOKS, and ACTIVE SESSIONS — for every PHOTON_DIR the daemon serves.

bash
photon ps                          # full snapshot, all bases
photon ps --json                   # same, structured for scripts
photon ps --type active            # one section only
photon ps --base ~/Projects/kith   # filter to one PHOTON_DIR

Subcommands:

CommandWhat it does
photon ps enable &lt;photon&gt;:&lt;method&gt;Move a DECLARED @scheduled to ACTIVE. Writes to {base}/.data/.active-schedules.json.
photon ps disable &lt;photon&gt;:&lt;method&gt;Stop firing AND record the disable so a daemon restart doesn't re-enroll. Use this for "I never want this to fire on this machine."
photon ps pause &lt;photon&gt;:&lt;method&gt;Keep enrollment but skip ticks until resumed. Use this for short-lived suppression (e.g. while debugging).
photon ps resume &lt;photon&gt;:&lt;method&gt;Undo pause.
photon ps history &lt;photon&gt;:&lt;method&gt;Show recent firings: timestamp, duration, success/failure. --limit N, --since &lt;iso&gt;, --json.

Manual cron schedules without a @scheduled tag are added through the Beam UI's Pulse panel ("Add schedule") or programmatically via this.schedule.create() from photon code. They land in the active list with the cron string embedded so the boot loader can re-arm them after a daemon restart.

If the same photon name lives in two PHOTON_DIRs and the command would be ambiguous, every subcommand accepts --base &lt;dir&gt; to disambiguate.

Where state lives

Per-base, under {base}/.data/:

{base}/.data/.active-schedules.json   # which @scheduled methods are ACTIVE,
                                       # plus the suppressed list (persistent disables)
                                       # and migratedFromAutoRegister flag
{base}/.data/{photon}/schedules/      # ScheduleProvider files (this.schedule.create)
{base}/.data/{photon}/state/          # @stateful instance state

Daemon-wide, under ~/.photon/.data/:

~/.photon/.data/.bases.json   # every PHOTON_DIR the daemon has served
~/.photon/.data/daemon.log    # boot, fire, error events — first stop for "why isn't this firing"
~/.photon/.data/daemon.sock   # IPC socket

Multi-host setups: .photon-no-host

If you run the same ~/Projects/&lt;base&gt; on multiple machines (Syncthing, Dropbox, NFS, etc.) you only want one machine to actually fire the schedules. Put a marker file in the base on every quiet machine:

bash
touch ~/Projects/kith/.photon-no-host

The daemon refuses to load any ScheduleProvider files, register any @scheduled annotations, or wire any @webhook routes for that base. photon run and direct CLI invocations still work — host mode only suppresses background activation. Remove the file to re-enable.

Diagnosing "my schedule isn't firing"

  1. photon ps — is your method in ACTIVE? If it's in DECLARED, run photon ps enable &lt;photon&gt;:&lt;method&gt;.
  2. photon ps history &lt;photon&gt;:&lt;method&gt; — has it ever fired? Most recent attempt's error is right there.
  3. tail -f ~/.photon/.data/daemon.log | grep &lt;photon&gt; — boot discovery, registration, fire attempts. Filter for the photon name.
  4. .photon-no-host in the base? — host-disabled. Daemon log will say Skipping … host-disabled base.
  5. Was the daemon restarted recently? — a few seconds after restart, loadAllPersistedSchedules (sync) has populated ACTIVE for this.schedule.create() jobs but discoverProactiveMetadataAtBoot (async) may still be scanning sources for @scheduled. Wait a few seconds and re-run photon ps.

Common Patterns

Filesystem Operations

typescript
import { readFile, writeFile, readdir } from 'fs/promises';
import { join, resolve, relative } from 'path';
import { existsSync } from 'fs';
import { homedir } from 'os';

export default class Filesystem {
  constructor(
    private workdir: string = join(homedir(), 'Documents'),
    private maxFileSize: number = 10485760
  ) {
    if (!existsSync(workdir)) {
      throw new Error(`Directory does not exist: ${workdir}`);
    }
  }

  async read(params: { path: string }) {
    const fullPath = this._resolvePath(params.path);
    const content = await readFile(fullPath, 'utf-8');
    return { success: true, content };
  }

  async write(params: { path: string; content: string }) {
    const fullPath = this._resolvePath(params.path);
    await writeFile(fullPath, params.content, 'utf-8');
    return { success: true, path: fullPath };
  }

  // Security: prevent directory traversal
  private _resolvePath(path: string): string {
    const resolved = resolve(this.workdir, path);
    const rel = relative(this.workdir, resolved);

    if (rel.startsWith('..')) {
      throw new Error('Access denied: path outside working directory');
    }

    return resolved;
  }
}

HTTP Requests

typescript
export default class Fetch {
  constructor(
    private timeout: number = 5000,
    private maxRedirects: number = 5
  ) {}

  async get(params: { url: string; headers?: Record<string, string> }) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), this.timeout);

    try {
      const response = await fetch(params.url, {
        method: 'GET',
        headers: params.headers,
        signal: controller.signal,
        redirect: 'follow'
      });

      const data = await response.text();

      return {
        success: true,
        status: response.status,
        data,
        headers: Object.fromEntries(response.headers)
      };
    } catch (error: any) {
      return { success: false, error: error.message };
    } finally {
      clearTimeout(timeoutId);
    }
  }

  async post(params: {
    url: string;
    body: string | object;
    headers?: Record<string, string>;
  }) {
    const body = typeof params.body === 'string'
      ? params.body
      : JSON.stringify(params.body);

    const headers = {
      'Content-Type': 'application/json',
      ...params.headers
    };

    const response = await fetch(params.url, {
      method: 'POST',
      headers,
      body
    });

    return {
      success: response.ok,
      status: response.status,
      data: await response.text()
    };
  }
}

Database Operations

typescript
import Database from 'better-sqlite3';
import { join } from 'path';
import { homedir } from 'os';

export default class Sqlite {
  private db?: Database.Database;

  constructor(
    private dbPath: string = join(homedir(), 'data.db'),
    private readonly: boolean = false
  ) {}

  async onInitialize() {
    this.db = new Database(this.dbPath, {
      readonly: this.readonly
    });
    console.error(`Database ready: ${this.dbPath}`);
  }

  async onShutdown() {
    this.db?.close();
  }

  async query(params: { sql: string; params?: any[] }) {
    if (!this.db) throw new Error('Database not initialized');

    try {
      const stmt = this.db.prepare(params.sql);
      const rows = params.params
        ? stmt.all(...params.params)
        : stmt.all();

      return { success: true, rows };
    } catch (error: any) {
      return { success: false, error: error.message };
    }
  }

  async execute(params: { sql: string; params?: any[] }) {
    if (!this.db) throw new Error('Database not initialized');

    try {
      const stmt = this.db.prepare(params.sql);
      const result = params.params
        ? stmt.run(...params.params)
        : stmt.run();

      return {
        success: true,
        changes: result.changes,
        lastInsertRowid: result.lastInsertRowid
      };
    } catch (error: any) {
      return { success: false, error: error.message };
    }
  }
}

Shell Commands

typescript
import { exec } from 'child_process';
import { promisify } from 'util';

const execAsync = promisify(exec);

export default class Git {
  constructor(
    private repoPath: string = process.cwd(),
    private timeout: number = 10000
  ) {}

  async status(params: {}) {
    return this._exec('git status --porcelain');
  }

  async log(params: { count?: number }) {
    const count = params.count || 10;
    return this._exec(`git log -n ${count} --oneline`);
  }

  async commit(params: { message: string }) {
    return this._exec(`git commit -m "${params.message}"`);
  }

  private async _exec(command: string) {
    try {
      const { stdout, stderr } = await execAsync(command, {
        cwd: this.repoPath,
        timeout: this.timeout
      });

      return {
        success: true,
        output: stdout || stderr
      };
    } catch (error: any) {
      return {
        success: false,
        error: error.message
      };
    }
  }
}

CLI Command Reference

Photon provides a comprehensive suite of commands for running, managing, and developing MCPs.

Runtime Commands

CommandUsage
photon mcp &lt;name&gt;Run a Photon as an MCP server. Use --dev for hot-reload.
photon cli &lt;photon&gt; [method]Execute Photon methods directly from the command line.
photon sse &lt;name&gt;Launch a single-tenant SSE server for browser or remote access.
photon beamOpen an interactive web UI for all your installed Photons (formerly playground).
photon beam owner/repo/nameInstall a photon from GitHub and open it in Beam.
photon cli owner/repo/name methodInstall a photon from GitHub and run a method.
photon serveStart a local multi-tenant MCP server host for development.
photon host &lt;command&gt;Manage cloud hosting: deploy (ship to cloud) or preview (run local sim).

Management Commands

CommandUsage
photon add &lt;name&gt;Install a Photon from the marketplace.
photon add owner/repo/nameInstall a Photon directly from a GitHub repository.
photon remove &lt;name&gt;Remove an installed Photon.
photon upgrade [name]Upgrade Photon(s) to the latest version.
photon info [name]Show detailed metadata and configuration for a Photon.
photon search &lt;query&gt;Search enabled marketplaces for Photons.

Build Commands

CommandUsage
photon build &lt;name&gt;Compile a photon into a standalone binary (uses Bun).
photon build &lt;name&gt; --with-appInclude embedded Beam UI in the binary.
photon build &lt;name&gt; -t &lt;target&gt;Cross-compile for a specific platform (e.g., bun-linux-x64).

Developer Tools (maker)

CommandUsage
photon maker new &lt;name&gt;Create a new Photon from the default template.
photon maker validate &lt;name&gt;Validate syntax, schemas, and dependencies.
photon maker syncGenerate photons.json manifest for a marketplace.
photon maker initSet up a marketplace with auto-sync git hooks.
photon maker diagram &lt;name&gt;Generate a Mermaid dependency/flow diagram.

Maintenance

CommandUsage
photon doctorDiagnose your environment (Node, npm, ports, config).
photon updateRefresh marketplace indexes and check for CLI updates.
photon clear-cacheClear compiled Photon artifacts.

Advanced

CommandUsage
photon test [name]Run test methods defined in your Photons (supports unit/integration).
photon alias &lt;name&gt;Create a global CLI alias for a Photon (e.g. run-my-tool).
photon marketplaceManage marketplace sources (add/remove git repos).

Testing and Development

Local Development

1. Create MCP:

bash
photon maker new my-tool

2. Edit file:

bash
# Opens ~/.photon/my-tool.photon.ts
code ~/.photon/my-tool.photon.ts

3. Run in dev mode:

bash
photon mcp my-tool --dev

Dev mode features:

  • ✅ Hot reload on file changes
  • ✅ Detailed error messages
  • ✅ Console logging visible

4. Validate:

bash
photon maker validate my-tool

Shows:

  • Tool count
  • Schema extraction results
  • Compilation errors

Testing with MCP Inspector

Use the official MCP Inspector:

bash
# Install globally
bun add -g @modelcontextprotocol/inspector

# Test your MCP
bunx @modelcontextprotocol/inspector photon my-tool.photon.ts

# pnpm alternative
pnpm dlx @modelcontextprotocol/inspector photon my-tool.photon.ts

Manual Testing

Create a test script:

typescript
// test.ts
import { join } from 'path';
import { homedir } from 'os';

async function test() {
  // Import your MCP class
  const { default: MyMCP } = await import('./my-tool.photon.ts');

  // Instantiate with test config
  const mcp = new MyMCP(join(homedir(), 'test-data'));

  // Initialize
  await mcp.onInitialize?.();

  // Test tools
  const result = await mcp.myTool({ input: 'test' });
  console.log('Result:', result);

  // Cleanup
  await mcp.onShutdown?.();
}

test().catch(console.error);

Run:

bash
bunx tsx test.ts

Debugging

Enable verbose logging:

typescript
async onInitialize() {
  console.error('Configuration:');
  console.error(JSON.stringify({
    workdir: this.workdir,
    enabled: this.enabled
  }, null, 2));
}

Check environment variables:

bash
# List all environment variables
env | grep MY_TOOL

# Run with specific vars
MY_TOOL_WORKDIR=/tmp/test photon my-tool --dev

Validate schemas:

bash
photon maker validate my-tool

Deployment

Claude Desktop

1. Generate config:

bash
photon info my-tool --mcp

Output:

json
{
  "mcpServers": {
    "my-tool": {
      "command": "bunx",
      "args": ["-y", "@portel/photon", "mcp", "my-tool"],
      "env": {
        "MY_TOOL_WORKDIR": "~/Documents",
        "MY_TOOL_MAX_FILE_SIZE": "10485760"
      }
    }
  }
}

2. Add to Claude Desktop config:

macOS:

bash
code ~/Library/Application\ Support/Claude/claude_desktop_config.json

Windows:

bash
code %APPDATA%\Claude\claude_desktop_config.json

3. Restart Claude Desktop

Claude Code CLI

Add to .claude/claude.json:

json
{
  "mcpServers": {
    "my-tool": {
      "command": "photon",
      "args": ["mcp", "my-tool"],
      "env": {
        "MY_TOOL_WORKDIR": "${workspaceFolder}/data"
      }
    }
  }
}

Cursor/Windsurf

Add to MCP settings:

json
{
  "mcpServers": {
    "my-tool": {
      "command": "bunx",
      "args": ["-y", "@portel/photon", "mcp", "my-tool"]
    }
  }
}

Environment Variables

Option 1: In MCP config (recommended):

json
{
  "my-tool": {
    "command": "photon",
    "args": ["mcp", "my-tool"],
    "env": {
      "MY_TOOL_API_KEY": "sk-...",
      "MY_TOOL_ENDPOINT": "https://api.example.com"
    }
  }
}

Option 2: System environment:

bash
export MY_TOOL_API_KEY="sk-..."
photon mcp my-tool

Option 3: .env file (not recommended for production):

bash
# .env
MY_TOOL_API_KEY=sk-...

Cloudflare Workers

Test your Photon locally in a simulated Cloudflare environment:

bash
photon host preview cf my-tool

Deploy your Photon to the edge with Cloudflare Workers:

bash
photon host deploy cloudflare my-tool

This will:

  1. Generate an optimized bundle.
  2. Create a wrangler.toml configuration with a Durable Object binding for the photon.
  3. Deploy the service to your Cloudflare account.

Use --dev to enable the interactive playground in the deployed worker, and --logs to opt into Cloudflare Workers Logs for dashboard observability.

Stateful photons. this.memory, this.emit, this.schedule, this.call, this.sample, this.confirm, and this.elicit all work the same on Cloudflare as on the local daemon. Each instanceName runs in its own Durable Object — storage is backed by ctx.storage, this.emit({channel, ...}) broadcasts to subscribers connected at wss://&lt;worker&gt;/events?channel=&lt;name&gt;, this.schedule.create({...}) registers cron entries that fire from the DO's alarm, this.call('foo.bar', ...) hops to a sibling photon DO declared in the host's @photons, and this.sample / this.confirm / this.elicit ride MCP server-initiated requests on the active tool call's SSE response (clients must signal Accept: text/event-stream). Pick the instance per request with ?instance=&lt;name&gt; or the X-Photon-Instance header; omit both to land on the shared 'default' singleton. See docs/internals/CF-DURABLE-OBJECTS.md for the full capability mapping.


How Photon Works

Understanding Photon's internals helps debug issues and optimize performance.

Architecture Overview

┌─────────────────────────────────────────┐
│         .photon.ts file                 │
│  export default class MyMCP { ... }     │
└────────────────┬────────────────────────┘


┌─────────────────────────────────────────┐
│           Loader (loader.ts)            │
│  1. Compile TypeScript → JavaScript     │
│  2. Extract constructor parameters      │
│  3. Resolve environment variables       │
│  4. Instantiate class with config       │
└────────────────┬────────────────────────┘


┌─────────────────────────────────────────┐
│      Schema Extractor (schema.ts)       │
│  1. Parse JSDoc comments                │
│  2. Extract TypeScript types            │
│  3. Generate JSON schemas               │
└────────────────┬────────────────────────┘


┌─────────────────────────────────────────┐
│         MCP Server (server.ts)          │
│  1. Implement MCP protocol              │
│  2. List tools (from public methods)    │
│  3. Call tools (invoke class methods)   │
│  4. Handle lifecycle (init/shutdown)    │
└────────────────┬────────────────────────┘


┌─────────────────────────────────────────┐
│      stdio/JSON-RPC Transport           │
│  Communicate with MCP clients via       │
│  standard input/output                  │
└─────────────────────────────────────────┘

Photon ID

Every photon instance is assigned a unique 12-character hash ID based on its file path:

typescript
// Generated from path using SHA-256
// Path: /Users/you/.photon/kanban.photon.ts
// ID:   f5c5ee47905e

import { createHash } from 'crypto';

function generatePhotonId(photonPath: string): string {
  return createHash('sha256').update(photonPath).digest('hex').slice(0, 12);
}

Why hashed IDs?

  • Unique across systems: Different installations get different IDs
  • Stable: Same path always produces same ID
  • Multi-tenant safe: No collisions between different users or projects
  • Short: 12 characters is enough for practical uniqueness

Where IDs are used:

  • Channel subscriptions: {photonId}:{itemId} format for daemon pub/sub
  • MCP responses: x-photon-id header in tools/list
  • PhotonInfo: id field in photon metadata

Access in code:

typescript
// In your photon (via PhotonMCP base class)
export default class MyPhoton extends PhotonMCP {
  async myMethod() {
    console.log('My ID:', this.photonId);  // e.g., "f5c5ee47905e"
  }
}

// In Custom UI (via bridge)
const photonId = photon.photonId;

Compilation Process

1. Source → JavaScript:

typescript
// Input: calculator.photon.ts
export default class Calculator {
  async add(params: { a: number; b: number }) {
    return params.a + params.b;
  }
}

// Output: Compiled JavaScript (ESM)
export default class Calculator {
  async add(params) {
    return params.a + params.b;
  }
}

Tool: esbuild (fast TypeScript compiler)

Cache: ~/.photon/.data/.cache/compiled/{hash}.js

2. Constructor Parameter Extraction:

typescript
// From source file (not compiled)
constructor(private workdir: string = '/default')

// Extracted parameters:
[
  {
    name: 'workdir',
    type: 'string',
    hasDefault: true,
    defaultValue: '/default'
  }
]

3. Environment Variable Resolution:

typescript
// Parameter: workdir
// MCP name: filesystem
// Env var: FILESYSTEM_WORKDIR

const envValue = process.env.FILESYSTEM_WORKDIR;
const finalValue = envValue || defaultValue;

Schema Extraction

Input (TypeScript + JSDoc):

typescript
/**
 * Add two numbers together
 * @param a First number
 * @param b Second number
 */
async add(params: { a: number; b: number }) {
  return params.a + params.b;
}

Output (JSON Schema):

json
{
  "name": "add",
  "description": "Add two numbers together",
  "inputSchema": {
    "type": "object",
    "properties": {
      "a": {
        "type": "number",
        "description": "First number"
      },
      "b": {
        "type": "number",
        "description": "Second number"
      }
    },
    "required": ["a", "b"]
  }
}

Process:

  1. Parse JSDoc with regex
  2. Extract TypeScript types from source
  3. Map TS types → JSON Schema types
  4. Combine descriptions with schemas

MCP Protocol Implementation

Tool listing:

json
{
  "jsonrpc": "2.0",
  "method": "tools/list",
  "result": {
    "tools": [
      {
        "name": "add",
        "description": "Add two numbers together",
        "inputSchema": { ... }
      }
    ]
  }
}

Tool call:

json
{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "add",
    "arguments": { "a": 5, "b": 3 }
  }
}

Response:

json
{
  "jsonrpc": "2.0",
  "result": {
    "content": [
      { "type": "text", "text": "8" }
    ]
  }
}

Hot Reload

In --dev mode:

  1. Watch file: chokidar monitors .photon.ts
  2. On change:
    • Recompile with esbuild
    • Reload class dynamically
    • Re-extract schemas
    • Update tool registry
  3. Server continues: No restart needed

Cache and Resilience

Photon caches compiled artifacts and installed dependencies so repeated startups are fast. Here is where things live and what happens when they go stale.

Dependency cache: ~/.photon/.data/.cache/dependencies/{cacheKey}/

Each photon's @dependencies get their own isolated node_modules. The cache key is derived from the dependency list, so changing your @dependencies tag naturally creates a fresh cache.

Auto-invalidation on photon-core updates: The cache tracks the resolved version of @portel/photon-core (not the semver range). If you upgrade photon-core from 2.5.3 to 2.5.4, the next run detects the mismatch and rebuilds the dependency cache automatically. No manual intervention, no mysterious "it works on my machine" moments.

Daemon auto-restart: If the daemon process has crashed or become unreachable (ECONNREFUSED), the next CLI command automatically restarts it and retries the operation. You might notice a slightly longer first response, but that is the only visible sign.

Manual cache clearing: When all else fails:

bash
photon clear-cache

This wipes compiled artifacts and dependency caches. The next run rebuilds everything from scratch.


Best Practices

Security

1. Path Traversal Protection:

typescript
private _resolvePath(userPath: string): string {
  const resolved = resolve(this.workdir, userPath);
  const rel = relative(this.workdir, resolved);

  if (rel.startsWith('..') || resolve(rel) === rel) {
    throw new Error('Access denied: path outside working directory');
  }

  return resolved;
}

2. Input Validation:

typescript
async process(params: { email: string }) {
  // Validate format
  if (!params.email.includes('@')) {
    return { success: false, error: 'Invalid email format' };
  }

  // Sanitize input
  const email = params.email.trim().toLowerCase();

  // Process safely
  return await this._processEmail(email);
}

3. Command Injection Prevention:

typescript
// ❌ BAD: Direct string interpolation
async git(params: { message: string }) {
  await exec(`git commit -m "${params.message}"`); // Vulnerable!
}

// ✅ GOOD: Use parameterized commands
async git(params: { message: string }) {
  // Escape or use library
  const escaped = params.message.replace(/"/g, '\\"');
  await exec(`git commit -m "${escaped}"`);
}

// ✅ BETTER: Use child_process with args array
import { spawn } from 'child_process';
async git(params: { message: string }) {
  return new Promise((resolve) => {
    spawn('git', ['commit', '-m', params.message]);
  });
}

4. File Size Limits:

typescript
async read(params: { path: string }) {
  const fullPath = this._resolvePath(params.path);
  const stats = await stat(fullPath);

  if (stats.size > this.maxFileSize) {
    return {
      success: false,
      error: `File too large: ${stats.size} bytes (max: ${this.maxFileSize})`
    };
  }

  return { success: true, content: await readFile(fullPath, 'utf-8') };
}

Performance

1. Lazy Initialization:

typescript
export default class Database {
  private db?: DatabaseConnection;

  async query(params: { sql: string }) {
    if (!this.db) {
      this.db = await this._connect();
    }
    return this.db.execute(params.sql);
  }
}

2. Connection Pooling:

typescript
export default class HTTP {
  private agent?: Agent;

  constructor() {
    this.agent = new Agent({
      keepAlive: true,
      maxSockets: 10
    });
  }

  async fetch(params: { url: string }) {
    return fetch(params.url, { agent: this.agent });
  }
}

3. Streaming Large Files:

typescript
async readLarge(params: { path: string }) {
  const stream = createReadStream(this._resolvePath(params.path));
  let content = '';

  for await (const chunk of stream) {
    content += chunk;
  }

  return { success: true, content };
}

Error Handling

1. Structured Errors:

typescript
async process(params: { input: string }) {
  try {
    // Validate
    if (!params.input) {
      return {
        success: false,
        error: 'Input is required',
        code: 'MISSING_INPUT'
      };
    }

    // Process
    const result = await this._process(params.input);

    return {
      success: true,
      result,
      timestamp: new Date().toISOString()
    };
  } catch (error: any) {
    return {
      success: false,
      error: error.message,
      code: error.code || 'UNKNOWN_ERROR'
    };
  }
}

2. Graceful Degradation:

typescript
async fetch(params: { url: string }) {
  try {
    return await this._fetchWithRetry(params.url, 3);
  } catch (error) {
    // Log but don't crash
    console.error('Fetch failed:', error);
    return {
      success: false,
      error: 'Service temporarily unavailable'
    };
  }
}

Documentation

1. Comprehensive JSDoc:

typescript
/**
 * Search for text patterns in files (grep-like functionality)
 *
 * Recursively searches through files in the specified directory,
 * matching lines that contain the search pattern. Results include
 * file paths, line numbers, and matched content.
 *
 * @param pattern Text pattern to search for (case-sensitive)
 * @param path Directory to search in (relative to working directory, default: root)
 * @param filePattern Optional file pattern (e.g., "*.ts" for TypeScript files)
 * @returns List of matches with file, line number, and content
 */
async search(params: {
  pattern: string;
  path?: string;
  filePattern?: string;
}) {
  // Implementation
}

2. File Header:

typescript
/**
 * Filesystem - File and directory operations
 *
 * Provides essential file system utilities: read, write, list, search, delete.
 * All paths are resolved relative to the configured working directory for security.
 *
 * Common use cases:
 * - Organize documents: "Categorize my documents by topic"
 * - Search files: "Find all PDFs about project planning"
 * - Bulk operations: "Move all .txt files to Archive folder"
 *
 * Configuration:
 * - workdir: Working directory (default: ~/Documents)
 * - maxFileSize: Max file size in bytes (default: 10MB)
 * - allowHidden: Allow hidden files (default: false)
 *
 * Dependencies: None (uses Node.js built-in fs)
 *
 * @version 2.0.0
 * @author Your Name
 * @license MIT
 */

Troubleshooting

Common Issues

1. "Cannot find module" error:

Error: Cannot find module 'my-dependency'

Solution: Install dependencies in the same directory:

bash
cd ~/.photon
bun add my-dependency

2. Environment variables not working:

Error: API key is required

Solution: Check environment variable naming:

bash
# For MCP named "my-tool" with parameter "apiKey"
export MY_TOOL_API_KEY="your-key"

# Or use --config to see correct names
photon my-tool --config

3. Constructor validation fails:

Error: Working directory does not exist: /invalid/path

Solution: The MCP loads but tools fail with helpful error. Users see:

Configuration Error: Working directory does not exist: /invalid/path

To configure this MCP, set environment variables:
  FILESYSTEM_WORKDIR=~/Documents

Or add to your MCP config:
{
  "env": {
    "FILESYSTEM_WORKDIR": "/path/to/docs"
  }
}

4. Hot reload not working:

Solution: Check file permissions and paths:

bash
# Ensure file is writable
chmod +w ~/.photon/my-tool.photon.ts

# Check for syntax errors
photon maker validate my-tool

5. Schema extraction fails:

Warning: Could not extract schema for tool 'myTool'

Solution: Ensure proper TypeScript types:

typescript
// ❌ BAD: No type annotations
async myTool(params) { }

// ✅ GOOD: Explicit types
async myTool(params: { input: string }) { }

Debugging Tips

1. Enable verbose logging:

typescript
async onInitialize() {
  console.error('[debug] Configuration:', this);
  console.error('[debug] Environment:', process.env);
}

2. Validate schemas:

bash
photon maker validate my-tool

Shows:

  • ✅ Tools found: 5
  • ✅ Schemas extracted: 5
  • ❌ Compilation errors

3. Test compilation:

bash
# Compile manually
bunx esbuild my-tool.photon.ts --bundle --platform=node --format=esm

4. Check MCP protocol:

bash
# Use MCP Inspector
bunx @modelcontextprotocol/inspector photon my-tool

5. Verify environment:

bash
# List environment variables
env | grep MY_TOOL

# Test with specific values
MY_TOOL_DEBUG=true photon my-tool --dev

Getting Help

  1. Check examples: photon-examples has working MCPs
  2. Read logs: stderr output shows detailed error messages
  3. Validate: Use photon maker validate my-tool
  4. GitHub Issues: https://github.com/portel-dev/photon/issues
  5. MCP Docs: https://modelcontextprotocol.io/

Advanced Topics

Custom Type Mappings

For complex types, use JSDoc to guide schema generation:

typescript
/**
 * Process user data
 * @param user User object with name, age, and email
 */
async process(params: {
  user: {
    name: string;
    age: number;
    email: string;
  }
}) {
  // Photon extracts nested object schema automatically
}

Pre-generated Schemas

For bundled MCPs, create .photon.schema.json:

json
[
  {
    "name": "add",
    "description": "Add two numbers",
    "inputSchema": {
      "type": "object",
      "properties": {
        "a": { "type": "number" },
        "b": { "type": "number" }
      },
      "required": ["a", "b"]
    }
  }
]

Photon will use this instead of extracting from source.

Multi-File MCPs

While Photon is designed for single-file MCPs, you can import utilities:

typescript
// helpers.ts
export function sanitize(input: string) {
  return input.trim().toLowerCase();
}

// my-tool.photon.ts
import { sanitize } from './helpers.js';

export default class MyTool {
  async process(params: { input: string }) {
    return sanitize(params.input);
  }
}

Compile with esbuild's bundling (automatic in Photon).


Summary

Key Takeaways:

  1. Single File - One .photon.ts = one MCP server
  2. No Config - Convention over configuration
  3. Constructor → Env Vars - Automatic config injection
  4. Public Methods → Tools - No decorators needed
  5. JSDoc → Descriptions - Documentation becomes MCP metadata
  6. TypeScript → JSON Schema - Type safety built-in
  7. Lifecycle Hooks - Optional onInitialize and onShutdown
  8. Hot Reload - Dev mode for rapid iteration

Next Steps:

  1. Create your first MCP: photon maker new my-tool
  2. Study examples: photon-examples
  3. Test in dev mode: photon mcp my-tool --dev
  4. Deploy to Claude Desktop: photon info my-tool --mcp

Happy building! 🚀

Released under the MIT License.