Embedding the engine

Your product can let its customers write movements and run them inside it. The engine runs in your own Node process, as WebAssembly: no server of ours, no network of its own. You give it what it needs: your product as a module, a place for each customer's secrets, the models it may call, and the limits it must keep.

A working example is in undefined: a small HTTP app where a customer saves a movement, the app checks it, and a company created in the app runs it. Its tests run it end to end, with customers whose schemas differ and a ready-made module loaded for each.

The packages #

packagewhat it is
@listen-fire/engineThe engine and its host contract. Reached only through its public entries, @listen-fire/engine/public/<entry>.
@listen-fire/engine-contractsThe data the engine is handed: a run's input, the tenant id, the event, the parked state, the module protocol.
@listen-fire/module-kitHow a module is defined (defineModule) and loaded, in your process or served as a process.
@listen-fire/embedThe embedded host, createEmbeddedHost: connections, checking, routing and parked runs in one call, over the engine's public entries and the module kit.
@listen-fire/authoring-mcpThe authoring tools an agent writes, checks, saves and runs movements with, over MCP for one of your tenants (section 7). Optional.
@listen-fire/movement-langThe language: parser, checker, catalog types. The engine depends on it.
@listen-fire/model-providersChat vendors beyond Anthropic (OpenAI, Gemini, Claude on Vertex): the models slot. Optional.
@listen-fire/embedding-providers, @listen-fire/image-providers, @listen-fire/transcription-providersEmbeddings, image generation and transcription over OpenAI and Gemini, each its own package on model-providers' host type. Optional.
@listen-fire/files-s3The files slot over S3 or any S3-compatible service. Optional.
@listen-fire/ocr-googleScanned-PDF text through Google Document AI, for file-text's ocrPdf. Optional.
@listen-fire/file-textA file's text: PDF, decks, spreadsheets, audio. The file-reading slot's default module; leave it out and an extract over a file is refused by the file's name. Optional.
@lf/<name>Ready-made modules: @lf/attio, @lf/slack, @lf/google-sheets, @lf/web, and the rest.

The core packages (engine, engine-contracts, movement-lang) share one version; every other package (embed, module-kit, the modules) has its own, and takes any core from 0.12 up to 1.0. The TypeScript engine is not part of the kit: the kit runs the language of its own release, which is also the pin a movement is saved with (ENGINE_VERSION). Node 22 or later. With TypeScript, use moduleResolution node16, nodenext or bundler (plain node works too, through the packages' typesVersions).

npm install @listen-fire/embed @listen-fire/engine @listen-fire/engine-contracts @listen-fire/module-kit

The public entries of @listen-fire/engine you will use:

entryfor
public/embedinProcessHost, introspectAdapter, pricedModels, MovementEngineError
public/catalogbuildCatalogSnapshot: what a customer's movements are checked and run against
public/movement-enginerunMovement, resumeMovement, checkPinnedInRust, parsePinned
public/inboundlistenPlansOf, plannedListensOf, listenFires, listenPipeline, arrivalReceiptKey, triggerAwaitsOf, landingNode, landingMisfits, arrivalTriggerOf: from what arrived to a run
public/secretsthe secrets family, and an in-memory vault to start from
public/adapterwhat an adapter is
public/modelsthe model registry and map, the run's spend and cost cap
public/run-hostthe store and rules for runs that park (await, sleep)
public/logthe engine's log lines: routeEngineLogsTo hands them to your own logger (the default prints to the console)
public/testingfor your tests: the store contract suite your own store must pass

The shape of it #

createEmbeddedHost (@listen-fire/embed) does all of this. You write your module (its describe, its reads and writes), hand the host what arrives on its listenable edges, and call its tick from a scheduler.

1. Your product as a module #

A movement reaches your product through a module: an adapter over your own data. Define it once with the module kit.

import { defineModule } from '@listen-fire/module-kit';

export const crm = defineModule({
  kind: 'adapter',
  name: '@acme/crm',                     // answers to adapter type `crm`
  manifest: { adapterType: 'crm', displayName: 'CRM', supportedTriggers: ['webhook'], methods: [...], declaredCredentials: ['crm'], introspectedSchema: true },
  credentials: [{ name: 'crm', kind: 'static', fields: [{ name: 'api_key' }] }],
  create: (ctx) => crmAdapter(ctx),      // the adapter methods, over ctx.config.tenant
});

An entry point a listen may end on declares listenable: true and how its arrivals come (delivery: 'connection': your product reports them, section 2). describe answers for the tenant the module was loaded for (ctx.config.tenant), so each customer's schema can differ: one customer's Stage a select of Lead | Customer, another's Prospect | Partner. A value outside the customer's options is refused when its movement is saved (MOV_ENUM_UNKNOWN_VALUE). See examples/embed-saas/src/crm.ts.

2. The embedded host #

import { createEmbeddedHost } from '@listen-fire/embed';
import { pricedModels } from '@listen-fire/engine/public/embed';
import { slackModule } from '@listen-fire/slack';

const host = createEmbeddedHost({
  modules: [crm, { module: slackModule, package: '@lf/slack' }],
  engine: { llm: pricedModels({ client: models }) },
  limits: { maxSteps: 100_000, costCapMicrodollars: 50_000 },
});

await host.connect('acme', { name: 'workspace', module: crm, credential: { api_key } });
await host.connect('acme', { name: 'slack_workspace', module: slackModule, credential: { accessToken } });

await host.save('acme', { name: 'welcome', source });    // { saved: true, listens: 1 } or { saved: false, problems }
await host.dispatch('acme', { adapterType: 'crm', arrivals }); // { runs: one outcome per run it started or woke, notJudged }
await host.tick();                                          // one outcome per parked run it stepped on

Options beyond modules:

optiondefaultwhat it is
storein memoryConnections, saved movements, run records, and the run host's store (RunHostStore, for parked runs). Over your database, each is a table; the run host's store must pass the contract suite in public/testing.
secretsin memoryYour vault: a SecretsBackend (public/secrets) with a put to store a new credential.
enginenoneThe engine's other slots: models (llm), files, write locks, a trace (section 4).
limitsthe engine'sEach run's limits and cost cap (section 5).
nowthe wall clockThe clock runs read and timers are due by.
moduleHostquiet log, no files, state in memoryWhat a loaded module's context holds besides its tenant and credential.
logthe console's warningsWhere the host says what it did not run: an arrival a listen's judge could not decide.

The example, examples/embed-saas/src/embedding.ts, is this and nothing else: a customer connects its CRM and, optionally, Slack, and its HTTP routes call save, dispatch and tick.

3. The parts underneath #

The embedded host is built from the engine's public parts, for a host that needs another shape:

4. The host's slots #

lf.yaml's core: section has ten slots (core slots): models, judge, store, files, limiter, browser, wake, ocr, secrets and movements. An embedded engine has the first four; you fill each with your own part (engine on the embedded host, or inProcessHost directly). The rest belong to listen-fire's run host.

slotwhat a run asks of ithow you fill it
modelsai(…) and extract(…) callsllm: pricedModels({ client, extractCall }). A client answers { parsedJson } and may report what it cost. The Anthropic clients are makeAnthropicLlmClient and makeAnthropicExtractCallClient, over anthropicClient, all from @listen-fire/anthropic (the models slot's module for Claude, which also holds the price table).
judgewhether a written record is one already there, when the rules alone cannot tellllm.arbitrateEntities. Absent: such a write fails, naming it.
storeparks: await, sleepThe embedded host's store. memoryRunHostStore holds parked runs for one process; a store that must outlive a restart implements RunHostStore over your database and must pass the contract suite in public/testing, and the waits cases every store runs (storeWaitsCases, there too, with the run rows listen-fire's stores keep beside it, RunRowStore). The embedded host's runHost serves more: a delivery's one write (keepDelivery, DeliveryRowStore), its run rows done once they ran (completeKey) and its provider id forgotten when they did not (forgetDeliveryId); and a run an arrival starts with its receipt and its run row, what it started with (startArrivalRun, ArrivalRunStore: false, and no run, for a receipt its team took before). The store keeps library texts too (sources: a text kept once under its hash, read back by it). In a Rust embedding (listen-fire-engine), Store requires save_source and load_source the same way: an implementation that omits them does not compile. A run that awaits an adapter's listenable edge also needs the store's waits (wait_mark, open_wait, wait_arrivals, note_arrival, commit_wait_park, wake_wait: an arrival log and a wait per run and record, as listen-fire's run host keeps them, docs/contracts/host-families.md park.waits); a store that keeps parks without them fails such a run where the awaited record enters it (the mark is taken before the record's write or read), not at the await. Hand a run started by a delivery its mark (RunInput.trigger_mark) so its trigger's waits read what arrived since.
filesreading a file's text, rendering a filefiles.resolveFileText (@listen-fire/file-text reads PDFs, decks and sheets), files.render.

Two more pieces are yours and have safe defaults:

5. Limits and the cost cap #

A customer's movement is code you did not write. Set every limit, and a cost cap on every run: the embedded host's limits takes all of them, flat, and puts them on each run's input.

The full table is docs/contracts/run-limits.md.

6. One tenant cannot reach another #

7. Serving the authoring tools #

An agent (Claude, or any MCP client) can author your customers' movements for them: read the handbook, see the customer's systems and fields, save a movement (checked first), fix it, and run it. @listen-fire/authoring-mcp serves those tools over MCP. You mount one handler; it asks you which customer each request is for.

import { createAuthoringHandler } from '@listen-fire/authoring-mcp';

const authoring = createAuthoringHandler({
  // Your own auth, on every request, before any tool is listed.
  tenant: async (request) => {
    const tenant = customerOf(request);
    if (tenant === undefined) return { ok: false, status: 401, message: 'sign in first' };
    return {
      ok: true,
      tenant: {
        catalog: async ({ fresh }) => ({ snapshot: await host.catalog(tenant), connections: await connectionsOf(tenant) }),
        programs: programStoreFor(tenant),
        runs: runsFor(tenant), // optional: without it, the four run tools are not offered
      },
    };
  },
});

http.createServer((req, res) => void authoring(req, res));

The package does the rest: the handbook and its search, the digest of the customer's systems, the check (a save is refused while it has errors, unless the agent passes the user's consent), and diagnostics that name the handbook section showing the fix. The tool names and descriptions are the hosted product's, so an agent or skill written against one works against the other.

The example mounts it at /customers/:tenant/mcp, the customer's API key as a bearer token (src/authoring.ts, test/authoring.test.ts: getStarted, a save refused with MOV_ENUM_UNKNOWN_VALUE, the fix through editAutomation, a run and checkRun).

What the example leaves out #