Skip to content

Events contract

A pack emits events to the cloud so remote surfaces can observe and react to what the pack is doing. This page is the contract pack authors sign: what fields must appear in the manifest, what shape .emit() calls must have, how to pick a backpressure policy, and what idempotency guarantees a subscriber handler must meet.

For live agent-to-agent coordination on a single workspace, see Agent addressing. That session-level memory traffic complements, rather than replaces, the pack event contract described on this page.

This page is grounded in ADR-013 §2 (delivery guarantee), §3 (ordering), §4 (backpressure), and the “Event Namespacing” + “Manifest Extension Fields” sections. Every rule here is load-bearing for the cross-pack event registry; if you think a rule is arbitrary, read the ADR first.

Manifest fields — the declarative surface

Section titled “Manifest fields — the declarative surface”

Every pack manifest that emits or consumes events declares four additional fields (ADR-013 “Manifest Extension Fields”):

// packages/@lumenflow/packs/<your-pack>/manifest.ts
export const MY_PACK_MANIFEST: DomainPackManifest = {
  id: 'my-pack',
  version: '1.0.0',
  slug: 'my-pack',
  // ...

  emitted_event_kinds: [
    'my-pack:job_started',
    'my-pack:job_completed',
    'my-pack:approval_requested',
  ],

  subscribed_event_kinds: [
    'conductor:recovery_requested', // inbound command this pack handles
  ],

  required_approvals: [
    'my-pack:job_destructive', // operator approval before destructive jobs
  ],

  surfaces_required: ['http'], // declare every transport surface the pack needs
};

Field semantics:

FieldMeaning
emitted_event_kindsCanonical kinds this pack may emit. Every .emit() call is validated against this list at runtime; unknown kinds are rejected.
subscribed_event_kindsKinds this pack handles. The runtime wires a dispatcher that routes incoming events of these kinds to pack handlers.
required_approvalsApproval surface identifiers this pack may request. Checked against installed approval providers at workspace load.
surfaces_requiredTransport surfaces this pack needs (http, mcp, sidekick-channel). Runtime refuses to activate a pack whose required surfaces are unavailable.

All four fields are additive. A manifest that omits them gets the default emitted: [], subscribed: [], approvals: [], surfaces: ['http'].

Every emitted kind must match <pack-slug>:<event_name_snake_case>. Kernel-intrinsic kinds stay unprefixed (task_created, task_claimed, task_completed from ADR-011 §2) for backward compatibility; everything else carries the pack slug as the prefix, colon-separated.

my-pack:job_started            ✅
my-pack:jobStarted             ❌  (camelCase — reject)
job_started                    ❌  (kernel-intrinsic namespace collision)
other-pack:job_started         ❌  (prefix does not match this pack's slug)

A boundary lint rule (INIT-060 WU-1) rejects manifest entries that violate the rule. Runtime assertion at pack load also rejects emit calls with a kind not in the pack’s emitted_event_kinds list.

Every pack emission flowing to the cloud goes through buildKernelEventV2 from the kernel-event-sync path. You don’t construct the wire envelope by hand; the builder populates schema_version, timestamp, and event_id for you.

import { buildKernelEventV2 } from '@lumenflow/cli/kernel-event-sync/emitters';
import type { ConductorPayload } from '@lumenflow/conductor-sdk';

// 1. Construct the kind-specific payload. TypeScript narrows by `kind`.
const payload: ConductorPayload = {
  kind: 'my-pack:job_started',
  job_id: 'job-42',
  lane: 'my-lane',
  from: workspaceIdentity, // the token subject claim
};

// 2. Build. The builder adds schema_version=2, timestamp, and event_id.
const event = buildKernelEventV2(payload, {
  idempotencyKey: `${payload.job_id}:started`, // optional — enables content-hashed event_id
});

// 3. Hand to the sync port. The cloud receives it at-least-once.
await controlPlaneSyncPort.pushKernelEvents([event]);

Field contract for the payload:

  • kind — must match one of the pack’s emitted_event_kinds. Checked at emit time.
  • from — the workspace-identity (or phone-device) subject from the enrollment token. Not a free-text field. ADR-013 §5.
  • Everything else is kind-specific; see @lumenflow/conductor-sdk’s tagged union for the allowed shape per kind.

buildKernelEventV2 accepts an idempotencyKey option. When provided, event_id is derived by content-hashing {idempotencyKey, kind, timestamp, payload}. Two emitter calls with the same key in the same window produce the same event_id. Cloud’s dedup layer then drops the replay.

When idempotencyKey is omitted, event_id is a random UUID — every call is distinct, replay behaviour depends entirely on the cloud’s content-aware dedup. Prefer idempotency keys for every emission where “the same thing happened twice” is a real scenario (network retry, local restart mid-turn, subscriber re-drain). Omit it only when the event describes a genuinely unique occurrence (turn_started with a unique turn id in the payload is already unique).

ADR-013 §4 splits events into two classes: ephemeral (dropped on disconnect) and queue + replay (buffered in the outbox and drained FIFO on reconnect). The split rule is declared per kind in the manifest, not decided per emitter call:

// packs/<your-pack>/manifest.ts
export const MY_PACK_MANIFEST: DomainPackManifest = {
  // ...
  emitted_event_kinds: [
    'my-pack:job_started',
    'my-pack:job_completed',
    'my-pack:approval_requested',
  ],

  backpressure_policy: {
    'my-pack:job_started': 'ephemeral',
    'my-pack:job_completed': 'ephemeral',
    'my-pack:approval_requested': 'queue', // commands/approvals must replay
  },
};

Picking a policy:

Kind describes…Policy
Observation the cloud can recompute from later eventsephemeral
Operator action the cloud must act on (approval request)queue
Inbound command targeting the agentqueue
State transition that terminates a longer-running processephemeral (the turn_completed will re-sync state; the intermediate turn_started is recomputable)
Anything whose loss would cause a user-visible “I clicked but nothing happened” bugqueue

If you are tempted to make everything queue “just to be safe,” read the ADR-013 §4 “Uniform backpressure (everything queues)” rejection: ephemeral telemetry queued for a long disconnect fills the outbox and possibly the disk, for no observational benefit. Fail-silent is the right default for telemetry.

A manifest validator (INIT-060 WU-11) rejects adding a new emitted_event_kind without a matching backpressure_policy entry. You cannot forget.

required_approvals — the approval contract

Section titled “required_approvals — the approval contract”

A kind in required_approvals maps to an approval provider the runtime checks before the pack may act on an incoming command or before the pack may call a governed tool. The manifest field declares the surface identifier; the runtime wires in a provider at workspace load.

required_approvals: [
  'sidekick:channel_send', // phone-message send requires operator approval
  'my-pack:job_destructive', // destructive job requires approval
];

If a declared provider is missing at load time, the workspace surfaces a diagnostic — not a silent skip. A pack that declares an approval it cannot satisfy refuses to activate, rather than running un-gated.

Idempotent handlers — the subscriber contract

Section titled “Idempotent handlers — the subscriber contract”

Every subscribed_event_kinds handler must be idempotent under replay (ADR-013 §2, “Delivery Guarantee” — at-least-once).

// packs/<your-pack>/handlers/recovery-requested.ts
import type { PackEventHandler } from '@lumenflow/conductor-sdk';

export const onRecoveryRequested: PackEventHandler<'conductor:recovery_requested'> = async (
  event,
  ctx,
) => {
  const replayKey = `recovery:${event.payload.wu_id}:${event.event_id}`;

  // Idempotency check — have we handled this event_id before?
  if (await ctx.replayLog.has(replayKey)) {
    return { status: 'replayed', action: 'noop' };
  }

  // Do the work. MUST be safe to repeat — upserts, not increments.
  await ctx.runRecovery(event.payload.wu_id);

  // Record the replay key atomically with the effect.
  await ctx.replayLog.put(replayKey);

  return { status: 'handled', action: 'recovered' };
};

Rules:

  1. Dedup by event_id. Every event carries a content-hashed event_id (per-emitter idempotency key) or a random UUID. Store the key once the handler’s effect has committed; subsequent invocations short-circuit.
  2. Upsert, don’t increment. A handler that increments a counter is non-idempotent — two replays count twice. Either store state keyed by event_id, or use set-semantics (INSERT … ON CONFLICT DO NOTHING).
  3. No partial writes. Persist the replay key in the same transaction as the effect. A handler that commits the effect, crashes before recording the key, and then replays will double-apply.
  4. Decorate with @idempotent or replay_key. The conformance test (INIT-060 WU-11) requires every registered handler to either carry the @idempotent static flag or expose a replay_key(event) function returning a deterministic dedup string. A handler without either fails the boot check.

A repo-wide test (INIT-060 WU-11) fires every subscribed kind twice through a replay harness and asserts equal post-state. If your handler has non-obvious non-idempotency (a timer increment, a clock read, a random-id generator), the test catches it before the pack ships.

Writing a handler that passes the test is simpler than retrofitting one. Follow the rules above from the first commit.

Worked example — the software-delivery pack

Section titled “Worked example — the software-delivery pack”

Trimmed excerpt of the real manifest (packages/@lumenflow/packs/software-delivery/manifest.ts):

import type { SoftwareDeliveryPackManifest } from './manifest-schema';

export const SOFTWARE_DELIVERY_MANIFEST: SoftwareDeliveryPackManifest = {
  id: 'software-delivery',
  slug: 'software-delivery',
  version: '4.24.0',
  // ...
  emitted_event_kinds: [
    'software-delivery:wu_claimed',
    'software-delivery:gate_passed',
    'software-delivery:gate_failed',
  ],
  subscribed_event_kinds: [],
  required_approvals: [],
  surfaces_required: ['http'],
  backpressure_policy: {
    'software-delivery:wu_claimed': 'ephemeral',
    'software-delivery:gate_passed': 'ephemeral',
    'software-delivery:gate_failed': 'ephemeral',
  },
};

Emit site (simplified):

import { buildKernelEventV2 } from '@lumenflow/cli/kernel-event-sync/emitters';

export async function emitGatePassed(ctx: EmitContext, gateName: string) {
  const event = buildKernelEventV2(
    {
      kind: 'software-delivery:gate_passed',
      from: ctx.workspaceIdentity,
      wu_id: ctx.wuId,
      gate_name: gateName,
    },
    { idempotencyKey: `${ctx.wuId}:${gateName}:passed` },
  );

  await ctx.controlPlaneSyncPort.pushKernelEvents([event]).catch(() => {
    // Ephemeral — fail-silent on disconnect, per §4 and the policy above.
  });
}

Note the .catch(() => {}) on the push — it matches the ephemeral policy declared for this kind. For a queue kind, the emitter would hand the event to the outbox, which retries until drained. The pack author doesn’t write the retry loop; they pick the policy.

Manifest declares the kind

Every kind you emit appears in emitted_event_kinds. Namespace prefix matches your pack slug.

Backpressure picked

Each kind has an entry in backpressure_policyephemeral or queue.

Uses buildKernelEventV2

No hand-rolled envelope. The builder owns schema_version, timestamp, event_id.

Handlers are idempotent

Every subscribed handler dedupes by event_id and uses upsert semantics. Decorated with @idempotent or replay_key.