Photon Architecture
This document defines the vision, architecture, and constraints of Photon.
Vision: Co-Exploration and Co-Creation
The future is not humans OR AI using tools separately. It's humans AND AI working together - co-exploring problems, co-creating solutions, using the same tools through the same protocol.
Photon is the infrastructure that makes this possible.
┌─────────────────┐ ┌─────────────────┐
│ Human │ │ AI │
│ │ │ (Claude) │
└────────┬────────┘ └────────┬────────┘
│ │
│ Same Protocol (MCP) │
│ Same Tools (Photons) │
│ Same Real-Time State │
│ │
└──────────────┬──────────────────────┘
│
┌───────▼───────┐
│ Photon │
│ Ecosystem │
└───────────────┘What is Photon?
The Smallest Unit
A photon (in physics) is the smallest unit of light.
A .photon.ts file is the smallest unit of an MCP server - just a TypeScript class:
// This IS a complete MCP server
export default class Calculator {
add(params: { a: number; b: number }) {
return params.a + params.b;
}
}No boilerplate. No protocol handling. No server setup.
The Ecosystem
Photon is an ecosystem for MCPs and more:
| Component | Purpose |
|---|---|
.photon.ts | The tool definition (a TypeScript class) |
| Beam | MCP client for humans (web UI) |
| CLI | MCP client for terminal |
| Daemon | Central orchestrator (pub/sub, locks, jobs, webhooks) |
| Marketplace | Share and discover photons |
| PWA Export | Package as standalone desktop apps |
What Photon is NOT
To avoid confusion, here is what does not exist in the Photon runtime:
- No configuration file. There is no
.photonrc.json,.photonrc, or similar. All configuration comes from constructor parameters (mapped to environment variables), JSDoc tags, and file structure. - No
MiddlewareRegistryclass. Middleware is declared via JSDoc@usetags anddefineMiddleware()exports. There is no programmatic registry. - No
FormGeneratororResultRendererclass. UI generation is internal to the Beam runtime and not exposed as a public API. - No subpath exports. The npm package
@portel/photonexports a CLI binary only. There are no importable subpath modules like@portel/photon/server,@portel/photon/security,@portel/photon/cache, or@portel/photon/monitoring. - No
PhotonServerclass. The MCP server is managed internally by the runtime. Usephoton mcp <name>to start it. - No WebSocket. Beam uses MCP Streamable HTTP (SSE) exclusively. WebSocket is architecturally forbidden in both the server and frontend.
- No Jest. Tests use vitest and tsx.
What is Beam?
Beam is an MCP client for humans.
Just as Claude Desktop is an MCP client for AI, Beam gives humans the same interface to MCPs. The aggregation of photons and the web UI exist to serve this purpose.
┌─────────────────────────────────────────────────────────┐
│ BEAM │
│ (MCP Client for Humans) │
├─────────────────────────────────────────────────────────┤
│ • Interact with photons via web UI │
│ • See real-time updates from AI actions │
│ • Configure photons (env vars, settings) │
│ • Test and develop (hot reload) │
│ • Export as PWA desktop apps │
└─────────────────────────────────────────────────────────┘The Four Interfaces to Photons
| Interface | For | Protocol |
|---|---|---|
| MCP (stdio) | AI clients (Claude Desktop, Cursor) | MCP over stdio |
| CLI | Humans in terminal | Direct method calls |
| Beam | Humans in browser | MCP Streamable HTTP |
| PWA | End users | Standalone app (MCP + UI bundled) |
Client-First UI Contract
Beam is an MCP client, not a parallel UI protocol. Server code owns business logic, tool/resource discovery, permissions, and notifications. Browser code owns DOM creation, renderer selection, interaction binding, and hydration.
Auto UI must therefore flow through MCP primitives:
tools/listexposes tool schemas, annotations, output schemas, and_meta["photon/render"]render hints plus inferred intent.tools/callreturnscontentfor model-visible compatibility andstructuredContentfor client renderers.resources/list,resources/templates/list, andresources/readexpose custom UI assets as MCP resources such asui://....- Custom or generated HTML always runs as a sandboxed MCP Apps resource, never as direct Beam DOM.
Compatibility aliases such as x-output-format and x-layout-hints may remain on the wire during migration, but _meta["photon/render"] is the authoritative render contract.
The render contract carries intent as a surface-neutral summary of what the method does: action, subject, safety, input requirements, and output shape. The server derives that from MCP-visible method metadata; clients decide how the intent should look on their surface.
Design Philosophy: Simplest Path to Best Practice
Photon's goal: Find the simplest way to get something working. That becomes the best practice.
The runtime layer (Beam) between MCPs and UIs is Photon's key advantage. It can transform, simplify, and standardize what other platforms pass through raw.
The Runtime Layer Advantage
Standard MCP Apps:
┌──────────┐ Raw MCP Format ┌──────────┐
│ MCP │ ───────────────────────▸│ UI │
│ Server │ {content: [{text}]} │ (App) │
└──────────┘ └──────────┘
Must parse & handle
Photon Apps:
┌──────────┐ MCP Format ┌──────────┐ Clean Data ┌──────────┐
│ MCP │ ──────────────────▸│ Beam │ ────────────────▸│ UI │
│ Server │ │ (Runtime)│ {repos: [...]} │ (App) │
└──────────┘ └──────────┘ └──────────┘
Transforms & Just use it
simplifiesData Handling: Clean Data, Standard Patterns
| Aspect | Standard MCP Apps | Photon Apps |
|---|---|---|
| Success | Check structuredContent or parse content[].text | Get clean data directly |
| Errors | Check isError flag, extract message from content | Standard try/catch |
| Boilerplate | Parse, validate, transform in every app | Zero - runtime handles it |
Photon App:
try {
const repos = await callTool('repos', {});
updateUI(repos); // Already parsed!
} catch (error) {
showError(error.message); // Standard JS error
}Standard MCP App:
const result = await app.callServerTool({ name: 'repos', arguments: {} });
if (result.isError) {
const errorText = result.content.find(c => c.type === 'text')?.text;
showError(errorText);
return;
}
const repos = result.structuredContent ?? JSON.parse(result.content[0].text);
updateUI(repos);Principle: Absorb Complexity in the Runtime
When designing Photon features:
- Find the simplest developer experience - What would a developer ideally write?
- Make the runtime do the work - Transform, validate, simplify in Beam
- That simplest path becomes the standard - Document it, enforce it
- Keep apps portable - Photon apps should still work in standard MCP clients
The runtime layer is not overhead - it's where Photon adds value by making best practices the only path.
The Daemon: Central Orchestrator
Photon comes batteries included with a daemon that provides infrastructure for real-world applications:
Capabilities
| Feature | Purpose | Example |
|---|---|---|
| Pub/Sub Channels | Real-time cross-process messaging | AI moves a task → Human sees it instantly |
| Distributed Locks | Coordinate exclusive access | Only one process writes to a file at a time |
| Scheduled Jobs | Cron-like background execution | Archive old tasks daily at midnight |
| Webhooks | HTTP endpoints for external services | GitHub issue → Kanban task |
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ DAEMON │
│ ~/.photon/.data/daemon.sock │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────┐│
│ │ Pub/Sub │ │ Locks │ │ Scheduled │ │Webhooks ││
│ │ Channels │ │ Distributed │ │ Jobs │ │ HTTP ││
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └────┬────┘│
│ │ │ │ │ │
│ └────────────────┴────────────────┴───────────────┘ │
│ │ │
│ Unix Socket Protocol │
└─────────────────────────────────────────────────────────────────┘
│
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Claude MCP │ │ Beam │ │ Another │
│ Session │ │ Server │ │ Process │
└──────────────┘ └──────────────┘ └──────────────┘Pub/Sub: Real-Time Sync
// Photon emits an event (two-argument form)
this.emit('task-moved', { taskId, column });
// All subscribers receive it instantly
// - Other browser tabs (via Beam SSE)
// - AI sessions (via MCP notifications)
// - Other processes (via daemon socket)Distributed Locks: Coordinate Access
import { acquireLock, releaseLock } from './daemon-client.js';
// Only one holder at a time
if (await acquireLock('kanban', 'board-write')) {
try {
await updateBoard(changes);
} finally {
await releaseLock('kanban', 'board-write');
}
}Single-Node Constraint
The built-in lock implementation uses the daemon's Unix socket (~/.photon/.data/daemon.sock) and is scoped to a single machine. It ensures exclusive access within one node/process group but does not work in multi-node deployments.
For multi-node setups:
- Implement a custom lock backend (Redis Redlock, etcd leases, Consul, etc.)
- Override the lock manager in
applyMiddlewareto use your distributed backend - The lock interface is minimal:
acquire(name, timeout)andrelease(name)
Identity-aware locking: Photon extends standard lock protocols by checking this.caller.id. In @locked methods, only the lock holder's caller can proceed - attempts by other callers return an error. This Photon-specific feature is not found in standard lock implementations.
Scheduled Jobs: Background Tasks
import { scheduleJob } from './daemon-client.js';
// Run daily at midnight
await scheduleJob('kanban', 'archive-old-tasks', {
method: 'scheduledArchiveOldTasks',
cron: '0 0 * * *' // minute hour day month weekday
});Webhooks: External Integration
# GitHub webhook → Photon method
curl -X POST http://localhost:3458/webhook/handleGithubIssue \
-H "X-Webhook-Secret: $SECRET" \
-d '{"action": "opened", "issue": {...}}'Stateful Photons: Cross-Client Persistence
Photons marked with @stateful have their state persisted to disk and shared across all clients (CLI, Beam, Claude Desktop). This enables scenarios like: add items via CLI → Beam auto-updates → Claude Desktop sees the same data.
Architecture
State on Disk
~/.photon/.data/{name}/state/{instance}/state.json
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ DAEMON │
│ (Single shared instance) │
│ │
│ ┌──────────────────┐ ┌──────────────────────────────────────┐ │
│ │ Photon Instance │ │ Event Buffer (in-memory, 30 events) │ │
│ │ (shared session) │ │ Per-channel circular buffer │ │
│ └────────┬─────────┘ │ Supports replay via lastEventId │ │
│ │ └──────────────────────────────────────┘ │
│ │ │
│ Tool execution │
│ → mutates state │
│ → persists to disk │
│ → publishes {name}:state-changed │
└──────────────────────┬───────────────────────────────────────────┘
│
┌──────────────┼──────────────────┐
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────────┐
│ CLI │ │ Beam │ │ Claude Desktop│
│ │ │ (MCP srv)│ │ (MCP stdio) │
└─────────┘ └────┬─────┘ └──────────────┘
│
SSE broadcast
photon/state-changed
│
▼
┌───────────┐
│ Browser │
│ (MCP cli) │
│ auto- │
│ refreshes │
└───────────┘Consistency Model: Eventually Consistent
State is durable. Notifications are best-effort.
| What | Durability | Mechanism |
|---|---|---|
| State data | Durable | Persisted to disk on every mutation |
| Change notifications | Best-effort | In-memory event buffers, lost on daemon restart |
| Recovery | Guaranteed | Any client can re-execute a method to get current state |
This is an eventually-consistent model: if a notification is missed, the client's view is stale until the next explicit request. The _silentRefresh() mechanism in Beam auto-re-executes on notification, but if that notification is lost, a manual re-execute always works.
Sync Protocol: Delta Sync vs Full Sync
Events are identified by timestamps (Date.now()) — no sequential ID generation needed. Each layer maintains a time-based buffer (5-minute retention window).
When a client reconnects, it sends its last seen timestamp. The server responds with one of:
| Scenario | Response | Client Action |
|---|---|---|
| Timestamp within buffer window | Delta sync: replay missed events | Apply events incrementally |
| Timestamp older than buffer | Full sync signal (refresh-needed) | Re-fetch entire state |
| No timestamp (fresh client) | No replay | Fetch state on demand |
Reliability at Each Layer
Layer 1: Client → Daemon (Unix Socket)
├── CLI: retry once + auto-restart daemon, then fail
├── Beam: retry once + auto-restart daemon, then fail
└── Recovery: re-execute the command
Layer 2: Daemon → Subscribers (Pub/Sub)
├── Time-based buffer: 5-minute retention window per channel
├── Delta sync on reconnect via lastTimestamp
├── Auto-reconnect with exponential backoff (subscribeChannel)
├── Full sync signal when client is stale (beyond buffer window)
└── Gap: buffer lost on daemon restart (in-memory only)
Layer 3: Beam → Browser (SSE)
├── Beam-side event buffer: 5-minute retention window per channel
├── Delta sync on SSE reconnect via lastTimestamp
├── SSE keepalive every 30s, stale detection at 60s
├── Full sync signal when client is stale
└── Gap: events during SSE disconnect are lost
Layer 4: Browser → Beam (HTTP POST)
├── Operation queue with 30s expiry
├── Auto-process on SSE reconnect
├── Connection error detection and queuing
└── Gap: queue lost on page reload (in-memory)Recovery Strategy
When a notification is missed at any layer, the system self-heals:
- Delta sync (Layer 2):
subscribeChannel({ reconnect: true })auto-reconnects with exponential backoff, restarts daemon if needed, replays events missed during the outage vialastTimestamp - Full sync (Layer 2/3): When client's timestamp is older than the 5-minute buffer, server sends
refresh-neededsignal — client re-fetches entire state - SSE reconnect (Layer 3): Browser's
EventSourceauto-reconnects, Beam replays buffered events - Silent refresh (Layer 3→4): On
state-changednotification, Beam UI re-executes the displayed method without spinner - Manual re-execute (any layer): User can always click Execute to get current state — disk is the source of truth
Daemon Lifecycle
| Event | Behavior |
|---|---|
Beam starts with @stateful photons | ensureDaemon() auto-starts daemon |
CLI runs photon cli <stateful> <method> | Auto-starts daemon if not running |
| Daemon crashes | subscribeChannel(reconnect: true) detects drop, restarts daemon, resubscribes |
| All clients disconnect | Daemon stays running (detached process) |
| Machine reboot | Daemon restarts on next client interaction, state restored from disk |
Worker Thread Isolation
Photons that manage long-running runtime resources (WebSocket connections, auth sessions, polling loops) run in dedicated worker threads for crash isolation.
┌─────────────────────────────────────────────────┐
│ Daemon Process (main thread) │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ todo (in- │ │ calculator │ Simple │
│ │ process) │ │ (in-process) │ photons │
│ └──────────────┘ └──────────────┘ │
│ │
│ WorkerManager ─── routes calls via IPC ────── │
│ │ │ │
├───────┼────────────────────┼────────────────────┤
│ ┌────▼─────┐ ┌───▼──────┐ │
│ │ Worker 1 │ │ Worker 2 │ Isolated │
│ │ whatsapp │ │ telegram │ photons │
│ │ (socket) │ │ (poll) │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────┘Detection logic (in priority order):
@noworkertag → in-process (explicit opt-out)@workertag → worker thread (explicit opt-in)- Has both
onShutdown()+onInitialize()→ worker thread (auto-detected) - None → in-process (default)
Cross-worker communication:
- Tool calls: main thread routes via
WorkerManager.call()(IPC) @photondeps: resolved via RPC proxy through main thread- Pub/sub:
WorkerBrokerbridges events between workers and mainInProcessBroker - Hot-reload: main thread sends reload message; worker handles lifecycle hooks internally
See docs/reference/DOCBLOCK-TAGS.md for usage details.
Shared Session Model
All clients use the same daemon session ID (shared-{photonName}) for stateful photons. This means:
- One photon instance in memory, shared across CLI, Beam, and MCP
- Mutations from any client are immediately visible to all others (via state-changed notifications)
- State constructor params are restored from disk on daemon restart
Photon Discovery & State Persistence
Discovery Priority
When resolving a photon by name, the runtime searches multiple sources in priority order:
1. PHOTON_DIR (env var) — explicit override, always wins
2. cwd (process.cwd()) — if the directory contains .photon.ts files
3. ~/.photon — global installed photons (always included)For listing (Beam sidebar, CLI): results from all applicable sources are merged. Higher-priority sources win on name collision — a photon named list in your local workspace shadows the global list in ~/.photon.
For resolution (photon mcp list, photon cli list): the first match in priority order is used. Bundled photons (maker, marketplace, tunnel) are checked before all user sources.
State Persistence: Always Canonical
State (@stateful memory, settings, instance context, event logs) always persists to ~/.photon/.data/ regardless of how the process was launched:
~/.photon/.data/{namespace}/{photonName}/state/{instance}/state.jsonThis is a hard rule. The PHOTON_DIR env var and cwd workspace detection affect discovery only (which photons are available to run). They never affect where state is stored.
Why: Without this, the same photon would have split-brain state depending on the launcher. CLI and Beam share state because they run from the same terminal. Claude Desktop would see different data because its cwd differs. Anchoring state to ~/.photon eliminates this class of bugs entirely.
Local Workspace Development
When you cd into a marketplace folder (or set PHOTON_DIR), the runtime overlays those photons on top of ~/.photon:
# Global photons only
cd ~
photon beam # discovers ~/.photon/*.photon.ts
# Global + local workspace (local wins on name collision)
cd ~/Projects/photons
photon beam # discovers ./**.photon.ts + ~/.photon/*.photon.ts
# Explicit override (highest priority)
PHOTON_DIR=~/Projects/my-marketplace photon beamThis makes it easy to develop and test photons locally without installing them globally. The local version shadows the global one, but state is shared because it always goes to ~/.photon/.data/.
Environment Variable: PHOTON_DIR
| Aspect | Behavior |
|---|---|
| Discovery | Photons in $PHOTON_DIR are discovered alongside ~/.photon, with $PHOTON_DIR taking priority |
| State | Always ~/.photon/.data/ — PHOTON_DIR has no effect on state paths |
| Config | config.json is read from $PHOTON_DIR when set |
| When set automatically | If cwd contains .photon.ts files, the runtime sets PHOTON_DIR=cwd for child processes |
Communication Patterns
Allowed Protocols
| Path | Protocol | Implementation |
|---|---|---|
| Browser ↔ Beam | MCP Streamable HTTP | POST /mcp + SSE responses |
| Cross-process sync | Daemon Unix Socket | ~/.photon/.data/daemon.sock |
| Photon ↔ External MCP | stdio / SSE / HTTP | @mcp directive |
| CLI ↔ Photon (stateless) | Direct method call | In-process |
CLI ↔ Photon (@stateful) | Daemon Unix Socket | Shared session via daemon |
Beam ↔ Photon (@stateful) | Daemon Unix Socket | Routed through daemon for shared instance |
| External Agent ↔ Beam | AG-UI over MCP | ag-ui/run + ag-ui/event notifications |
MCP List Pagination
MCP list operations that can grow with a workspace must honor cursor pagination:
tools/listresources/listresources/templates/listprompts/listtasks/list
Servers choose the page size and emit an opaque nextCursor when more results exist. Clients must keep requesting with params.cursor until nextCursor is absent. Beam's browser client follows this flow so large photon workspaces do not silently hide tools or resources after the first page.
Real-Time Flow
┌─────────────────┐ POST /mcp ┌─────────────────┐
│ Browser/Client │ ───────────────────► │ Beam Server │
│ │ │ │
│ MCP Client │ ◄─────────────────── │ Streamable │
│ (EventSource) │ SSE notifications │ HTTP Transport │
└────────┬────────┘ └────────┬────────┘
│ │
│ ┌─────────┴─────────┐
│ │ Daemon Broker │
│ ◄─────────────────────────────│ (Unix Socket) │
│ (via MCP notifications) │ │
│ │ Cross-process │
│ │ pub/sub │
│ └───────────────────┘
│
┌────────┴────────┐
│ Custom UI │ Uses window.photon.invoke()
│ (iframe) │ Receives events via postMessage
└─────────────────┘Lessons Learned
These constraints exist because we made these mistakes and paid the price.
| Mistake | Consequence | Rule |
|---|---|---|
| WebSocket for Beam real-time | Complex state, firewall issues | Use MCP Streamable HTTP (SSE) |
| In-memory cache for shared data | Cross-process sync failures | Use disk + daemon pub/sub |
| Swallowed errors (catch returning null) | Hidden bugs, silent failures | Log errors, never swallow |
| fetch() without timeout | Hung requests, blocked UI | Always use AbortSignal.timeout |
| Hardcoded localhost URLs | Broken in Docker/production | Use environment variables |
| Magic timeout numbers | Inconsistent behavior | Define named constants |
| Silent logger suppression | Hidden syntax errors | Use log levels, not null streams |
Forbidden Patterns
These patterns cause real bugs. Pre-commit hook blocks errors, warns on others.
ERRORS (Commit Blocked)
WebSocket in Beam
// FORBIDDEN in src/auto-ui/
import { WebSocketServer } from 'ws';
new WebSocket('ws://...');
// USE INSTEAD
import { handleStreamableHTTP, broadcastNotification } from './streamable-http-transport.js';Why: Beam is a pure MCP interface. WebSocket breaks that model.
WARNINGS (Review Required)
In-Memory Cache for Shared Data
// WARNING - causes cross-process sync issues
const boardCache = new Map<string, Board>();
// USE INSTEAD - disk + daemon pub/sub
async function loadBoard(name: string): Promise<Board> {
return JSON.parse(await fs.readFile(boardPath, 'utf-8'));
}fetch() Without Timeout
// WARNING - can hang indefinitely
const response = await fetch(url);
// USE INSTEAD
const response = await fetch(url, {
signal: AbortSignal.timeout(10000)
});Required Patterns
Real-Time Updates (Cross-Client Sync)
Photon uses standard MCP protocol for real-time sync, enabling events to flow between Beam, Claude Desktop, and any MCP Apps-compatible client.
SERVER: Photon Class CLIENT: Mirrored API
┌─────────────────────────┐ ┌─────────────────────────┐
│ class Kanban { │ │ kanban.onTaskMove(cb) │
│ taskMove(params) { │ │ kanban.onTaskCreate(cb) │
│ this.emit('taskMove'│ ─────────► │ kanban.taskMove(params) │
│ , data); │ │ │
│ } │ │ │
│ } │ │ │
└─────────────────────────┘ └─────────────────────────┘
│ ▲
▼ │
┌──────────────────────────────────────────────────────────────┐
│ WIRE: Standard MCP notification │
│ { │
│ method: 'ui/notifications/host-context-changed', │
│ params: { _photon: { event: 'taskMove', data: {...} } } │
│ } │
│ │
│ Claude Desktop forwards this (standard notification) │
│ Photon bridge extracts _photon and routes to onTaskMove() │
└──────────────────────────────────────────────────────────────┘Server-side: emit events
// Simple event emission
this.emit('taskMove', { taskId, column });
// Or with explicit channel
this.emit({
channel: `${this.photonId}:${boardName}`,
event: 'taskMove',
data: { taskId, column }
});Client-side: direct window API
// Subscribe to specific events (recommended)
kanban.onTaskMove((data) => {
moveTaskInUI(data.taskId, data.column);
});
// Or use generic event subscription
photon.on('taskMove', (data) => {
moveTaskInUI(data.taskId, data.column);
});
// Call server methods
await kanban.taskMove({ id: 'task-1', column: 'Done' });Why standard protocol?
- Claude Desktop and other MCP Apps hosts forward standard notifications
- No custom protocol support required from hosts
- Events work cross-client (Beam ↔ Claude Desktop)
Error Handling
try {
const result = await riskyOperation();
return { success: true, data: result };
} catch (error) {
logger.error('Operation failed', { error, context: { ... } });
return { success: false, error: error.message };
}Pre-Commit Hook
The .git/hooks/pre-commit script enforces architectural constraints:
Errors (Blocks Commit):
- WebSocket in
src/auto-ui/
Warnings (Review Required):
- In-memory caches for shared data
- Swallowed errors
- fetch() without timeout
- Hardcoded localhost
- Magic timeout numbers
- Silent logger suppression
- Critical TODOs
Run manually: bash .git/hooks/pre-commit
Protocol Interoperability
Photon's protocol stack aligns with the emerging industry standard layers:
| Layer | Standard | Photon Implementation |
|---|---|---|
| Agent ↔ Tool | MCP (Anthropic) | Core protocol — every .photon.ts is an MCP server |
| Agent ↔ UI | AG-UI (open protocol) | Adapter layer on MCP transport — ag-ui/run + ag-ui/event |
| Async Operations | MCP Tasks | tasks/create + tasks/get — non-blocking long-running methods |
| Server Discovery | MCP Server Cards | GET /.well-known/mcp-server — auto-generated from photon metadata |
| Agent ↔ Agent | A2A (Google) | GET /.well-known/agent.json — Agent Cards with skills from methods |
| Observability | OTel GenAI (CNCF) | gen_ai.tool.call spans on executeTool — opt-in via @opentelemetry/api |
AG-UI Protocol Support
AG-UI events flow as MCP notifications over the existing SSE transport. No separate endpoint.
Two modes:
Proxy — external AG-UI agents (LangGraph, CrewAI, Google ADK, etc.) stream through Beam:
ag-ui/run { agentUrl: "https://agent.example.com", input: RunAgentInput } → proxies SSE events as ag-ui/event MCP notificationsLocal — Photon methods emit AG-UI-compatible events:
ag-ui/run { photon: "name", method: "tool", input: RunAgentInput } → wraps yields (stream, progress, emit) as AG-UI events
Event mapping:
| Photon Yield | AG-UI Event |
|---|---|
| Stream chunks (strings) | TEXT_MESSAGE_START / CONTENT / END |
yield { emit: 'progress' } | STEP_STARTED / STEP_FINISHED |
| Channel events (patches) | STATE_DELTA (RFC 6902 JSON Patch) |
this.emit() | CUSTOM event |
| Tool result | STATE_SNAPSHOT + RUN_FINISHED |
| Error | RUN_ERROR |
Spec compliance:
- MCP: Custom notifications are legal per JSON-RPC 2.0. Advertised via
experimental.ag-uicapability. - AG-UI: Transport-agnostic by design. Events arrive in order via SSE. Terminal event guaranteed.
Files: src/ag-ui/types.ts, src/ag-ui/adapter.ts, handler in streamable-http-transport.ts
Bidirectional State Exposure
Custom UIs passively expose context to photon methods via _clientState:
UI sets widgetState → bridge auto-attaches as _clientState →
loader strips before schema validation → available as this._clientStateCLI calls without widgetState work unchanged — the field is optional.
Persistent Approval Queue
Durable human-in-the-loop that survives navigation and restart:
yield { ask: 'confirm', persistent: true, expires: '24h' }
→ written to ~/.photon/.data/{photon}/state/{instance}/approvals.json
→ exposed as approval:// MCP resources
→ resolved via beam/approval-responseMCP Tasks (Async Long-Running Operations)
Non-blocking execution for methods that take time. Client gets a task ID immediately, polls for completion.
tasks/create { photon: "name", method: "tool", arguments: {...} }
→ returns { taskId: "task_xxx", state: "working" }
tasks/get { taskId: "task_xxx" }
→ returns { state: "completed", result: {...} }Task states: working → completed | failed | cancelled. Generator yields update progress; yield { ask: ... } sets input_required.
Storage: ~/.photon/.data/tasks/{taskId}.json
Files: src/tasks/types.ts, src/tasks/store.ts, handlers in streamable-http-transport.ts
MCP Server Cards (Discovery)
Auto-generated metadata describing the server's capabilities, photons, and tools — enabling discovery without connecting.
GET /.well-known/mcp-server → ServerCard JSONAlso available via MCP: server/card handler.
Files: src/server-card.ts, route in beam.ts
A2A Agent Cards (Multi-Agent Discovery)
Each Beam instance is discoverable as an A2A agent. Photon methods become A2A skills.
GET /.well-known/agent.json → AgentCard JSONAlso available via MCP: a2a/card handler.
Capabilities auto-detected: tool_execution, stateful, streaming, ag-ui
Files: src/a2a/types.ts, src/a2a/card-generator.ts, route in beam.ts
OpenTelemetry GenAI (Observability)
Optional instrumentation using OTel GenAI semantic conventions. Zero cost when @opentelemetry/api is not installed — falls back to no-op spans.
executeTool("photon", "method", params)
→ creates span: gen_ai.tool.call { gen_ai.agent.name, gen_ai.tool.name, gen_ai.operation.name }Attributes: gen_ai.agent.name, gen_ai.tool.name, gen_ai.operation.name, photon.instance, photon.stateful, photon.caller
Files: src/telemetry/otel.ts, instrumentation in src/loader.ts
Related Documentation
- DAEMON-PUBSUB.md - Detailed pub/sub protocol
- AUTO-UI-ARCHITECTURE.md - UI system architecture
- ADVANCED.md - Integration patterns (external services)
Last updated: March 2026
