Skip to content

Control-plane SDK

@hellmai/lumenflow-control-plane-sdk is the published package that defines the orchestration state contract every LumenFlow surface speaks. The package is intentionally small — it owns the state vocabulary, the artifact document shapes, schema-versioned event vocabulary, and the parsers that validate them.

pnpm add @hellmai/lumenflow-control-plane-sdk

When you read a status.json, receive an MCP coordination-tool payload, or integrate a third-party launcher or remote control surface, the SDK is what guarantees the shape.

import {
  ORCHESTRATION_STATUS_VALUES,
  ORCHESTRATION_NEXT_SAFE_ACTION_TYPES,
  ORCHESTRATION_CLAIM_MODES,
  ORCHESTRATION_HANDOFF_FORMATS,
} from '@hellmai/lumenflow-control-plane-sdk';

ORCHESTRATION_STATUS_VALUES.CONTAMINATED; // 'contaminated'
ORCHESTRATION_NEXT_SAFE_ACTION_TYPES.FINISH_WU; // 'finish_wu'
ORCHESTRATION_CLAIM_MODES.BRANCH_PR; // 'branch-pr'
ORCHESTRATION_HANDOFF_FORMATS.MARKDOWN; // 'markdown'

These are the single source of truth for the state machine. Internally, the software-delivery pack’s orchestrator module aliases the same values as ORCHESTRATION_EXECUTION_STATES for intra-module readability, but that alias is not part of the pack’s public initiatives entry point (or the deprecated @hellmai/lumenflow-initiatives compatibility shell) — consumers outside the pack should import ORCHESTRATION_STATUS_VALUES from @hellmai/lumenflow-control-plane-sdk directly.

import type {
  OrchestrationPlanDocument,
  OrchestrationLaunchDocument,
  OrchestrationStatusDocument,
  OrchestrationLaunchReceipt,
  OrchestrationNextSafeAction,
  OrchestrationStatusWu,
} from '@hellmai/lumenflow-control-plane-sdk';

These are the shapes that plan.json, launch.json, and status.json emit. See the artifact bundle page for how each one is layered.

The SDK ships validating parsers, not pass-through casts:

import {
  parseOrchestrationPlanDocument,
  parseOrchestrationLaunchDocument,
  parseOrchestrationStatusDocument,
} from '@hellmai/lumenflow-control-plane-sdk';

const raw = JSON.parse(fs.readFileSync('status.json', 'utf8'));
const status = parseOrchestrationStatusDocument(raw);
// status is now typed + validated; unknown orchestration_state values throw.

Legacy 'ready' is normalized to 'eligible' so older status files stay readable.

import { ORCHESTRATION_ARTIFACT_DIR } from '@hellmai/lumenflow-control-plane-sdk';

Used by the CLI monitor, MCP adapters, and any downstream tool that needs to find orchestration bundles on disk.

The SDK is the .dev-side source of truth for the opaque, semantic routing and residency labels that campus governance reasons about. LumenFlow Cloud mirrors these with a Zod .strict() extension; cloud owns the schema change and migration, while this package owns the type contract, so both sides compile in lockstep.

import {
  DATA_EGRESS_CLASS_VALUES,
  CAMPUS_AFFINITY_VALUES,
  RESIDENCY_CLASS_VALUES,
  type DataEgressClass,
  type CampusAffinity,
  type ResidencyClass,
} from '@hellmai/lumenflow-control-plane-sdk';

// Cloud builds the mirrored enum directly from the source of truth:
// z.enum(DATA_EGRESS_CLASS_VALUES)

Per the campus governance boundary’s vendor-name-free invariant, every label is opaque and semantic — it describes what a constraint means, never who provides the compute. No vendor brand string may appear in any value; vendor identity, where it must exist, lives in workspace-owned configuration mapped behind these labels.

TypeValues (most → least restrictive)Meaning
DataEgressClasscampus_bound, campus_restricted, region_restricted, unrestrictedWhere data is permitted to flow. The resolver branches on this class, never on a vendor name.
CampusAffinityrequired, preferred, noneHow strongly work must be scheduled on a specific campus. A required affinity that cannot be met is refused, not silently downgraded.
ResidencyClasscampus_local, region_local, multi_region, globalThe geographic / jurisdictional scope within which data must remain.

The _VALUES tuples are exported as const so cloud can build the mirrored Zod enum directly from this source of truth, guaranteeing the two sides never drift.

import {
  EVENT_SCHEMA_FAMILIES,
  validateControlPlaneEvent,
} from '@hellmai/lumenflow-control-plane-sdk';

const result = validateControlPlaneEvent({
  schema_version: 2,
  kind: 'brief:token_reported',
  timestamp: '2026-05-13T12:00:00.000Z',
  event_id: 'evt-example',
  wu_id: '<wu-id>',
  client_name: 'codex-cli',
  prompt_hash: 'sha256:abc123',
  total_tokens: 1280,
  sections: [{ name: 'Stable Prefix', tokens: 420, stable: true }],
});

if (!result.valid) {
  console.error(result.issues);
}

console.log(EVENT_SCHEMA_FAMILIES.map((family) => family.id));

The JSON Schemas for these events are the same control-plane contract for local CLI views, self-hosted dashboards, third-party observability, and hosted consumers.

Validate event files locally with:

pnpm events:validate
pnpm events:validate path/to/events.ndjson

The validator rejects missing schema_version, unknown kind, and schema-invalid payloads. The default command validates the committed valid fixtures.

The measurement event family includes brief:token_reported records emitted by wu:brief --report-tokens. Those records are appended to a local brief-metrics.ndjson telemetry file for local inspection with pnpm cost:summary --brief; any hosted or self-hosted dashboard consumes the same schema rather than a dashboard-specific export.

@hellmai/lumenflow-control-plane-sdk/schema-bundle publishes a versioned, deterministic JSON-Schema bundle for the kernel/control-plane wire types (signals, sessions, assignments, evidence envelopes, and the halt/kill-intervention envelope), so an external client written in any language can generate its own types instead of hand-copying Zod shapes.

import {
  CONTROL_PLANE_SCHEMA_BUNDLE,
  CONTROL_PLANE_SCHEMA_BUNDLE_VERSION,
  buildControlPlaneSchemaBundle,
} from '@hellmai/lumenflow-control-plane-sdk/schema-bundle';

CONTROL_PLANE_SCHEMA_BUNDLE.bundle_version; // '1.0.0'
CONTROL_PLANE_SCHEMA_BUNDLE.families.map((family) => family.id);
// ['emit-energy', 'governance-events', 'halt-kill-intervention', ..., 'kernel-events']

The bundle is built from EVENT_SCHEMA_FAMILIES — the same catalog the event schema section above documents — so it never drifts from what validateControlPlaneEvent accepts. It does not pull in the schemas of @hellmai/lumenflow-kernel or @hellmai/lumenflow-mcp: this SDK’s only runtime dependency is zod, so each of those packages advertises its own single tool schemas to its own consumer rather than feeding this bundle.

The halt/kill-intervention envelope (kind: 'control:halt_kill_intervention') mirrors the graceful-halt control signal carried on the Connected Compute runtime-claim lifecycle response — pending_control_action is additive-only, with 'kill' the sole recognised value; a runner derives no capability expansion from it. Build determinism is guaranteed: two calls to buildControlPlaneSchemaBundle() produce byte-identical output.

The connected-compute client also parses the control plane’s per-assignment stop record (additive, optional). pending_stop carries schema_version (connected-compute-stop.v1), idempotency_key, workspace_id, assignment_id, runtime_id, fencing_generation, requested_at, reason (job_cancelled, operator_release or operator_stop) and acked_at (null on delivery) on extend, receipt ack and runtime heartbeat results. A claim response with kind: 'stop_control' is control only: claimWithHints returns assignment: null and the records as stop_control, and it is never parsed as an assignment. ackStop posts stop_ack (schema_version and idempotency_key) to the existing ack route and returns the { schema_version, idempotency_key, acked_at } readback, which must name the current schema_version and the key sent; it is separate from ack, so a lifecycle wrapper that already acknowledged the receipt does not suppress it. Every request carries X-LumenFlow-Control-Protocol: connected-compute-stop.v1 unless the client is built with controlProtocol: false (a probe, which must not negotiate stop delivery). The current version is parsed strictly and a malformed record parses to nothing; a record of another schema_version is parsed by identity alone and flagged wellFormed: false, so a consumer can trip on it and never acknowledge it. The control plane’s delivery is not live yet; this is the client half of the contract.

CONTROL_PLANE_SCHEMA_BUNDLE_VERSION changes on every bundle-shape change — MINOR for an additive change, MAJOR for a breaking one, PATCH for a metadata-only edit.

The @hellmai/lumenflow-control-plane-sdk/runtime-claim client requires a lease_token and fencing_generation on every ToolDispatchEvidenceReceipt. Pass the exact values from the claimed assignment: the lease token proves ownership of that assignment, and the fencing generation rejects a stale owner after takeover. The SDK sends both fields unchanged to the assignment-scoped evidence route. The runtime bearer credential separately authenticates the runner; it cannot replace the assignment lease token.

@hellmai/lumenflow-control-plane-sdk/runtime-claim is a supported subpath. Its claim, lease lifecycle and evidence types, and the ./cadence polling types, are also exported from the package root, so import them from @hellmai/lumenflow-control-plane-sdk without a deep path:

import {
  createConnectedComputeClient,
  createConnectedComputeLifecycle,
  createPollCadence,
  type ConnectedComputeAssignment,
  type ClaimWithHintsResult,
  type PollOutcomeKind,
} from '@hellmai/lumenflow-control-plane-sdk';

Supported means additive-only evolution within a major version: new optional fields and new error kinds can appear, and an existing field, route or error code is not removed or retyped without a major bump.

A runner that holds an engine admission record sends one optional object beside its posture. The posture enum is unchanged (ConfinementPosture is exactly 'linux-default' | 'stronger' | 'reduced', and the pairing exchange’s effective_isolation accepts the same three values); the object is additive.

import {
  createConnectedComputeClient,
  parseConfinementAdmission,
  type ConfinementAdmission,
} from '@hellmai/lumenflow-control-plane-sdk/runtime-claim';

const admission: ConfinementAdmission | undefined = parseConfinementAdmission(candidate);
const client = createConnectedComputeClient({ baseUrl, credential });
await client.claimWithHints({
  session_id,
  capabilities,
  confinement_posture: 'linux-default',
  ...(admission ? { confinement_admission: admission } : {}),
});

ConfinementAdmission has seven required fields: verdict (enforced | refused | waived), mechanism, engine_id, catalogue_digest, admission_ref (both SHA-256 hex), admitted_at and expires_at (UTC instants, expires_at after admitted_at). parseConfinementAdmission is strict: an extra key, a missing key, a malformed value or an unknown verdict returns undefined, and the claim and heartbeat clients send the object only when it parses. The object is absent when the runner has no admission record. A verdict never raises a posture, and an expired record counts as absent. On the pairing exchange, send it through the typed extensions input (extensions: { confinement_admission: admission }) of the generated runner-onboarding client.

An application that talks to LumenFlow Cloud reaches its routes through typed clients in this package rather than hand-built URLs. Each client takes { baseUrl, credential: { token } } (or apiKey), returns a typed result for every Cloud answer and network failure instead of throwing, sends the API key in the Authorization header only, and strips it from every text it returns.

ClientCloud routes
createEvidenceClientPOST/GET /api/v1/evidence (submit, list, ?verifyChain=1, ?getProof=<id>), POST /api/v1/evidence/anchor, GET /api/v1/evidence/signing-keys
createCostsClientGET /api/v1/costs
createApprovalsClientPOST /api/v1/approvals/list (the API-key route)
createOpenApiClientGET /api/v1/openapi.json (public, the key is never sent)
createReferencesClient/api/v1/references; complete(referenceId, ownerId) sends the ownerId Cloud requires
createAppConnectorsClient/api/v1/app-connectors

A failure is { ok: false, code, httpStatus, message, retryAfterSeconds?, errorCode?, requestId?, upstreamStatus?, upstreamCode?, limit?, limitValue?, scope?, resetsAt?, issues? } with code one of invalid_request, unauthorized, forbidden, not_found, conflict (HTTP 409), rate_limited, service_unavailable, server_error, unexpected_response, unreachable or client_timeout. Input is validated before anything is sent, so a refused request has httpStatus: null. errorCode is Cloud’s machine code from a { error: { code } } body (for example no_route), present only when the answer carried a well-formed one. A size refusal (a 400, 413 or 429) also carries limit (messages, message_chars, request_body_bytes, tokens_per_call or another registry limit) and limitValue, the effective cap, which an operator can raise per workspace for the byte and token caps. A refusal also carries scope (workspace, api_key, …) and resetsAt (an RFC 3339 instant) when the body names a well-formed one; a malformed value is dropped, never passed on. Nothing is retried unless you opt in (see Retry, pooling and conditional reads). The signing-keys and openapi routes are public; the evidence client calls signing-keys without the key. createCostsClient().query also groups spend by the reference an inference call carried: groupBy: 'caller_reference' returns one row per reference, with the reference as the row key (calls that carried none fall under (unknown)), and callerReference (sent as caller_reference) narrows to one reference. It combines only with groupBy caller_reference or day, never with wuId, agentId, model or sessionId; the client refuses that combination before sending. The accepted groupBy values are checked against the pinned hosted OpenAPI document.

The approvals client does not wrap GET /api/v1/approvals or /approvals/resolve: those authenticate a signed-in workspace member, not an API key.

Every route client, createAppConnectorsClient and createReferencesClient share one request path and accept three options. All are off by default: with none set a call makes a single attempt, exactly as before.

import { Agent } from 'undici';
import { createCatalogueClient } from '@hellmai/lumenflow-control-plane-sdk/cloud-routes/catalogue';

const catalogue = createCatalogueClient({
  baseUrl: 'https://cloud.example.com',
  apiKey: process.env.LUMENFLOW_API_KEY ?? '',
  // Keep-alive and a connection cap: any dispatcher your runtime's fetch accepts.
  dispatcher: new Agent({ connections: 16, keepAliveTimeout: 30_000 }),
  // Retry a 429, 502, 503, 504 or network failure, spread by jitter.
  retry: { maxAttempts: 4, baseDelayMs: 500, maxDelayMs: 30_000 },
  // Serve repeat reads for a minute; revalidate with If-None-Match after that.
  // This client holds one API key and serves one identity, so say so (see readCache below).
  readCache: { ttlMs: 60_000, fixedIdentity: true },
});
  • retry waits with the same equal-jitter backoff the poll cadence uses, so callers that meet one 429 or 503 do not return together. Retry-After is honoured as a minimum wait; when the server asks for longer than maxDelayMs the call stops and returns the failure with retryAfterSeconds. Only an idempotent request is retried: GET, HEAD, PUT and DELETE, or a POST or PATCH that carries an Idempotency-Key header. A governed refusal is never retried (a Cloud machine code such as budget_exceeded or residency_violation, a size limit, an insufficient_scope 403), nor is any other 4xx, a streaming call, or the connectors invoke. maxAttempts counts the first attempt (1 to 10); a malformed policy throws when the client is built. random and sleep are injectable for tests.
  • dispatcher is passed to every fetch the client makes (each retry and the signed reference upload included), so one pooled agent serves them all. The SDK adds no HTTP dependency.
  • readCache (route clients and the connectors client) keeps a client’s GET answers that carry an ETag, up to maxEntries (default 64). Inside ttlMs a repeat read sends nothing; after it, the GET sends If-None-Match and a 304 is answered from the cache. Answers marked no-store or without a validator are never kept, and a write through the same client clears the cache. Create the client for the reads you want cached, such as the provider catalogue. The cache never crosses a caller identity. Each entry is keyed on a keyed digest of the URL and every request header except If-None-Match (a different X-Tenant-Subject, key or any other header gets its own entry); the store holds digests, never a header value or the URL. An answer whose Vary names a header the request did not send (or Vary: *) is not kept (Accept-Encoding is ignored). Without fixedIdentity: true a request is cached only when every header it sends is public (accept, accept-language, content-type, user-agent, x-tenant-subject, x-workspace-id), so an authenticated request, or any request on a client given a fetch or dispatcher option (even the global fetch) or whose fetch is not the runtime one the SDK captured at load, is not cached. fixedIdentity: true means: this client holds one credential, and all per-user identity is carried in headers the SDK client sets. The credential is bound on the first stored answer; a request with another credential is not cached. If a fetch wrapper or dispatcher adds identity from ambient state (for example a request-scoped header), the cache cannot see it: do not combine that with fixedIdentity. Two cases no code can detect: a global undici interceptor installed with setGlobalDispatcher, and a global fetch patched before the SDK module loads. Do not enable readCache in a process that does either. Build one client per identity. maxTotalBytes (default 8 MiB, at most 64 MiB) and maxStaleMs bound memory; Cache-Control: no-cache or max-age=0 answers are kept but revalidated on every read. Entries are served for at most the smaller of ttlMs and the server’s max-age without asking, so a revoked key can still be answered from the cache until then: keep ttlMs short for anything access-controlled. Hosted Cloud currently answers with Vary: rsc, next-router-state-tree, ..., so a route that sends that Vary with an ETag is not cached.

A durable outbox for anything an application must deliver to another system at least once and in order. The SDK ships the port, a driver, three adapters and a conformance kit; it adds no database driver (its only dependency is zod).

SubpathProvides
@hellmai/lumenflow-control-plane-sdk/outboxDeliveryOutbox port, deliverOutbox driver, OutboxSendError, parseRetryAfter, createInMemoryOutbox
@hellmai/lumenflow-control-plane-sdk/outbox/filecreateFileOutbox({ directory }): append-only log, fsync, crash safe
@hellmai/lumenflow-control-plane-sdk/outbox/sqlcreateSqlOutbox({ client }) over an injected query interface; sqlOutboxSchema
@hellmai/lumenflow-control-plane-sdk/outbox/conformancerunOutboxConformance(factory, it) and outboxConformanceCases

The port: enqueue(item, { idempotencyKey, partition }), lease(partition, limit, { leaseMs? }), ack(ids) and nack(ids, retryAt).

  • Order. Items of one partition are leased in enqueue order. While any item is leased, or the head waits for a nack retry time, nothing behind it is handed out. A nack also releases the items leased behind the nacked one. Partitions never block each other.
  • At least once. A lease expires after leaseMs; an unacked item is leased again with a higher attempts. Make the receiver idempotent on idempotencyKey.
  • Idempotency. A key is accepted once; a repeat returns { duplicate: true } and the original id, even after the item was acked, until idempotencyRetentionMs (default 7 days) has passed.
  • Payloads must be JSON-serialisable.
import {
  deliverOutbox,
  OutboxSendError,
  parseRetryAfter,
} from '@hellmai/lumenflow-control-plane-sdk/outbox';
import { createFileOutbox } from '@hellmai/lumenflow-control-plane-sdk/outbox/file';

const outbox = createFileOutbox<{ body: string }>({ directory: '.state/outbox' });
await outbox.enqueue({ body: 'hello' }, { idempotencyKey: 'evt-1', partition: 'tenant-7' });

const summary = await deliverOutbox(
  outbox,
  async (item) => {
    const res = await fetch(url, {
      method: 'POST',
      body: item.payload.body,
      headers: { 'Idempotency-Key': item.idempotencyKey },
    });
    if (res.status === 429) {
      throw new OutboxSendError('rate limited', {
        retryAfterMs: parseRetryAfter(res.headers.get('retry-after')),
      });
    }
    if (!res.ok) throw new OutboxSendError(`HTTP ${res.status}`, { permanent: res.status < 500 });
  },
  { partitions: ['tenant-7'], maxAttempts: 8, backoff: { baseMs: 1000, maxMs: 300_000 } },
);
// Schedule the next pass at summary.nextRetryAt.

deliverOutbox is a timer-free pass: it drains each partition in order, nacks a failed item with exponential backoff (jittered, raised to a Retry-After, capped at backoff.maxMs) and stops that partition so order holds. An item that is out of attempts or failed permanently goes to onExhausted (resolve to dead-letter it); without the hook it is held and its partition stays blocked. The caller schedules passes.

The file adapter serialises writers with a lock file, so several processes can share one directory. The SQL adapter takes a client with one method, transaction(run), whose tx has query(sql, params) and execute(sql, params) (returning { changes }). It uses ? placeholders (placeholders: 'numbered' rewrites them to $1), the SQLite and PostgreSQL common subset, and sqlOutboxSchema() / ensureSchema() for the tables.

Run the same cases against your own adapter:

import { describe, it } from 'vitest';
import { runOutboxConformance } from '@hellmai/lumenflow-control-plane-sdk/outbox/conformance';

describe('my outbox', () => {
  runOutboxConformance(async ({ clock, leaseMs }) => {
    const make = () => myAdapter({ now: clock.now, leaseMs });
    return { outbox: make(), reopen: async () => make(), dispose: async () => cleanup() };
  }, it);
});

The cases cover per-partition order, head-of-line blocking, duplicate keys (including concurrent enqueue and after ack), nack timing, redelivery after a crash between lease and ack, restart durability and concurrent leases. The adapter must read time from the injected clock, and reopen() returns a new instance over the same storage.

New Cloud route families are not hand-written. The SDK pins Cloud’s openapi.json and generates a zod-validated client per family. Each generated client calls the same transport as the clients above, so it never throws, refuses bad input before any call, redacts the key, bounds reads and surfaces Retry-After exactly as they do. The transport sends GET, POST, PUT, PATCH and DELETE, and encodes path parameters with encodeURIComponent.

Which operations form a family is set by tag, path prefix or exact METHOD /path; an operation left to a hand-written client, such as streaming inference, is excluded.

Generation fails closed instead of guessing: it stops and names every mapped operation that has no operationId or no 2xx JSON response schema, plus any schema construct it cannot express. Response objects keep unknown properties; request objects are closed at the top level (see “Forward compatibility” below). Response string enums are open: the known values stay in the type (Known | (string & {})), and a value Cloud adds later is accepted, so an additive response enum value never breaks a pinned client. Your code that switches on a response enum must handle the unknown case. Request enums stay closed.

Each generated family is a package subpath export. Import the one you need; the root export is unchanged.

import { createJobsClient } from '@hellmai/lumenflow-control-plane-sdk/cloud-routes/jobs';

const jobs = createJobsClient({ baseUrl, apiKey });
const created = await jobs.createJob({
  body: { kind: 'build', tenant_subject, end_user_subject, actor_kind, actor_ref },
});
if (!created.ok) return created.code; // never throws
SubpathClientMethods
./cloud-routes/jobscreateJobsClientlistJobs, createJob, getJob, controlJob (POST /jobs/{id}/controls), recordJobEvents
./cloud-routes/eval-runscreateEvalRunsClientcreateEvalRun (post a scenario-evaluation report; 201 is a new run, 200 an idempotent replay of the same run_id), listEvalRuns (the runs, or with query.scenario_id one scenario’s history)
./cloud-routes/lanescreateLanesClientlistWorkspaceLanes, setWorkspaceLanes (read and set a workspace’s lane WIP limits; the set replaces the whole list)
./cloud-routes/jobs-streamcreateJobsStreamClientstreamJob (server-sent events, hand-written)
./cloud-routes/approvalscreateApprovalsClientlistApprovals, createApproval, getApproval, actOnApproval, commentOnApproval, searchApprovals, requestApproval, resolveApproval
./cloud-routes/agentscreateAgentsClientlistAgents (typed response)
./cloud-routes/workspacescreateWorkspacesClientlistWorkspaces (query.view: "provisioning" pages the org token’s workspaces), createWorkspace (the result carries the new workspace apiKey, shown once; headers.idempotencyKey makes a retry safe), archiveWorkspace, rotateWorkspaceApiKey, listAppKeys, createAppKey, revokeAppKey
./cloud-routes/invitescreateInvitesClientcreateWorkspaceInvite (the result carries the invite token), acceptWorkspaceInvite
./cloud-routes/model-configcreateModelConfigClientlistModelConfigs, putModelConfig, deleteModelConfig, testModelConfig, discoverModelCatalog (bring-your-own-key model routes; the provider apiKey is write-only; testModelConfig also validates a config without storing it)
./cloud-routes/model-config-admincreateModelConfigAdminClientlist, create, update and delete for the org routing configs (listOrgModelConfigs, createOrgModelConfig, updateOrgModelConfig, deleteOrgModelConfig)
./cloud-routes/org-service-tokenscreateOrgServiceTokensClientlistOrgServiceTokens, createOrgServiceToken (the result carries the token apiKey, shown once), revokeOrgServiceToken
./cloud-routes/inferencecreateInferenceClientcreateInference (one typed, non-streaming model call; headers.idempotencyKey makes a retry safe; request and response are Cloud’s schemas); listInferenceRoutes (the tiers the key can reach and its effective limits; interim name)
./cloud-routes/runner-onboardingcreateRunnerOnboardingClientgetRunnerOnboardingPolicy, putRunnerOnboardingPolicy, deleteRunnerOnboardingPolicy, confirmRunnerOnboardingPolicyChange, issueRunnerOnboardingGrant (the result carries the grant grant, shown once), listRunnerOnboardingGrants, revokeRunnerOnboardingGrant, createPairingCode (the result carries the one-time code), exchangePairingCode (the result carries runtime_credential, shown once)
./cloud-routes/compute-onboardingcreateComputeOnboardingClientgetComputePolicy, updateComputePolicy, listComputeSources, updateComputeSource, listEnrollments, createEnrollment (the result carries the runtime credential runtimeCredential, shown once), updateEnrollment (a key rotation answer carries runtimeCredential, shown once), deleteEnrollment, getManagedComputeEntitlements
./cloud-routes/memberscreateMembersClientlistWorkspaceMembers, updateWorkspaceMemberRole, removeWorkspaceMember, listWorkspaceInvites, revokeWorkspaceInvite (createWorkspaceInvite stays in the invites client)
./cloud-routes/limitscreateLimitsClientgetWorkspaceInferenceLimits, getInferenceKeyLimits, getSubjectInferenceLimits, deleteSubjectInferenceLimits, getWorkspaceJobLimits, putWorkspaceInferenceLimits, putInferenceKeyLimits, putSubjectInferenceLimits, putWorkspaceJobLimits (all interim names)
./cloud-routes/cataloguecreateCatalogueClientlistConnectorCatalogue (the workspace connector catalogue; optional query.kind, query.availability and query.source)
./cloud-routes/tool-connectionscreateToolConnectionsClientlistToolConnections (the tenant-bound connection list; optional query.connectorKey and query.status, and headers.tenantSubject sent as X-Tenant-Subject); createToolConnectionAttempt, getToolConnectionAttempt and deleteToolConnection (pre-release, see below)
./cloud-routes/app-connectorscreateAppConnectorsClientputAppConnector, listAppConnectors, getAppConnector, deleteAppConnector, invokeAppConnector
./cloud-routes/memorycreateMemoryClientlistMemoryNodes, syncMemory (push, pull or bidirectional)

The runner-onboarding family is generated from the pinned Cloud snapshot like the others; every operationId is Cloud-published, so it has no interim overlay. Scope and credential rules: the policy and grant routes need an owner or admin session, or a workspace key holding the opt-in runners:manage scope; issueRunnerOnboardingGrant names a non-empty subset of the closed runner:onboard.* scopes and a lifetime of 7 to 30 days, and a scope outside the published set is refused before any call. exchangePairingCode is authenticated by the one-time code, and Cloud allows it anonymously or with the grant that requested the code. The client reads that from the contract: build it with no credential (createRunnerOnboardingClient({ baseUrl })) and the exchange sends no Authorization header, or build it with the grant (lf_rog_..., with scheme: 'runnerOnboardingGrant'; scheme is required whenever a credential is supplied: platformAuth for a workspace key; a client with no credential needs none) and the exchange sends it. A client holding a workspace key sends no credential to the exchange, and a client holding a grant is refused locally on the platform-only operations; Cloud re-checks the grant against the policy in force. Every other runner-onboarding operation needs a credential and, on a client built without one, refuses locally before any request with an unauthorized failure that names the operation. The grant, the pairing code and the runtime_credential are shown once: store them in a secret store, never in a log, a WU note or a command line. The client scrubs the credential it was built with, and the pairing code you pass (Cloud marks it writeOnly), from every failure and never logs.

exchangePairingCode returns the default exchange answer only: Cloud also offers a store-only answer behind a format query parameter, which this client never requests and whose input has no way to set. The answer carries runtime_credential and runtime_store (baseUrl, token, lane), where token is the same credential again.

The compute-onboarding family covers the compute policy, the compute sources, the runtime enrollments and the managed-compute entitlements of a workspace. A workspace key needs the read scope for the reads and write for the writes (or an owner or admin session). GET /api/v1/runner/bootstrap is not generated: it answers JavaScript or shell source, not JSON.

Cloud marks every string it returns exactly once in plaintext with x-sensitive: true (API keys, the runtime credential, the onboarding grant, the pairing code, the invite token and its accept link). Each generated operation whose answer carries one exports <Operation>SensitiveFields (for example ExchangePairingCodeSensitiveFields), the paths of those values. The result still carries the value, because you must store it, but redactSensitiveFields(result.data, paths) (exported from the package root) returns a copy with each value replaced by <redacted>; log or print only that copy. A top-level request field marked writeOnly or x-sensitive is removed from every error the client returns; a marker anywhere else in the request body (under a union, in an array, nested, or on a property that is not a string) stops generation rather than going unredacted. When you refresh the pinned snapshot, generation stops on any response string whose name looks secret (token, credential, secret, key, grant, password, code, passphrase, bearer) and that is neither marked nor argued for in families.json (sensitiveRatchet); it uses the same name rules and scan as Cloud’s own document-wide check.

The workspaces family also covers headless provisioning with an org service token that holds workspaces:manage: listWorkspaces with query.view: "provisioning", createWorkspace, archiveWorkspace and rotateWorkspaceApiKey (the body must name scopes, drawn from read, write and model_config:write; an org scope is refused before any call). The app-key routes (listAppKeys, createAppKey, revokeAppKey) need a workspace owner or admin session or a workspace key holding write. createAppKey names a non-empty subset of payments:checkout.create, entitlements:read, app_connectors:invoke, tool_connections:read and tool_connections:connect, and its result carries the app key apiKey, shown once. The org token and every key a request carries are scrubbed from errors and logs.

The connector catalogue and the tool-connections list both need a workspace key holding the read scope; an inference-only key is refused (forbidden, HTTP 403), and app keys are not accepted on the tool-connections route yet. listConnectorCatalogue returns the entries of the workspace catalogue (built-in, operator-added and workspace-added, each tagged with its source and a connect_mode) plus the install_kinds a workspace can add; query.kind, query.availability and query.source narrow it, and a value outside the hosted set is refused before any request. listToolConnections lists the workspace’s own connections when no headers.tenantSubject is given, or one end user’s connections when it is; it never returns a token, an external account id or an account handle. Response enums (status, source, connect_mode) stay open, so a value added later still parses.

The connect flow is a pre-release contract and is not live until Cloud enables it; no connector should be presented as connectable yet. createToolConnectionAttempt (path connectorKey; body tenantSubject, returnUrl, optional appId and variant) answers HTTP 503 keyless_connect_unavailable until then. That is a typed failure (code: 'service_unavailable', errorCode: 'keyless_connect_unavailable') and is never retried, even with retry opted in. getToolConnectionAttempt polls an attempt (it has no live-mode check, so it says nothing about whether connect is live) and deleteToolConnection removes a connection (body tenantSubject, actorKind, actorRef; not gated). beginUrl and attemptId are single-use capabilities: open beginUrl only in the person’s own browser, never store it, and pass a result through redactSensitiveFields with CreateToolConnectionAttemptSensitiveFields or GetToolConnectionAttemptSensitiveFields before logging.

The members operations need an org service token holding the opt-in members:manage scope, or a signed-in workspace owner or admin; no caller can demote or remove the last owner. The limits operations read the effective value of each self-service limit, the scope it comes from and its ceiling, for the workspace, one inference key, one end-user subject, or the workspace’s job limits; they need a workspace key holding read, or an owner or admin session. listInferenceRoutes takes an inference-only key (inference:invoke) or the workspace key with read. The operation names for the limits and inference routes are interim: Cloud publishes no operationId for them yet, and they are replaced when it does. The four limit write operations (putWorkspaceInferenceLimits, putInferenceKeyLimits, putSubjectInferenceLimits and putWorkspaceJobLimits) take a typed body whose limits map must hold at least one entry (the contract’s minProperties). Response enums stay open.

The inference, catalogue and memory routes take a workspace key. The generated ./cloud-routes/app-connectors client takes { baseUrl, apiKey } and covers every Cloud route of the family. The hand-written createAppConnectorsClient from the package root and ./app-connectors (put, list, remove, invoke, with credential: { token }) keeps every public name and keeps working; a compatibility test checks that both address the same routes.

createModelConfigClient manages a workspace’s own model routes with a workspace key that holds the opt-in model_config:write scope. The provider key is write-only: it goes in putModelConfig or discoverModelCatalog requests, and every response carries only maskedKey (the last four characters). The client removes the provider key, and the workspace key, from every message, validation issue and error code it returns, on the success, validation, transport and HTTP 4xx and 5xx paths alike. The generator reads writeOnly and x-sensitive fields from Cloud’s request schemas to decide which values to remove, and stops if a writeOnly field is nested where it cannot.

A call whose key lacks the scope answers 403 with reasonCode: insufficient_scope. The result carries errorCode: 'insufficient_scope', requiredScope and grantedScopes; isInsufficientScope(result) narrows it.

Scopes per operation: with a workspace key, listModelConfigs, putModelConfig, deleteModelConfig, testModelConfig and discoverModelCatalog all require model_config:write; a dashboard session needs no scope. Each listModelConfigs row carries endpointRegion (eu_west, us_east, us_west, apac, or null): the region declared when the route was saved, never verified.

testModelConfig has two forms. { body: { role } } tests a stored route. A body carrying the connection fields (provider, model, apiKey, baseUrl, endpointRegion, providerPreset) validates that config against the endpoint and stores nothing; the apiKey in it is write-only and removed from every result like the one in putModelConfig. The response always carries role (the role sent, otherwise probe). A config that fails validation answers 400.

requestId is Cloud’s top-level request_id, kept only when it is a short token. The code provider_rate_limited (the upstream provider answered 429 for this call; Cloud made one attempt and did not fail over) is a refusal the client never retries, even with retry opted in. On the streamed inference client it arrives as the terminal error event and becomes { code: 'rate_limited', errorCode: 'provider_rate_limited' } with retryAfterSeconds from retry_after_seconds (an integer from 1 to 3600; anything else is dropped, not coerced); the same code after model output stays provider_error with no hint.

When Cloud refuses a call because the upstream provider refused (for example a bring-your-own-key route that ran out of credit), the outer answer is a Cloud error such as HTTP 502 provider_error and the provider’s own status is nested in error.details. A failure now carries what was observed, as two optional fields next to the unchanged httpStatus, errorCode and code:

  • upstreamStatus is the provider’s HTTP status. It is set only when error.details.upstream_status is an integer from 400 to 599. When the value is absent, a 2xx, a string, a fraction or out of range, the field is absent. It is never taken from httpStatus: an outer 502 says nothing about the provider.
  • upstreamCode is one of bad_request, auth_failed, permission_denied, not_found, rate_limited, overloaded, request_too_large, provider_internal or unknown (exported as UPSTREAM_CODES). Beside an upstreamStatus it is always present, and a value outside that set becomes unknown. Without a status, a set member other than unknown is kept and anything else is dropped.

The provider’s message and every other details field are never forwarded, so neither a failure, a streamed event nor a log line can carry provider text. The streamed inference client applies the same rule to the terminal error event (the outer status there is 200), and the agent runtime Cloud provider exposes the two fields as upstream_status and upstream_code in the turn error details. An older consumer that ignores the fields behaves as before.

When the agent runtime host loop ends because a provider turn failed, the loop result error and the failed turn report’s failure carry the same two fields, upstream_status and upstream_code, each only when valid (the SDK validator is applied again at that point). Nothing else from the failure details is copied into the loop result. Without a valid observation the result is unchanged, so branch on result.error.upstream_status to tell a deterministic refusal (for example 402) from a transient failure. upstreamObservationOf is exported from the package root for the same validation in your own code.

The fields exist so your own retry logic can stop retrying a deterministic refusal. The SDK’s own opt-in retry never retries an answer whose observed upstreamStatus is 400, 401, 402, 403, 404 or 422; every other case, including an observed 408, 429 or 5xx, keeps the existing rule. UPSTREAM_CODES and the UpstreamCode type are exported from the package root.

Both testModelConfig and discoverModelCatalog are rate limited for key callers (429 rate_limited). The result has code: 'rate_limited', httpStatus: 429 and retryAfterSeconds taken from the Retry-After header; nothing is retried by the client unless you opt in to retry.

import { createModelConfigClient } from '@hellmai/lumenflow-control-plane-sdk/cloud-routes/model-config';
import { isInsufficientScope } from '@hellmai/lumenflow-control-plane-sdk';

const models = createModelConfigClient({ baseUrl, apiKey: workspaceKey });
const saved = await models.putModelConfig({
  body: {
    provider: 'openai-compatible',
    model: 'regional-model-v1',
    baseUrl: 'https://models.example.com/v1',
    endpointRegion: 'eu_west',
    apiKey: providerKey,
  },
});
if (!saved.ok) {
  if (isInsufficientScope(saved)) console.error(`add scope ${saved.requiredScope}`);
  return saved.message; // never contains providerKey
}
const tested = await models.testModelConfig({ body: {} });
const validated = await models.testModelConfig({
  body: { provider: 'openai-compatible', model: 'regional-model-v1', baseUrl, apiKey: providerKey },
});

Prompt-caching prices are declared on the same request. declaredPrices takes inputPerMillion, outputPerMillion, cachedInputPerMillion (the cached-read rate) and cacheWritePerMillion (the cache-write rate), each 0 to 10000 USD per million tokens. Without cacheWritePerMillion the hosted side bills cache writes at the input rate, so a bring-your-own-key receipt understates cost. provider: 'anthropic' takes an explicit baseUrl (a Messages-protocol endpoint) and cacheDialect: 'anthropic-ephemeral'. The request is still strict: an unknown field, at the top level or inside declaredPrices, is refused locally before any call.

The org variant (/orgs/{orgId}/model-config) is a separate model-config-admin family: it is addressed by organisation rather than by a workspace key’s own workspace, and they carry routing policy (role, billing mode, fallback chain) with no provider key, so they stay off the workspace client.

A key scoped to a tenant names that tenant on every job read. The jobs client takes it as headers.tenantSubject, sent as X-Tenant-Subject, on createJob, getJob and listJobs; query.tenant_subject is the same value for a caller that cannot set a header. The job status stream takes tenantSubject and sends the header, or the query parameter with tenantSubjectVia: 'query'.

const listed = await jobs.listJobs({ headers: { tenantSubject } });

import { createJobsStreamClient } from '@hellmai/lumenflow-control-plane-sdk/cloud-routes/jobs-stream';
const stream = await createJobsStreamClient({ baseUrl, apiKey }).streamJob({ id, tenantSubject });
if (stream.ok) for await (const e of stream.data.events) console.log(e.event, e.data);

Job create idempotency, typed job answers and lanes

Section titled “Job create idempotency, typed job answers and lanes”

createJob takes headers.idempotencyKey (1 to 255 printable ASCII characters), sent as Idempotency-Key. With a key, a retry of the same request is safe, and the three answers are distinct typed results: 202 is a new job (ok: true, httpStatus: 202), 200 is a replay of the same key and body (ok: true, httpStatus: 200, the existing job), and 409 is the same key with a different body (ok: false, code: 'conflict', httpStatus: 409). Without a key every call creates a new job. classifyCreateJobResult (package root) names the outcome as created, replayed, conflict or failed.

The create, list and get answers are typed from the hosted contract: create returns { job_id, status }, listJobs returns { jobs } of job summaries, and getJob returns the status snapshot with its result and result_envelope. Status values are open, so a value Cloud adds later is accepted.

import { classifyCreateJobResult } from '@hellmai/lumenflow-control-plane-sdk';

const outcome = classifyCreateJobResult(
  await jobs.createJob({ body, headers: { idempotencyKey: 'build-queue-7f3a' } }),
);
if (outcome.kind === 'conflict') throw new Error(outcome.failure.message);

import { createLanesClient } from '@hellmai/lumenflow-control-plane-sdk/cloud-routes/lanes';
const lanes = createLanesClient({ baseUrl, apiKey });
await lanes.setWorkspaceLanes({
  path: { workspaceId },
  body: { lanes: [{ lane: 'Build', wip_limit: 2 }] },
});
const current = await lanes.listWorkspaceLanes({ path: { workspaceId } });

Typed approval decisions and evaluation reports

Section titled “Typed approval decisions and evaluation reports”

resolveApproval (./cloud-routes/approvals) takes a typed body: approval_id, decision (approved, rejected or expired) and an optional reason. Any other field, an empty id or an unknown decision is refused before a call, and the answer is the approval record (status values are open). Cloud attributes the decision to the authenticated workspace member; the route takes no end-user subject.

createEvalRunsClient posts a continuous-scenario evaluation report (run_id is the idempotency key) and reads runs back with an optional limit (1 to 200) or one scenario’s pass history with scenario_id. The report body is stored verbatim; the scenario results are typed.

import { createEvalRunsClient } from '@hellmai/lumenflow-control-plane-sdk/cloud-routes/eval-runs';

const evalRuns = createEvalRunsClient({ baseUrl, apiKey });
const posted = await evalRuns.createEvalRun({
  body: { run_id, generated_at, verdict: true, report_schema_version: 1, report, scenario_results },
});
const history = await evalRuns.listEvalRuns({ query: { scenario_id: 'checkout-flow' } });

The job reads (listJobs, getJob) carry the tenant subject as shown above; the pinned hosted contract declares it on both. Cloud publishes no agent-task snapshot (steps, pending approval, cost, stop reason); getJob returns the typed job status snapshot.

Every operation that returns a secret Cloud shows only once (createWorkspace, rotateWorkspaceApiKey, createAppKey, createOrgServiceToken, createWorkspaceInvite with its accept link, createPairingCode, issueRunnerOnboardingGrant, exchangePairingCode, createEnrollment and a key rotation through updateEnrollment) exposes it in the result so you can store it; no client logs it, and it never appears in a failure. See “Once-shown secrets” above for redactSensitiveFields.

The package root exports CLOUD_OPENAPI_CONTRACT: { sha256, infoVersion, syncedAt, families }, the lock the generated clients were built from. Compare sha256 with the Cloud document you run against to see whether the shipped clients match it. The test suite asserts that the constant equals the lock, and the generator refuses to run when the lock and families.json list different families.

import { CLOUD_OPENAPI_CONTRACT } from '@hellmai/lumenflow-control-plane-sdk';
console.log(CLOUD_OPENAPI_CONTRACT.infoVersion, CLOUD_OPENAPI_CONTRACT.families);

Forward compatibility: new Cloud fields need no SDK release

Section titled “Forward compatibility: new Cloud fields need no SDK release”

A field Cloud publishes after the pinned contract is reachable without waiting for an SDK release:

  • Known request fields stay typed and validated.
  • Every operation with a closed-object request body (and a body-less GET with a query) accepts one extra input property, extensions: Record<string, unknown>. Its entries are merged into the wire body (or query), so client.putWorkspaceJobLimits({ path, body, extensions: { newField: 1 } }) sends newField next to the known fields. Values must be JSON-serialisable; a query extension must be a string, number or boolean.
  • Extension names match ^[A-Za-z_][A-Za-z0-9_.-]{0,63}$, and values must be plain JSON (depth at most 32, at most 10,000 nodes, 1 MiB of text; no class instances, accessors or cycles); __proto__ is refused at any depth. Extensions go to the body or query only, never to a header. Do not send a secret through extensions: only the typed write-only fields are scrubbed from answers.
  • path, query and headers inputs are strict too, so a misspelt header name is refused instead of being silently dropped. Four bodies (createWorkspaceInvite, updateWorkspaceMemberRole, createAppKey, syncMemory) now refuse an extra key they used to drop.
  • Any other unknown top-level key is refused locally with an invalid_request failure (no request is sent), so a misspelt field is still caught. A body that is already an open record takes any key and needs no extension.
  • An extension key that collides with a known field is refused locally the same way, with an issue at extensions.<key>. Set the known field itself.
  • Responses keep properties the pin does not name, at every depth, and response enums stay open. As before, the credential and any write-only request value are removed from every string in the answer.

Do not base authentication, routing or tenancy decisions on fields the pin does not name: only the typed, pinned fields are validated.

Once Cloud’s contract is re-pinned, the field becomes a typed property and the extension entry becomes a collision error, so move it into the typed body then.

The generated clients cover the routes in Cloud’s published OpenAPI document. Route families that document does not describe are neither generated nor exported. The hand-written createApprovalsClient exported from the package root is a different client; the generated one lives only on its subpath.

Cloud’s document does not yet describe most routes, so those families carry an interim overlay: the engine names the operations and, where it knows them, types the request and response (the workspaces, invites, org-service-tokens, app-connectors, catalogue and memory families; inference uses Cloud’s own schemas); otherwise request bodies are accepted as any JSON object and responses are returned unvalidated. Tenant-scoped fields (tenant_subject, end_user_subject, actor_kind as an opaque string, actor_ref) therefore pass through in the body today. Once Cloud publishes them, regenerating the clients turns them into typed input and response schemas.

verifyControlPlaneApiKey posts the key once and classifies the answer. It throws a ControlPlaneKeyVerificationError whose code is one of:

codeWhen
invalid_keyHTTP 401 with exactly {"error":"Invalid API key"}, or HTTP 401/403 with {"error":{"code":"invalid_api_key"}}.
denied_unattributedAny other HTTP 401 or 403 (other JSON, HTML, text, empty, oversize). The request was denied before the key could be confirmed.
service_unavailableHTTP 503. reason is maintenance only for {"error":{"code":"maintenance"}} (with an optional clamped retryAfterMs), else unavailable.
endpoint_unreachableNetwork error, timeout or any other non-success status.
malformed_responseHTTP 200 without a usable identity body.

Check service status before replacing a key after denied_unattributed or service_unavailable: neither says anything about the key. Maintenance is never inferred from a 403, a header or body text. No outcome retries, returns an identity or changes configuration. Messages never carry the key, a body excerpt or a header value; a maintenance message may show the clamped retry hint in whole seconds (and says “Retry later.” for less than a second), never the raw header text. One overall deadline (timeoutMs) covers the headers and the body read: a body that stalls after a 401/403 is denied_unattributed, after a 503 service_unavailable with reason unavailable, and after a 200 endpoint_unreachable. A 200 body that also carries an error is malformed_response, never an identity. The recognized forms are exported as CONTROL_PLANE_AUTH_DENIAL_CONTRACT, CONTROL_PLANE_MAINTENANCE_CONTRACT and CONTROL_PLANE_SERVICE_UNAVAILABLE_REASON.

Every Cloud route client (the references, app-connectors, evidence, costs, approvals, openapi, stream and generated clients) accepts an optional W3C trace context, so a consumer’s trace can be linked to the exact Cloud calls it caused. Supply it per client with the traceContext option, or per call as the trailing { traceContext } argument (a stream client takes it in its input object). A per-call context replaces the client’s wholesale. Both are plain strings, with no tracing library required:

const client = createInferenceClient({
  baseUrl,
  apiKey,
  traceContext: { traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' },
});
await client.listInferenceRoutes(); // sends traceparent
await client.listInferenceRoutes({ traceContext: { traceparent, tracestate: 'a=1' } }); // this call only

The shared transport validates the value against the W3C grammar and sends traceparent (and tracestate when given) unchanged on every request, including the retries of one call. An invalid value (upper-case hex, version ff, an all-zero trace or parent id, a malformed or over-long tracestate of more than 512 characters or 32 members) is refused locally with invalid_request and nothing is sent; the refusal text names the field, never the value. Without a supplied context no trace header is sent: the SDK never invents an id. The signed upload target of the references client never receives one. The opt-in read cache ignores trace headers in its key, so a new trace id neither splits the cache nor lets two tenants share an entry; a cache hit sends no request and so carries no trace to Cloud.

Before the SDK existed, each LumenFlow package carried its own orchestration types. The CLI collapsed rich states to in_progress. MCP ran a second classifier that silently drifted from the initiatives reconciler. Remote parity was aspirational — every surface paraphrased the state vocabulary slightly differently.

The SDK fixes that by being the only source of:

  • The 13-value orchestration state vocabulary.
  • The 5-value next-safe-action vocabulary (launch_wu, finish_wu, recover_wu, relaunch_wu, wait).
  • The snake_case JSON schema every persisted artifact uses.
  • The schema-versioned event catalog for control-plane measurement and routing.
  • The parsers that enforce that schema at every boundary.

The Software Delivery pack imports from the SDK. @hellmai/lumenflow-cli imports from the SDK. @hellmai/lumenflow-mcp imports from the SDK and from @hellmai/lumenflow-packs-software-delivery/initiatives so it shares the canonical reconciler. Remote control surfaces import from the SDK too. One contract, many consumers.

The same OrchestrationStatusDocument that your local CLI writes to status.json is the same contract a remote control surface can expose over HTTP or another transport. That means:

  • A WU you start locally can hand off to another surface without reshaping the state.
  • An agent that reads MCP coordination tools today can talk to a remote orchestrator tomorrow tomorrow without code changes.
  • Third-party launchers that implement the SDK contract get to participate in the same state machine — not their own dialect of it.

This is the principle of “one initiative state model, many launchers.”

Events use kind as the discriminant and schema_version as the compatibility switch. Additive payload fields are compatible. Changing the required shape of an existing kind needs a version bump or a parallel compatibility path. New measurement emitters must add schema coverage before they emit measurement, routing, skill-hint, or prompt-optimizer events.

@hellmai/lumenflow-control-plane-sdk/cadence is the shared, pure adaptive backoff primitive for anything that polls the control plane — the Connected Compute runner claim loop, the control-plane sidecar tick, and the surfaces control-plane event subscriber. It replaces flat setInterval polling with equal-jitter exponential backoff per outcome class, a startup spread that de-synchronises a freshly-installed fleet, and precedence for server-sent cadence hints.

import {
  createPollCadence,
  parseRetryAfterMs,
  POLL_AFTER_HEADER,
  type PollOutcome,
} from '@hellmai/lumenflow-control-plane-sdk/cadence';

const cadence = createPollCadence({ baseMs: 30_000 });

// Before the first poll, spread a fleet restart across [0, startupSpreadMs).
await sleep(cadence.startupDelay().delayMs);

// After every poll, classify the outcome and wait the returned delay.
const outcome: PollOutcome = { kind: 'idle' };
const { delayMs } = cadence.next(outcome);
await sleep(delayMs);

The primitive is pure: no timers, no fetch, no process access, and randomness is injected (the only default implementation reads node:crypto, never Math.random). It is the caller’s job to classify each poll result into work | idle | transient | permanent and to carry any server-sent cadence hint from the response into the next PollOutcome.

Every caller honours the same precedence, highest first:

  1. Retry-After (retryAfterMs on PollOutcome) — delta-seconds or an HTTP-date, parsed by parseRetryAfterMs. Sent on 429, 503, and on any permanent 403/401 the server chooses to pace.
  2. X-LumenFlow-Poll-After-Ms (pollAfterMs on PollOutcome; the header name is exported as POLL_AFTER_HEADER), or next_heartbeat_ms on a heartbeat response. This is the fleet-wide lever: the server can slow every client without a release.
  3. Local adaptive default — the equal-jitter backoff computed from the outcome kind and the caller’s configured caps.

A hint is always clamped to [floorMs, ceilingMs] and jittered by hintJitterFraction (default 10 percent) before use. Absence of every hint means local defaults; no server-side change is required for an existing client to keep working, and no client-side change is required for the server to stay correct without ever sending a hint.

Adoption: the HTTP surface’s control-plane sync

Section titled “Adoption: the HTTP surface’s control-plane sync”

@hellmai/lumenflow-surfaces/http’s control-plane event subscriber (wrapping the in-process event stream) and the detached sync sidecar wu:claim spawns both replaced their flat setInterval sync cycle with two independent PollCadence instances driven by a single self-rescheduling timer, sharing one scheduling core so the two mounts cannot drift apart:

  • Heartbeat: baseMs 60,000 (the documented server contract), floor 30,000, every cap (idle/transient/permanent) and the hint-clamp ceiling 240,000 — a successful heartbeat is work (steady state a ~60s-centred jittered band, since pollAfterMs is always passed as next_heartbeat_ms ?? pollAfterMs ?? 60_000, so the delay always runs through the hint-jitter path); a failure is transient and never exceeds the 240s stall-detector ceiling. The heartbeat is never gated behind the policy pull.
  • Policies: baseMs is the caller’s configured pollIntervalMs (controlPlaneSyncIntervalMs), idle cap 300,000. When the port exposes pullPoliciesConditional, an unchanged pull is idle (backs off); a changed pull is work with pollAfterMs ?? baseMs (resets to a jittered band around base and re-evaluates event forwarding). Absent that method, every pullPolicies result is treated as work the same way, so an older sync-port implementation degrades to “poll at a jittered band around base forever” rather than breaking.
  • Both cadences wait an independent startup spread before their first poll.
  • Attempt bookkeeping (the backoff counter) and delay computation are independent: outcome.kind alone governs the attempt counter, so a server hint riding a work outcome still resets backoff instead of leaving it stuck at whatever it had climbed to (see “Hint precedence” above).

A permanent outcome — no backoff relief without operator or server action — is: any 401, or a claim 403 with code capability_mismatch, session_not_found, runtime_session_mismatch, compute_class_mismatch, confinement_posture_required, capability_not_approved, or source_policy_denied. Adding a code to this set is a contract change on both sides, not decided by a single client’s own classification table.

The control-plane sidecar (spawned by wu:claim and agent:session and stopped by wu:done) drives two independent createPollCadence instances instead of the historical flat setInterval(30_000) tick:

  • Heartbeat — base 60s (the documented contract), floor 30s, every cap (idle/transient/permanent) and the ceiling 240s: the server’s stall detector releases claims after 5 minutes with no heartbeat, so liveness must never wait longer. A successful heartbeat, and a heartbeat carrying a next_heartbeat_ms/poll-after hint, both stay jittered — never a flat deterministic wait, which would re-synchronise a fleet on the heartbeat route. A failed heartbeat backs off through the transient class.
  • Policies — base from LUMENFLOW_SIDECAR_INTERVAL_MS (default 30s), idle cap 5 minutes. Each pull is conditional (If-None-Match, falling back to the last desired_state.revision as an ETag); a 304 or an unchanged revision is idle (equal-jitter backoff to the 5-minute cap), a changed body is work (jittered reset to base), and Cache-Control: max-age is honoured as a floor under either.

Each tick runs whichever of the two is due; heartbeat-only side effects (NDJSON telemetry sync, memory/signal drain, kernel-event outbox replay) ride the heartbeat cadence exactly as before. The last-tick state file carries next_heartbeat_at and next_policy_pull_at alongside the existing heartbeat_ok/policy_pull_ok fields, and pnpm compute:doctor prints them as part of the sidecar-sync check’s message.

Sidecar start and memory/signal sync health

Section titled “Sidecar start and memory/signal sync health”

A spawned pid is not reported as a running sidecar. Once its config and credential resolve and its first authenticate round trip completes, the runner writes a started record in the workspace state. wu:claim and agent:session wait up to 10 seconds for that record. Then they print one of three results:

  • Control-plane sync sidecar started (pid=…) when the record names the spawned pid.
  • A warning that the sidecar has not confirmed its start.
  • A warning that it exited before starting. The cause is a missing runner, an invalid control_plane block, or no credential.

The first heartbeat comes later, because it is spread over up to four minutes.

The memory/signal route keeps its watermark, cursors and health in its own sync state file:

  • Atomic writes. The file is written to a temp file, then renamed into place. On Windows the rename is retried while another process holds the file. A zero-byte, truncated or non-object file reads as empty state, the next sync rewrites it, and a control_plane_sync_state_unreadable line goes to flow.log.
  • Budget. Each round trip has a budget, 2000 ms by default. Set software_delivery.telemetry.cloud_sync.remote_timeout_ms to change it. Signals are pushed 100 per batch, and the watermark moves only after a batch is stored. A call over budget, or a failure the HTTP port reports, defers the rest of the work to the next tick. Nothing is dropped.
  • No echo. Cloud returns pushed signals in the workspace’s own bucket. The pull leg skips any id this workspace wrote or already ingested, so a local signal never comes back as a [REMOTE-SIGNAL].

pnpm lumenflow:doctor reports the sidecar check red in four cases:

  • The control plane is configured but the sidecar has never run.
  • The sidecar started more than five minutes ago and has never ticked.
  • The last tick failed to heartbeat.
  • The memory/signal sync’s own last error is set, or the sync state file is unreadable. The message names the failing leg and its error. Each failure is also written to flow.log as control_plane_sync_failed.

The SDK publishes the documents a host and a gates engine exchange for one programmatic run, plus the RFC 8785 (JSON Canonicalization Scheme) implementation every digest in that design uses.

  • @hellmai/lumenflow-control-plane-sdk/canonical-json is stable. It exports canonicalizeJson, canonicalJsonBytes, canonicalJsonSha256 and the pinned snapshot manifest encoding (encodeSnapshotManifest, snapshotContentId). Non-JSON values, non-finite numbers and lone surrogates are refused with a CanonicalJsonError that never contains the value.
  • @hellmai/lumenflow-control-plane-sdk/canonical-json/conformance is stable. It exports the shared RFC 8785 known-answer corpus: DOCUMENT_VECTORS, the 24 finite APPENDIX_B_NUMBER_VECTORS, NON_FINITE_BIT_PATTERNS and NEGATIVE_VECTORS (a sparse array, accessors and Proxies included). Any package that canonicalises through ./canonical-json can run it against its own entry point.
  • @hellmai/lumenflow-control-plane-sdk/experimental/gate-run is unstable until the shapes are frozen. It exports lumenflow.gate-run-request/1, gate-run-result/1, gate-exec-request/1 and gate-exec-result/1 as zod schemas, types and parse*/safeParse* helpers.

The request and the two executor documents refuse any unknown field. The executor request keeps the policy-declared command separate from what the engine adds: injected_flags and injected_files are placed by the layout trusted policy declares per command (files_separator and files_position, both part of command_id), never go through a shell, and are valid only with an argv command. A flag value uses a small safe character set, and a path flag must point inside the gate output directory. On Windows there is no cmd.exe route: argv[0] must be an absolute path to node.exe followed by a .js, .cjs or .mjs script, and a shell or interpreter is refused as argv[0] on every platform. One executor request is one command-line chunk within the declared platform’s budget. The request in particular has no field for an outcome, a record, a MAC, a key or an identity the engine computes, so a caller cannot assert a pass; the master key is passed outside the document. The result is validated strictly when produced, and read with a must-ignore view: unknown fields are dropped, an unknown gate state reads as failed, an unknown reuse reason reads as miss, and an unknown overall outcome refuses the document. Parse errors report fixed reasons and schema paths and never echo input. The request digest is a binding between a result and the request it names, not authentication: a result’s authenticity comes from the dedicated result channel.

import {
  computeRequestDigest,
  parseGateRunResult,
} from '@hellmai/lumenflow-control-plane-sdk/experimental/gate-run';

const result = parseGateRunResult(JSON.parse(engineOutput));
const requestDigest = computeRequestDigest(sentRequest); // binds the result to the request it names