From a Basecamp card to a pull request.
A card moves, and a few minutes later there is a comment on it, or a pull request.
Our client plans their product in Basecamp. Cards move into a column called "Figuring it out" when a decision is needed, and into "In Progress" when it is time to build. That is the whole process, and the whole team actually uses it, which is why we did not want to change it.
The friction was on our side. Every card needed a developer to notice it, read it, work out an approach, ask a clarifying question, wait for the answer, then start. On small changes the waiting and the relaying took longer than the building.
So we put an agent on the board instead. A card moves, and a few minutes later there is a comment on it, or a pull request. It has been running on a live client board since July 2026. This is how it works and what we got wrong building it.
What it does
Three triggers, three behaviours, and every one of them ends in exactly one Basecamp comment.
A card enters the analysis column. The agent reads the card, downloads its attachments, and explores the actual codebase: the models, controllers and views the request touches. Then it posts options with trade-offs and a recommendation. If the card is too vague to act on, it posts numbered questions instead and stops.
The attachments mattered more than we expected. Most cards are one line of text and a screenshot with a red arrow on it. The screenshot is the requirement, and a playbook that only reads the card title is reading the least useful part of the card.
Someone replies and @mentions the bot. It answers, revises its proposal, or says what is still unclear.
A card enters the build column. It reconstructs the agreed approach from its own analysis plus every human reply after it, writes the code, pushes a branch, and opens a pull request that is ready for review. The card comment is a link and two plain sentences about what is now different for the people using the product.
Everything it posts is prefixed with a marker: 🤖 <Product> Agent. That is partly for the humans reading the board, and partly for the agent, which needs to recognise its own voice.
Why there is a Cloudflare Worker in the middle
One card event · end to end
Basecamp webhook (card moves + comments)
→ Cloudflare Worker drops what cannot produce work
→ repository_dispatch GitHub Actions
→ resolver re-fetch, diff against saved state, decide
→ headless Claude Code runs one playbook
→ one Basecamp comment
The Worker is there for a boring reason that took us a while to find. Basecamp's webhook posts plain JSON. GitHub's repository_dispatch endpoint requires an Authorization header, and Basecamp cannot send one. Something has to sit in between and re-sign the request.
A free Worker does that in about a hundred lines. The webhook secret lives in the URL path rather than a header, so a leaked Worker URL on its own is useless:
worker.js · the secret is the path, not a header
const url = new URL(request.url);
if (url.pathname !== `/hook/${env.WEBHOOK_SECRET}`) {
return new Response('not found', { status: 404 });
}
One detail worth copying: return 200 for events you drop deliberately. Anything non-2xx and Basecamp will retry them, so you get the same unwanted event again and again. Non-2xx belongs to one case only: the dispatch to GitHub failed, where a retry is exactly what you want.
Webhooks are doorbells, not instructions
This is the decision that made the whole thing reliable, and it is the one to take away even if you never touch Basecamp.
Basecamp fires several events for a single card drag. They arrive duplicated and out of order. Act on what the payload says and you will build something that works in testing and misbehaves in week two.
So the payload is never treated as data. It means only: something happened, go look.
Every run re-fetches the card and its comments from the API and compares them against what it saw last time. Last time lives on an orphan branch in the same repo, one small file per card:
state/<card_id>.json · written only after a run succeeds
{
"column_id": "...",
"phase": "analyzed",
"last_comment_id": "...",
"branch": "...",
"pr_url": "..."
}
No database. The repository already exists, and it is already backed up.
That one file makes duplicates harmless: the second event finds nothing new and skips. It also gives you a phase to reason about: new → awaiting_answers ⇆ analyzed → implementing → pr_open.
The rule that took us longest to learn: a failed run must not save state. Save it anyway and the column move is marked as consumed, so the retry looks, sees nothing new, and skips forever. The symptom is horrible to debug, because nothing is red. The card sits in the build column while every run reports success.
Filter at the edge, not in CI
Early on, roughly half of our Actions runs were waking up, looking around, and finding nothing to do. Every comment anywhere on the board started a runner.
The fix was to move the filtering into the Worker, where it costs nothing. Card moves dispatch only for the two watched columns. Comments dispatch only when they @mention the bot and were not written by it:
worker.js · comment wake-ups are mention-gated
const body = String(recording.content || '');
const mentionsBot = body.includes(`Person/${env.BOT_PERSON_ID}`);
const fromBot = String(event.creator?.id ?? '') === String(env.BOT_PERSON_ID);
if (fromBot || !mentionsBot) {
return new Response('ignored: comment without bot mention', { status: 200 });
}
That gate is also the first of three defences against the agent replying to itself: the Worker drops its own echoes, the resolver skips marker-prefixed comments, and after a successful run the driver records its own comment id into state. Remove any one of the three and it eventually holds a conversation with itself.
Mention-gating has an honest cost, and the team has to be told about it. A reply that does not name the bot is invisible to it, and dragging a card quietly back into a watched column does not wake it either, because the column filter eats the move. You talk to it by naming it.
Playbooks, not prompts
Each behaviour is a markdown file checked into the repo: analyze, respond, implement. They are versioned, reviewed and diffed like any other file, and they point at the same conventions the developers follow: the repo's CLAUDE.md, and its written guides for dates and timezones, money, tables and views. The agent is not inferring a house style. It is reading the file a new hire is handed.
Two rules from the implement playbook are worth lifting directly:
implement.md · hard rules
## Hard rules
- PRs are ALWAYS against the release branch, created ready for review (not draft).
Never merge or close PRs, never force-push.
- One branch per card; re-runs push to the same branch.
- Basecamp writes are comments ONLY - never move the card, even when the work is done.
- If blocked, post one comment saying exactly what is blocking, and write no code.
And a comment-style rule we did not think we would need:
implement.md · comment style
Card readers are business stakeholders first, developers second. No class names, file
paths, code snippets or framework jargon; describe the change by what users can now do
differently. Technical depth belongs in the PR body, not the card comment.
We learned that one from feedback. Stakeholders read the board. File paths on a card read as noise to them.
What actually stops it
There are two places to put a guard: in the playbook, where it is a request, and in your repo settings, where it is a rule. Everything in that hard-rules block is a request. It holds up well in practice, and it is still an instruction to a model.
So make the ones that matter into rules. Before you switch an agent on:
- Branch protection on your main and release branches, with required reviews. Merging should be impossible rather than forbidden.
- Its own identity. A GitHub token scoped to one repository, and a dedicated Basecamp person for it to comment as. What it can reach becomes a setting, not a sentence.
- A real audit trail. Every run uploads the exact prompt, a timestamped transcript of each tool call, and the result. When something looks wrong you read what it did, rather than guess.
What we got wrong
Five things, each of which cost a day or more.
A single space. A pasted token with a leading space produced 401s that looked exactly like an expired credential. We now trim whitespace from every auth secret before use.
An empty variable beat a working one. With both auth options set and one of them blank, the blank API key won on precedence and shadowed the valid token. Unset empty auth variables rather than leaving them defined.
Test events vanished silently. repository_dispatch only triggers workflows that live on the default branch. Ours was still on a feature branch, so every test event was accepted and did nothing. Nothing errors. Merge the workflow first, then test it.
Our own settings file blocked the runner. A committed local-development settings file with deny rules applied inside CI too, and quietly blocked the QA commands. The workflow now deletes it in the disposable runner.
Tests that were not testing anything. The worst one. The QA step was green for weeks; the test configuration pinned a database the runner did not have, so it ran nothing. The runner now does a syntax check only, and the pull request body says so in one line: formatting and the full suite run at review. A small honest check beats a green one that means nothing.
What it costs
About twenty seconds of setup per run, two seconds to decide what changed, and three to nine minutes of actual work on an implement run depending on the card. No servers: a free Worker and GitHub Actions. Edge filtering removed around half the runs, which were the ones that never had anything to do.
The part we did not build
None of this asked the client to change anything. They did not adopt a tool, learn a workflow, or open GitHub. They move a card, the way they always have, and a few minutes later there is something to read.
Chelsea DiMarzio at LD Expert put up a video about working with us on the platform this agent now works on. That part we did not write.
The interesting thing is not that a model can write the code. It is that the queue people already keep (a board, a table, a spreadsheet of what is next) can be the thing that starts the work. Most teams already have one. It is just waiting for a doorbell.
Built by Akshay Patel, TechUp Labs.