Photon turns a single TypeScript file into:
- MCP server for AI agents
- CLI tool for automation
- Web interface for humans
Photon is free and open source software released under the MIT license.
Interfaces are optional. Intent is mandatory.
// hello.photon.ts
export default class Hello {
greet(name: string) {
return `Hello, ${name}!`;
}
}That's a complete photon. From this single file you get:
$ photon cli hello greet --name Ada # CLI
$ photon # Web UI at localhost:3008
$ photon mcp hello # MCP server for Claude, Cursor, etc.
No decorators. No registration. No server boilerplate. Just define the intent. Photon handles the rest.
Most software is built around interfaces: web apps, CLI tools, APIs, and now MCP servers for AI agents. But the underlying logic is often the same.
Photon starts from a different place: capture the intent once in a TypeScript file and let the system expose it through multiple interfaces — CLI tools, web interfaces, and MCP servers.
One definition. Multiple surfaces.
From zero to an MCP server connected to Claude Desktop in three commands:
bun add -g @portel/photon
photon new my-tool # Scaffolds ./my-tool.photon.ts in your CWD
photon mcp install my-tool # Registers it in Claude Desktop's config
# Restart Claude Desktop. Your tool is live.Prefer the web dashboard? Skip step 3 and run photon instead — it opens Beam, the auto-generated UI.
Or try without installing globally:
bunx @portel/photon new my-tool
bunx @portel/photon mcp install my-tool
# pnpm users can use pnpm dlx instead:
pnpm dlx @portel/photon new my-tool
pnpm dlx @portel/photon mcp install my-toolRequires Node.js 20+. TypeScript is compiled internally; no
tsconfig.jsonneeded.Where do photon files live?
./(a project directory you cd into) or~/.photon/(global, auto-discovered). User settings persist under~/.photon/state/<photon>/. See Where things live.
You write a TypeScript class. Methods are your capabilities. Types describe what's valid. Comments explain the intent. Photon reads all of it and generates three interfaces from one file. Same logic. Same validation. Same data.
analytics.photon.ts → Web UI (Beam) · CLI · MCP Server for AI
The more you express, the more Photon derives:
| What you write | What Photon derives |
|---|---|
| Method signatures | Tool definitions: names, inputs, outputs |
| Type annotations | Input validation rules, UI field types |
| JSDoc comments | Documentation for AI clients and human users |
| Constructor parameters | Config UI, environment variable mapping, runtime injection (Photon, Cloudflare, CloudflareEnv) |
@tags |
Validation, formatting, scheduling, webhooks |
When you add a @param city {@pattern ^[a-zA-Z\s]+$} annotation, Beam validates it in the form, the CLI validates it before running, and the MCP schema enforces it for the AI. One annotation. Three consumers.
extends Photon is one shape. You can also inject Photon as a constructor parameter when you already extend something else, or compose without inheritance — same API either way. CF resources reach the photon through a separate Cloudflare injection so portable photons stay portable. See docs/guides/PHOTON-INJECTION.md.
Beam is the web dashboard. Every photon becomes an interactive form automatically. Run photon. That's the whole command.
The UI is fully auto-generated from your method signatures: field types, validation, defaults, layouts. You never write frontend code. When you add a {@choice a,b,c} tag to a parameter, Beam renders a dropdown. When you mark a string as {@format email}, the field validates email format. The UI evolves as your code does.
When forms aren't the right interface for what you're building, you can replace Beam's auto-generated view with your own HTML. A global named after your photon is auto-injected (e.g., analytics.onResult(data => ...)) — no framework required. window.photon.url is also injected and resolves to the Beam base URL so your HTML can construct fetch paths correctly whether running locally or behind a reverse proxy.
Custom UIs follow the MCP Apps Extension (SEP-1865) standard and work across compatible hosts. See the Custom UI Guide.
Photons that declare @get or @post HTTP routes are shown in Beam as web apps. Routes support dynamic path segments (e.g. @get /items/:id) matched by specificity: literal segments win over parameters. Beam proxies requests to those routes and injects an x-photon-base-path header so the app can construct correct absolute paths regardless of where Beam is hosted.
photon info analytics --mcp{
"mcpServers": {
"analytics": {
"command": "photon",
"args": ["mcp", "analytics"]
}
}
}Paste into your AI client's config. Your photon is now an MCP server. Claude can call your methods. Cursor can call your methods. Any MCP-compatible host can call your methods.
The AI sees the same thing a human sees in Beam: the method names, the parameter descriptions from your JSDoc, the validation rules from your types. The JSDoc comment you wrote to document the tool for yourself is what Claude reads to decide when and how to call it.
The MCP tools themselves work with Claude Desktop, Claude Code, Cursor, and any MCP-compatible client.
When your photon has a custom UI, clients that support the MCP Apps Extension render it natively, no separate app needed. The photon below is running inside Claude Desktop, same UI, same data as Beam.
Here is how a photon grows. Each step adds one thing and gets multiple capabilities from it.
/**
* Weather - Check weather forecasts worldwide
*/
export default class Weather {
/**
* Get the weather forecast for a city
* @param city City name (e.g., "London")
*/
async forecast(params: { city: string }) { ... }
}The class description becomes how AI clients introduce the tool to users. The @param description is what the AI reads before deciding what value to pass. Same comments. Human help text and AI contract at once.
export default class Weather {
/** User-tunable knobs. Photon auto-generates a `settings` tool from this. */
protected settings = {
/** Units for forecast values */
units: 'metric',
/** Polling interval in seconds */
pollIntervalSec: 300,
};
async forecast(params: { city: string }) {
const res = await fetch(`...?units=${this.settings.units}`);
return await res.json();
}
}protected settings is the canonical way to expose runtime knobs. Photon reads the JSDoc on each property, generates an MCP settings tool with typed inputs, and persists user changes to ~/.photon/state/<photon>/<instance>-settings.json. Inside methods, this.settings is a read-only Proxy. To change a value, the user (or AI) calls the auto-generated settings tool.
For secrets that should never be persisted in a settings file (API keys, tokens), use a constructor parameter instead. Photon maps the parameter name to an env var:
export default class Weather {
constructor(private apiKey: string) {} // → WEATHER_API_KEY
}The constructor pattern is for primitives that come from .env. The protected settings pattern is for everything else, including any knob the user should be able to change at runtime without restarting. When in doubt, reach for settings.
/**
* @dependencies node-fetch@^3.0.0
*/
export default class Weather {
/**
* @param city City name {@example London} {@pattern ^[a-zA-Z\s]+$}
* @param days Number of days {@min 1} {@max 7}
* @format table
*/
async forecast(params: { city: string; days?: number }) { ... }
}@dependencies installs node-fetch automatically on first run, no manual package install needed. The {@pattern} validates in the form, the CLI, and the MCP schema simultaneously. days becomes a number spinner with bounds. @format table renders the result as a table in Beam. One annotation, three surfaces.
If your photon wraps a command-line tool, declare it and Photon enforces it at load time:
/**
* @cli ffmpeg - https://ffmpeg.org/download.html
*/
export default class VideoProcessor {
async convert({ input, format }: { input: string; format: string }) {
// ffmpeg is guaranteed to exist when this runs
}
}Things you don't build because Photon handles them:
| Auto-UI | Forms, field types, validation, layouts generated from your signatures |
| Stateful instances | Multiple named instances of the same photon, each with isolated state |
| Persistent memory | this.memory gives your photon per-instance key-value storage, no database needed |
| Scheduled execution | @scheduled runs any method on a cron schedule |
| Webhooks | @webhook exposes any method as an HTTP endpoint |
| OAuth (client) | Built-in OAuth 2.0 flows for Google, GitHub, Microsoft |
| OAuth Authorization Server | Issue tokens to MCP clients yourself: CIMD + DCR, PKCE, OIDC id_token, RFC 8693 token exchange |
| SQLite persistence | Audit log, execution history, and OAuth grants survive daemon restart (bun:sqlite or better-sqlite3) |
| Daemon ops | photon ps lists and controls scheduled jobs, webhooks, and live sessions |
| Distributed locks | @locked serializes access: one caller at a time, across processes |
| Cross-photon calls | this.call() invokes another photon's methods |
| Cloudflare runtime | this.cf.r2('blobs'), this.cf.d1('app'), this.cf.kv('cache') — same shape locally (miniflare) and deployed (real bindings). See CF-BINDINGS.md |
| Real-time events | this.emit() fires named events to the browser UI with zero wiring |
| Live rendering | this.render() pushes formatted output to CLI and Beam in real time |
| Delegated LLM | this.sample() asks the driving agent's model to generate text — no API key, agent pays |
| Inline confirm / input | this.confirm() and this.elicit() route through the client's native UI (Beam dialog, Claude prompt) |
| Scoped remote access | photon claim generates a short-lived code to scope a remote MCP session to one directory |
| Standalone binaries | photon build compiles any photon to a single executable via Bun |
| Dependency management | @dependencies auto-installs npm packages on first run |
Two primitives. Together they unlock a class of things that are surprisingly hard to build today.
Locks serialize access. When a method is marked @locked, only one caller can execute at a time, whether that caller is a human in Beam, a CLI script, or an AI agent. Everyone else waits their turn.
Events push state changes to any browser UI in real time. this.emit({ event: 'boardUpdated', data: board }) on the server becomes chess.onBoardUpdated(handler) in your custom UI — named after your photon file. No WebSockets to configure. No polling. Events are delivered via SSE through the MCP Streamable HTTP transport.
Together: turn-based coordination with live state.
export default class Chess {
/** Make a move. Locks ensure human and AI alternate turns. */
/** @locked */
async move(params: { from: string; to: string }) {
const result = await this.applyMove(params.from, params.to);
// Browser UI updates instantly, no polling needed
this.emit({ event: 'boardUpdated', data: result.board });
this.emit({ event: 'turnChanged', data: { next: result.nextPlayer } });
return result;
}
}// In your custom UI (ui/chess.html)
// The global `chess` is auto-injected, named after your photon file
chess.onBoardUpdated(board => renderBoard(board));
chess.onTurnChanged(({ next }) => showTurn(next));
// Call server methods directly
chess.move({ from: 'e2', to: 'e4' });A human moves through Beam. Claude is configured with the MCP server. The lock ensures they truly alternate. Events keep the board live on both sides. That's a fully functional turn-based chess game, human vs AI, in about 50 lines of application logic.
The same pattern applies beyond games: approval workflows where a human reviews before AI continues, collaborative tools where edits from any source appear instantly, simulations where steps must execute in strict sequence, any system where who acts next matters.
The MCP protocol's user-facing primitives are surfaced as plain methods on every photon instance — no decorators, no capability flags, no SDK imports. The runtime routes each call through whichever surface the request arrived on (Beam, Claude Desktop, Cursor, CLI).
export default class Editor {
async summarize(params: { text: string }) {
// Ask the driving agent's LLM. No API key. Agent pays.
return await this.sample({
prompt: `Summarize in one sentence:\n\n${params.text}`,
maxTokens: 128,
});
}
async deploy() {
if (!(await this.confirm('Ship to production?'))) return;
const env = await this.elicit<string>({
ask: 'select',
message: 'Which environment?',
options: ['staging', 'prod'],
});
await this.run(env);
}
}| Primitive | What it does |
|---|---|
await this.sample({ prompt }) |
Delegates LLM generation to the caller's model via MCP sampling |
await this.confirm(question) |
Yes/no prompt — returns boolean |
await this.elicit(params) |
Arbitrary input (text, select, form, file, etc.) |
this.status(msg) / this.progress(v) |
Live feedback during long work; routes to SSE stream in Beam |
this.roots |
MCP workspace roots declared by the connected client (roots/list) |
this.notifyResourceUpdated(uri) |
Push notifications/resources/updated to subscribed clients |
Full reference: docs/reference/MCP-PRIMITIVES.md.
By default every installed photon is visible to every connected MCP client. When you want to pair a remote agent with a subset of your photons — your phone driving Beam, a teammate reviewing one project, a CI agent scoped to a single directory — generate a claim code:
$ photon claim --scope /workspace/proj --ttl 4h --label "phone"
✓ Claim code: R3K-9QZ
Scope: /workspace/proj
Expires in: 4hThe remote client presents the code as the Mcp-Claim-Code header on
its MCP session. tools/list then only exposes photons whose source
lives under that directory. Sessions without a code keep full access —
the feature is strictly opt-in.
Full reference: docs/reference/CLAIM-CODES.md.
32 photons ready to install: databases, APIs, developer tools, and more.
photon search postgres
photon add postgresYou can also install directly from any GitHub repository using qualified refs:
photon add owner/repo/photon-nameBrowse the full catalog in the official photons repository. You can also host a private marketplace for your team: internal tools that stay off the public internet.
# Run
photon # Open Beam UI
photon mcp <name> # Run as MCP server
photon mcp <name> --dev # MCP server with hot reload
photon cli <name> [method] # Run as CLI tool
# Install from GitHub
photon beam owner/repo/name # Install & open in Beam
photon cli owner/repo/name method # Install & run via CLI
# Create
photon maker new <name> # Scaffold a new photon
# Build
photon build <name> # Compile to standalone binary
photon build <name> --with-app # Include Beam UI in binary
# Manage
photon info # List all photons
photon info <name> --mcp # Get MCP client config
photon maker validate <name> # Check for errors
# Marketplace
photon add <name> # Install photon
photon search <query> # Search marketplace
photon upgrade # Upgrade all
# Ops
photon doctor # Diagnose environment
photon test # Run tests
photon ps # Observe & control scheduled jobs, webhooks, sessionsphoton ps is the operator surface for the daemon. Without arguments
it prints a four-section snapshot — ACTIVE schedules, DECLARED-but-
not-enrolled, WEBHOOKS, and ACTIVE SESSIONS.
photon ps # full snapshot
photon ps --json # structured output for scripts
photon ps --type active # one section only
photon ps --base ~/Projects/kith # filter to one PHOTON_DIRTwo-step model. A @scheduled annotation in source is DECLARED
until enrolled. Enrollment is per-machine, persistent, and explicit:
photon ps enable newsletter:sendDigest # DECLARED → ACTIVE
photon ps disable newsletter:sendDigest # ACTIVE → suppressed (survives restart)
photon ps pause newsletter:sendDigest # stop firing without removing enrollment
photon ps resume newsletter:sendDigest # undo pause
photon ps history newsletter:sendDigest # last 20 firings: timestamp, status, errorFor manual cron schedules without a @scheduled tag, use the Beam Pulse
panel ("Add schedule") or call this.schedule.create() from photon code.
this.schedule.create() (programmatic schedules) skips DECLARED and
goes straight to ACTIVE. See
docs/GUIDE.md#scheduling
for the full reference, the daemon state layout, and .photon-no-host
for multi-host setups.
Use qualified refs to install and run photons directly from any GitHub repository:
photon beam Arul-/photons/claw # Install from GitHub, open in Beam
photon cli Arul-/photons/todo add # Install from GitHub, run methodThe format is owner/repo/photon-name. Transitive @photon dependencies from the same repo are resolved automatically.
Build standalone executables from any photon — no Node.js required on the target machine:
photon build my-tool # Binary for current platform
photon build my-tool -t bun-linux-x64 # Cross-compile for Linux
photon build my-tool --with-app # Embed Beam UI as a desktop appUses Bun's compiler under the hood. The binary bundles the photon, its @dependencies, and transitive @photon deps into a single file.
| Tag | Where | What it does |
|---|---|---|
@dependencies |
Class | Auto-install npm packages on first run |
@cli |
Class | Declare system CLI dependencies, checked at load time |
@format |
Method | Result rendering (table, list, markdown, code, etc.) |
@param ... {@choice a,b,c} |
Param | Dropdown selection in Beam |
@param ... {@choice-from method} |
Param | Dynamic dropdown populated from another method's return value |
@param ... {@format email} |
Param | Input validation and field type |
@param ... {@min N} {@max N} |
Param | Numeric range constraints |
@ui |
Class/Method | Link a custom HTML template |
@expose |
Method | Auto-bind to POST /api/<kebab> for SPA fetch (public skips the SameSite gate) |
@get /path |
Method | HTTP-only GET route; shown as a web app in Beam, not an MCP tool. Supports :param segments |
@post /path |
Method | HTTP-only POST route; shown as a web app in Beam, not an MCP tool. Supports :param segments |
@resource <uri> |
Method | Dynamic MCP resource resolver (canonical form; replaces @Static) |
@prompt |
Method | MCP prompt template (canonical form; replaces @Template) |
@webhook |
Method | Expose as HTTP endpoint |
@scheduled |
Method | Run on a cron schedule |
@locked |
Method | Distributed lock across processes |
@autorun |
Method | Auto-execute when selected in Beam |
@mcp |
Class | Inject another MCP server as a dependency |
@icon |
Class/Method | Set emoji icon |
See the full Tag Reference for all 30+ tags with examples.
Start here:
| Guide | |
|---|---|
| Getting Started | Install, build, and run your first photon in 5 minutes |
| Core Concepts | The 6 ideas behind Photon |
| Output Formats | Visual gallery of every @format type |
| Settings | Declare runtime knobs with protected settings (the canonical config pattern) |
| Troubleshooting | Common issues and solutions |
Go deeper:
| Topic | |
|---|---|
| Custom UI | Build rich interactive interfaces with the photon bridge API |
| OAuth | Built-in OAuth 2.0 with Google, GitHub, Microsoft |
| MCP Client Registration | Register MCP clients with Photon's AS via CIMD or DCR |
| Observability | OpenTelemetry traces, metrics, logs, and structured errors |
| Protocol Features | Capability handshake, structured errors, trace correlation |
| Daemon Pub/Sub | Real-time cross-process messaging |
| Webhooks | HTTP endpoints for external services |
| Locks | Distributed locks for exclusive access |
| Advanced Patterns | Lifecycle hooks, dependency injection, interactive workflows |
| Marketplace Configuration | Sharing settings across related photons in one marketplace |
| Deployment | Docker, Cloudflare Workers, AWS Lambda, Systemd |
Operate:
| Topic | |
|---|---|
| Security | Best practices and audit checklist |
| Marketplace Publishing | Create and share team marketplaces |
| Best Practices | Patterns for production photons |
Reference: Complete Developer Guide · Tag Reference · Naming Conventions · Architecture · OAuth Authorization Server · Lifecycle & Ingress · PHOTON_DIR & Namespace · Changelog · Contributing
Photon is free and open source under the MIT license.
The project is still evolving and contributions are welcome.
- Star the repository if the idea resonates
- Report issues
- Contribute improvements or examples









