The adapter protocol

Version: 2 Minor: 8

A remote adapter connects a system you run to Listen-Fire. It is an HTTP server you host, in any language. Listen-Fire calls it to learn your data model, to read and write your records, and to wait on things that happen in your system. Your server calls Listen-Fire back when something happens.

This document is the contract. The source of truth is the zod schema in packages/engine-contracts/protocol.ts; where this page and the schema disagree, the schema wins. The reference adapter is the fake CRM, a single file in this repository, examples/lf-acme/externals/acme_crm/server.mjs (see Build your first adapter).

Contents:

The envelope #

Every call is a POST of JSON to one URL, your baseUrl. The method name is in the body, not the path.

{
  "protocolVersion": "2",
  "method": "describe",
  "cacheScopeId": "c0ffee",
  "params": { "typeId": "Company" }
}

You answer with HTTP 200 and one of two bodies:

{ "ok": true, "result": { "typeId": "Company", "displayName": "Company", "fields": [], "references": [] } }
{ "ok": false, "error": { "code": "adapter_error", "message": "CRM timed out", "retryable": true } }

Types, fields and edges each have two names, and the two may differ:

Record ids are identity, and cross the wire unchanged.

Transport #

Every module kind — adapter, plugin, and every core slot (store, models, judge, files, limiter, browser, wake, ocr, secrets, movements) — is reached this one way: this envelope over HTTP. Where it listens decides how:

where the module runsits addresshow
on the host's box, started by the hostunix:<socket path>HTTP/1.1 on a Unix socket
on the host's box, where there are no socketshttp://127.0.0.1:<port>/loopback TCP
elsewhere (a remote module: only a manifest on the host)https://…HTTPS

Versioning #

The protocol's version is 2.N: a major, 2, and a minor, N, counted within it.

Minors of version 2 #

minorwhat hosts speaking it addsince
2.0everything before minors were counted0.12.0
2.1a <slot>.watch method (Watching for changes)listen-fire 0.12.2
2.2work.enqueueIfAbsent (the store slot)listen-fire 0.12.5
2.3a host behaviour: a polled source labels each poll event with its kind, eventType, and a host delivers an unlabelled one to the listen that polled it, with a notice (Polled sources). An older host routes a labelled event correctly, so no module needs to state 2.3listen-fire 0.12.8
2.4refreshEventSubscription (Event subscriptions that expire)listen-fire 0.12.10
2.5awaitKeys, arrivals on a delivery's reading, the wake door's arrivals, the store slot's waits.* (waits.log names it) (Waiting on your system)listen-fire 0.13.0
2.6arrivals for listens (Reporting arrivals): one on a root edge names no key; one may carry parents, about, notFor, takenApplied, occurredAt and, on its landing and each parent, unknown; the landing and each parent state their recordType, and an arrival that states none runs no listen. A host fires the listens on the edge it landed on. A polled listenable edge is polled as poll({ edge, filter?, scope?, cursor? }), once per connection, edge, taken filter and scope (Polled sources); a pushed one is subscribed for as ensureEventSubscription({ edge, filter?, scope?, callbackUrl, current? }), once per the same (Subscribing for an edge's arrivals). Each head awaitKeys is asked about names its recordType where the host knows it. checkInbound may be handed replayed: true, when the host runs a request it kept itself again (A module that checks its own inbound); inbound.captureHeaders names the headers a kept request holds and inbound.redactFields the body and query fields it drops (Declared inbound signatures). A module that uses any of these states protocolMinor: 6: an older host would refuse a root arrival, judge without unknown or recordType, or keep a request wholelisten-fire 0.13.0
2.7host behaviours, additive: a poll's answer may say when the edge is next worth polling (pollAgainAt), and a host polls it then when that is sooner than its interval (Polled sources); an arrival may say who acted (actor, as extractActor answers), and a run an arrival fires is handed its about and actor and stamped with its occurredAt (Reporting arrivals). An older host ignores both members (it polls on its interval and asks extractActor), so no module needs to state 2.7listen-fire 0.13.1
2.8a host behaviour, additive: a runner's requests to the slots it fills carry hostRelease, the release of the runner that sent it (The envelope). A module serving runners of several releases answers each by its line; a request without it did not state its sender's release (a runner before 0.15.2, or a host or module that does not set it). An older module ignores the member, so no module needs to state 2.8listen-fire 0.15.2

A module offering one of these methods states no minor for it. Until 0.12.9 the kits stamped a module with the minor of the methods it offered; from 0.12.10 they stamp only what its author declares.

The first listen-fire that reads protocolMinor is 0.12.6. A listen-fire up to 0.12.5 ignores the field and loads the module as before.

Authentication #

Listen-Fire sends your secret on every call. Your manifest picks how:

authStrategyHeader Listen-Fire sends
{ "kind": "bearer" }Authorization: Bearer <secret>
{ "kind": "shared_secret" }x-listen-fire-adapter-secret: <secret>
{ "kind": "shared_secret", "header": "x-acme-key" }x-acme-key: <secret>

The manifest and installing #

The manifest file registers your server with a team:

{
  "protocolVersion": 2,
  "protocolMinor": 0,
  "adapterType": "acme_crm",
  "displayName": "Acme CRM",
  "description": "Our internal CRM.",
  "baseUrl": "https://crm.acme.example/listen-fire",
  "authStrategy": { "kind": "bearer" },
  "supportedTriggers": ["webhook"],
  "runtimeCapabilities": {
    "traversal": { "incoming": false, "edgeProperties": false },
    "resources": false
  },
  "methods": [
    "listEntryPoints", "describe", "resolveEntity", "getFieldValue", "getRelated",
    "iterateRelatedPage", "createRecord", "updateRecord", "deleteRecord",
    "preprocessInbound", "listEventTypes", "awaitKeys",
    "ensureEventSubscription", "removeEventSubscription"
  ]
}

Install it on the Adapters page, pasting the manifest and the secret together. An agent can instead give the user a link to enter the secret out of band.

Methods #

Required: manifest, listEntryPoints, describe, resolveEntity, getFieldValue, getRelated, createRecord, updateRecord, deleteRecord. A pure sink returns null and [] from the reads; a read-only source can throw from the writes. Everything else is optional and declared in methods.

Each example shows params → result (the result inside ok: true).

manifest #

{} → the same declarations as the manifest file:

{
  "protocolVersion": 2,
  "adapterType": "acme_crm",
  "supportedTriggers": ["webhook"],
  "runtimeCapabilities": { "traversal": { "incoming": false, "edgeProperties": false }, "resources": false },
  "methods": [
    "listEntryPoints", "describe", "resolveEntity", "getFieldValue", "getRelated",
    "iterateRelatedPage", "createRecord", "updateRecord", "deleteRecord",
    "preprocessInbound", "listEventTypes", "awaitKeys",
    "ensureEventSubscription", "removeEventSubscription"
  ]
}

The hosted product reads only protocolVersion from it (see installing). listen-fire, which has no manifest file, reads every declaration from it, the listen declarations included.

webhookEventTypeId (a string) is optional.

listEntryPoints #

{} → the types an automation can start from:

[
  { "typeId": "Company", "displayName": "Company", "writable": true, "readable": false, "listenable": true, "collectionName": "Companies",
    "description": "A company. A listen on `Companies` is delivered each one somebody creates in the CRM." },
  { "typeId": "Comment", "displayName": "Comment", "writable": false, "readable": false }
]

describe #

{ "typeId": "Company" } → one type, or null if unknown:

{
  "typeId": "Company",
  "displayName": "Company",
  "fields": [
    { "fieldId": "Name", "displayName": "Name", "kind": "string", "writable": true, "required": true,
      "description": "The company's legal name." },
    { "fieldId": "Description", "displayName": "Description", "kind": "string", "writable": true, "required": false }
  ],
  "references": [
    { "fieldId": "Comments", "name": "Comments", "targetTypeId": "Comment", "cardinality": "many",
      "direction": "outgoing", "writable": false, "listenable": true, "identity": "Id",
      "sequenced": "chronological",
      "description": "What people said about the company, oldest first." }
  ]
}

resolveEntity #

Decides whether a record Listen-Fire is about to write already exists.

{
  "record": { "Name": "Vireo Robotics" },
  "recordType": "Company",
  "candidates": [],
  "constraints": { "any": [ { "all": [ { "field": "Name" } ] } ] }
}

→

{ "candidates": [ { "adapterType": "acme_crm", "externalId": "company-7", "recordType": "Company", "data": { "Name": "Vireo Robotics" } } ] }

Return { "candidates": [] } when nothing matches; Listen-Fire then creates.

getFieldValue #

{
  "position": { "adapterType": "acme_crm", "recordType": "Company",
                "identity": { "kind": "stable", "recordId": "company-7", "data": { "Name": "Vireo Robotics" } } },
  "fieldId": "Name"
}

→ "Vireo Robotics" (any JSON value, or null).

A position is how Listen-Fire hands a record back to you: the recordType, your recordId, and the data you attached when you returned it. Read from data when you can; it saves a call to your system.

A position an automation produced, rather than read from you, also carries originRef (its kind is extract, transform or resource) and may carry evidence and resources. You may ignore all three. A file ref inside them is in the wire's spelling (Files): its owner type and handle, never Listen-Fire's credential or construction args.

getRelated #

{
  "position": { "adapterType": "acme_crm", "recordType": "Company", "identity": { "kind": "stable", "recordId": "company-7" } },
  "fieldId": "Comments",
  "direction": "outgoing"
}

→

[ { "position": { "adapterType": "acme_crm", "recordType": "Comment",
                  "identity": { "kind": "stable", "recordId": "comment-1", "data": { "Text": "approved" } } } } ]

createRecord #

{
  "recordType": "Company",
  "fields": { "Name": "Vireo Robotics" },
  "mutationContext": { "source": { "type": "structured_input" }, "occurredAt": "2026-10-07T09:54:31Z" },
  "parentLinks": [ { "recordType": "Deal", "externalId": "d_1", "edgeName": "Company" } ]
}

→

{ "adapterType": "acme_crm", "externalId": "company-1", "recordType": "Company", "data": { "Name": "Vireo Robotics" } }

updateRecord #

The createRecord input plus externalId →

{ "adapterType": "acme_crm", "externalId": "company-1", "recordType": "Company", "data": { "Name": "Vireo" }, "association": "none" }

deleteRecord #

{ "recordType": "Company", "externalId": "company-1", "mutationContext": { … } } → {} (optionally { "events": [] }).

Asked only for a type whose descriptor says "deletable": true: under an automation cannot delete any other.

Optional methods #

Methodparamsresult
iterateRelatedPagegetRelated params + cursor?{ items, nextCursor? } (Paging)
awaitKeys{ edge, heads: [{ recordId, headData?, recordType? }] }[{ key } or { gone: true }], one per head (awaits); adapter protocol 2.5
resolveAwait{ recordId, edge, headData? }pending / landed / empty (the key form)
wakeKeys{ raw }string[] (the key form)
preprocessInbound{ raw, checkpoint? }{ arrivals, checkpoint? } — required to receive webhooks through the host door (Reporting arrivals)
listEventTypes{}[{ tag, positionType, match? }]
ensureEventSubscription{ edge, filter?, scope?, callbackUrl, current? } (Subscribing for an edge's arrivals); adapter protocol 2.6{ externalId?, secret, expiresAt? }
removeEventSubscription{ edge, filter?, scope?, callbackUrl, externalId? }; a webhook a 0.12 listen registered is removed as { callbackUrl, externalId?, scope? }null
refreshEventSubscription{ edge, filter?, scope?, externalId }{ expiresAt? } or null (Event subscriptions that expire); adapter protocol 2.4
readRecord{ recordType, externalId, fieldIds? }the record's fields, or null (lets an update with no changes be skipped)
linkRecords / unlinkRecords{ from: { recordType, externalId }, edgeName, to: { … }, mutationContext }{ created } / { removed }
resolveFileRef{ ref }{ url, contentType? } (Files)
translateFilter{ expression, entityType }{ native, residual }
getDedupRules{ typeRef }{ constraints } or null
getActorCandidates{ event }: the run's trigger, whose arrival holds the landing, its parents, about and actor (Who acted)[{ identity, source }]
extractActor{ event }, as above; asked only when the arrival names no actoran actor identity, or null
describeOpaqueId{ fieldPath, value }a label, or null
invokeFieldFunction{ recordType, fieldId, functionName, args: { instructions, data } }the composed value
edgesFrom{ position, cursor? }see describe
poll{ edge, filter?, scope?, cursor? }{ arrivals, cursor?, pollAgainAt? } (Modules); needs poll declared; adapter protocol 2.6 (pollAgainAt 2.7)
refreshCredential{}{ fields } (Modules); needs credentials declared
runPeriodic{ name }null (Modules); needs periodic declared
checkInbound{ request, secret?, checkpoint?, replayed? }accepted / refused / reply (Modules); needs inbound.scheme: module

Event subscriptions that expire #

Some sources let a subscription lapse unless it is renewed (Airtable's webhooks expire 7 days after their last refresh). Such a module says when:

listen-fire keeps each subscription's expiresAt and refreshes it a day before then, or halfway there when less than two days remain. The refresh is a work row (refresh_subscription), so listen-fire serve runs it on its tick, and listen-fire drain takes it in a scale-from-zero deployment, whose wake slot is told when it is due. A refresh that fails is said once and tried again 15 minutes on. A subscription whose expiresAt has passed has lapsed: listen-fire registers it anew (ensureEventSubscription with no current) and keeps the new id and secret. A module that answers no expiresAt is never refreshed.

listEventTypes example — the CRM's one event, delivered as a Company:

[ { "tag": "company.created", "positionType": "Company" } ]

Waiting on your system (awaits) #

An automation can wait for something to happen in your system:

c = write crm-[:Company]-> { Name: "Vireo Robotics" }
r = await c-[:Comments]->

The run stops at the await and resumes when a comment arrives. Listen-Fire owns all the waiting state: a log of what arrived on each record's edge, and each run's wait on it. Your server does two things (adapter protocol 2.5):

An arrival reported before anybody waits is kept for a while, so a run that starts waiting just after the write that caused it still takes it.

Declaring a listenable edge #

On the reference in describe:

What a listen needs to know (an entry point that is listenable takes the same keys). Every number you declare is a default the operator's lf.yaml overrides; you state it because Listen-Fire has none of its own.

Each is refused, by name, when listen-fire describes your adapter if it cannot mean what it says: a delivery that is none of the three, a polled edge without pollEverySeconds (or pollEverySeconds on one that is not polled), bounded: false without boundedBy (or boundedBy on a bounded edge), a boundedBy field the landing does not declare, a carries hop that is no edge into the type the next one hangs off or a type before it, a scopedBy on an edge not delivered per scope or on a root collection, or naming a hop some path to the edge does not come through, a membersChange that is neither "pushed" nor { relistEverySeconds }, membersChange: "pushed" on an edge that is not listenable, a number that is not a positive whole number of seconds, a field of kind owned that names no scalar, delivery: scope on a root collection (nothing comes before it to scope by), an identity or version naming a field the landing does not declare, or one listenable edge name that lands on the same record type from two record types: an arrival says only its edge and the record type it landed on, so give each its own record type to land on.

A reference that still declares awaitable or watchable (retired in 0.13) is refused when listen-fire describes your adapter, naming listenable. One that declares resolvesEmpty (retired in 0.13) is refused too: a record that goes (a gone arrival) leaves the wait waiting on every edge, so there is nothing to declare. The host marks such a wait stranded and shows it (stranded waits).

awaitKeys #

{ "edge": "Comments", "heads": [ { "recordId": "company-1", "headData": { "Name": "Vireo Robotics" } } ] }

→

[ { "key": "thread:company-1" } ]

Reporting arrivals #

One arrival names the edge, the key of the record it landed on, its identity (the landing's value of the field the edge declares as identity), its version when the edge declares one, and the landing:

{ "edge": "Comments", "key": "thread:company-1", "identity": "comment-7",
  "landing": { "recordId": "comment-7", "recordType": "Comment", "fields": { "Id": "comment-7", "Text": "approved" } } }

A record that went is a gone arrival: { "edge": "Comments", "key": "thread:company-1", "gone": true }.

From adapter protocol 2.6 an arrival may say more, for the listens on its edge (listen to crm-[c:Company WHERE c.Name != "Ignored"]-> fire welcome):

A run an arrival fires is handed the arrival's landing, its parents, its about and its actor (2.7), and nothing else: there is no raw event after the door. Put on the landing every field its record type's edges read (the record as your provider sent it, with your named fields over it), so an edge walked from the fired record reads what it would read from a listed one.

Who acted #

Set actor where you build the arrival: the member who posted, the sender of a mail, the owner of a meeting.

{ "edge": "Messages", "key": "C0DEALS", "identity": "C0DEALS:1730541600.000100",
  "landing": { "recordId": "C0DEALS:1730541600.000100", "recordType": "Message", "fields": { "user": "U001", "text": "Acme wants a term sheet" } },
  "actor": { "identifier": "U001", "scheme": "opaque", "label": "U001" } }
{ "edge": "Company", "identity": "company-1",
  "landing": { "recordId": "company-1", "recordType": "Company", "fields": { "id": "company-1", "Name": "Osprey" } } }

A module that reports a root arrival states protocolMinor: 6: a host speaking 2.5 refuses an arrival with no key.

Report them in one of three places:

What Listen-Fire does with them:

The key form (before 2.5) #

The key form is retired in 0.13.0: no host calls resolveAwait or wakeKeys, a checkInbound acceptance's wakeKeys is ignored, and the wake door refuses { keys } by name (400, saying to send arrivals).

Telling Listen-Fire something happened #

There are two ways in. Use whichever fits your system:

Either way what you report is arrivals (Reporting arrivals).

The door you call #

POST {listen-fire}/api/public/adapter/{adapterType}/wake   { "arrivals": [ … ] }

The emit door of earlier releases is retired (below).

Authentication:

Answers:

StatusBodyWhen
202{ "accepted": n }queued
400{ "error": "bad_request", "issues": [{ "path", "message" }] }malformed body
410{ "error": "emit_retired", "message" }any body sent to emit, once authenticated
401{ "error": "unauthorized" }the secret matches no install of this type, the type is not installed, the named team has no matching install, or the type is a name a built-in adapter answers to
409{ "error": "ambiguous_credential", "message" }the secret matches installs on several teams and no x-listen-fire-team was sent
413the body is over 1 MB
500{ "error": "internal" }retry later

emit: retired in 0.13 #

From 0.13.0 an event fires no listen: a listen is a traversal, fired by the arrivals on its edge. So emit refuses by name rather than accept what could run nothing: once the caller is authenticated (401 and 409 are as for wake), whatever the body, it answers 410 { "error": "emit_retired", "message" } and keeps nothing. Report what happened on wake.

wake: what arrived #

{ "arrivals": [ { "edge": "Comments", "key": "thread:company-1", "identity": "comment-7",
                  "landing": { "recordType": "Comment", "fields": { "Id": "comment-7", "Text": "approved" } } } ] }

Inbound through the host door #

If your system sends webhooks, let Listen-Fire subscribe:

  1. While any listen ends on a listenable edge of yours that declares delivery: connection or scope, Listen-Fire calls ensureEventSubscription for it (Subscribing for an edge's arrivals): json { "edge": "Companies", "callbackUrl": "https://listen-fire.example/api/public/webhook-sync/acme_crm/5f0c…" } → json { "externalId": "crm-sub-1", "secret": "8c1d…" }
    • Make it an idempotent upsert. current ({ externalId }) arrives when the subscription is already held; keep it rather than adding another.
    • Return the same secret to keep it. A different secret rotates it: Listen-Fire stores it and verifies the next delivery with it.
    • Being asked again is how a subscription you lost (a restart, a wiped store) comes back.
    • Treat callbackUrl as opaque. It is stable for the install.
    • secret is required. Without one the subscription is refused, withdrawn (removeEventSubscription), and reported as a problem when the automation is saved.
  2. Deliver each webhook as a POST to callbackUrl, signed: x-listen-fire-signature: <lowercase hex HMAC-SHA256 of the exact raw body, keyed by the secret> The body is your own format, sent as content-type: application/json, at most 100 KB. Listen-Fire answers: | Status | When | |---|---| | 200 { "ok": true, "eventsProcessed": n } | accepted; n counts the arrivals (and any events) the delivery brought | | 400 { "error": "provider_mismatch" } | the URL's adapter segment does not match the subscription | | 401 { "error": "signature_invalid" } | bad or missing signature | | 404 { "error": "subscription_not_found" } | unknown or deleted subscription | | 503 { "error": "arrivals_not_kept", "message" } | the delivery reports arrivals and Listen-Fire's store cannot keep them yet; nothing was kept: send it again | | 500 | your adapter does not implement preprocessInbound; an arrival it reports names no identity; or an internal failure |
  3. While your delivery is open, Listen-Fire calls you back: preprocessInbound({ raw }) turns your body into the arrivals it reports (Reporting arrivals). A single-threaded server must not block on its own delivery. json { "raw": { "companies": [ { "id": "company-3", "Name": "Larch Bio" } ] } } → json { "arrivals": [ { "edge": "Companies", "identity": "company-3", "landing": { "recordId": "company-3", "recordType": "Company", "fields": { "Name": "Larch Bio" } } } ] } checkpoint is optional: whatever you return is handed back on the next delivery.
  4. When no listen needs the subscription any more, Listen-Fire calls removeEventSubscription.

Protocol 2 has no replay protection: a captured signed delivery verifies again. Keep the callback on HTTPS.

Subscribing for an edge's arrivals #

From adapter protocol 2.6 a listenable edge declaring delivery: connection or scope is subscribed for with its own shape, once for every listen on it (Declaring a listenable edge):

A webhook several runners share #

Several runners, of one release or of several, may share a secrets module and so a credential: separate listen-fire serve processes, or a platform's own subscription sync beside them. A subscription's id and secret live on the credential, so they share each subscription too. Every host that registers subscriptions on a shared credential follows this rule. It changes nothing a module does.

The rule.

  1. Registering does not replace. A host that finds the subscription held asks with current. It makes another only when none is held.
  2. Registering never narrows. A subscription is the same one for every host that wants it: its door names the connection, edge, filter and scope, so what a host wants that another does not is a subscription of its own, and the subscriptions held are the union of what every live holder wants. A host that registers by events (none does from 0.13) registers the union of its live holders' events.
  3. Removing waits for the last holder. No host removes a subscription while another's holder entry is live. The last to leave removes it.

The holders. A credential's webhook_holders field says who holds what. It is one more text field of the entry: a JSON object, by subscription key ("" for a subscription kept in the credential's own fields), of holders by instance.

{ "edge-5f0c…": { "3b1e…": { "release": "0.15.2", "events": [], "until": "2026-11-04T15:00:00Z" } } }

A host must:

What each case comes to.

What a deployer must do.

Modules: credentials, signatures and schedules #

An installable module (a built-in integration such as @lf/attio, or your own server) can hand its housekeeping to the host by declaring it in the manifest. Every declaration is optional and is an addition within version 2: a server that declares nothing is unchanged.

A declaration and its method go together. The manifest is refused when one is present without the other (poll with the poll method, periodic with runPeriodic, plugins with invokePlugin), or when a name is declared twice. refreshCredential needs credentials, but credentials does not need refreshCredential.

Credentials #

Declare the credential your module needs to reach its provider. Several declarations are ALTERNATIVES (Gmail sign-in or a service account): a connection uses exactly one (connect `@lf/gmail` using alice_gmail), and the host sends only that one. A module that needs two secrets at once declares them as two fields of one credential:

"credentials": [
  {
    "name": "attio",
    "kind": "oauth2",
    "fields": [{ "name": "workspace_id", "label": "Workspace", "secret": false }],
    "oauth2": {
      "authorizeUrl": "https://app.attio.com/authorize",
      "tokenUrl": "https://app.attio.com/oauth/token",
      "scopes": ["record_permission:read-write"],
      "clientAuth": "body",
      "pkce": true
    }
  }
]

Your server's secret belongs to the install; a declared credential belongs to the CONNECTION. One install serves many users: a movement connects once per account, naming the credential each connection acts as,

hs_alice = connect #hubspot using alice_hubspot
hs_bob = connect #hubspot using bob_hubspot

and every call made through hs_alice is signed with the install's secret and carries Alice's credential, every call through hs_bob Bob's. The host binds each stored credential to one module and one of its declared credentials (the platform's credential row; listen-fire's module: + credential: secrets entry), and refuses at check a connection naming a credential bound to another module, or to a credential the module does not declare. The OAuth app you register at your provider (its client id and secret) belongs to the install too, and stays with the host, which refreshes with it.

The host sends the connection's credential on every request, beside params:

{
  "protocolVersion": "2",
  "method": "poll",
  "cacheScopeId": "c0ffee",
  "params": { "config": {} },
  "credential": { "name": "attio", "fields": { "access_token": "…", "refresh_token": "…", "expires_at": "2026-10-17T12:00:00.000Z" } }
}

If your call changed the credential (you refreshed the token, or the provider rotated the refresh token), say so beside the result:

{ "ok": true, "result": { "events": [] }, "credentialUpdate": { "name": "attio", "fields": { "access_token": "…", "refresh_token": "…" } } }

Refreshing:

Declared inbound signatures #

A module whose provider sends webhooks names how they are signed, and the host verifies each delivery before preprocessInbound sees it:

"inbound": {
  "providerKey": "attio",
  "scheme": "attio",
  "signatureHeaders": ["attio-signature", "x-attio-signature"]
}

A module that checks its own inbound #

A provider no scheme fits (Mailgun's form signature, Resend's Svix), or one that needs a handshake answered (Meta's verification GET), is checked by the module itself. The manifest says so, and lists checkInbound:

"inbound": { "providerKey": "email", "scheme": "module" }
{
  "request": {
    "method": "POST",
    "path": "/api/public/webhook-sync/email/mail",
    "query": "",
    "headers": { "content-type": "application/x-www-form-urlencoded" },
    "body": "c2VuZGVyPWFkYSU0MGV4YW1wbGUuY29tJi4uLg=="
  },
  "secret": "key-3b2f…"
}

The answer is one of three:

outcomeFieldsThe door answers
acceptedevents, arrivals?, checkpoint?, deliveryId?, reply?200 { ok, eventsProcessed } once the arrivals are logged and the events are queued, as for any delivery, or reply (a 2xx status with text, html or json) in its place; a non-empty deliveryId already kept at this subscription answers 200 { ok, duplicate: true, eventsProcessed: 0 } and queues nothing
refusedstatus? (a 4xx)status, default 401, { "error": "signature_invalid" }
replystatus, text?, html? or json?exactly that: text as text/plain, html as text/html (a page your module serves at its door), json as JSON, none as an empty body; nothing runs
{ "outcome": "reply", "status": 200, "text": "1158201444" }

A throw is an internal failure: the door answers 500 and the provider retries. The module kit reads the request for you (inboundRequest(input): its bytes, text, JSON, form, query and headers) and builds the answers (accepted, refused, reply).

Polled sources and periodic tasks #

A source with no webhooks is polled:

"poll": { "intervalSeconds": 300 }

A listenable edge declaring delivery: polled is polled once for every listen on it (Declaring a listenable edge; adapter protocol 2.6). The declaration says the module answers poll; the interval is the edge's own (below).

Housekeeping on a schedule, per install (renewing a webhook the provider expires, say):

"periodic": [{ "name": "renew_webhook", "intervalSeconds": 86400 }]

Plugins #

A module may serve plugins instead of (or as well as) an adapter: functions a movement calls. It declares them as one package, which a program imports by its tag:

"plugins": {
  "package": "@lf/web",
  "plugins": [
    {
      "name": "fetch-url",
      "spec": {
        "args": ["url", "email"],
        "priced": false,
        "argTypes": { "url": "text", "email": "text" },
        "requiredArgs": ["url"],
        "effects": { "reads": ["the web"] },
        "output": { "kind": "value", "type": "text" },
        "description": "The page at a URL, as text."
      }
    }
  ]
}
import { `@lf/web` as web } from plugins
page = web.fetchUrl(u)

Kinds, settings, spend and the host's models #

Serving a module #

A module written with the module kit (packages/module-kit, defineModule) is served as a process by serve-module <module>: it binds the socket MODULE_SOCKET names (else loopback on PORT, 0 picks one), prints listening unix:<path> (or listening <port>), and requires Authorization: Bearer <MODULE_TOKEN> when that is set. The platform loads the same module in process; both declare the same manifest.

A module's own state (ctx.kv) is small JSON per install: get, set, delete, list(prefix), and compareAndSet(key, expected, value), which writes only when the key still holds expected (undefined: unset), compared as JSON values, and answers whether it wrote — of two writers that read the same value, one wins.

Paging #

getRelated returns everything at once. For a large collection implement iterateRelatedPage and list it in methods:

{ "position": { … }, "fieldId": "Company", "direction": "outgoing", "cursor": 2 }

→

{ "items": [ { "position": { … } }, { "position": { … } } ], "nextCursor": 4 }

The TypeScript reference kit (createAdapterProtocolHandler) serves iterateRelatedPage for you. If your in-process adapter only offers a stream (iterateRelated), the kit drains it into one page capped at 1,000 items, with no nextCursor, and logs a warning naming the adapter. If it offers neither, the method answers method_not_implemented.

Files #

File bytes never ride the JSON envelope; they move by URL. A file is what the envelope declares one, never a shape the data carries: a field your catalog declares "kind": "file" (in describe), or a resource's fileRef. A file ref anywhere else is data to Listen-Fire, whatever keys it carries.

Watching for changes (<slot>.watch) #

A core slot may let listen-fire hear of a change instead of asking on a timer. The movements slot is the first to ask for it (movements.watch). The shape is the same for any slot that adopts it.

<slot>.watch takes { cursor?: string, waitMs: number } and answers { cursor: string, events: [...] } or { resync: true }.

Limits and timeouts #

Errors #

Inside the envelope (HTTP 200, ok: false):

coderetryableMeaning
method_not_implementedfalseUnknown method, or one you do not implement
adapter_errortrueYour code failed (the flag is not yet acted on: Limits)
bad_requestfalseparams did not match the method's schema (the message is <method>: <path>: <problem>), or you refused the request (a cursor you never minted)
protocol_versionfalseThe request's protocolVersion is not "2"

Outside it:

Use your own code for anything else; set retryable honestly.

Build your first adapter #

The Acme CRM is a complete protocol-2 adapter in one dependency-free file: undefined. It has one type, Company, with a comment thread. It uses both push doors and the host door's methods, and it pages. Read it next to this walkthrough, and drive it with listen-fire, the same binary a user runs your adapter under.

listen-fire has no adapter conformance command. These three commands make up the check, and each runs against the example project:

The doors themselves are held to the cases every host's doors pass by listen-fire --contract-doors, which is not a command to type: it serves the doors for the contract suite over a JSON-RPC bridge on stdin and stdout. Run it as node --conditions=movement-engine-internals --test --require tsx/cjs src/__test__/inbound_contract.test.ts from apps/cli.

  1. Install the example and keep listen-fire check green as you build. Copy examples/lf-acme to a folder of your own, give it its secrets, and check: bash cp -R examples/lf-acme my-adapter && cd my-adapter mkdir .lf && cp secrets.example.yaml .lf/secrets.yaml && chmod 600 .lf/secrets.yaml listen-fire check # 1 file(s), 0 error(s) listen-fire catalog # the catalog, with acme_crm's types, fields and the listenable Comments edge To build your own, replace externals/acme_crm/ with a folder of the same shape (lf-external.yaml names the command that serves it, or a url: already serving) and put your credential in .lf/secrets.yaml. listen-fire refuses a secrets file other users can read. listen-fire catalog shows what describe published: a field you meant to be writable, a file field, a missing listenable edge, are all visible there before any movement uses them. Then deliver a webhook. The example verifies HMAC-SHA256 of the raw body in x-listen-fire-signature, keyed by the credential's webhook_secret: bash SIG=$(openssl dgst -sha256 -hmac dev-acme-webhook-secret -hex < events/webhook_body.json | awk '{print $NF}') listen-fire run --webhook acme_crm --body events/webhook_body.json --install acme \ --header "x-listen-fire-signature:$SIG" Without the header, or with a wrong one, it refuses (signature did not verify for external "acme_crm") and runs nothing. listen-fire run --event events/company_created.json routes one event, as your server's preprocessInbound would have shaped it, to the listens it matches. listen-fire runs and listen-fire runs show <run-id> show what each run saw. The fake CRM passes all of this. What a real host does with a push is step 7.
  2. Describe your data. listEntryPoints publishes Company (writable, fires) and Comment. describe gives Company its fields and its Comments edge, declared listenable (with identity: "Id", a field Comment declares, and sequenced).
  3. Implement the required methods. resolveEntity always answers no match, so every write creates. createRecord stores the company and returns its id and fields. getFieldValue reads the position's data.
  4. Serve it. In TypeScript, wrap your adapter with the reference kit: ts const handler = createAdapterProtocolHandler({ adapter, authenticate: (headers) => headers['authorization'] === `Bearer ${secret}`, }); http.createServer((req, res) => void handler(req, res)).listen(port); The kit answers manifest, checks the version, validates params, maps throws to adapter_error, and serves paging. In another language, follow the envelope.
  5. Make the thread awaitable. awaitKeys answers thread:<company id> for each company, and { "gone": true } for one the CRM deleted. Its getRelated lists a company's comments oldest first, so a plain await takes one already there.
  6. Push. When somebody comments, the CRM POSTs the comment as an arrival, { "arrivals": [{ "edge": "Comments", "key": "thread:<company id>", "identity": "<comment id>", "landing": … }] }, to /wake. When somebody creates a company, it POSTs the company as an arrival on Company to /wake too. Both send Authorization: Bearer <secret>.
  7. Try it under listen-fire serve. Start clean (rm -rf .lf/state.db .lf/externals, so the ids below are the CRM's first) and replace the example's movements/companies.mvt with movements/discuss.mvt: crm = connect #acme_crm using acme function discuss(company: <crm-[:Company]->>) { thread = write crm-[:Company]-> { Name: "Discuss ${company.Name}" } c = await thread-[:Comments]-> write crm-[:Company]-> { Name: "Answered ${c.Text}" } } listen to crm-[:Company]-> fire discuss Serve the project and read the port listen-fire started the CRM on; the example keeps its companies in .lf/externals/acme_crm/state.json, and the CRM pushes to the host's doors with the credential listen-fire hands it: bash listen-fire serve --port 5871 & # wait for its address line, and for .lf/externals/acme_crm/port to appear CRM=http://127.0.0.1:$(cat .lf/externals/acme_crm/port) curl -s -XPOST $CRM/dev/companies -d '{"Name":"Larch Bio"}' # the CRM pushes the company's arrival to wake: {"id":"company-1","arrived":{…}} # listen-fire serve prints: run … discuss:discuss parked (it wrote "Discuss Larch Bio", then awaits its first comment) curl -s -XPOST $CRM/dev/comments -d '{"company":"company-2","text":"approved"}' # the CRM pushes the comment's arrival to wake; listen-fire serve prints: resumed … discuss:discuss completed curl -s $CRM/dev/state # every company the CRM holds, ending in "Answered approved" The doors refuse what they should. A wake presenting a credential no install holds answers 401, and one naming keys rather than arrivals answers 400: bash curl -s -o /dev/null -w "%{http_code}\n" -XPOST -H 'authorization: Bearer nope' \ -H 'content-type: application/json' -d '{"keys":["thread:company-1"]}' \ http://127.0.0.1:5871/api/public/adapter/acme_crm/wake # 401 Stop listen-fire serve when done. listen-fire runs show <run-id> shows the park and the wake that ended it.

Changes #