Skip to content
All posts

The board exists. It just isn't alive.

Agent Loopr: The board exists. It just isn't alive — scattered work signals converging into a structured task board

Your team makes decisions all day. In a standup. In a Slack thread at 11pm. In a PR review. In an email chain with a client. Someone says "I'll have that by Thursday," four people nod, and it's real work, and everyone means it.

Then the meeting ends.

And the board, the one your whole company is supposed to be running on, knows nothing about it.

Work context gets generated continuously at every touchpoint and captured at almost none of them. Cards go stale. Decisions made on calls evaporate. And everybody spends real cognitive effort translating work they already did into project-management updates about work they already did.

We got tired of it and built a system to close the gap. This week we open-sourced the deterministic core of it: Triage, Apache-2.0, TypeScript, with ClickUp and Linear adapters. It's the part that's been running our company for months.

What's in the box

Three parts.

Eight role agents. Engineer, designer, product manager, QA, marketer, sales, delivery lead, founder. Each one is a markdown file in config/roles/: what that role owns, what it watches for, how it phrases an update. You rename Engineer to your actual engineer, write a paragraph about what she actually owns, and work starts routing to her.

That design changed on the way out the door. Internally we run one agent per real person, and those profiles are full of one company's actual people. Useless to anyone else, impossible to publish. Archetypes generalize, and they de-identify by construction: there's no real name to strip because the concept itself is generic.

A board agent above them, deciding which items need a closer look and summarizing what a run actually did.

An eight-pass pipeline underneath, where the real work happens. Feed it a meeting transcript, a channel log, a GitHub feed, an email thread or a Drive activity log. It decides, item by item, what belongs on the board. Deterministic code gates sit between every pass, and no model goes anywhere near the write itself.

The pipeline is the default path and runs on its own. The agent layer sits above it, ships off by default, and is read-only. It can propose. It can trigger a hold. It can never write and never resolve a hold.

The number we'd normally hide

27.3%.

That's how much of its own work the system refuses to do alone.

Across 49 real production runs (48 meetings and one channel log), 711 items total:

applied automatically, no human62.6%
held for a human27.3%
skipped as a duplicate8.2%
failed2.0%

Figures are rounded to one decimal, which is why they sum to 100.1%.

Most products bury that middle number. We lead with it, because a system that writes to a real board is only as trustworthy as its judgment about what it shouldn't decide alone.

And "shouldn't" is never a feeling here. Every hold traces to a deterministic rule. A required field nobody specified. An assignee who isn't on the roster. A possible duplicate of something already on the board. A claim with no evidence behind it.

Plus one rule of an entirely different kind, which gets its own section below.

What happens to a held item

A hold that nobody can answer is just a blocked queue, so the loop has to close:

npm run answer                    # every open hold, and why each is held
npm run answer -- <id> --approve  # writes the card
npm run answer -- <id> --skip     # drops it; nothing is written

No model runs when you approve. It replays the decision Pass 2a/2b already made and stored, through the same plan-and-execute path a clean item takes. That's deliberate. A second inference on resume would mean the human approved one thing and something else got written, and the divergence would be invisible, because both outputs look equally like the model's work.

This file exists because it didn't. The pipeline could raise a hold, persist it, and store the exact decision needed to finish it. The function to resume one was written, tested and correct. Nothing called it. "Human-in-the-loop" named a loop that didn't close: the headline claim, half-built, sitting behind a green test suite. There's now a test that fails on any module only a test imports, because that was the fourth time this repo shipped that exact shape.

It checks the board before it touches the board

Here's the failure that kills these tools within a month. They can't see what's already there. So every recurring piece of work gets a second card, then a third, and within a few weeks nobody trusts the board and everyone's back to a spreadsheet.

Before Triage decides anything, it pulls the live board: every open card, its status, its assignee. Then every candidate new task gets title-matched against every open card in that snapshot, rather than only against the other items that arrived in the same batch. Comparing each item against a handful of nearby candidates is how these tools quietly fill a board with near-duplicates.

Close enough match? It doesn't create anything. It hands you the matching card and asks.

For the closest few candidates it goes further, pulling those cards' comment history, so the decision gets made against what actually happened on the card.

All of that is fetched host-side, before the model runs, and handed over as a plain completion. That matters more than it sounds. It's why the prompts are byte-identical run to run, and why the whole thing replays offline.

The matching is lexical, not semantic. A sufficiently reworded duplicate gets past it. Closed cards aren't in the snapshot. The board is read once at the start of a run, so a card created by someone else mid-run isn't seen. A human can mark a specific pair "not a duplicate," and the check respects it from then on.

Where the tool calls went

Our production agents are tool-using. They decide they want a card's history and go get it. The open-source pipeline deliberately doesn't work that way. Every pass gets a plain completion with the evidence already gathered. No loop, no agent deciding what to look at next.

We gave up the model's ability to chase something it finds interesting. In exchange: a prompt that's identical every run, an offline replay that actually proves something, and a much smaller surface where a model can talk its way into somewhere it shouldn't be.

Tool calling does ship in the repo, in the optional agent layer that's off by default. Three tools: fetch a task, read its comments, search the board. What's missing from that list is the point. There is no write tool, and the adapter behind them refuses every write in code. The capability isn't there for the model to reach for in the first place.

A second flag adds it back, deliberately. With BOARD_AGENT_WRITES on, the board agent gets write tools too, and every write it originates is re-run through the same deterministic gates before it reaches the tracker. A write the gates refuse becomes a hold. That's the shape production actually runs: its board agent calls a write command behind a guard layer. Off stays the default, and on is there for anyone who wants the closer match.

The gate that doesn't care how confident it is

credentials        api key/token, secret, .env, rotate/revoke, grant access
client-pii         SSN, passport, card number, DOB, tax id
production-deploy  deploy to prod, push to prod, cut a release
client-comms       send the proposal/contract/invoice, email the client

A write matching any of these gets held even when every other gate passed and the system is completely certain. Certainty was never the right question for rotating a credential or emailing a client a contract.

Those patterns are compiled constants. No prompt, no correction file, and nothing the model itself produces can reach them, edit them, or carve out an exception. There's exactly one switch: a boolean an operator flips deliberately, and the pipeline's own inputs can't touch it. We tested the case that actually worries us. An agent proposing a change that turns an ordinary task into a credential-touching one still gets caught by this gate on the way out.

What the gate is not: complete. It matches against the item text an earlier pass produced, so an item that got paraphrased on the way through ("rotate the Stripe key" arriving as "refresh payment auth") doesn't match, and doesn't get held. The patterns can't be reached or edited. They also can't catch what never reached them in those words. It's a floor under the worst writes. It doesn't cover everything that could be written this way.

Everything it reads was written by somebody else

The input is a Slack message, a GitHub issue opened by a stranger, an inbound email. It is the least trusted string in the system and it goes straight into a prompt. Two things happen to it first, and the asymmetry between them is deliberate.

Secret redaction is unconditional. A credential that reaches a model provider isn't recoverable. You can't un-send it. So anything matching a secret shape gets stripped whether or not anything else about the text looks wrong. An API key pasted into a Slack thread doesn't leave the process.

The injection banner is conditional, appearing only when a pattern actually matched. A warning on every clean input is noise that teaches you to ignore it.

There's a smaller thing that would be easy to miss. Every prompt wraps the source in a delimited block. Source text containing one of those delimiters would close the block early, and everything after it would read as trusted prose in the model's own voice. Those get neutralized. No real transcript line is a row of dashes around the word END.

Pattern matching is a regex list, and a rephrased attack walks straight past it. What actually bounds the damage is everything downstream: the writer is deterministic, every write passes the gates, the second read re-derives the categorization blind.

A successful injection can mislead a categorization. It cannot author a write.

That guarantee is structural, which is why it's the one worth stating.

Two reads, and the second one is blind

This is the mechanism we'd point a skeptical engineer at first.

The pipeline categorizes each item once. Then, before anything is written, a second pass re-derives the same judgment blind, built from the original extracted item only. It never sees the first pass's category, its matched card, its assignee, or its list.

If the two would produce a genuinely different write (create versus comment, or the same action aimed at two different cards), it holds. If they'd produce the same write under different labels, that isn't a disagreement and nothing stops.

Two blind reads that agree are evidence. A second read that's been shown the first answer is a rubber stamp with extra steps.

Both reads run on the same model, so this removes anchoring and does nothing about correlated model bias. If the model is wrong the same way twice, agreement proves nothing. What it reliably catches is the case where the first read's answer was doing the work, and that turns out to be a lot of cases.

So there's a test that fails loudly if anyone ever "helpfully" wires the first answer into the second one. That property decays silently otherwise. Feed the context in and the outputs get more agreeable, and every test still passes while the guarantee quietly stops existing.

It remembers, and it learns from being corrected

Two separate mechanisms, and the distinction matters.

Each role has its own memory file. The pipeline writes it after every run, built from what actually got created or updated. The next run reads it back into the prompt. So a second meeting that mentions work someone already picked up sees that they already picked it up, instead of confidently opening a duplicate.

There's also a field in that file the pipeline never touches: free text a human maintains. "Priya is on leave until the 14th." It gets read into every run and overwritten by none of them.

One file per role rather than one shared file, for an unglamorous reason. The unit a human edits is a role. You open engineer.json, add a line, done. A single combined file makes every human edit a merge conflict with the pipeline's own writes, and the pipeline writes after every run.

Corrections are the other half. When someone resolves a hold (no, those two cards are different work; Rowan owns growth now), the decision gets recorded and fed back into later runs. The machine-actionable ones are consulted directly by the gates, so "those two aren't duplicates" actually stops the duplicate gate from firing on that pair again.

Nobody edits code. The next run behaves differently. That's the difference between a system that's configured and one that learns, and it's a small enough distinction that most of these tools skip building it.

Then it checks that what it said happened, happened

There's a pass after the write whose only job is to re-read the board and compare it against the plan.

It can detect and never prevent, and it deliberately doesn't judge whether a decision was correct. A wrongly-but-plausibly routed card matches the plan and passes. Correctness belongs to the pre-write gates. This pass answers a narrower question that no pre-write gate structurally can: did what we said we did actually happen?

The failures that live in that gap are the quiet ones. A write that reported success and produced no card. A partial run that left half the plan unwritten. A card whose parent got deleted between the snapshot and the write. A held item that somehow executed anyway. A card that landed on the wrong list from a stale mapping.

All of those look like success from inside the process that did them. Never trust a report that a write happened. Go look.

What it costs to run, and what a redelivery costs

Both providers, measured over the same eight scenarios, with the script that produced the numbers in the repo:

CallsStandard rateDiscounted rate
DeepSeek80$0.96 (peak)$0.48 (off-peak)
Claude66$0.65$0.43 (intro)

Claude comes out cheaper at both tiers, on fewer calls, which is the opposite of what most teams assume when they reach for DeepSeek on cost grounds. Reproduce either with npm run cost:deepseek or npm run cost:claude and your own key. The metered client wraps the live provider purely to sum tokens, so running it can't corrupt the recorded fixtures the offline demo replays. PROVIDERS.md documents where the two models diverge in output quality, pass by pass.

Publishing this is unusual and that's most of why we did it. Cost-driven model selection across a multi-pass pipeline is a real engineering decision that almost nobody publishes real numbers for. A number you can re-derive on your own key is worth more than one you have to take on faith.

A redelivery costs nothing at all. Three layers of idempotency cover the same webhook delivered twice, the same meeting reprocessed after a restart, and the same content arriving under a different id. The ordering is the part worth stealing: the second check runs before the first token is spent, so a redelivery costs zero instead of a full run that gets thrown away at the end.

npm run demo -- --twice   # second run: zero model calls, $0.00

What we won't claim

No precision. No recall. Anywhere in the repo.

The only ways to produce a precision or recall number are a hand-labeled ground truth that doesn't exist, or a model grading a model, which measures self-agreement and nothing else.

Volume and hold rate are honest. Accuracy claims from an LLM pipeline usually aren't, and the ones you see quoted most confidently are usually the ones that were never checkable in the first place.

We also shipped a LIMITATIONS.md that says out loud what doesn't work, and an EXTRACTION.md documenting exactly what changed between the version we run and the version you can read.

One of those limitations got its own writeup. Three of the stores this pipeline persists to disk shared a lock that only worked inside a single process, and every test in the suite ran in a single process, so the suite stayed green the entire time the bug existed. The full story, including the concurrency test that turned out not to be testing concurrency, is here: Hundreds of passing tests couldn't see this bug. Here's why.

Run it yourself

No API key. No account. No signup.

npm ci
npm run demo             # 8 scenarios, offline, under a second
npm run demo -- --twice  # a redelivery costs zero tokens

Eight real scenarios, including the ones we'd rather not have to show you. A run that writes nothing at all because both deliverables were already on the board. A pure-discussion meeting where nothing gets extracted. A GitHub feed where two of four items hold, because a code feed tells you who wrote a change and never who owns the follow-up.

The demo replays recorded model responses through the real prompts, the real parsers and the real gates. The model's judgment is fixed; everything the deterministic code does with it is live. It pins the behavior we actually control, and it can't pretend to pin the part we don't.

github.com/agentloopr/Triage: Apache-2.0, TypeScript, ClickUp and Linear adapters.


A note on what's here and what isn't. This is a reference that runs, not a maintained product. No support channel, no compatibility promise. The architecture is what we run; the prompts tuned on months of our own accuracy work are swapped for generic ones. Internally the pipeline runs on meetings and Slack, and the public version generalizes it to five sources. That generalization is this repo's work rather than a description of our production wiring. The agent layer ships off by default, read-only, and it's the one component written for this repo instead of extracted.

Ready to talk about
what you're building?

Book a 30-minute call and come as you are. No preparation needed on your end.

Book a Discovery Call