Skip to content

Protocol Features Guide

Seven protocol features that make your photons discoverable, observable, and interoperable with external agents and UIs.

MCP Discovery Pagination

Photon exposes large workspaces through the standard MCP list operations: tools/list, resources/list, resources/templates/list, prompts/list, and tasks/list. These methods support MCP cursor pagination.

Clients should treat nextCursor as opaque and keep requesting the same method with params.cursor until the response omits nextCursor:

json
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/list",
  "params": { "cursor": "opaque-server-token" }
}

Beam does this automatically. Custom MCP clients should do the same so every tool, resource, template, prompt, and task remains visible in large photon installations.

AG-UI Events

AG-UI (Agent-to-UI) maps photon yields to a standard event protocol that external UIs can consume.

How it works: The AG-UI adapter wraps your photon's output handler. String yields become TEXT_MESSAGE events, progress yields become STEP events, and the final return value becomes a STATE_SNAPSHOT.

typescript
export default class MyAgent {
  /**
   * Stream text to AG-UI clients
   */
  async *stream(params: { topic: string }) {
    // String yields → TEXT_MESSAGE_START + TEXT_MESSAGE_CONTENT
    yield `Researching ${params.topic}...`;
    yield `Here are the findings on ${params.topic}.`;

    // Progress yields → STEP_STARTED / STEP_FINISHED
    yield { emit: 'progress', value: 0.5, message: 'Analyzing' };
    yield { emit: 'progress', value: 1.0, message: 'Done' };

    // Return value → STATE_SNAPSHOT
    return { topic: params.topic, status: 'complete' };
  }
}

Yield-to-event mapping:

Photon yieldAG-UI event
yield "text"TEXT_MESSAGE_CONTENT
yield { emit: 'progress', value: 0.5 }STEP_STARTED
yield { emit: 'progress', value: 1.0 }STEP_FINISHED
yield { channel, event, data }STATE_DELTA (JSON Patch)
yield { emit: 'render', ... }CUSTOM event
return { ... }STATE_SNAPSHOT

When to use: When your photon needs to stream results to CopilotKit, AG-UI-compatible UIs, or other agent frameworks that consume the AG-UI event protocol.

A2UI v0.9 (declarative UI on AG-UI)

Methods tagged @format a2ui emit a valid A2UI v0.9 JSONL message sequence (createSurfaceupdateComponentsupdateDataModel) derived from the return value. Each message rides as an AG-UI CUSTOM event with name: 'a2ui.message', so any AG-UI consumer that also speaks A2UI can render the output without a Photon-specific integration. Google frames this as a "day-zero bridge" — AG-UI is the pipe, A2UI is the payload.

typescript
/** @format a2ui */
async dashboard() {
  return [
    { title: 'API latency', value: '42ms', trend: '-5%' },
    { title: 'Error rate', value: '0.12%', trend: '+0.01%' },
  ];
}

Auto-mapping covers the common shapes (array of rows, single object, card with actions, primitive). For full control, return { __a2ui: true, components, data } and emit the A2UI component tree verbatim. See formats guide for the full matrix.

Current version is producer-side only: the runtime emits valid A2UI output but does not yet route action messages from the renderer back into photon methods, and does not ship a Beam-side A2UI renderer. Use an external A2UI consumer (web_core, Lit, React) or paste the stream into A2UI Theater to render.


Bidirectional State

Frontend widget state flows into your photon methods automatically. The bridge injects _clientState into tool call arguments, and the loader extracts it onto this._clientState.

typescript
export default class ContextAware {
  /**
   * Suggest items based on what the user has selected in the UI
   */
  async suggest(params: { query: string }) {
    // Access frontend widget state
    const state = (this as any)._clientState;

    if (state?.selectedItems?.length > 0) {
      return {
        suggestions: `Based on your ${state.selectedItems.length} selections...`,
        viewMode: state.viewMode || 'list',
      };
    }

    return { suggestions: `General results for "${params.query}"` };
  }
}

Frontend side:

javascript
// In your @ui template
window.photon.setWidgetState({ selectedItems: ['a', 'b'], viewMode: 'grid' });
// Next tool call automatically includes this state

When to use: When photon methods need context from the frontend UI — selected items, current view mode, scroll position, form state, etc.


Persistent Approvals

Human-in-the-loop confirmations that survive page navigation and server restarts. Approvals are stored as JSON files in ~/.photon/approvals/.

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

export default class DeployPipeline {
  /**
   * Deploy a service with persistent approval gate
   * @destructive
   */
  async *deploy(params: { service: string; version: string }) {
    yield io.emit.status(`Preparing ${params.service} v${params.version}...`);

    // Persistent confirmation — survives navigation/restart
    const approved: boolean = yield io.ask.confirm(
      `Deploy ${params.service} v${params.version} to production?`,
      {
        persistent: true,
        destructive: true,
        expires: '24h',
      }
    );

    if (!approved) {
      return { status: 'cancelled' };
    }

    yield io.emit.progress(0.5, 'Deploying...');
    yield io.emit.progress(1.0, 'Complete');

    return { status: 'deployed', service: params.service, version: params.version };
  }
}

Key options:

  • persistent: true — approval survives navigation/restart
  • destructive: true — UI shows red/danger styling
  • expires: '24h' — auto-reject after duration (supports m, h, d)

When to use: For destructive operations (deploys, deletions, billing changes) where you need an audit trail and the approval might not happen immediately.


MCP Tasks

Fire-and-forget async operations with progress polling. The client gets a task ID immediately and polls for completion.

typescript
export default class BackgroundJob {
  /**
   * Process items in the background
   *
   * Designed for tasks/create — returns immediately with task ID,
   * client polls tasks/get for progress.
   */
  async *process(params: { items: string[] }) {
    const total = params.items.length;

    for (let i = 0; i < total; i++) {
      yield io.emit.progress(i / total, `Processing ${params.items[i]}...`);
      // Simulate work
      await new Promise(r => setTimeout(r, 100));
    }

    yield io.emit.progress(1.0, 'All items processed');

    return {
      processed: total,
      results: params.items.map(item => ({ item, status: 'done' })),
    };
  }
}

Task lifecycle:

tasks/create → { taskId }     (client gets ID immediately)
tasks/get    → { state: 'working', progress: 0.5 }
tasks/get    → { state: 'completed', result: {...} }

Task states: workingcompleted | failed | cancelled

When to use: For long-running operations (data processing, report generation, bulk imports) where the client shouldn't block waiting for a response.


Server Cards

Auto-generated metadata at GET /.well-known/mcp-server that describes your server's capabilities without requiring an MCP connection.

typescript
/**
 * Weather Data Service
 *
 * Provides real-time weather data for any location.
 *
 * @version 2.1.0
 * @stateful
 */
export default class Weather {
  /** Get current weather for a city */
  async current(params: { city: string }) { /* ... */ }

  /** Get 5-day forecast */
  async forecast(params: { city: string; days?: number }) { /* ... */ }
}

Generated Server Card:

json
{
  "name": "photon-beam",
  "version": "1.9.0",
  "protocol": "mcp",
  "transport": [{ "type": "streamable-http", "url": "http://localhost:3000/mcp" }],
  "capabilities": ["tools"],
  "tools": [
    { "name": "weather/current", "description": "Get current weather for a city" },
    { "name": "weather/forecast", "description": "Get 5-day forecast" }
  ],
  "photons": [{
    "name": "weather",
    "description": "Provides real-time weather data for any location.",
    "methods": ["current", "forecast"],
    "stateful": true
  }]
}

No code changes needed — the card is generated from your existing photon metadata.

When to use: For MCP server discovery — registries, IDEs, and agent orchestrators can learn about your server without connecting.


A2A Agent Cards

Auto-generated at GET /.well-known/agent.json, following Google's A2A (Agent-to-Agent) protocol. Photon methods map to A2A skills, and capabilities are inferred from your tags.

typescript
/**
 * Data Analysis Agent
 *
 * Analyzes datasets and generates insights.
 *
 * @stateful
 */
export default class Analyst {
  /**
   * Analyze a dataset
   * @param source Data source URL or path
   */
  async analyze(params: { source: string }) { /* ... */ }

  /**
   * Generate a summary report
   * @param format Output format: pdf, html, or markdown
   */
  async report(params: { format: string }) { /* ... */ }
}

Generated Agent Card:

json
{
  "name": "analyst",
  "description": "Analyzes datasets and generates insights.",
  "url": "http://localhost:3000",
  "version": "1.0.0",
  "capabilities": [
    { "name": "tool_execution", "description": "Executes tools via MCP protocol" },
    { "name": "stateful", "description": "Maintains state across interactions" },
    { "name": "streaming", "description": "Supports streaming responses via SSE" },
    { "name": "ag-ui", "description": "Supports AG-UI protocol for agent-to-agent UI" }
  ],
  "skills": [
    { "id": "analyst/analyze", "name": "analyst analyze", "description": "Analyze a dataset" },
    { "id": "analyst/report", "name": "analyst report", "description": "Generate a summary report" }
  ]
}

Capability detection:

  • @statefulstateful capability
  • Methods with tools → tool_execution
  • SSE transport → streaming (always on)
  • AG-UI adapter → ag-ui (always on in Beam)

When to use: When other agents need to discover and invoke your photon's capabilities. Works with Google A2A orchestrators, LangChain agent frameworks, and any A2A-compatible client.


OTel GenAI

Optional observability following CNCF OpenTelemetry GenAI semantic conventions. Install @opentelemetry/api and get auto-instrumented spans — zero code changes.

bash
# Opt-in: install the OTel API
bun add @opentelemetry/api
typescript
import { startToolSpan, isTracingEnabled } from '../src/telemetry/otel.js';

// In your photon or middleware:
const span = startToolSpan('my-photon', 'analyze', { source: 'data.csv' });
try {
  const result = await doWork();
  span.setStatus('OK');
  return result;
} catch (err) {
  span.setStatus('ERROR', err.message);
  throw err;
} finally {
  span.end();
}

What gets traced:

SpanAttributes
gen_ai.tool.call {photon}.{tool}gen_ai.tool.name, gen_ai.agent.name, gen_ai.operation.name
gen_ai.agent.invoke {photon}gen_ai.agent.name, gen_ai.operation.name

Zero-cost when disabled: Without @opentelemetry/api installed, all span functions return no-op objects. No performance overhead, no errors.

typescript
import { isTracingEnabled } from '../src/telemetry/otel.js';

// Check at runtime
if (isTracingEnabled()) {
  console.log('OTel tracing is active');
}

When to use: When you need production observability — latency tracking, error rates, request tracing across distributed systems. Works with Jaeger, Zipkin, Datadog, Grafana Tempo, and any OTel-compatible backend.

Released under the MIT License.