The adapter protocol
Version: 2
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 lf-platform, apps/api/src/scripts/dev/fake_crm_adapter.ts (see Build your first adapter).
Contents:
- The envelope
- Transport
- Versioning
- Authentication
- The manifest and installing
- Methods
- Waiting on your system (awaits)
- Telling Listen-Fire something happened
- Modules: credentials, signatures and schedules
- Paging
- Files
- Limits and timeouts
- Errors
- Build your first adapter
- Changes
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" }
}
protocolVersionis the string"2".cacheScopeIdis opaque. You may scope a per-call cache by it, or ignore it.paramsis the method's input (see Methods).
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 } }
- An application failure is still HTTP 200, with
ok: false. - Non-200 is for transport faults only: 401 when authentication fails, 400 when the body is not a request envelope. Listen-Fire treats any non-2xx as a transport error, marked retryable (see Limits for what that does today).
- Listen-Fire validates every
resultagainst the method's schema. A wrong shape fails the call and names the method and the offending path.
Types, fields and edges each have two names, and the two may differ:
typeId(and a field'sfieldId) is your own id. Listen-Fire hands atypeIdback todescribeand reads it in a reference'stargetTypeId. Keep it stable.displayName(a reference'sname, for an edge) is the natural name. Automations write it (crm-[:Company]->), and every other call names the type, field or edge by it:recordType: "Company", the keys offields,edge: "Comments". It is not just presentation: renaming it breaks the saved automations that use it.describemay be asked by either name, so accept both. Simplest: give each type one name and use it for both.
Record ids are identity, and cross the wire unchanged.
Transport #
Every module kind — adapter, plugin, and every core slot (store, models, judge, files, limiter, loopGuard, browser) — is reached this one way: this envelope over HTTP. Where it listens decides how:
| where the module runs | its address | how |
|---|---|---|
| on the host's box, started by the host | unix:<socket path> | HTTP/1.1 on a Unix socket |
| on the host's box, where there are no sockets | http://127.0.0.1:<port>/ | loopback TCP |
| elsewhere (a remote module: only a manifest on the host) | https://… | HTTPS |
- A module the host starts is told where to listen:
MODULE_SOCKET(a socket path) when set, elsePORT(0picks one). It requiresAuthorization: Bearer <MODULE_TOKEN>when that is set, and prints one line on its stdout once it answers:listening unix:<socket path>, orlistening <port>. - A URL below a socket (a file a module serves) is
http+unix://<socket path, percent-encoded>/<path>. - A remote module is an
lf-external.yamlwithurl:(andauth:naming the install field holding its secret). A credential goes only over HTTPS, a socket or loopback. - Outgoing auth. A remote module behind a platform's own sign-in (Cloud Run with IAM, a proxy) is reached with a second credential, set per install:
outgoing_auth: google-identitysends a Google identity token foroutgoing_audience(default: the URL's origin), minted by the metadata server;outgoing_auth: bearersendsoutgoing_token. It goes inoutgoing_header, by defaultauthorization, orx-serverless-authorizationwhen the module's own secret holdsauthorization. The module never sees these fields as settings.
Versioning #
- A request carries
"protocolVersion": "2". A server built on the reference kit answers any other version with the error codeprotocol_version. - Your
manifestanswer carries"protocolVersion": 2(a number). - Installing probes your server: Listen-Fire calls
manifestwith the secret and refuses the install unless it answersprotocolVersion: 2. - An install made before its secret is supplied stores the version the manifest file claims. The probe runs when the secret is linked.
- Additions that older servers can ignore (a new optional field, a new optional method) do not change the version. A change in meaning does.
- Within a version, the two sides learn what the other speaks from the manifest, never from the version: a method is called only when it is listed in
methods, and a declaration (credentials,inbound,poll,periodic) is acted on only when it is present. An older server sends neither and is unchanged; an older host ignores both. - The version is an integer on the manifest and its decimal string on a request.
MANIFEST_PROTOCOL_VERSIONandPROTOCOL_VERSION(packages/engine-contracts/protocol.ts) hold it, and a test holds them equal to the version at the top of this page. The contracts index lists what each release supports.
Authentication #
Listen-Fire sends your secret on every call. Your manifest picks how:
authStrategy | Header 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> |
- Check the header on every request; answer HTTP 401 when it is missing or wrong.
- The secret is entered at install time and stored encrypted. It never goes in the manifest.
- The secret belongs to the INSTALL: every call to your server carries it, whichever user the call acts as. Who the call acts as at your provider rides separately, as the request's
credential(Credentials). - Every call to your server carries
x-listen-fire-team: <team id>, the UUID of the team whose install is calling. A server that serves several teams learns from it which one. It is not a credential: authenticate by the secret. - When your server calls Listen-Fire (the push doors), it sends the same secret. The team header is optional there; send it only to disambiguate a secret shared across teams. See the doors.
The manifest and installing #
The manifest file registers your server with a team:
{
"protocolVersion": 2,
"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", "wakeKeys", "resolveAwait",
"ensureEventSubscription", "removeEventSubscription"
]
}
adapterTypeis the slug an automation imports (import { acme_crm } from adapters). One install per slug per team. Charset:^[a-z][a-z0-9_]*$(ASCII lower case). It may not be a name a built-in adapter answers to (slack,gmail,inbound_email,kg_mutation, …, in any casing), norcallbackorask: the install is refused.methodslists exactly what you implement. Listen-Fire never calls an optional method you did not list.- The file is what Listen-Fire uses. Installing calls your
manifestmethod only to check itsprotocolVersionand its shape;methods,supportedTriggersandruntimeCapabilitiescome from the file. Keep the two answers the same. supportedTriggersis["webhook"]if your system announces events, else[].description(optional): one or two sentences about your system. An author's editor shows it when they hover your adapter or a connection to it.- Optional fields:
handbookSection({ title, content }, prose the authoring handbook shows about your system),vocabulary(an icon and event phrasing),webhookEventTypeId. - Optional module declarations:
credentials,inbound,pollandperiodic(Modules). - What a listen on your adapter may say, also optional:
subscribableEvents: the values of a listen'sevents: [...](listen to crm { events: ["company.created"] } fire welcome). An event is routed to that listen only when itseventTypeis one of them.defaultSubscribedEvents: what a listen with noeventsreceives (absent means every subscribable event).listenConfig: further keys, as[{ key, required?, format? }], whereformatiscron,timezone,fieldsorstrings. Declare them and the checker refuses any other key or event. Declare none and a listen's config goes unchecked.
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", "wakeKeys", "resolveAwait",
"ensureEventSubscription", "removeEventSubscription"
]
}
The hosted product reads only protocolVersion from it (see installing). lf, 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, "fires": true,
"description": "A company. A listen is delivered each one somebody creates in the CRM." },
{ "typeId": "Comment", "displayName": "Comment", "writable": false, "readable": false }
]
writable: automations may create this type.readable: automations may enumerate it from the root.fires: a listen is delivered records of this type (see emit). An event type is usuallyreadable: false: it is pushed, never listed.- Publish every type your edges reach, even one only reached along an edge (
Commentabove), so it is named. description(optional): what a record of this type is. An author's editor leads with it when they hover the type, a walk to it or a parameter holding one. Adescriptionon the type'sdescribeanswer wins over this one.
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, "awaitable": true, "watchable": true, "resolvesEmpty": true,
"description": "What people said about the company, oldest first." }
]
}
- A field's
kindis one ofstring,number,boolean,date,file,jsonorenum(withenumValues), orreference(an edge, not a value). A field with any otherkindis silently dropped, and the type's position is then left open: automations may read any field name off it unchecked. Add"cardinality": "many"for a list. - Fields and references pass through with whatever extra keys you declare. On a reference Listen-Fire reads:
readable— defaults to true;falsemarks a write-only edge;writable— only an explicittrueallows creating along the edge; absent means read-only;awaitable,watchable,resolvesEmpty(see awaits).
description(optional) on the type, a field or a reference: what it is, in a sentence. An author's editor shows it when they hover the record, the field or the walk along the reference. A write-only field's description is also quoted when an automation tries to read it, so say there where the value can be read instead.deletable(optional, on the type):truesays yourdeleteRecordremoves a record of this type. Absent means no. Under language version 5 (Dancing Wren) adeleteof a type that does not say"deletable": trueis refused before the automation is saved (MOV_DELETE_NOT_DECLARED); automations pinned to an earlier version are unaffected.edgesFrom(optional) replacesdescribefor nested systems:{ position, cursor? }→{ descriptor, targetPositions?, nextCursor? }ornull. The adapter handbook's schema chapter covers it.
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" } } } } ]
- Every position you return must name its
recordType; a typeless one is rejected. where,orderByandlimitmay arrive as hints. You may ignore them; Listen-Fire applies them again.- For a large collection, implement undefined too.
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" } }
fieldsis keyed by your field display names.parentLinks(optional) are parents to attach the new record to, along the named edge. A link may carrydata: the parent's own payload, in its adapter's shape.- Optional inputs:
evidence(per field),resources,bridgeToExternal. - The
datayou return is what an automation reads off the written record, and what undefined later receives asheadData. - Optional outputs:
url,events.
updateRecord #
The createRecord input plus externalId →
{ "adapterType": "acme_crm", "externalId": "company-1", "recordType": "Company", "data": { "Name": "Vireo" }, "association": "none" }
- If the record is gone, return
{ "notFound": true }; do not throw. associationsays what became of theparentLinks:made,already,unsupported(you cannot attach an existing record along that edge) ornone(no parent was named). Omitted, it reads asunsupported.
deleteRecord #
{ "recordType": "Company", "externalId": "company-1", "mutationContext": { … } } → {} (optionally { "events": [] }).
Asked only for a type whose descriptor says "deletable": true: under language version 5 an automation cannot delete any other.
Optional methods #
| Method | params | result |
|---|---|---|
iterateRelatedPage | getRelated params + cursor? | { items, nextCursor? } (Paging) |
resolveAwait | { recordId, edge, headData? } | pending / landed / empty (awaits) |
wakeKeys | { raw } | string[] (awaits) |
preprocessInbound | { raw, checkpoint? } | { events, checkpoint? } — required to receive webhooks through the host door |
listEventTypes | {} | [{ tag, positionType, match? }] |
ensureEventSubscription | { events, callbackUrl, current?, scope? } | { externalId?, secret } |
removeEventSubscription | { callbackUrl, externalId?, scope? } | null |
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 } | [{ identity, source }] |
extractActor | { event } | an 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 | { config, checkpoint? } | { events, checkpoint? } (Modules); needs poll declared |
refreshCredential | {} | { fields } (Modules); needs credentials declared |
runPeriodic | { name } | null (Modules); needs periodic declared |
checkInbound | { request, secret?, checkpoint? } | accepted / refused / reply (Modules); needs inbound.scheme: module |
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 FIRST(c-[:Comments]->)
The run stops at the await and resumes when a comment arrives. Listen-Fire owns all the waiting state. Your server answers two questions: "has it happened yet, and if not, what key will you wake it with?" (resolveAwait) and "which keys does this event wake?" (wakeKeys or the wake door).
Declaring an awaitable edge #
On the reference in describe:
awaitable: true— automations mayawaitit.watchable: true— you will wake waiting runs (throughwakeor a delivery to the host door). Without it,await FIRST(…)is refused when the automation is saved, and the author is told to poll instead.resolvesEmpty: true— the wait can end with nothing (for example, the record was deleted). Then what follows theawaitruns zero times.
resolveAwait #
{ "recordId": "company-1", "edge": "Comments", "headData": { "Name": "Vireo Robotics" } }
recordIdis theexternalIdyou returned when the record was written.headDatais thedatayou returned with it, when there was any.
Answer one of three:
{ "status": "pending", "correlationKey": "thread:company-1" }
{ "status": "landed", "correlationKey": "thread:company-1",
"landings": [ { "recordId": "comment-1", "recordType": "Comment", "fields": { "Text": "approved" } } ] }
{ "status": "empty" }
pending: nothing yet.correlationKeyis required: it is the key you will later wake with. Listen-Fire stores the key against the waiting run.landed: here is what arrived.correlationKeyis required here too: if no landing passes the automation'sWHERE, the run keeps waiting on it.empty: the wait is over with nothing, and what follows theawaitruns zero times. Only on an edge declaredresolvesEmpty. Listen-Fire does not yet enforce this: anemptyon any other edge is bound anyway, and its downstream silently runs zero times where the author never planned for it. Never return it there.- Each landing's
fieldsare read back through yourgetFieldValuefor itsrecordType, so key them the way your positions'datais keyed. - The first landing that passes the
WHEREwins.
When Listen-Fire calls it:
- once at the
await; - once more right after the run is parked, so a wake that arrived between the two is not lost;
- again on every wake for the run's key.
Make it a cheap, current read of your system. It must be safe to call any number of times.
Waking a run #
Wake by key, two ways:
- push the keys yourself:
POST …/wake(the doors); - or, if Listen-Fire receives your webhooks (host door), implement
wakeKeys:{ "raw": <the delivery body> }→["thread:company-1"]. Listen-Fire calls it for every delivery, whether or not the delivery also carries events. IfwakeKeysfails, the delivery's events are still receipted and queued, and the delivery is answered 500 so you send it again; the resend replays the events without running them twice and retries the wakes.
Commit before you wake. A wake makes Listen-Fire call resolveAwait for the key at once. If the landing is not yet visible to that read (still in an open transaction, or on a replica that lags), the run parks again on pending and nothing follows: no second wake comes unless you send one.
A wake for a key nobody is waiting on does nothing. A run woken early simply calls resolveAwait again and parks again on pending.
Cancelling a waiting run is Listen-Fire's alone: it drops its key. A later wake for that key finds nothing.
Telling Listen-Fire something happened #
There are two ways in. Use whichever fits your system:
- You push to the two doors (
emit,wake). Simplest: no subscription, no signatures. - Listen-Fire subscribes to you and you deliver webhooks to the URL it gives you (the host door). Use this when your system already sends webhooks.
The two are independent and unordered. Nothing relies on a wake arriving before or after an event.
The two doors you call #
POST {listen-fire}/api/public/adapter/{adapterType}/emit { "events": [ … ] }
POST {listen-fire}/api/public/adapter/{adapterType}/wake { "keys": [ … ] }
Authentication:
- Send the same header, with the same secret, that Listen-Fire sends you (your
authStrategy). The install whose secret matches is the team. - You never name the team, except with the optional
x-listen-fire-teamheader (the same header, and the same team id, Listen-Fire sends on every call to you). Use it when one secret serves several teams' installs of your adapter: it narrows the lookup to that team. Its value must be the team's id, a UUID; anything else is answered 401 with no further detail.
Answers:
| Status | Body | When |
|---|---|---|
| 202 | { "accepted": n } | queued |
| 400 | { "error": "bad_request", "issues": [{ "path", "message" }] } | malformed body |
| 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 |
| 413 | the body is over 1 MB | |
| 500 | { "error": "capture_failed", "accepted", "failed" } | some events could not be receipted (see below) |
| 500 | { "error": "internal" } | retry later |
- Authentication is checked before the body, so an unauthenticated caller learns nothing about the body's shape.
- 401 is the only refusal for anything unauthenticated: never 404.
- A body carries 1 to 1,000 events or keys, in at most 1 MB of JSON (
content-type: application/json). - Delivery is at least once. Retry on 5xx or a network failure.
- A
capture_failed500 onemitis partial: the events counted inacceptedwere receipted and queued before the 500. Sinceemitis not idempotent, resending the whole batch runs those again. Resend only the events that failed, or accept the duplicate runs.
emit: new data #
{
"events": [
{
"payload": { "id": "company-3", "Name": "Larch Bio" },
"externalId": "company-3",
"tag": "company.created",
"eventType": "company.created",
"changeType": "create",
"recordType": "Company"
}
]
}
- Each event is matched against the team's listens on your adapter, exactly as a webhook delivery's events are.
tagis matched against yourlistEventTypesto decide the event's type. payloadis what the automation reads: it becomes the delivered record's data, read through yourgetFieldValue.- Every field but
payloadis optional; there are alsoactor,occurredAtandchangedFields. acceptedcounts receipts: one per listen the event matched. An event no listen selects is accepted as 0 and leaves nothing behind.- One malformed event fails the whole request with 400; nothing is queued.
emitis not idempotent in protocol 2. Sending an event twice runs its automations twice. (A dedupe key is a planned addition.)- While your request is open, Listen-Fire calls you back (
listEventTypes). A single-threaded server must not block on its own push.
wake: resume waiting runs #
{ "keys": ["thread:company-1"] }
acceptedis the number of keys queued. A key nobody waits on is accepted and does nothing.
Inbound through the host door #
If your system sends webhooks, let Listen-Fire subscribe:
- While any automation listens to your adapter, Listen-Fire calls
ensureEventSubscriptionon every reconcile of the team's listens (each time an automation is saved or deleted), even when nothing changed:json { "events": [], "callbackUrl": "https://listen-fire.example/api/public/webhook-sync/acme_crm/5f0c…" }→json { "externalId": "crm-sub-1", "secret": "8c1d…" }eventsis the selection. An empty selection means every event you offer.- Make it an idempotent upsert.
current(the previousexternalIdandevents) arrives when a subscription already exists; update 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
callbackUrlas opaque. It is stable for the install. secretis required. Without one the subscription is refused, withdrawn (removeEventSubscription), and reported as a problem when the automation is saved.
- Deliver each webhook as a
POSTtocallbackUrl, 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 ascontent-type: application/json, at most 100 KB. Listen-Fire answers: | Status | When | |---|---| | 200{ "ok": true, "eventsProcessed": n }| accepted | | 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 | | 500 | your adapter does not implementpreprocessInbound;wakeKeysfailed or its wakes could not be queued (the events are already receipted: send the delivery again); or an internal failure | - While your delivery is open, Listen-Fire calls you back:
preprocessInbound({ raw })turns your body into events,wakeKeys({ raw })turns it into keys, andlistEventTypestypes the events. A single-threaded server must not block on its own delivery.json { "raw": { "companies": [ { "id": "company-3", "Name": "Larch Bio" } ] } }→json { "events": [ { "payload": { "id": "company-3", "Name": "Larch Bio" }, "externalId": "company-3", "tag": "company.created" } ] }checkpointis optional: whatever you return is handed back on the next delivery. - 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.
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 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
}
}
]
nameis ASCII lower case. It names the credential inlf.yaml, a connect link and the request.kindisstaticoroauth2:staticis entered once and never refreshed by the host (an API key, a bot token). It holds exactly itsfields.oauth2is connected through the provider's authorize page. It holdsaccess_token,refresh_tokenandexpires_at(an ISO time) besides itsfields, and needs theoauth2block.
- Each field is
{ name, label?, secret?, optional? }. A field is secret unless it says"secret": false: the host keeps secret fields out of logs, traces and error text. oauth2:authorizeUrlandtokenUrl(required),scopes,clientAuth(basic, the default, sends the client id and secret as an HTTP Basic header on the token call;bodysends them in the form),pkce, andauthorizeParams(extra query parameters for the authorize page).
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; lf'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" } }
}
- Every field value is a string.
- The fields sent are the ones the declaration names, plus
access_token,refresh_tokenandexpires_atfor anoauth2one. Anything else the host keeps beside them (its client secret, say) is never sent. credentialis the one the connection names, under the declared credential it fills. Never more than one: the request has nocredentialslist (an older host that sends one is ignored). A module that declares none is sent none.- The host sends a credential only to an installed module that declares it, and only over loopback or HTTPS.
- Never log it, echo it in an error, or store it beyond the call.
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": "…" } } }
- Only the fields you name change; the host merges them over the stored ones and writes them back to the credential the call carried (Alice's update lands on Alice's credential, never Bob's).
- Name only fields your declaration names (and the three token fields of an
oauth2credential). An update naming any other field is refused, and the call fails. credentialUpdatemay ride a failure (ok: false) too. Send it whenever a token changed, or a rotated refresh token is lost.
Refreshing:
- The host refreshes an
oauth2credential itself, with the standard refresh-token grant attokenUrland the install's OAuth app, before a call whenexpires_atis near and once more when a call fails as unauthorized. - A provider whose refresh is not the standard grant implements
refreshCredential:{}→{ fields }, the changed fields. The credential to refresh is the request'scredential. - The host runs at most one refresh of a credential at a time, so two calls never spend the same refresh token.
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"]
}
providerKeyis the door's provider segment:/api/public/webhook-sync/<providerKey>/<subscription>. A built-in keeps today's key, so no subscription's URL changes.schemeis one of: |scheme| Provider | |---|---| |hmac_sha256_hex| lower-case hex HMAC-SHA256 of the raw body | |airtable_mac| Airtable'sx-airtable-content-mac| |attio| Attio's signature | |affinity_hex_or_b64| Affinity's signature, hex or base64 | |slack_v0| Slack'sv0signature over the timestamp and body | |telegram_secret_token| Telegram's secret token, compared whole | |remote| this protocol's ownx-listen-fire-signature(host door) |signatureHeaderslists the headers that may carry the signature, in lower case; the first one present is read.slack_v0also readsx-slack-request-timestamp, which the scheme fixes.intercepts(optional) names requests the host answers itself, unsigned, before verifying:slack_url_verificationechoes Slack'schallenge.secretIssuedBy(optional) says who sets the key deliveries are signed with.installer, the default: whoever installs the module sets it (lf connectgenerates one or takes the one typed).provider: the provider issues it when the webhook is registered, asensureEventSubscription'ssecret; nobody is asked for it, and a registration that returns none is refused (and withdrawn). Attio and Airtable issue theirs.- One host rule for every scheme: a delivery is refused when the key it should be signed with is missing, blank or still the placeholder a subscription holds before its registration returns a secret, and when its signature is wrong. No scheme is lenient for a module (a built-in keeps its door's behaviour only until it moves onto a module manifest).
- A scheme or intercept the host does not know refuses the manifest; it is never silently left unverified. A provider no scheme fits checks its own requests (below).
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" }
- No
signatureHeadersand nointercepts.secretIssuedByis as above. - The door takes
POSTandGETfor it (onlyPOSTfor a declared scheme). - The host hands each request to
checkInbound, as it arrived, and sends what the module answers. It verifies nothing itself, and calls neitherpreprocessInboundnorwakeKeysfor it.
{
"request": {
"method": "POST",
"path": "/api/public/webhook-sync/email/mail",
"query": "",
"headers": { "content-type": "application/x-www-form-urlencoded" },
"body": "c2VuZGVyPWFkYSU0MGV4YW1wbGUuY29tJi4uLg=="
},
"secret": "key-3b2f…"
}
bodyis the exact bytes, base64. Header names are lower case, each with its first value.secretis the subscription's key, present only when it is usable (not missing, blank or the placeholder before registration). Refuse when you need it and it is absent.checkpointis what your lastacceptedanswer returned.- The request carries the install's credential, as any call does: the keys a check reads (a verify token, the API key a thin notification is fetched with).
The answer is one of three:
outcome | Fields | The door answers |
|---|---|---|
accepted | events, wakeKeys?, checkpoint? | 200 { ok, eventsProcessed } once the events and wakes are queued, as for any delivery |
refused | status? (a 4xx) | status, default 401, { "error": "signature_invalid" } |
reply | status, text? or json? | exactly that: text as text/plain, json as JSON, neither 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 }
- The host calls
polleveryintervalSecondsfor each listen on the module; a listen's owneveryoverrides it. poll:{ config, checkpoint? }→{ events, checkpoint? }.configis the listen's options.checkpointis whatever the previous poll returned, absent the first time. Each event has theemitdoor's shape (emit).- Return only what is new since the checkpoint. A backfill is not a poll.
Housekeeping on a schedule, per install (renewing a webhook the provider expires, say):
"periodic": [{ "name": "renew_webhook", "intervalSeconds": 86400 }]
- The host calls
runPeriodic({ name })everyintervalSecondsfor each install, with that install's credential. It answersnull.
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)
packageis the tag, matched exactly and never parsed.- Each plugin's
nameis its registered name; a program calls it by the camel case of it (fetch-url→fetchUrl). Two names that spell the same (fetch-url,fetch_url) are one plugin declared twice, and refused. specis what the checker reads: the arguments a call may write, their types, the required ones, its effects, whether it may cost money (priced), what it hands back (output), the arguments athrough [ … ]stage feeds it (fedArgs), andfedByExtractionfor a plugin that only reads what the stage extracted. Its optionaldescriptionsays what the plugin does; an author's editor shows it when they hover a call.pluginspairs with the methodinvokePlugin:{ name, config, extractedContext }→{ text?, data?, records?, outcome?, costMicrodollars?, files?, warnings? }.configis the author's arguments by name;extractedContextthe fields extracted so far inside a stage (empty otherwise). The answer is what the plugin added: fetchedtextto extract from,datafields,records(one per thing it fetched, for a records output), anoutcomefor the trace, and what its own work cost that nothing else priced.files(a plugin that makes a file):{ "<field>": { name?, contentType?, base64 } }. Bytes do not ride the envelope as a URL a plugin could serve, so the plugin hands them over inline and the host stores each file (the platform under the team's key prefix, lf as one of the run's documents) and puts the file it made atdata.<field>. A plugin declaring avalueoutput of typefileanswers it asfiles.value: the call's value is that file.warnings: text the run's author should know; each is a warning on the run (MOVENG_PLUGIN_WARNING) naming nothing but what the plugin said. A plugin that did what it could, and tells you what it could not (@lf/pdf: characters its default font cannot draw).- A plugin module answers
manifestwithsupportedTriggers: []and lists onlymanifestandinvokePlugin(andrefreshCredentialif it needs one) inmethods.
Kinds, settings, spend and the host's models #
- Kind. The manifest may say what the module is:
"kind": "adapter","plugin"or"core". Acoremodule fills one of the host's core services and names it in"slot":models,judge,store,files,limiter,loopGuardorbrowser. The host names one module per slot (lf.yaml'score:); a slot's contract is its methods and its conformance suite: store, limiter, loopGuard, models, judge, files, browser, each over the transport. A core module answersmanifestwith{ protocolVersion: 2, name, kind: "core", slot, methods }. Withoutkind, a module is an adapter, or a plugin package when it declaresplugins. - Settings. Construction arguments the install supplies (an Airtable
base, a Sheetsspreadsheet) are declared, each{ name, label?, secret?, optional? }:json "settings": [{ "name": "base", "label": "The Airtable base id" }]The host asks for them when the module is set up (lf connect, the platform's install screen) and sends them on every request as"settings": { "base": "app123" }. - One credential. A request carries
credential, the one the connection names, under the declared alternative it fills. Read it fromctx.credential;ctx.credential.namesays which alternative it is. OnecredentialUpdaterides an answer: that credential's. - A refused token. Answer
ok: falsewith the codecredential_refusedwhen the provider refused the request's credential. The host refreshes it (its OAuth 2 grant, or yourrefreshCredential) and retries the call once. - Spend. An answer (on success or failure) may carry what the call spent on a paid service:
json { "ok": true, "result": { … }, "cost": [{ "microdollars": 1500, "label": "enrichment-api" }] }The host adds each line to the spend of the run that made the call, so the run's cost cap (maxRunCostUsd) counts it. - The host's models. A module that declares
"hostModels": truemay ask the host's models slot for a model call:POST {listen-fire}/api/public/adapter/{adapterType}/models { "label": "affinity.formatLocation", "system": "…", "userMessage": "…", "model": "claude-sonnet-5" } → { "text": "…", "cost": [{ "microdollars": 812, "label": "anthropic/claude-sonnet-5" }] }Authentication is the push doors'. Aschema(a JSON schema, withtoolNameandtoolDescription) asks for the reply asdata. The door bills no run itself: add itscostto thecostof the answer whose work asked it. The module kit does both for you. - The host's OCR. Any module the host started may ask its ocr slot for the text of a scanned PDF or an image (ocr-slot.md):
POST {listen-fire}/api/public/adapter/{adapterType}/ocr { "bytes": "<base64>", "contentType": "application/pdf", "label": "web.vc-url-retrieval" } → { "text": "…", "pages": 12, "cost": [{ "microdollars": 18000, "label": "document-ai/ocr" }] }Authentication is the models door's. An empty slot answers 503ocr_slot_empty, saying so. Like the models door it bills no run itself; the module kit'sctx.ocrcharges the read to the answer that asked. - Limits. The manifest may declare the provider's limits on calls, so the host never sends more than it allows:
json "limits": [{ "scope": "credential", "calls": 100, "per": "10s" }, { "scope": "module", "concurrent": 4 }]Each limit counts perscope:credential(one user's grant),module(every install on the host),installorteam.callsandper(a duration:500ms,10s,1m,1h,1d) go together;concurrentcaps calls at once. The host holds a call until it is within all of them (its limiter slot). - The limits door. One call to a module can become several requests to its provider, plus retries. A module that declares
"hostLimits": true(its author's choice, never implied bylimits; see the last bullet) asks the host before each of those requests, so itslimitscount them rather than the host's calls. On each call the host hands it the door:json { "method": "listEntryPoints", …, "hostLimits": { "url": "http://127.0.0.1:4100/api/public/adapter/%40acme%2Fcrm/limits", "ticket": "…" } }Before each provider request, and after it:POST {hostLimits.url} Authorization: Bearer {hostLimits.ticket} { "op": "acquire" } → { "outcome": "granted", "permit": "p7" } or { "outcome": "wait", "retryAfterMs": 400 } { "op": "release", "permit": "p7" } → {} { "op": "throttle", "retryAfterMs": 30000 } → {}- The door never waits. On
wait, wait that long and ask again. throttlereports a provider's "too many requests" (HTTP 429) and itsRetry-After. Every request counted as the same credential (or install) is held until then, the host's own calls included.- The ticket names the call to the host. The host counts under the module, install, credential and team it resolved for that call; the module sees no counter and no scope's value.
- The ticket is good until the call answers. A permit not yet released then is released by the host.
- A call that carries
hostLimitsis not counted by the host itself, so no request counts twice. A call without it was counted by the host as a whole (lf offers the door only to a module on its own box, from a process that listens). The module then asks nobody. - The module kit does all of this inside
ctx.fetch. A module declareshostLimits(TypeScript:hostLimits: trueindefineModule; Rust:.declare("hostLimits", Js::Bool(true))) only if every provider request goes throughctx.fetch(TypeScript) orctx.permit()(Rust). A request made any other way (the globalfetch, another client) is counted by nobody, since the host stops counting that module's calls. The kit never declares it for you:limitsalone leaves the host counting each call once. Requests to the host's own address (its files, its doors) are not counted. The Rust kit has no HTTP client of its own: takectx.permit()before each request, and hand it back withrelease()orthrottled(retry_after). - A host that loads modules in process (the platform) or an embedder that hands no door is unchanged:
ctx.fetchis then the plain fetch, and the host counts calls as before.
- The door never waits. On
- Throttle. An answer (on success or failure) may carry
retryAfter, in seconds: the provider asked for quiet. The host sends no further call as that credential (or install, without one) until then.json { "ok": false, "error": { "code": "rate_limited", "message": "429", "retryable": true }, "retryAfter": 30 } - Event idempotency. An event (
emit,poll,preprocessInbound) may carryidempotencyKey, the provider's own id for the occurrence; a host that sees one key twice runs it once. A file ref'ssize(bytes) is kept wherever the ref travels.
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 first call has no
cursor. ReturnnextCursorwhile more remain and omit it on the last page. - The cursor is any JSON value you like. Listen-Fire echoes it back unchanged.
- Returning the same cursor you were given fails the walk (it would never end), and so does a walk past 10,000 pages.
- Refuse a cursor you did not mint with
bad_request(not retryable). On the TypeScript reference kit, throwAdapterBadRequestError; any other throw becomesadapter_error, which is marked retryable: a caller that honours the flag would ask again with the same cursor.
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.
- Into your system. A write may carry a file (in a field you declare a file, or a resource's
fileRef) with aurl:json { "__brand": "FileRef", "name": "deck.pdf", "contentType": "application/pdf", "source": { "ownerAdapterType": "email", "handle": "…" }, "url": "https://listen-fire.example/api/files/blob/abc123" }GETtheurlat write time; it is short-lived.__brandis the ref's spelling; the field it sits in is what makes it a file. Thesourcenames the owner type and its handle only: no ref Listen-Fire sends names its credential or construction args. The reference kit makes the value at each field you declare a file a file before your code sees it. - Out of your system. Return a file ref only at a field your catalog declares a file. Its
sourceis{ "ownerAdapterType": "<your adapterType>", "handle": "<your id>" }(or aurlalone), and you implementresolveFileRef:json { "ref": { "__brand": "FileRef", "name": "deck.pdf", "source": { "ownerAdapterType": "acme_crm", "handle": "f_42" } } }→json { "url": "https://crm.acme.example/files/f_42?sig=…", "contentType": "application/pdf" }Listen-Fire fetches the URL server-side, so it can be signed and short-lived. The reference kit answersresolveFileRefonly for a ref that already carries aurl; any other ref answersmethod_not_implemented. A ref returned at a field you do not declare a file reaches Listen-Fire as data: it is never read as a file (the conformance CLI'sfiles-declaredcheck names such a field).
Limits and timeouts #
- Push doors (
emit,wake): a JSON body of at most 1 MB, carrying 1 to 1,000 events or keys. A larger body is answered 413 before anything is read. - Host webhook door: the raw body is read only for
content-type: application/json, up to 100 KB. A larger body is answered- A body of any other content type is not read, so its signature cannot verify (401).
- Calls to your server: Listen-Fire sets no timeout of its own; Node's
fetchdefaults apply (about 5 minutes for the response headers, and 5 minutes between body chunks). A call is never retried in place, and theretryableflag is not yet acted on: a failed call fails the automation run that made it (an install'smanifestcall fails the install).
Errors #
Inside the envelope (HTTP 200, ok: false):
code | retryable | Meaning |
|---|---|---|
method_not_implemented | false | Unknown method, or one you do not implement |
adapter_error | true | Your code failed (the flag is not yet acted on: Limits) |
bad_request | false | params did not match the method's schema (the message is <method>: <path>: <problem>), or you refused the request (a cursor you never minted) |
protocol_version | false | The request's protocolVersion is not "2" |
Outside it:
- HTTP 401
unauthorized: authentication failed. - HTTP 400
bad_request: the body is not JSON, or not a request envelope.
Use your own code for anything else; set retryable honestly.
Build your first adapter #
The fake CRM is a complete protocol-2 adapter in one file, in lf-platform: apps/api/src/scripts/dev/fake_crm_adapter.ts. 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.
Its paging is exercised by its integration test (scripts/dev/__test__/fake_crm_adapter.integration.test.ts) and by the conformance CLI (step 1), not by an automation: Company is not readable from the root, so no automation can walk the collection.
- Run the conformance CLI against your server. Start with it and keep it green as you build. It is lf-platform's; run it from an lf-platform checkout. It needs no Listen-Fire stack and no database:
bash export ADAPTER_CONFORMANCE_SECRET=<your secret> pnpm adapter:conformance --url https://crm.acme.example/listen-fire --auth bearer \ --scratch-type Company --scratch-fields '{"Name":"Conformance Co"}' \ --sample-delivery '{"companies":[{"id":"c1","Name":"A"}]}' \ --wait-for-push 30It prints one line per check (PASS,FAILorSKIP, then the check's name and detail) and exits 1 on anyFAIL. The checks: the manifest and its version; the refusal of an olderprotocolVersion; the error envelopes (bad_requestnaming the method and path,method_not_implemented);listEntryPoints,describeand the natural names;resolveEntity,getRelated,iterateRelatedPage(paging),getFieldValue,createRecord,files-declared(no field answers a file ref unless your catalog declares it a file),updateRecord,deleteRecord;resolveAwait; the event subscription (a secret, a stableexternalId);listEventTypes,preprocessInboundandwakeKeys; and the two push doors. Flags:--url <baseUrl>and--auth bearer|secretare required. For--auth secret,--auth-header <name>names the header (defaultx-listen-fire-adapter-secret).- The secret is read from the environment variable
--secret-envnames (defaultADAPTER_CONFORMANCE_SECRET), never from the command line. --scratch-type <typeId>with--scratch-fields '<json>'names a type the CLI may write. It creates three records with those fields (the first string field gets#1,#2,#3appended), updates one, reads a field back and compares it with what it wrote, deletes them all, and uses them to check paging andresolveAwait. Without the flags, those checks are skipped with a notice.--sample-delivery '<json>'is a webhook body your server sends. The CLI cannot make you deliver one, so it hands this body topreprocessInboundandwakeKeysand checks the shapes.--wait-for-push <seconds>makes the CLI wait for your server to push to the doors. The CLI serves a stand-in for Listen-Fire's host and prints its URL on stderr (--host-port <n>fixes the port,--host-bind <address>picks the address, default127.0.0.1: use one your server can reach); point your server's host URL at it, then trigger an event and a wake. It checks the credential and the body of each push against the doors' own schemas. At0(the default) the door checks are skipped.--jsonprints the report as a JSON array. The fake CRM passes every check. The CLI cannot check what the real host does with a push; the dev loop in step 7 does.
- Describe your data.
listEntryPointspublishesCompany(writable, fires) andComment.describegivesCompanyits fields and itsCommentsedge, declaredawaitable,watchableandresolvesEmpty. - Implement the required methods.
resolveEntityalways answers no match, so every write creates.createRecordstores the company and returns its id and fields.getFieldValuereads the position'sdata. - 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 answersmanifest, checks the version, validatesparams, maps throws toadapter_error, and serves paging. In another language, follow the envelope. - Make the thread awaitable.
resolveAwaitanswersemptyfor a deleted company,pendingwiththread:<company id>for a thread with no comments, andlandedwith the comments otherwise. - Push. When somebody comments, the CRM
POSTs{ "keys": ["thread:<company id>"] }to/wake. When somebody creates a company, itPOSTs the event to/emit. Both sendAuthorization: Bearer <secret>. - Try it in lf-platform's dev loop (from an lf-platform checkout).
bash pnpm dev:loop:agent # starts the fake CRM with the stack pnpm dev:seed # installs it; the install probes for protocol 2Save these two automations (pnpm dev:movement provision --file <file>):import { manual, acme_crm } from adapters crm = acme_crm() runs = manual() function crm_review(go: <runs-[:Invocation]->>) { c = write crm-[:Company]-> { Name: "Vireo Robotics" } r = await FIRST(c-[:Comments]->) write crm-[:Company]-> { Name: "Vireo Robotics (reviewed)", Description: COALESCE(r.Text, "no comment") } } listen to runs {} fire crm_reviewimport { acme_crm } from adapters crm = acme_crm() function crm_intake(e: <crm-[:Company]->>) { write crm-[:Company]-> { Name: "Seen: ${e.Name}" } } listen to crm {} fire crm_intakeThen:bash pnpm dev:movement run crm_review # writes company-1, then waits pnpm dev:inspect crm # the wait, keyed thread:company-1 pnpm dev:inject crm-comment --company company-1 --text "approved" # the CRM pushes wake; the run finishes pnpm dev:inject crm-company --name "Larch Bio" # the CRM pushes emit; crm_intake runs pnpm dev:inspect crm # every company the CRM holds
Changes #
- The ocr slot and the host's OCR door (within version 2, no bump; 2026-10-20; additive). A ninth core slot,
ocr(ocr-slot.md), andPOST …/{adapterType}/ocrfor a module'sctx.ocr. - One transport for every module kind (within version 2, no bump; 2026-10-19). Every core slot's methods ride this envelope (Transport): a Unix socket on the host's box, loopback TCP without one, HTTPS for a remote module; the store, limiter and loop guard slots' stdin/stdout JSON-RPC wire is retired. New slot methods for models, judge, files and browser; a core module's manifest; per-install outgoing auth;
ctx.kv.compareAndSet. Additive for adapters and plugins: an older server is unchanged. - Conformance CLI. lf-platform's
pnpm adapter:conformancechecks a server against this protocol; run it first when building an adapter (Build your first adapter). - Version 2 (current; the only version this release speaks). Breaking for a server built against version 1:
- Requests carry
protocolVersion: "2", and themanifestanswersprotocolVersion: 2. Installing probes the server and refuses anything older. - Names on the wire are the natural display names;
typeIdandfieldIdare the server's own ids. - Params are validated, and a mismatch answers
bad_requestnaming the path. - New: awaits (
resolveAwait,wakeKeys), the two push doors (emit,wake), paging (iterateRelatedPage), and files by URL. - A webhook subscription must return a secret, and deliveries are signed.
- Clarified within version 2 (no bump; the reference adapter declares no file field, and conformance is unchanged for it): a file is read only at a field the catalog declares
kind: "file"or in a resource'sfileRef; a file ref anywhere else is data. File refs Listen-Fire sends (in a write, a position's resources, evidence) name no credential. The CLI gainsfiles-declared.
- Requests carry
inbound.secretIssuedBy(within version 2, no bump; additive, absent meansinstaller). A module says whether the installer sets its webhook signing secret or the provider issues it at registration; forprovider,lf connectasks for none (Declared inbound signatures).deletableon a type (within version 2, no bump; language version 5, Dancing Wren). A type'sdescribemay say"deletable": truewhendeleteRecordremoves one. The field is additive: an older server that never sends it still installs, speaks version 2 and passes conformance, and an automation pinned to language version 4 or earlier deletes as before. The ruling: no protocol version bump. Under language version 5 an adapter that omits it has everydeleteof its types refused when the automation is checked (MOV_DELETE_NOT_DECLARED); declare it to allow them.- Modules (within version 2, no bump). The manifest may declare
credentials,inbound,pollandperiodic; a request may carrycredential, and a responsecredentialUpdate; three optional methods,poll,refreshCredentialandrunPeriodic. All additive: an older server declares none of them and is unchanged, and an older host ignores them. The ruling: no protocol version bump. See Modules. - Descriptions an editor shows (within version 2, no bump; 2026-10-20). A manifest's
description, a type's, a field's and a reference's, and a plugin spec's, all optional. Additive: an older server sends none and is unchanged, and nothing is checked against them. See describe and Plugins. - Plugin modules (within version 2, no bump). The manifest may declare
plugins(a package of them), paired with the optional methodinvokePlugin. Additive: a server that declares none is unchanged, and an older host ignores the declaration. See Plugins. - The limits door (within version 2, no bump; 2026-10-20). The manifest may declare
hostLimits; a request may carryhostLimits(the door'surland the call'sticket). Additive: a module that declares none is counted per call as before, a host that hands no door counts the call itself, and an older module ignores the field. See the limits door. - One credential per request (within version 2, no bump; 2026-10-17). The request's
credentialslist is removed;credentialis the one the connection names. A module declaring several credentials declares alternatives. - Core modules, settings, spend (within version 2, no bump; 2026-10-17). The manifest may declare
kind,slot,settingsandhostModels; a request may carrysettings; an answer may carrycost; the error codecredential_refused; the host's models door; an event'sidempotencyKey. All additive: an older server sends and declares none of them and is unchanged, and an older host ignores them. See Kinds, settings, spend and the host's models. A store module may be a process speaking the store slot's wire. - Limits and throttles (within version 2, no bump; 2026-10-18). The manifest may declare
limits; an answer may carryretryAfter; thelimitercore slot and its wire; the store wire'swork.claimDuetakeskinds. All additive: an older server declares and sends none of them and is unchanged, and an older host ignores them. - A module checks its own inbound (within version 2, no bump; 2026-10-19).
inbound.schememay bemodule, paired with the optional methodcheckInbound: the host hands the raw request and sends the module's answer (events, a refusal, or a reply such as a handshake); the door also takesGETfor such a module. Additive: an older server declares no such scheme and is unchanged, and an older host refuses the unknown scheme rather than leave it unverified. See A module that checks its own inbound. - Version 1: the first protocol. Not supported: an install that answers it is refused, naming the version.