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 the app's own event runs it. Its test runs it end to end.

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/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.

Every package has the same version as the engine. The TypeScript engine is not part of the kit: the kit runs language version 5 and later. Node 22 or later. With TypeScript, use moduleResolution node16, nodenext or bundler (plain node works too, through the packages' typesVersions).

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

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

entryfor
public/movement-enginerunMovement, resumeMovement, checkPinnedInRust, parsePinned
public/embedinProcessHost, introspectAdapter, collectFileListeners, pricedModels, MovementEngineError
public/catalogbuildCatalogSnapshot: what a customer's movements are checked and run against
public/inboundeventMatchesTriggerScope, triggerEventOf: from your event 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 #

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: [...], subscribableEvents: ['company.created'], declaredCredentials: ['crm'], introspectedSchema: true },
  credentials: [{ name: 'crm', kind: 'static', fields: [{ name: 'api_key' }] }],
  create: (ctx) => crmAdapter(ctx),      // the adapter methods, over ctx.config.tenant
});

Load it in your process for one tenant with adapterInProcess(crm, host). The host is the module's context: the tenant, its credential, a log, files, and a small store of its own (kv). secretsCredential reads the credential from your secrets; memoryKv keeps module state in memory. Set validateResults: true while you develop: every answer is checked against the protocol.

The ready-made modules (@lf/attio, @lf/slack, …) load the same way. A module can also run as its own process (serve-module, in @listen-fire/module-kit), reached over the module protocol (public/modules); see docs/contracts/adapter-protocol.md.

2. The catalog #

The catalog is what a customer's movements are checked and run against, as plain data. Build it per tenant.

const { schema } = await introspectAdapter(adapterFor(tenant), crm.manifest);
const catalog = buildCatalogSnapshot({
  adapters: [{ manifest: crm.manifest, schemas: { workspace: schema } }],
  credentials: { workspace: { adapters: ['crm'], credential: 'crm' } },
  plugins: {},
});

3. Check a customer's movement #

checkPinnedInRust({ source, catalog, languageVersion: 5 });

It throws MovementEngineError when the movement does not check, with the code MOVENG_CHECK (or MOVENG_PARSE, MOVENG_LANGUAGE_VERSION) and every problem in its message. isCheckFailure(error) tells a check failure from anything else. Save the source with the language version you checked it under: a saved movement is pinned to it (language versions).

Read its routing table once, as you save it:

const listeners = collectFileListeners(parsePinned({ source, languageVersion: 5 }));
// [{ adapter: 'crm', config: { events: ['company.created'] }, movementName: 'welcome', … }]

4. Run it when its event happens #

if (listener.adapter === 'crm' && eventMatchesTriggerScope(event, listener.config)) {
  const { triggerEvent } = triggerEventOf({ pipelineInputId, adapterType: 'crm', triggerType: 'webhook', event, eventTypes, now: new Date() });
  const result = await runMovement({
    source, languageVersion: 5, movementName: listener.movementName,
    event: triggerEvent,
    teamId: tenantIdOf(tenant),
    catalog,
    credentialIds: { workspace: credentialRef(tenant) },
    costCapMicrodollars: 50_000,
    limits: { maxSteps: 100_000, maxTextBytes: 1 << 20, maxCollectionItems: 10_000, maxResultBytes: 4 << 20 },
    host,
  });
}

5. The host: filling the slots #

const host = inProcessHost({
  adapters: { resolve: ({ adapterType, teamId, credentialsId }) => adapterFor(teamId, credentialsId) },
  credentials: { belongsTo: ({ teamId, credentialsId }) => vault.tenantOf(credentialsId) === teamId },
  llm: pricedModels({ client: models }),
});

lf.yaml's core: section has eight slots (core slots): models, judge, store, files, limiter, loopGuard, browser and wake. An embedded engine's host has the first four; you fill each with your own part. The rest belong to lf'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, sleep, callbackspark.sink, park.wake, park.bindings, park.callbacks, with the run-host store (public/run-host) behind them. Absent: a movement that parks fails. memoryRunHostStore (public/embed) 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.
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:

6. 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 full table is docs/contracts/run-limits.md.

7. One tenant cannot reach another #

What the example leaves out #