Agentic systems should be proactive, reliable and cost-effective.

listen-fire is a language your agent writes automations in. They run on every event without waking the agent.

Get it on GitHub
Open source · Apache 2.0 · Rust and WebAssembly
Real runs of the same request handled three ways over 25 failed payments: an agent woken for every payment, a JavaScript script it wrote, and a listen-fire program it wrote. The agent is slow and paid every time, the script misses payments silently, and the program is right on every payment in every attempt. Stripe, the CRM, Gmail and Slack are stand-ins; the program’s author tested on them for real, and they were cleared before it went live.

Same job, less than half the code.

An agent wrote both for the same request. Marked lines are work the script does by hand, and why listen-fire doesn't have to.

handler.js 91 lines
const SLACK_CHANNEL = 'accounts';Currency formatting by hand → listen-fire has a helperfunction formatAmount(cents, currency) {  const cur = String(currency || 'usd').toUpperCase();  const zeroDecimal = ['BIF','CLP','DJF','GNF','JPY','KMF','KRW','MGA','PYG','RWF','UGX','VND','VUV','XAF','XOF','XPF'];  const major = zeroDecimal.includes(cur) ? cents : cents / 100;  const whole = Number.isInteger(major);  try {    return new Intl.NumberFormat('en-US', {      style: 'currency', currency: cur,      minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: whole ? 0 : 2,    }).format(major);  } catch (e) {    return `${whole ? major : major.toFixed(2)} ${cur}`;  }}async function alertAccounts(text) {  await slack.post({ channel: SLACK_CHANNEL, text });}async function handler(event) {Is this the right event → listen-fire guarantees it  if (!event || event.type !== 'invoice.payment_failed') {    console.log('ignored event type', event && event.type);    return { ignored: true };  }Guard against duplicates → listen-fire deduplicates automatically  const doneKey = `done:${event.id}`;  if (event.id && (await kv.get(doneKey))) {    console.log('duplicate event, already handled', event.id);    return { duplicate: true };  }Safety guards everywhere → listen-fire provides live type safety  const inv = event.data && event.data.object;  if (!inv) throw new Error('payment_failed event has no data.object');  const stripeEmail = (inv.customer_email || '').trim();  const amount = formatAmount(inv.amount_due, inv.currency);  const invoiceLink = inv.hosted_invoice_url;  const who = `${inv.customer_name || stripeEmail || inv.customer || 'unknown customer'}`;  const invoiceRef = `${inv.number || inv.id || ''}`.trim();  if (!stripeEmail) {    await alertAccounts(`:warning: Payment of ${amount} failed (invoice ${invoiceRef}, Stripe customer ${inv.customer}) but the invoice has no customer email, so I couldn't look them up in the CRM. No email was sent.`);Manual auditing → all writes automatically logged    if (event.id) await kv.set(doneKey, 'alerted-no-email');    return { alerted: 'no-email' };  }Handle the format of every API → every system gets the same interface with the engineering handled  const res = await crm.request('POST', '/v2/objects/people/records/query', {    filter: { email_addresses: stripeEmail },    limit: 5,  });  if (res.status !== 200) {    throw new Error(`CRM lookup failed: ${res.status} ${JSON.stringify(res.body)}`);  }Cautious double-checking → confidence from live typing  const matches = (res.body.data || []).filter(r =>    ((r.values.email_addresses) || []).some(e => (e.email_address || '').toLowerCase() === stripeEmail.toLowerCase()));  if (matches.length !== 1) {    const why = matches.length === 0 ? 'no CRM record matches' : `${matches.length} CRM records match`;    await alertAccounts(`:warning: Payment of ${amount} failed for ${who} <${stripeEmail}> (invoice ${invoiceRef}), but ${why} that email, so I couldn't tell their plan. No email was sent; please follow up manually.${invoiceLink ? ' Invoice: ' + invoiceLink : ''}`);    if (event.id) await kv.set(doneKey, 'alerted-lookup');    return { alerted: why };  }Field juggling from concerns about missing values → confidence from live typing  const person = matches[0];  const v = person.values;  const planTitle = (((v.plan || [])[0] || {}).option || {}).title || '';  const plan = planTitle.toLowerCase();  const name = ((v.name || [])[0] || {}).full_name || inv.customer_name || 'there';  const manager = ((v.account_manager || [])[0] || {}).value;  const company = ((v.company || [])[0] || {}).value;  if (plan === 'monthly') {    if (!invoiceLink) {      await alertAccounts(`:warning: Payment of ${amount} failed for ${name} <${stripeEmail}> (monthly), but the invoice has no hosted_invoice_url, so the retry email wasn't sent.`);      if (event.id) await kv.set(doneKey, 'alerted-no-link');      return { alerted: 'no-link' };    }    await email.send({      to: stripeEmail,      subject: "Your Ledgerline payment didn't go through",      body:`Hi ${name},We tried to take your Ledgerline payment of ${amount} and it didn't go through. It's usually an expired card or a bank check.You can update your card and pay here: ${invoiceLink}We'll try again in a few days. If anything's changed, just reply to this email.The Ledgerline team`,    });    if (event.id) await kv.set(doneKey, 'emailed');    return { emailed: stripeEmail };  }  if (plan === 'yearly' || plan === 'annual') {    const mgr = manager ? `Account manager: ${manager}.` : 'No account manager is set in the CRM for this account.';    await alertAccounts(`:warning: Annual-plan payment of ${amount} failed for ${name}${company ? ' (' + company + ')' : ''} <${stripeEmail}>. ${mgr}${invoiceLink ? ' Invoice: ' + invoiceLink : ''}`);    if (event.id) await kv.set(doneKey, 'slacked');    return { slacked: true };  }  await alertAccounts(`:warning: Payment of ${amount} failed for ${name} <${stripeEmail}>, but their CRM plan is ${planTitle ? '"' + planTitle + '"' : 'not set'} (not monthly or yearly). No email was sent; please follow up manually.${invoiceLink ? ' Invoice: ' + invoiceLink : ''}`);  if (event.id) await kv.set(doneKey, 'alerted-plan');  return { alerted: 'unknown-plan' };}
payment_failed.mvt 29 lines
stripe = connect `@lf/stripe` using stripe_accountcrm = connect `@lf/attio` using crmmail = connect `@lf/gmail` using billing_mailchat = connect `@lf/slack` using team_slackfunction `Payment Failed`(c: <stripe-[:InvoiceChange]->>) {  invoice = only(c-[:Invoice]->)  if (invoice == null) { fail("payment_failed event ${c.Id} has no invoice") }  email = invoice.`Customer Email`  person = first(crm-[p:People WHERE p.Email == email ORDER BY p.`Created At`]->)  if (person == null) { fail("${email} not found in the CRM (invoice ${invoice.Id})") }  figure = Number.format(invoice.`Amount Due`, "grouped")  amount = invoice.Currency == "usd" ? "$${figure}" : "${figure} ${Text.upper(invoice.Currency)}"  if (person.Plan == "monthly") {    write mail-[:Messages]-> {      To: [email]      Subject: "Your Ledgerline payment didn't go through"      Body: "Hi ${coalesce(person.Name, invoice.`Customer Name`, "there")},\n\nWe tried to take your Ledgerline payment of ${amount} and it didn't go through. It's usually an expired card or a bank check.\n\nYou can update your card and pay here: ${invoice.`Hosted Invoice URL`}\n\nWe'll try again in a few days. If anything's changed, just reply to this email.\n\nThe Ledgerline team"    }  } else if (person.Plan == "yearly") {    channel = only(chat-[ch:Channels WHERE ch.Name == "accounts"]->)    if (channel == null) { fail("#accounts not found") }    write channel-[:Messages]-> {      Message: "Annual customer payment failed: ${coalesce(person.Name, email)} (${email}), ${amount}. Account manager: ${coalesce(person.`Account manager`, "not assigned")}. Invoice: ${invoice.`Hosted Invoice URL`}"    }  } else {    fail("${email} has no plan in the CRM (invoice ${invoice.Id})")  }}listen to stripe-[:Invoices]->-[c:Changes WHERE c.Kind == "payment_failed"]-> fire `Payment Failed`

The two programs from the video, the same attempt. Line counts leave out blanks and comments. Each author test-ran its program on the example event before it went live: the listen-fire author 6 times, the script's author 7.

Agents are fluent in code. Give them code made for automations.

Workflow tools now have agents that wire nodes together for you. But an agent thinks in text, and code is where it is most fluent and most flexible. listen-fire, the language, is code built for this one job: short, checked against your tools, and safe to leave running.

Approach What the agent producesSame result every timeChecked before it runsSign in, rate limits, paging
An agent on a schedule Nothing lasting; it redoes the work each time NoNoThrough its tools, sometimes
A script the agent writes General purpose code YesNo, it fails at run timeYours to host and handle
Workflow builders, like n8n or Zapier A graph of nodes, edited one setting at a time YesPartlyHandled
listen-fire A short program, in a language made for it YesYes, against your live toolsHandled by adapters
The checker

Checked against reality, before anything runs.

The checker reads the fields, stages and options from the systems you have connected. A wrong value is refused when the agent saves, with the values that would work. The agent fixes it in one edit.

MOV_ENUM_UNKNOWN_VALUE founder_intake.mvt · line 28
"Sourcing" isn't a value of enum (Sourced | Diligence | Term Sheet | Passed) — expected one of: Sourced | Diligence | Term Sheet | Passed.

The real diagnostic listen-fire check prints for the draft.

founder_intake.mvt Saved · live
node Pitch: "the startup pitching us in this email" {  company: <text> "the startup's name"  raise:   <text | null> "what they are raising, e.g. $3M seed"  summary: <text> "what the startup does, in one sentence"}(lines left out)function `Founder Intake`(msg: <mail-[:Messages]->>) {(lines left out)  pitch   = extractOne([msg.Body], Pitch, { tier: "quick" })(lines left out)  company = write crm-[:Companies]-> {    Name:        pitch.company    Domains:     [domain]    Description: pitch.summary  }(lines left out)Removed:   write company-[:`VC Deal Flow`]-> { Stage: "Sourcing" }Added:   write company-[:`VC Deal Flow`]-> { Stage: "Sourced" }  write company-[:Notes]-> { Title: msg.Subject, Content: msg.`Plain Body` }(lines left out)listen to mail-[m:Messages WHERE m.Labels CONTAINS "Dealflow"]-> fire `Founder Intake`
inbox = connect `@lf/email`crm   = connect `@lf/attio` using acme_mainnode Company: "the company this email is from" {  name: <text> "the company's name"}function Intake(e: <inbox-[:Emails]->>) {  company = extractOne([e.Body], Company)  if (company == null) { fail("no company in ${e.Subject}") }  write crm-[:Companies]-> {    unique by (Name)    Name: company.name  }}listen to inbox-[e:Emails WHERE e.Key == "intake"]-> fire Intake
The shape of every program

Listen to something. Fire a function.

A program names the systems it touches, listens to one of them, and fires a named function when something arrives. That is the whole shape, and it is where the name comes from.

In this one, every email sent to the inbox’s intake address becomes a company in Attio, named by a model that reads the email. An email about a company already there updates it instead of making a second one.

extractOne
A model reads the email into the shape declared above. It is the only model call, and the checker makes the program handle finding nothing.
-[:Companies]->
Your tools are read as records joined by links, so you say what moves where.
unique by
A repeat event updates the existing record instead of adding a duplicate.
Where the AI goes

AI is a function call, not the runtime.

Control flow, writes and duplicate checks are plain code. A model runs only where the program asks for one, like pulling the company out of an email, on the cheapest tier that does the job.

Run time
2.08 s
Writes to Attio and Slack
5
Model call
1
Total cost
$0.0036

Figures from the run in the video.

Connect anything.

The engine knows no system. Each one is a module, a separate program that presents it as records and links and handles sign-in, rate limits and paging. Write one in any language, over a versioned HTTP protocol.

The rest of what the engine needs, where runs are stored, which models answer, where files go, is a slot a module fills too, with a default that needs no setup.

Out of the box today. Anything else is a module away.

Run it like a language. Embed it like a library.

There is no service to sign up for. listen-fire is open source, and the engine goes wherever your code goes.

Run it

One binary, no database. listen-fire init starts a project, listen-fire check checks every automation against your connected tools, listen-fire run runs one, and listen-fire serve keeps them listening for events. The same binary runs in the cloud as a web-facing service plus workers that scale to zero, as the deploy guide describes.

curl -fsSL https://raw.githubusercontent.com/listen-fire/listen-fire/main/scripts/install.sh | shlisten-fire init my-project && cd my-projectlisten-fire checklisten-fire run hello

Prebuilt archives for macOS and Linux are on the GitHub releases page, and cargo install listen-fire works too.

Embed it

The same engine as a package, inside your own product. Your customers, or their agents, write automations against your data, and nothing leaves your process.

The embedding guide and a working example.