Constructor Injection
Photon uses a single mechanism for all dependency injection: constructor parameters. The runtime inspects each parameter and determines what to inject based on its type and matching docblock declarations.
Injection Types
/**
* DevOps Dashboard
* @mcp github anthropics/mcp-server-github
* @photon billing billing-photon
* @stateful true
*/
export default class Dashboard {
constructor(
apiKey: string, // 1. Environment variable
private github: any, // 2. MCP client (matches @mcp github)
private billing: any, // 3. Photon instance (matches @photon billing)
private incidents: Incident[] = [] // 4. Persisted state (restored on restart)
) {}
}| # | Type | Trigger | Managed by | Source |
|---|---|---|---|---|
| 1 | Constructor env | Primitive constructor param | Existing env-var mapping, captured by loader | Current process.env, then .data/{ns}/{photon}/env.json |
| 2 | Constructor env with default | Primitive constructor param with default | Same mapping; default applies when unset | Current process.env, then captured .data value |
| 3 | MCP client | Name matches @mcp declaration | Runtime | Proxy to running MCP server |
| 4 | Photon instance | Name matches @photon declaration | Runtime | Loaded photon class instance |
| 5 | Persisted state | Non-primitive with default, on @stateful photon | Runtime | .data/{ns}/{photon}/state/ |
See CONSTRUCTOR-CONTEXT.md for full details on constructor env capture and context-based state partitioning for
@statefulphotons.
Resolution Order
For each constructor parameter, the runtime resolves in this order:
- Matches
@mcptag? → Create/reuse MCP client proxy - Matches
@photontag? → Load/reuse photon instance - Primitive constructor param? → Current
process.envwhen present and captured, otherwise stored value from.databy param/env-style key - Non-primitive with default on
@stateful? → Restore from state snapshot - Fallback →
undefined(constructor default applies)
1. Environment Variables
Primitive constructor parameters are automatically mapped to environment variable names. When a value is present in the caller environment, Photon captures it into the runtime-owned .data store so daemon restarts and scheduled work can replay the same constructor input.
export default class Mailer {
constructor(
private smtpHost: string = 'localhost',
private smtpPort: number = 587,
private useTls: boolean = true
) {}
}| Parameter | Environment Variable | Conversion |
|---|---|---|
smtpHost | MAILER_SMTP_HOST | String (as-is) |
smtpPort | MAILER_SMTP_PORT | Number("587") → 587 |
useTls | MAILER_USE_TLS | "true" → true |
If the env var is not set but a captured value exists, the captured value is used. If neither exists and the parameter has a default, the default applies. If neither exists and the parameter is required, the runtime reports a configuration error.
2. MCP Client Injection
Declare MCP dependencies with @mcp and receive ready-to-use client proxies.
/**
* @mcp github anthropics/mcp-server-github
* @mcp filesystem npm:@modelcontextprotocol/server-filesystem
*/
export default class Manager {
constructor(
private github: any, // Injected: proxy to GitHub MCP
private filesystem: any // Injected: proxy to Filesystem MCP
) {}
async listRepos() {
return await this.github.list_repos({ org: 'my-org' });
}
async readFile(path: string) {
return await this.filesystem.read_file({ path });
}
}The injected proxy supports:
client.<tool>(params)— Call any tool directlyclient.list()— List available toolsclient.find(query)— Search tools by nameclient.call(name, params)— Call tool by string name
MCP clients are cached and reused across the photon's lifetime.
Source Types
| Format | Example | Description |
|---|---|---|
| GitHub | anthropics/mcp-server-github | Cloned and run via bunx |
| npm | npm:@modelcontextprotocol/server-filesystem | Installed from npm |
| URL | https://api.example.com/mcp | Remote Streamable HTTP |
| Local | ./my-local-mcp | Local file path |
3. Photon Instance Injection
Declare photon dependencies with @photon and receive initialized instances.
/**
* @photon rss rss-feed
* @photon weather ./weather.photon.ts
*/
export default class NewsDigest {
constructor(
private rss: any, // Injected: loaded rss-feed photon instance
private weather: any // Injected: loaded local weather photon instance
) {}
async digest() {
const articles = await this.rss.fetch({ url: 'https://news.ycombinator.com/rss' });
const forecast = await this.weather.today();
return { articles, forecast };
}
}Photon dependencies are resolved recursively — an injected photon can itself have dependencies that get injected.
Source Types
| Format | Example | Description |
|---|---|---|
| Marketplace | rss-feed | Installed from photon marketplace |
| GitHub | portel-dev/photons/rss-feed | Cloned from GitHub |
| npm | npm:@portel/rss-feed-photon | Installed from npm |
| Local | ./weather.photon.ts | Local file path |
4. Stateful Persistence
When a photon is marked @stateful true, non-primitive constructor parameters with defaults become automatically persisted. The runtime snapshots their values on every mutation and restores them on restart.
How It Works
/**
* A simple list
* @stateful true
*/
export default class List {
items: string[];
constructor(items: string[] = []) {
this.items = items;
}
add(item: string): void {
this.items.push(item);
}
remove(item: string): boolean {
const idx = this.items.indexOf(item);
if (idx !== -1) {
this.items.splice(idx, 1);
return true;
}
return false;
}
getAll(): string[] {
return this.items;
}
}First run:
- No snapshot exists →
new List()→ defaultitems = []applies - User calls
add("apples")→ reactive array detects.push() - Runtime persists
{ "items": ["apples"] }to~/.photon/.data/list/state/default/state.json
Daemon restarts:
- Runtime reads
~/.photon/.data/list/state/default/state.json→{ "items": ["apples"] } - Instantiates
new List(["apples"])— constructor default overridden - State is fully restored, user sees their data
Ongoing:
- Every mutation to
itemstriggers a debounced persist - The same mutation also emits events for auto-UI (via reactive array)
- Persistence is a side-effect of reactivity, not a separate mechanism
Why Constructor Injection
The constructor already serves as the dependency injection point for env vars, MCPs, and photons. Adding persisted state to the same mechanism means:
- No new API — constructors with defaults are standard TypeScript
- No
@persisttag —@stateful truealready declares the intent - Testable — pass mock data to the constructor in tests
- Explicit — the constructor signature documents what state the photon holds
Sync Methods Work
Reactive arrays work with synchronous methods. The layers are:
items.push('hello') ← sync, in your method
→ Proxy intercepts push() ← sync, reactive array
→ emitter('items:added') ← sync, queues the event
↓
daemon broadcasts via SSE ← async, runtime handles it
runtime persists to disk ← async, debounced, runtime handles itYour method is sync, returns void, finishes immediately. The reactive machinery captures the mutation synchronously, but network delivery (SSE to auto-UI) and disk persistence happen asynchronously in the runtime — the photon never needs to know or wait.
Shared State Across Clients
Because @stateful photons live in the daemon, all clients share the same instance:
CLI: photon cli list add --item "apples"
│
▼
Daemon instance ← single List instance
│
▼
Beam auto-UI sees 'items:added' event → re-rendersAdd from CLI → see it in Beam. Add from Beam → see it in CLI. One instance, multiple clients.
Combining All Four
A real-world photon might use all four injection types:
/**
* Incident tracker
* @mcp slack anthropics/mcp-server-slack
* @photon pagerduty pagerduty-photon
* @stateful true
*/
export default class IncidentTracker {
incidents: Incident[];
constructor(
private webhookUrl: string, // ENV: INCIDENTTRACKER_WEBHOOK_URL
private slack: any, // MCP: Slack client proxy
private pagerduty: any, // Photon: PagerDuty instance
incidents: Incident[] = [] // State: restored from snapshot
) {
this.incidents = incidents;
}
report(title: string, severity: string): Incident {
const incident = { id: crypto.randomUUID(), title, severity, status: 'open' };
this.incidents.push(incident);
// Reactive array: auto-emits 'incidents:added'
// Runtime: auto-persists to disk
return incident;
}
async escalate(id: string) {
const incident = this.incidents.find(i => i.id === id);
await this.slack.send_message({ channel: '#incidents', text: `Escalating: ${incident.title}` });
await this.pagerduty.trigger({ service: 'backend', description: incident.title });
return { escalated: true };
}
}The runtime resolves each parameter independently:
webhookUrl— primitive string → readsINCIDENTTRACKER_WEBHOOK_URLenv varslack— matches@mcp slack→ creates Slack MCP client proxypagerduty— matches@photon pagerduty→ loads pagerduty photon instanceincidents— non-primitive with default on@stateful→ restores from snapshot
