Skip to content

Pack-authoring quickstart

You are going to build a LumenFlow pack from zero. Manifest, one emitted event, one subscriber handler, one test. At the end, a conductor-mode sidecar will see your pack’s events on the wire.

Target time: 30 minutes. If you stall past that, something has drifted from this page — file an issue or ping #packs with the failing step.

A pack called acme-demo that:

  1. Declares a manifest with one emitted kind and one subscribed kind.
  2. Emits acme-demo:job_completed when a mock runJob() finishes.
  3. Handles inbound conductor:recovery_requested for its own jobs.
  4. Passes a conformance test (emit + replay).

That’s the whole surface. Everything else — pack registry, runtime wiring, pack metadata validation — is already in the host.

  • LumenFlow workspace already set up (pnpm lumenflow:integrate run at least once).
  • Node 20+. Repo’s pnpm bootstrap green.
  • Scratch directory inside packages/@lumenflow/packs/ where your pack source will live.

Step 1 — Scaffold the pack directory (5 min)

Section titled “Step 1 — Scaffold the pack directory (5 min)”
  1. Create the directory:

    mkdir -p packages/@lumenflow/packs/acme-demo/src/handlers
    mkdir -p packages/@lumenflow/packs/acme-demo/__tests__
  2. Create package.json:

    {
      "name": "@lumenflow/packs-acme-demo",
      "version": "0.1.0",
      "private": true,
      "main": "./dist/index.js",
      "types": "./dist/index.d.ts",
      "scripts": {
        "build": "tsc",
        "test": "vitest run"
      },
      "dependencies": {
        "@lumenflow/conductor-sdk": "workspace:*",
        "@lumenflow/kernel": "workspace:*"
      },
      "devDependencies": {
        "vitest": "catalog:",
        "typescript": "catalog:"
      }
    }
  3. Create a minimal tsconfig.json:

    {
      "extends": "../../../../tsconfig.base.json",
      "compilerOptions": {
        "outDir": "./dist",
        "rootDir": "./src"
      },
      "include": ["src/**/*"]
    }
  4. pnpm install from the repo root to wire workspace dependencies.

packages/@lumenflow/packs/acme-demo/src/manifest.ts:

import type { DomainPackManifest } from '@lumenflow/kernel';

export const ACME_DEMO_MANIFEST: DomainPackManifest = {
  id: 'acme-demo',
  slug: 'acme-demo',
  version: '0.1.0',
  config_key: 'acme_demo',
  task_types: [],
  tools: [],
  policies: [],
  evidence_types: [],
  state_aliases: {},
  lane_templates: [],

  // The four INIT-060 extension fields:
  emitted_event_kinds: ['acme-demo:job_completed'],
  subscribed_event_kinds: ['conductor:recovery_requested'],
  required_approvals: [],
  surfaces_required: ['http'],

  // Per-kind backpressure rule (ADR-013 §4):
  backpressure_policy: {
    'acme-demo:job_completed': 'ephemeral',
  },
};

Sanity check — run the manifest validator:

pnpm --filter @lumenflow/packs-acme-demo exec tsc --noEmit

If typecheck passes, your manifest is structurally valid. The runtime will assert consistency (every emitted kind must match a namespacing rule, every subscribed kind must have a registered handler) at pack load later.

packages/@lumenflow/packs/acme-demo/src/emit.ts:

import { buildKernelEventV2 } from '@lumenflow/cli/kernel-event-sync/emitters';
import type { ControlPlaneSyncPort } from '@lumenflow/control-plane-sdk';

export interface JobContext {
  syncPort: ControlPlaneSyncPort;
  workspaceIdentity: string; // from the enrollment token subject
  jobId: string;
}

export async function runJob(ctx: JobContext): Promise<void> {
  // Your business logic here. The mock "job" completes instantly.
  const result = 'ok';

  const event = buildKernelEventV2(
    {
      kind: 'acme-demo:job_completed',
      from: ctx.workspaceIdentity,
      job_id: ctx.jobId,
      result,
    },
    { idempotencyKey: `${ctx.jobId}:completed` },
  );

  // Ephemeral per manifest → fail-silent on disconnect.
  await ctx.syncPort.pushKernelEvents([event]).catch(() => {
    // intentional no-op; conductor reconstructs from later events
  });
}

The builder populated schema_version=2, timestamp, and event_id automatically. The idempotencyKey makes event_id deterministic — retries produce the same id, cloud dedupes cleanly.

Step 4 — Write the subscriber handler (5 min)

Section titled “Step 4 — Write the subscriber handler (5 min)”

packages/@lumenflow/packs/acme-demo/src/handlers/recovery-requested.ts:

import type { PackEventHandler } from '@lumenflow/conductor-sdk';

export interface RecoveryContext {
  replayLog: {
    has(key: string): Promise<boolean>;
    put(key: string): Promise<void>;
  };
  markJobRecovered(jobId: string): Promise<void>;
}

export const onRecoveryRequested: PackEventHandler<
  'conductor:recovery_requested',
  RecoveryContext
> = async (event, ctx) => {
  const replayKey = `acme-demo:recover:${event.event_id}`;

  if (await ctx.replayLog.has(replayKey)) {
    return { status: 'replayed', action: 'noop' };
  }

  // Only handle recoveries targeted at this pack's jobs.
  if (!event.payload.wu_id.startsWith('acme-job-')) {
    return { status: 'ignored', action: 'wrong-pack' };
  }

  await ctx.markJobRecovered(event.payload.wu_id);
  await ctx.replayLog.put(replayKey);

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

Note: the handler dedupes by event_id, uses an upsert-shaped side effect (markJobRecovered is idempotent), and records the replay key after the effect. ADR-013 §2 at-least-once tolerance is met.

packages/@lumenflow/packs/acme-demo/src/index.ts:

export { ACME_DEMO_MANIFEST } from './manifest';
export { runJob } from './emit';
export { onRecoveryRequested } from './handlers/recovery-requested';

Register the pack with the host (the host’s pack-loading config is project-local — the canonical example is packages/@lumenflow/host/src/pack-loader.ts):

// host registration, not part of the pack itself
import { ACME_DEMO_MANIFEST, onRecoveryRequested } from '@lumenflow/packs-acme-demo';

packRegistry.register({
  manifest: ACME_DEMO_MANIFEST,
  handlers: {
    'conductor:recovery_requested': onRecoveryRequested,
  },
});

packages/@lumenflow/packs/acme-demo/__tests__/emit-replay.test.ts:

import { describe, expect, it, vi } from 'vitest';
import { runJob } from '../src/emit';
import { onRecoveryRequested } from '../src/handlers/recovery-requested';

describe('acme-demo pack', () => {
  it('emits a namespaced kind through buildKernelEventV2', async () => {
    const pushKernelEvents = vi.fn().mockResolvedValue(undefined);
    const syncPort = { pushKernelEvents } as never;

    await runJob({
      syncPort,
      workspaceIdentity: 'ws-test',
      jobId: 'job-1',
    });

    expect(pushKernelEvents).toHaveBeenCalledOnce();
    const [events] = pushKernelEvents.mock.calls[0]!;
    expect(events[0].kind).toBe('acme-demo:job_completed');
    expect(events[0].schema_version).toBe(2);
    expect(events[0].event_id).toBeDefined();
    expect(events[0].from).toBe('ws-test');
  });

  it('handler is idempotent under replay', async () => {
    const seen = new Set<string>();
    const markJobRecovered = vi.fn().mockResolvedValue(undefined);
    const ctx = {
      replayLog: {
        has: async (k: string) => seen.has(k),
        put: async (k: string) => {
          seen.add(k);
        },
      },
      markJobRecovered,
    };

    const event = {
      schema_version: 2 as const,
      kind: 'conductor:recovery_requested' as const,
      event_id: 'evt-abc',
      timestamp: '2026-04-20T00:00:00Z',
      from: 'ws-test',
      payload: { wu_id: 'acme-job-1' },
    } as never;

    const first = await onRecoveryRequested(event, ctx);
    const second = await onRecoveryRequested(event, ctx);

    expect(first.status).toBe('handled');
    expect(second.status).toBe('replayed');
    expect(markJobRecovered).toHaveBeenCalledOnce(); // NOT twice
  });
});

Run it:

pnpm --filter @lumenflow/packs-acme-demo test

Two green tests. First proves the envelope is built correctly; second proves the handler is idempotent. Both are the minimum bar the conformance test will check.

From the repo root, with a conductor-mode enrollment token installed (.lumenflow/state/conductor/enrollment.json):

# Start the sidecar in one terminal
pnpm conductor:sidecar

# In another, trigger the job
pnpm --filter @lumenflow/packs-acme-demo exec tsx -e "
  import { runJob } from './src/emit';
  // ... wire a syncPort pointed at the sidecar and call runJob
"

The cloud subscriber (or your local mem:inbox if you are running the loopback adapter) sees an acme-demo:job_completed event with a stable event_id. You are done.

This quickstart deliberately kept scope minimal. Ship the pack first, then layer on:

FeatureWhen to add
required_approvalsWhen your pack exposes a destructive surface (phone send, wu destroy).
queue backpressure for approvalsWhen you add an approval_requested kind.
surfaces_required: ['sidekick-channel']When your pack integrates with the sidekick bridge.
@idempotent decorationWhen the conformance test (INIT-060 WU-11) lands repo-wide.
Cross-pack event subscriptionWhen your pack reacts to another pack’s events (e.g. software-delivery:gate_failed).

Every one of these is an additive change. None of them invalidate the manifest or emitter you just wrote.

  • “Unknown kind at emit” — the kind you passed to buildKernelEventV2 is not in your manifest’s emitted_event_kinds. The runtime rejects unknown kinds to protect the cloud event-schema registry.
  • “Pack slug prefix mismatch” — your kind starts with a prefix that is not your manifest’s slug. Rule: <slug>:<event_name_snake_case>.
  • “Missing backpressure_policy entry” — you declared an emitted kind but did not pick ephemeral vs queue for it. The validator refuses to load a pack with an under-declared policy.
  • “Handler called twice” — the conformance harness is testing idempotency. Your handler is missing its replay_log.has() check or its effect is non-upsert-shaped.