AL

askeleven/loop-breaker

Developer tools
0 stars 0 forks Quality 78 Trend 20MIT

Stop your agent replying to an autoresponder, a bounce, or another bot. Checks a thread before every reply for the signs of a loop, on any channel, with an optional ledger across threads.

Overview

An agent that answers every message will answer an out-of-office. The out-of-office answers back. So does the agent. Point two agents at each other, or one agent at a helpdesk that acknowledges every email with a new ticket number, and they will talk all night, politely, at your expense and in your name. Nothing errors. Every message is accepted and delivered. loop-breaker is the check that runs before each reply: a CLI for pipelines, an MCP server for agents, and a library for everything else. It returns a verdict, the facts behind it, and the reason for every problem it finds. Any channel: email, SMS, chat, webhooks. Zero dependencies. No signup, no account, no network. Before a reply, on the thread you pass in: With the optional ledger, across every thread with the same counterparty: means nothing was found. means a person should decide. means do not reply. Email headers decide it when they are there.

README

loop-breaker

Stop your agent replying to an autoresponder, a bounce, or another bot.

npx @askeleven/loop-breaker thread.json --reply reply.txt

An agent that answers every message will answer an out-of-office. The out-of-office answers back. So does the agent. Point two agents at each other, or one agent at a helpdesk that acknowledges every email with a new ticket number, and they will talk all night, politely, at your expense and in your name. Nothing errors. Every message is accepted and delivered. loop-breaker is the check that runs before each reply: a CLI for pipelines, an MCP server for agents, and a library for everything else. It returns a verdict, the facts behind it, and the reason for every problem it finds.

Any channel: email, SMS, chat, webhooks. Zero dependencies. No signup, no account, no network.


What it checks

Before a reply, on the thread you pass in:

Check Verdict
Automatic reply: out-of-office, autoresponder, read receipt, “driving with Focus on” Fail
Delivery failure: a bounce from the mail system Fail
Bulk or list mail: List-Id, Precedence: bulk, “Reply STOP to opt out” Fail
No-reply sender: noreply@, notifications@, mailer-daemon@ Fail
They asked you to stop: STOP, unsubscribe, “please stop emailing me” Fail
Same message again: they sent the same words twice, give or take a ticket number Fail
Your message echoed back: what you sent, returned to you Fail
You already sent this: your reply repeats one you sent on the thread Fail
Too many messages on the thread: default over 5 in 60 minutes Fail
Possibly an automatic reply: “We’ve received your request; ticket #4821 has been created” Review
Replies faster than typing: two or more replies that arrived at over 3 words a second Review

With the optional ledger, across every thread with the same counterparty:

Check Verdict
They asked you to stop on any thread or channel, at any time, until they write again Fail
Same message again: word for word what they sent on another thread Fail
Nobody there is reading: every reply from them in the last 7 days was automated Fail
Too many messages to them: default over 10 in 24 hours, across threads Fail

Keep means nothing was found. Review means a person should decide. Fail means do not reply.

How it tells a person from a machine

Email headers decide it when they are there. Auto-Submitted (RFC 3834), X-Autoreply, Precedence, List-Id, X-Failed-Recipients, a multipart/report body and a null Return-Path are only set by software, so they are certain. So are senders such as mailer-daemon@ and noreply@.

Without headers, as on SMS and chat, it reads the words the sender wrote, not the quoted message under them. “I am out of the office until Monday” is likely automated. “We’ve received your request” is only possible, because people write that too, so it asks for review rather than failing. “I’m out of office next week, can we start on the 10th?” is a person, and is answered.

Then it watches how the other side behaves. People do not send the same message twice, repeat yours back, or answer 60 words in two seconds. Machines do.

What it does not do

It does not send, delete or change anything. It says whether to reply. The ledger only grows when you record to it.

It does not decide what to say instead. When it fails a reply to an out-of-office, the right move might be a follow-up after the return date. That is your agent’s call, or a person’s.

It does not read every language. Headers work everywhere. The wording rules are English, with out-of-office subject lines in French, German, Spanish, Italian and Portuguese.

It cannot see what it is not given. A loop that runs across threads needs the ledger; a reply that arrives seconds later needs times on the messages.


Example

$ npx @askeleven/loop-breaker thread.json

FAIL  Do not reply

      Thread       4 messages, 2 in, 2 out
      Last message Automatic reply (possible)
                   "Thank you for your message. Our team will get back"
      Sent         3 in the last 60 minutes (limit 5)

FAIL
  Same message again
      The last message is the same as an earlier one from them, with at most
      a detail changed. People do not send the same message twice; machines
      answering each message do. Do not reply.
  You already sent this
      Your reply is the same as a message you already sent on this thread.
      Saying it again is the loop. Do not send it; if they have not
      answered, a person should decide what happens next.

REVIEW
  Possibly an automatic reply
      ...
  Replies faster than typing
      2 replies arrived faster than a person can type them, counting from
      your message before each (over 3 words a second). The other side is
      probably software. A person should look before this goes on.

One message on its own, such as a raw email, is classified:

$ npx @askeleven/loop-breaker reply.eml

AUTOMATED  Automatic reply, certain

      header   Auto-Submitted: auto-replied (automatic reply, certain)
      subject  Subject: Automatic reply: Your quote (automatic reply, likely)
      text     "I am out of the office until Monday 20 October with limited access" (automatic reply, likely)

The thread

A JSON list of messages, oldest first, or an object with messages, and optionally reply and counterparty:

{
  "messages": [
    { "direction": "out", "at": "2026-10-16T14:40:00Z", "text": "Hi, following up on pricing." },
    { "direction": "in", "at": "2026-10-16T14:40:02Z", "raw": "From: ...\nAuto-Submitted: auto-replied\n\n..." }
  ],
  "reply": "Thanks for getting back to me!",
  "counterparty": "[email protected]"
}

Each message has direction (in from them, out from you) and any of at (ISO 8601 with an offset), text, from, subject, headers, or raw, a whole email, from which the rest is read. Pass whatever your channel has: SMS needs only direction, at and text. When every message has a time they are put in order; without times the timing checks are skipped, and the result says so.

Options

--reply    The message you are about to send.
--ledger        JSONL ledger, for checks across threads.
--counterparty    Who the thread is with: an address, number or id.
--similarity     Messages this alike count as the same. Default 0.85.
--max-sends        Most messages you send on a thread within --window. Default 5.
--window     Default 60.
--max-per-day      Most messages to the counterparty in 24 hours. Default 10.
--now           Check as of this time. Default now.
--json                Machine-readable output.
--no-colour           Plain text.

Exit code 0 means OK to reply, 1 means do not reply (it failed, or a person should review it), 2 means it could not run. So it gates a pipeline:

npx @askeleven/loop-breaker thread.json --reply reply.txt && ./send reply.txt

The ledger

A loop that opens a new thread every time, an autoresponse that arrives on five threads, an opt-out sent by SMS to an agent that also emails: none of these are in the thread you are replying to. The ledger is an append-only JSONL file with one line per message sent or received, which every check can read.

npx @askeleven/loop-breaker record --ledger loops.jsonl --counterparty [email protected] --direction in reply.eml
npx @askeleven/loop-breaker record --ledger loops.jsonl --counterparty [email protected] --direction out
npx @askeleven/loop-breaker thread.json --ledger loops.jsonl --counterparty [email protected]

It never stores what a message said. A line holds who, when, which way, what kind of message it was, whether it was an opt-out, an optional thread id, and a 16-character hash of the normalized words, which is enough to spot the same message again and nothing more:

{"ts":"2026-10-16T14:01:05.000Z","counterparty":"[email protected]","direction":"in","thread":"t1","fp":"9f2c41d07ab3e815","kind":"auto_reply"}

Each record is one append of one line, so several processes can share a ledger.

For agents: MCP server

npx @askeleven/loop-breaker mcp --ledger loops.jsonl

It serves check_thread (the thread and your reply, with a counterparty for the ledger checks) and classify_message (one message). With --ledger it also serves record_message, and tells the model to record every message sent and received. The server tells the model never to reply when the verdict is fail, to stop on every thread after an opt-out, to show review reasons to a person, and not to reword a repeated reply to get past the check: the loop is the problem, not the wording.

Claude Code

claude mcp add loop-breaker -- npx -y @askeleven/loop-breaker mcp --ledger ~/.loop-breaker.jsonl

Claude Desktop, Cursor, and other clients that take a JSON config:

{
  "mcpServers": {
    "loop-breaker": {
      "command": "npx",
      "args": ["-y", "@askeleven/loop-breaker", "mcp", "--ledger", "/path/to/loops.jsonl"]
    }
  }
}

As a library

import { checkThread, readLedger, recordMessage } from '@askeleven/loop-breaker'

const { events } = await readLedger('loops.jsonl')
const result = checkThread(thread, { reply, counterparty: '[email protected]', history: events })

if (result.verdict === 'keep') {
  await send(reply)
  await recordMessage('loops.jsonl', { counterparty: '[email protected]', direction: 'out', message: reply })
}

checkThread(messages, options) takes reply, counterparty, history, similarity, maxSends, windowMinutes, wordsPerSecond, maxSendsPerDay, lookbackHours, and now (a clock, for tests). Every reason has a stable code (auto_reply, bounce, opt_out, repeated_inbound and so on) to match on, and a detail to show a person.

classifyMessage(message) returns the kind (person, auto_reply, bounce, bulk, no_reply), the confidence, whether it is an optOut, and the signals behind it. parseEmail(raw), similarity(a, b) and isOptOut(text) are exported too.

Privacy

Nothing leaves your machine. There is no network code. The ledger holds no message text.

Requirements

Node 20 or newer. No dependencies.


Why we built it

AskEleven runs AI employees that email and text candidates and customers for small businesses. An agent that answers every message on its own needs to know which messages were not written by anyone. This is that check, run before each reply.

Contributing

Issues and pull requests welcome, especially:

  • Autoresponders it misses, with the headers and the text (anonymised). These matter most.
  • People it mistakes for machines. A normal message a person sent that it fails.
  • Out-of-office wording in other languages.
  • MCP clients where the server does not work as documented.

Run the tests with npm test. They use a fixed clock and no network, so they are offline and fast.

License

MIT. See LICENSE.

View this README on GitHub

Install

npx -y @askeleven/loop-breaker mcp --ledger /path/to/loops.jsonl

Configuration

{ "mcpServers": { "loop-breaker": { "command": "npx", "args": ["-y", "@askeleven/loop-breaker", "mcp", "--ledger", "/path/to/loops.jsonl"] } } }