Skip to content

Lifecycle Hooks & Ingress Model

Status: Design approved, implementation in progress (as of 2026-04-17) Scope: One new lifecycle hook (onError) built on the existing onInitialize/onShutdown foundation plus a consistency fix to the Beam hot-reload path, webhook authentication v2, @scheduled syntax polish, removal of the handle* prefix convention


The organizing principle: ingress and visibility are orthogonal

Every method on a photon has two independent properties:

  • Ingress — who can trigger this method? (MCP client, webhook HTTP, scheduler, runtime itself)
  • Visibility — does it appear in the MCP tool list? (yes / no)

The new model treats them as independent axes:

IngressDefault visibilityRationale
Regular (no tag)VisibleDefault user/LLM-callable method.
@webhookHiddenPurpose-built for external HTTP events. Manual MCP invocation is nonsensical.
@scheduledVisibleMethod does real work; the schedule is one trigger among many. "Run now" is a valid user request.
@internalHiddenExplicit opt-out. Composes with any ingress.
Lifecycle hooksHiddenRuntime-only; never user-callable.

Composition is explicit:

ts
/**
 * Scheduled hidden-from-MCP cleanup (runs only on schedule).
 * @scheduled 0 0 * * *
 * @internal
 */
async nightlyCleanup() { ... }

1. Lifecycle hooks

1.1 Existing hooks (already shipped)

Photons already have two lifecycle hooks that this design does not change:

HookSignatureWhen it fires
onInitializeasync onInitialize(): Promise<void>After construction, before first method call
onShutdownasync onShutdown(): Promise<void>SIGTERM/SIGINT drain, hot-reload (old instance), explicit unload

Already wired end-to-end:

  • Both loaders call onInitialize after instance construction and dependency injection.
  • onShutdown is invoked on session teardown (src/server.ts:2742) and before hot-reload of the old instance (src/server.ts:2897).
  • Daemon SIGTERM/SIGINT handler drains all session managers, which invoke onShutdown on every loaded photon (src/daemon/server.ts:3870, :4034).
  • Method list extractor excludes both from MCP advertisement.
  • Photon templates scaffold both by default.
  • Worker-thread auto-detection places photons with both hooks into worker threads so the host process is protected from blocking cleanup.

Because this foundation already exists, no renaming or migration is part of this design.

1.2 New hook: onError

One genuinely new hook. Optional, async, hidden from MCP and CLI.

HookSignatureWhen it firesDefault timeout
onErrorasync onError(err: unknown, ctx: { tool: string; params: any }): Promise<void>Any tool method throws (observability only; cannot suppress)5s

onError provides a single handler for author-side observability (metrics, logging, alerts, custom reporting) without wrapping every method in try/catch. Wired into photon-core/src/base.ts executeTool, so every invocation path (CLI, daemon, lite loader, MCP, webhook dispatch) picks it up for free.

Contract:

  • Runs after the error is captured, before the error is re-thrown to the caller.
  • Cannot suppress or transform the error — throw from onError, or a return value, is ignored.
  • A throw or timeout inside onError is logged and swallowed; observability code never cascades into the request path.
  • Default timeout: 5s.

1.3 State preservation across hot reload (already shipped, now consistent)

Original design notes here proposed a new onReload hook for state-preserving reload. Investigation revealed the state-transfer mechanism already exists via context parameters on the existing hooks:

ts
async onInitialize?(ctx?: { reason?: string; oldInstance?: any }): Promise<void>;
async onShutdown?(ctx?: { reason?: string }): Promise<void>;
  • onShutdown({ reason: 'hot-reload' }) — old instance can skip destructive cleanup of resources the new instance will reuse.
  • onInitialize({ reason: 'hot-reload', oldInstance }) — new instance pulls non-copyable resources (sockets, timers, DB connections) from the old. In-memory non-function properties are also auto-copied by the runtime.

This pattern was already correctly wired in the daemon hot-reload path (src/daemon/server.ts:3665-3700) but not in the Beam server hot-reload path (src/server.ts). The latter is now fixed to match. A new onReload hook is not needed — the existing API already covers the use case, and making the Beam path consistent is the real round-1 work.

1.3 Ordering with @photon dependencies

onInitialize already fires in dependency-first order naturally, because @photon dependencies are constructed recursively before the dependent's construction completes. The new hooks inherit this:

  • onInitialize fires in dependency order (deps first).
  • onShutdown fires in reverse (dependents first).

If any onInitialize fails or times out, dependents fail to load. The existing PhotonInitializationError surfaces this.

1.4 Loader-lite gap

photon-loader-lite.ts (the programmatic photon() API) calls onInitialize but does not call onShutdown. In the lite path the caller owns the instance lifecycle, so this is arguably correct for programmatic use. This design surfaces the gap; a decision on whether lite should expose an explicit dispose or match the full loader is tracked as an open question (section 5).


2. Webhooks v2

2.1 @webhook methods are hidden from the MCP tool list

A method marked @webhook is registered only as an HTTP endpoint. It does not appear as an MCP tool. It remains reachable by:

  • POST /webhook/{photonName}/{method} (the existing daemon endpoint)
  • The CLI testing command (section 2.4)
  • this.call() from other photons (internal trust)

2.2 Per-service authentication

A new tag co-locates authentication with the method:

@webhook-auth <scheme> <header> <secret-ref>

&lt;secret-ref&gt; is env:VAR_NAME or settings:key. Built-in schemes:

SchemeVerificationTypical providers
stripeHMAC-SHA256 over {timestamp}.{body}, 5-minute toleranceStripe
github-sha256HMAC-SHA256 of raw body, sha256= prefixGitHub
github-sha1HMAC-SHA1, sha1= prefixGitHub (legacy)
slackHMAC-SHA256 over v0:{timestamp}:{body}, 5-minute toleranceSlack
twilioHMAC-SHA1 over {url}{sortedParams}Twilio
hmac-sha256Generic HMAC-SHA256 of raw bodyCustom services
hmac-sha1Generic HMAC-SHA1 of raw bodyLegacy custom
bearerAuthorization: Bearer &lt;secret&gt; exact matchOAuth-ish
shared-secretHeader value exact match (timing-safe)Current behavior
noneNo verificationPublic forms, IP-restricted internal

Examples:

ts
/**
 * @webhook stripe/events
 * @webhook-auth stripe Stripe-Signature env:STRIPE_WEBHOOK_SECRET
 */
async handleStripe(body: any) { ... }

/**
 * @webhook github/push
 * @webhook-auth github-sha256 X-Hub-Signature-256 env:GH_WEBHOOK_SECRET
 */
async onPush(body: any) { ... }

/**
 * @webhook public-form
 * @webhook-auth none
 */
async handleForm(body: any) { ... }

The runtime verifies the signature at the HTTP edge. Handlers never see unauthenticated requests.

2.3 Raw body access

HMAC verification is performed against the exact bytes received, before JSON parsing. The _webhook metadata object gains raw: Buffer for handlers that need the original bytes (rare, but some providers require it for custom checks).

2.4 CLI testing support

bash
# Fire a webhook against a local daemon
photon webhook forms handleSubmission --body @sample.json

# Compute and attach a Stripe-style signature
photon webhook stripe handleStripe --body @event.json --sign stripe --secret env:STRIPE_WEBHOOK_SECRET

# Generic HMAC with custom header
photon webhook my handler --body '{"x":1}' --sign hmac-sha256 --header X-Signature --secret env:MY_SECRET

# Dry-run: print the equivalent curl command without sending
photon webhook forms handleSubmission --body @sample.json --dry-run

The CLI uses the same verifier code paths as the server, so a passing photon webhook run guarantees the live endpoint would accept the same request.

2.5 Global fallback

PHOTON_WEBHOOK_SECRET continues to work as a coarse dev-mode fallback when a method has no @webhook-auth tag. Prefer per-method auth for production.

2.6 Removal of the handle* prefix convention

Methods named handle* no longer auto-register as webhooks. @webhook is the only declaration path. The runtime emits a one-time warning for one minor release before the warning goes silent. Migration: add @webhook to each handle* method.


3. @scheduled syntax

3.1 Canonical tag

@scheduled remains the canonical tag (consistent with @locked, @stateful). @cron is soft-deprecated: the alias continues to work but emits a load-time warning. The alias is removed in the next major version.

3.2 Broadened argument syntax

@scheduled 0 * * * *                   # cron (canonical for complex schedules)
@scheduled every 5 minutes             # interval
@scheduled every 2 hours
@scheduled daily at 9am                # natural
@scheduled daily at 09:00
@scheduled weekly on monday at 8am
@scheduled at 2026-05-01T00:00:00Z     # one-shot, fires once

Cron remains the most expressive; interval and natural forms exist for readability on common cases.


4. Migration summary

ChangeSeverityPath
onError hookAdditiveOpt-in; define the method if you want observability.
Beam hot-reload contextBug fixAlready-documented { reason, oldInstance } context now flows through the Beam path (already flowed through the daemon path).
@webhook hidden from MCPBehavioralIntentional; no migration needed unless you relied on MCP-calling a webhook method.
Per-method @webhook-authAdditiveGlobal PHOTON_WEBHOOK_SECRET still works.
handle*@webhookBreaking (one-minor warning window)Add @webhook to each handle* method.
@cron@scheduledSoft-deprecated (alias preserved)Rename at your convenience.
@scheduled syntax broadenedAdditiveExisting cron expressions unaffected.

Existing onInitialize/onShutdown behavior is unchanged.


5. Open questions (round 2)

  • Class-level default @webhook-auth: override per method. Useful for Stripe-only photons.
  • IP allowlist (@webhook-source &lt;cidr&gt;): for providers that publish source ranges.
  • Loader-lite shutdown: should photon-loader-lite.ts expose an explicit dispose that invokes onShutdown, or keep the "caller owns lifecycle" contract?
  • State/settings hooks: onStateLoad, onStateSave, onSettingsChange.
  • Webhook response schema enforcement: pass-through today; may want opinionated shape later.

6. Implementation order

  1. onError hook + Beam hot-reload context fix (smallest blast radius, independent)
  2. @webhook → MCP-list exclusion in photon-doc-extractor.ts
  3. Raw body + per-service @webhook-auth verifiers
  4. photon webhook ... CLI testing command
  5. @scheduled syntax broadening + @cron deprecation warning
  6. handle* prefix removal with deprecation warning
  7. Docs pass (update WEBHOOKS.md, DOCBLOCK-TAGS.md, GUIDE.md, skill references)

Each step is shippable independently.


Released under the MIT License.