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.
@listen-fire/embed. A customer asks its assistant for an automation, the agent writes it, and when someone marks a deal Won, the product’s own Onboarding field updates a moment later and the deal is posted to Slack, which is a stand-in.One real run. An agent writes an automation, the checker refuses a stage Attio doesn’t have, one edit fixes it, and it runs on a founder’s email. Gmail, Attio and Slack are stand-ins.One real run on the open source listen-fire. listen-fire check refuses a currency the program can’t receive, one edit fixes it, and each emailed invoice is read once from its PDF, filed in Drive by supplier and month, added to the ledger, and flagged in Slack when it’s over £5,000. Gmail, Google Drive, Google Sheets and Slack are stand-ins.One real run on the open source listen-fire. listen-fire check refuses a stage the base doesn’t have, one edit fixes it, and when a candidate moves to Interview the scorecard is copied, linked in Airtable, the interviewer is told in Slack and the email is drafted, all within a fraction of a second. Airtable, Google Drive, Slack and Gmail are stand-ins.One real run on the open source listen-fire. listen-fire check refuses a column the job log doesn’t have, one edit fixes it, and listen-fire serve handles two customer messages: a routine one is logged, an urgent one is logged and paged. WhatsApp, Google Sheets and Slack are stand-ins.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.
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' };} 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 produces | Same result every time | Checked before it runs | Sign in, rate limits, paging |
|---|---|---|---|---|
| An agent on a schedule | Nothing lasting; it redoes the work each time | No | No | Through its tools, sometimes |
| A script the agent writes | General purpose code | Yes | No, it fails at run time | Yours to host and handle |
| Workflow builders, like n8n or Zapier | A graph of nodes, edited one setting at a time | Yes | Partly | Handled |
| listen-fire | A short program, in a language made for it | Yes | Yes, against your live tools | Handled by adapters |
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.
The real diagnostic listen-fire check prints for the draft.
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 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.
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.
- Slack
- Gmail
- Attio
- Affinity
- Airtable
- Google Sheets
- Google Drive
- Dropbox
- Telegram
- Granola
- Evertrace
- Dealroom
- Your own system, over the adapter protocol
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.