Getting started with `listen-fire`

listen-fire runs movements from a project folder on your machine. Install it with cargo install listen-fire, build it with cargo build --release -p listen-fire (the binary is target/release/listen-fire), or install a release.

1. Make a project #

listen-fire init my-project && cd my-project

This writes lf.yaml, a starter movement in movements/, externals/ and .lf/ (run state and secrets, git-ignored). It never overwrites a file that exists. lf.yaml pins the engine version the project runs on, and its core: section chooses the modules run state, models, the judge and files come from (core slots). The defaults need no setup.

A working project to read or copy is undefined: one movement, one external, a sample secrets file (copy it to .lf/secrets.yaml).

The starter runs straight away: listen-fire check, then listen-fire run hello.

2. Write a movement #

manual and cron are the host's own sources, so a movement listens to them with no import:

function hello(run: <manual-[:Invocations]->>) {
  return "Hello, ${run.`Run by (name)`}!"
}
listen to manual-[:Invocations]-> fire hello                                    # listen-fire run hello

function digest(t: <cron-[:Ticks]->>) {
  return t.`Fired at`
}
listen to cron-[t:Ticks WHERE t.Schedule == "0 9 * * 1"]-> fire digest           # listen-fire serve or listen-fire drain fires it

A system's events need its external. Put a .mvt file in movements/:

crm = connect #acme_crm using acme

function welcome(company: <crm-[:Company]->>) {
  write crm-[:Company]-> { Name: "Welcome ${company.Name}" }
}

listen to crm-[:Company]-> fire welcome

A module with no webhooks (Granola, Evertrace, Dealroom, GitHub's notifications) is polled instead: its listenable edge declares delivery: polled and pollEverySeconds, and a listen on it reads like any other (listen to granola-[n:Meetings WHERE n.Folders CONTAINS "deals"]-> fire summarise).

listen-fire check prints every diagnostic; fix them before running.

3. Connect a module #

A system is reached through an external: a folder in externals/ holding an lf-external.yaml that says how the host starts or reaches it.

listen-fire modules                  # every module this listen-fire can install (the bundled @lf/* ones), one line each, and which this project has
listen-fire modules show <module>    # what one reads, writes and listens to, read from the module itself; no credential needed
listen-fire connect <module>         # installs it and asks for its server secret, webhook secret and OAuth app

Read modules show before writing a program against a module. It lists the record types a program can read with their fields and edges, whether the module writes anything (@lf/stripe is read-only), the events a listen can name, and the credential it needs. A module whose schema comes from your own account (Airtable's tables, a spreadsheet's tabs) says so instead: its records show only once a credential is added (listen-fire catalog).

listen-fire catalog prints the same notation for each connection the project holds (every credential bound to an installed module), whether a movement connects it yet or not, its live types included (an MCP server's tools, a CRM's fields), with the connect line that makes it. --json prints the catalog snapshot listen-fire check reads. A bare listen-fire connect prints the same list as listen-fire modules.

listen-fire connect <module> --yes asks nothing, so it works without a terminal. Each answer comes from a flag (--setting <name>=<value>, --client-id, --client-secret, --redirect-port, --server-secret, --webhook-secret) or from its default (a webhook secret is generated). When an answer has neither, the command names the flag it needs and writes nothing; modules show lists them under set up.

ai(…) and extract(…) call models: listen-fire connect @lf/anthropic stores your API key.

listen-fire connect <module> --base-url <url> points the module at a fake or a proxy instead of its provider; --oauth-authorize-url and --oauth-token-url do the same for its sign-in. undefined walks Attio through end to end, against real Attio or the repo's fake.

A plugin package is an external of kind: plugin. @lf/pdf draws text as a PDF file and needs no credential:

# externals/pdf/lf-external.yaml
name: pdf
kind: plugin
module: "@lf/pdf"
import { `@lf/pdf` as pdf } from plugins

function report(c: <crm-[:Company]->>) {
  write crm-[:Company]-> { Name: c.Name, Report: pdf.render("Report for ${c.Name}") }
}

pdf.render(text, { pageSize: "a4" | "letter", margin }) is a file (A4, 30 points of margin by default). Its writer is thin: Helvetica, no font to ship. A character outside Latin-1 (CJK, emoji) prints as ? and the run is warned, naming @lf/pdf; a PDF module that embeds a font can serve the same plugin in its place.

How listen-fire runs a module #

A movement imports only modules installed in externals/; installing is always listen-fire connect (or a folder you add), never an import.

A local module runs with your permissions. listen-fire has no sandbox yet: a module's process can read and write what you can. Install only modules you trust.

4. Add a credential #

listen-fire credential add --module <module> my_credential

This signs you in at the provider (OAuth) or asks for the module's fields, and stores the result in .lf/secrets.yaml. A movement names it after using: crm = connect #acme_crm using my_credential. listen-fire credential list|update|delete manage the rest. In containers and CI, set LF_SECRET__<NAME>__<FIELD> instead of keeping a file. Like ssh with a private key, listen-fire refuses a secrets file that other users can read; fix it with chmod 600 <path> (listen-fire itself always writes it that way).

5. Run #

listen-fire run hello                                   # run a movement a `listen to manual-[:Invocations]->` fires
listen-fire run hello --text "Acme Robotics"            # …with its Invocation's Text (`run.Text`)
listen-fire run --event events/company_created.json     # route one event to the listens it matches
listen-fire run --event events/company_created.json --dry-run
listen-fire run --poll summarise                        # poll now the polled source of each listen that fires it
listen-fire runs                                        # the runs, most recent first
listen-fire resume [<run-id>]                           # step a parked run, or every due one
listen-fire runs rerun <run-id> [--force]               # run a failed run again, as its next attempt

A run that failed can be run again with listen-fire runs rerun <run-id>. The rerun is the same run's next attempt: it runs on the event (or manual text) the run ran on, against the movement as the project holds it now, so fix the movement first and the fix takes effect. It runs from the start. listen-fire runs show <run-id> lists every attempt with its status, times, writes and error, and the run's status is its latest attempt's.

What a movement returns is its run's output, as a cron job's output is. Every run records it in its history, however the run started, and listen-fire runs show <run-id> shows it. listen-fire run and listen-fire resume also print it when they complete the run:

$ listen-fire run hello
run 32bc… hello:hello completed — 0 write(s), 0 trace entries
  returned "Hello, Ada!"

A run started by an event (a webhook, cron, a poll) sends the value nowhere: it only sits in the run's history. It never reaches an external system, and no door answers with it. The history keeps up to 64 KiB of the value's JSON, and says when it cut one short. A returned file is kept as a reference to the file, never its bytes.

6. Serve #

listen-fire serve [--port N] [--doors-only]

listen-fire serve receives webhooks, polls cron listens' ticks as each comes due (one clock per schedule and zone, however many listens share it), polls polled sources on their interval, and drains due work, so listens fire on their own. It starts only on a project that checks: with any check error it prints each one, as listen-fire check does, and stops (so do listen-fire drain, listen-fire run --webhook and listen-fire resume with no run id). A listen on a polled edge (listen to f-[n:Notes WHERE …]-> fire a) is polled once per connection, edge, filter the module takes and scope, however many listens share it; its first poll only sets the mark it fires from. It is polled every interval its edge declares, unless lf.yaml names one for the connection: pollEvery: { <credential>: 5m }, a connection that uses no credential named by its module. listen-fire drain does the same work once and exits (--until-idle): a tick or poll missed while nothing ran happens once on the next drain, and two workers never poll one listen at once. A listen that filters an earlier hop (listen to f-[b:Boards WHERE b.Name == "Ops"]->-[n:Notes]-> fire a) is watched on the boards that match: listen-fire lists them when it first serves the listen, and again as the module declares (every membersChange: { relistEverySeconds }, overridden per connection by lf.yaml's resolveEvery: { <credential>: <duration> }, or on each change the module reports). A board renamed out of the filter keeps firing until the next listing. A filter matching more than listenScopeLimit records (default 500) suspends the listen, by name. listen-fire listens shows each listen on a traversal: what it watches, when it last listed it, and whether it is ok. A run's writes can fire more runs: a two-way sync hears its own write come back, and two automations can feed each other. An arrival on a record a run wrote at most loopWindow (in lf.yaml, default 20s) before it happened continues that run's chain (listen-fire runs show lists a run's chain). The listen whose arrival would make a chain longer than loopDepth (default 10) runs is paused, LISTEN_LOOP, naming the chain's runs; its arrivals are then said, counted and not run (listen-fire listens shows the count), until listen-fire listens resume <file>:<movement>, which clears it. A write that would change nothing is never sent where its module reads a record back, so a sync's echo writes nothing and the chain ends: Affinity, Airtable, Attio, Dealroom, Evertrace, Gmail and Google Sheets do; Slack, Stripe, GitHub, Granola, Telegram, WhatsApp, email and webhook do not, and listen-fire listens names a listen on such a connection. listen-fire drain --kinds poll_edge is a pool: it claims only work of those kinds (resume_timer, wake_await, fire_event, poll_edge, resolve_listen, refresh_subscription, settle_race; a cron listen's ticks are poll_edge rows), so polls and run steps can be drained by separate fleets. Editing a movement under a running listen-fire serve takes effect within about a second; a set with any check error is printed once and the running one kept. Set publicUrl in lf.yaml when the outside world reaches it at another address. A listen on an edge whose module registers its own webhooks (Attio, Affinity, Airtable) gets one when listen-fire serve starts, made as the credential the connection names (connect `@lf/attio` using my_credential) and shared, as a poll is, by every listen on the same connection, edge, filter the module takes and scope, at <publicUrl>/api/public/webhook-sync/<provider>/<credential>/edge-<16 hex>; a restart keeps it. A delivery there is judged only against those listens. Airtable's is one per base, however many of its tables the listens watch. Airtable lets a quiet webhook lapse 7 days on: listen-fire refreshes each a day before it would, under listen-fire serve or a drain, and registers anew one that lapsed while nothing ran. An edit under a running listen-fire serve moves it with the listens: other events change it, and it is removed once no listen needs it. A registration that fails is printed once per message and tried again on the serve tick, waiting from 30 seconds doubling to an hour. A sender that posts to a URL you give it (@lf/webhook: Stripe, a form service, your own app) has none registered. Instead listen-fire serve prints each such connection's URL when it starts, <publicUrl>/hooks/<external>/<credential>. A delivery there is checked against the connection's signing, then against the node its listen states (listen to stripe-[:Requests]-><Payment>). One that does not fit is answered 400, naming the fields, and runs nothing. listen-fire cancel <run-id> acts on a parked or running run.

LF_LOG=debug adds the lines that explain why something ran nothing when nothing went wrong, such as an arrival a listen's WHERE refuses. Unset, they stay unsaid.

listen-fire serve --doors-only (or LF_DOORS_ONLY=1) is for compute billed by the request, which is frozen once a response is sent. It runs no drain, /hooks/… queues its events (answering 202 with the rows queued) instead of running them inside the request. listen-fire drain workers run the work, started on a schedule or by the wake slot's module.

Deliveries #

Every delivery a door takes is kept as a receipt: the events it produced, and the request it was received as (its body, as received but for the fields its module declares may hold a credential, and only the headers its module declares it reads; never a signature). listen-fire deliveries lists them, most recent first (--subscription <id>, --since <instant | 6h | 2d>, --json), listen-fire deliveries show <id> prints one with its events and the runs they started, and listen-fire runs show names the delivery a run came from. The request may hold personal data, so deliveries show prints it only with --request.

7. Upgrade a project #

engine: in lf.yaml is the language version the project is pinned to: a release's version, such as 0.13.0. This listen-fire reads every pin of its own compatibility key (under 0.x, the minor: 0.13). A newer pin is refused with "upgrade listen-fire"; an older key is refused with "run listen-fire upgrade".

listen-fire upgrade --check prints what would change; listen-fire upgrade rewrites the movement files into the current language version and sets engine: in lf.yaml to it.