# Photon MCP Developer Guide > **New to Photon?** Start with [Getting Started](getting-started.md) 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](#quick-start) 2. [Creating Your First MCP](#creating-your-first-mcp) 3. [Settings: User-Configurable Knobs](#settings-user-configurable-knobs) 4. [Constructor Configuration (for secrets)](#constructor-configuration-for-secrets) 5. [Writing Tool Methods](#writing-tool-methods) 6. [Docblock Tags](#docblock-tags) 7. [Return Formatting](#return-formatting) 8. [Dependency Injection](#dependency-injection) 9. [Assets and UI](#assets-and-ui) 10. [Advanced Workflows](#advanced-workflows) 11. [Lifecycle Hooks](#lifecycle-hooks) 12. [Configuration Convention](#configuration-convention) 13. [Reactive Collections](#reactive-collections) 14. [Real-Time Sync](#real-time-sync) 15. [Sampling: `this.sample()`](#sampling-thissample) 16. [Scheduling: `@scheduled`, `this.schedule`, `photon ps`](#scheduling-scheduled-thisschedule-photon-ps) 17. [Common Patterns](#common-patterns) 18. [CLI Command Reference](#cli-command-reference) 19. [Testing and Development](#testing-and-development) 20. [Deployment](#deployment) 21. [How Photon Works](#how-photon-works) 22. [Best Practices](#best-practices) 23. [Troubleshooting](#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.ts` → `calculator` 2. **Class name** (fallback): `class Calculator` → `calculator` ### 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//-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` for | Use the constructor for | |---|---| | Anything the user should change at runtime | Primitive secrets that belong in `.env` | | Polling intervals, thresholds, modes, paths | API keys, tokens, passwords | | Feature toggles, retry counts, defaults | Service URLs that never change between deploys | | Any value with a sensible default | Required 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](#settings-user-configurable-knobs). ### 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 Parameter | Environment Variable | |-----------------------|----------------------| | `workdir` | `FILESYSTEM_WORKDIR` | | `maxFileSize` | `FILESYSTEM_MAX_FILE_SIZE` | | `allowHidden` | `FILESYSTEM_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": "", "MY_TOOL_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. | Tag | Usage | Example | |---|---|---| | `@version` | Photon version | `@version 1.0.0` | | `@author` | Author name | `@author Jane Doe` | | `@license` | License type | `@license MIT` | | `@repository` | Source repository URL | `@repository github.com/user/repo` | | `@homepage` | Project homepage | `@homepage example.com` | | `@runtime` | **Required** runtime version range | `@runtime ^1.5.0` | | `@dependencies` | NPM packages to auto-install | `@dependencies axios@^1.0.0` | | `@mcp` | Inject MCP dependency | `@mcp github anthropics/mcp` | | `@photon` | Inject Photon dependency | `@photon utils ./utils.photon.ts` | | `@auth` | Configure MCP auth and populate `this.caller` | `@auth required` | | `@stateful` | Enable stateful mode | `@stateful true` | | `@idleTimeout` | Idle timeout in ms | `@idleTimeout 300000` | | `@ui` | Define UI template asset | `@ui main ./ui/index.html` | | `@prompt` | Define prompt asset | `@prompt system ./prompts/sys.txt` | | `@resource` | Define resource asset | `@resource data ./data.json` | ### Method-Level Tags Place these immediately preceding a tool method. | Tag | Usage | Example | |---|---|---| | `@param` | Describe parameter | `@param name User name` | | `@returns` | Describe return value | `@returns The result` | | `@example` | Provide usage example | `@example await tool.run()` | | `@format` | Output format hint | `@format table` | | `@icon` | Tool icon (emoji/name) | `@icon 🧮` | | `@autorun` | Auto-run in UI | `@autorun` | | `@ui` | Link to UI template | `@ui main` | | `@scope` | Override the inferred OAuth scope for this tool | `@scope bookings:write` | ### Daemon Tags (Advanced) Enable background features handled by the Photon Daemon. | Tag | Usage | Example | |---|---|---| | `@webhook` | Expose as HTTP webhook | `@webhook stripe` | | `@scheduled` | Cron schedule | `@scheduled 0 0 * * *` | | `@locked` | Distributed lock | `@locked resource:write` | ### Inline Parameter Tags Use these *within* `@param` descriptions for validation and UI generation. | Tag | functionality | Example | |---|---|---| | `{@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. | Format | Description | Used For | |---|---|---| | `primitive` | Formats result as a single value | Strings, numbers, booleans | | `table` | Formats results as a grid | Arrays of objects | | `list` | Formats results as a bulleted list | Arrays of primitives | | `grid` | Formats results as a visual grid | Arrays of objects/images | | `card` | Formats result as a detailed card | Single object | | `tree` | Formats results as a hierarchy | Nested objects/JSON | | `none` | Raw JSON output | Complex 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} */ ``` | Hint | Description | |---|---| | `@title field` | Primary display text | | `@subtitle field` | Secondary display text | | `@icon field` | Leading icon/image | | `@badge field` | Status badge | | `@columns N` | Grid column count | | `@style S` | Style: `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](core/CONSTRUCTOR-INJECTION.md) 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 | Message | Direction | Purpose | |---------|-----------|---------| | `ui/initialize` | Host → App | Initialize with theme, capabilities, dimensions | | `ui/ready` | App → Host | App is ready to receive data | | `tools/call` | App → Host | Request tool execution from the UI | | `ui/notifications/tool-result` | Host → App | Push 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 - [MCP Apps Specification](https://modelcontextprotocol.io/docs/extensions/apps) - [SEP-1865 GitHub](https://github.com/modelcontextprotocol/ext-apps) - [Platform Compatibility Source](./src/auto-ui/platform-compat.ts) --- ## 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](core/CONSTRUCTOR-INJECTION.md) 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`](#settings-user-configurable-knobs) 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 { const config = loadPhotonConfig('my-service', { apiEndpoint: '', maxRetries: 3, enableCache: true, }); return { ...config, configPath: getPhotonConfigPath('my-service'), }; } // Use config in your methods async fetchData() { const config = loadPhotonConfig('my-service'); const response = await fetch(config.apiEndpoint); // ... } } ``` ### How It Works | Aspect | Description | |--------|-------------| | Storage | `~/.photon/{photonName}/config.json` | | Scope | Shared across all instances | | Detection | Schema extractor finds `configure()` method | | Tools | Neither `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 | Method | Purpose | Appears 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 = []; 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`. 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(); 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: | Helper | Generator equivalent | Effect | |--------|---------------------|--------| | `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-` template. Inside that template you can reuse the auto-renderers via `photon.render(element, data, 'table'|'gauge'|...)` — see [Using Auto UI Renderers (photon.render)](./guides/CUSTOM-UI.md#using-auto-ui-renderers-photonrender) 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: | Prefix | Where 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 duplicates | Signal injected | |------------------------|----------------------------| | 1 | `INFO: repeat detected, consider varying your output` | | 2 | `WARN: 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::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: | Command | What it does | |--------------------------------------|--------------------------------------------------------------------------------| | `photon ps enable :` | Move a DECLARED `@scheduled` to ACTIVE. Writes to `{base}/.data/.active-schedules.json`. | | `photon ps disable :`| 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 :` | Keep enrollment but skip ticks until resumed. Use this for short-lived suppression (e.g. while debugging). | | `photon ps resume :` | Undo pause. | | `photon ps history :`| Show recent firings: timestamp, duration, success/failure. `--limit N`, `--since `, `--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 ` 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/` 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 :`. 2. **`photon ps history :`** — has it ever fired? Most recent attempt's error is right there. 3. **`tail -f ~/.photon/.data/daemon.log | grep `** — 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 }) { 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; }) { 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 | Command | Usage | |---|---| | `photon mcp ` | Run a Photon as an MCP server. Use `--dev` for hot-reload. | | `photon cli [method]` | Execute Photon methods directly from the command line. | | `photon sse ` | Launch a single-tenant SSE server for browser or remote access. | | `photon beam` | Open an interactive web UI for all your installed Photons (formerly playground). | | `photon beam owner/repo/name` | Install a photon from GitHub and open it in Beam. | | `photon cli owner/repo/name method` | Install a photon from GitHub and run a method. | | `photon serve` | Start a local multi-tenant MCP server host for development. | | `photon host ` | Manage cloud hosting: `deploy` (ship to cloud) or `preview` (run local sim). | ### Management Commands | Command | Usage | |---|---| | `photon add ` | Install a Photon from the marketplace. | | `photon add owner/repo/name` | Install a Photon directly from a GitHub repository. | | `photon remove ` | 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 ` | Search enabled marketplaces for Photons. | ### Build Commands | Command | Usage | |---|---| | `photon build ` | Compile a photon into a standalone binary (uses Bun). | | `photon build --with-app` | Include embedded Beam UI in the binary. | | `photon build -t ` | Cross-compile for a specific platform (e.g., `bun-linux-x64`). | ### Developer Tools (maker) | Command | Usage | |---|---| | `photon maker new ` | Create a new Photon from the default template. | | `photon maker validate ` | Validate syntax, schemas, and dependencies. | | `photon maker sync` | Generate `photons.json` manifest for a marketplace. | | `photon maker init` | Set up a marketplace with auto-sync git hooks. | | `photon maker diagram ` | Generate a Mermaid dependency/flow diagram. | ### Maintenance | Command | Usage | |---|---| | `photon doctor` | Diagnose your environment (Node, npm, ports, config). | | `photon update` | Refresh marketplace indexes and check for CLI updates. | | `photon clear-cache` | Clear compiled Photon artifacts. | ### Advanced | Command | Usage | |---|---| | `photon test [name]` | Run test methods defined in your Photons (supports unit/integration). | | `photon alias ` | Create a global CLI alias for a Photon (e.g. `run-my-tool`). | | `photon marketplace` | Manage 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:///events?channel=`, `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=` or the `X-Photon-Instance` header; omit both to land on the shared `'default'` singleton. See [`docs/internals/CF-DURABLE-OBJECTS.md`](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](https://github.com/portel-dev/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](https://github.com/portel-dev/photon-examples) 3. Test in dev mode: `photon mcp my-tool --dev` 4. Deploy to Claude Desktop: `photon info my-tool --mcp` Happy building! 🚀