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 #

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, loopGuard, browser) — 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 #

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,
  "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"
  ]
}

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 }
]

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." }
  ]
}

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 language version 5 an automation cannot delete any other.

Optional methods #

Methodparamsresult
iterateRelatedPagegetRelated 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:

resolveAwait #

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

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" }

When Listen-Fire calls it:

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:

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:

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:

Answers:

StatusBodyWhen
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
413the body is over 1 MB
500{ "error": "capture_failed", "accepted", "failed" }some events could not be receipted (see below)
500{ "error": "internal" }retry later

emit: new data #

{
  "events": [
    {
      "payload": { "id": "company-3", "Name": "Larch Bio" },
      "externalId": "company-3",
      "tag": "company.created",
      "eventType": "company.created",
      "changeType": "create",
      "recordType": "Company"
    }
  ]
}

wake: resume waiting runs #

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

Inbound through the host door #

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

  1. While any automation listens to your adapter, Listen-Fire calls ensureEventSubscription on 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…" }
    • events is the selection. An empty selection means every event you offer.
    • Make it an idempotent upsert. current (the previous externalId and events) 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 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 | | 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 implement preprocessInbound; wakeKeys failed or its wakes could not be queued (the events are already receipted: send the delivery again); or an internal failure |
  3. While your delivery is open, Listen-Fire calls you back: preprocessInbound({ raw }) turns your body into events, wakeKeys({ raw }) turns it into keys, and listEventTypes types 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" } ] } 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.

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
    }
  }
]

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" } }
}

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, wakeKeys?, checkpoint?200 { ok, eventsProcessed } once the events and wakes are queued, as for any delivery
refusedstatus? (a 4xx)status, default 401, { "error": "signature_invalid" }
replystatus, 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 }

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.

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

  1. 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 30 It prints one line per check (PASS, FAIL or SKIP, then the check's name and detail) and exits 1 on any FAIL. The checks: the manifest and its version; the refusal of an older protocolVersion; the error envelopes (bad_request naming the method and path, method_not_implemented); listEntryPoints, describe and 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 stable externalId); listEventTypes, preprocessInbound and wakeKeys; and the two push doors. Flags:
    • --url <baseUrl> and --auth bearer|secret are required. For --auth secret, --auth-header <name> names the header (default x-listen-fire-adapter-secret).
    • The secret is read from the environment variable --secret-env names (default ADAPTER_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, #3 appended), updates one, reads a field back and compares it with what it wrote, deletes them all, and uses them to check paging and resolveAwait. 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 to preprocessInbound and wakeKeys and 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, default 127.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. At 0 (the default) the door checks are skipped.
    • --json prints 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.
  2. Describe your data. listEntryPoints publishes Company (writable, fires) and Comment. describe gives Company its fields and its Comments edge, declared awaitable, watchable and resolvesEmpty.
  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. resolveAwait answers empty for a deleted company, pending with thread:<company id> for a thread with no comments, and landed with the comments otherwise.
  6. Push. When somebody comments, the CRM POSTs { "keys": ["thread:<company id>"] } to /wake. When somebody creates a company, it POSTs the event to /emit. Both send Authorization: Bearer <secret>.
  7. 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 2 Save 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_review import { 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_intake Then: 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 #