# Basecamp Agent Connector (experimental)
> [!WARNING]
> <mark>**Experimental.**</mark> This drives agents from Basecamp using the pieces that
> already exist: webhooks, the [`basecamp` CLI](https://basecamp.com/cli), a
> funnel on your own machine. Proper first-class support is being built; expect
> this to be replaced by it, not to grow into it.
Drive local coding agents from Basecamp. **@mention an agent user** (e.g.
`@Clawdito fix the calendar bug`) in any Basecamp comment, message, or card — and
an agent on **your** machine picks it up, gathers the surrounding context
from Basecamp, does the work in the right repo, and **replies as that agent
user**, right where you asked.
Basecamp is already where the work and its context live — a comment sits inside a
card, inside a project, with a thread and a creator. Instead of copy-pasting all
that into a terminal, you write where the work is and let the agent pull the
context it needs.
---
## Usage
### What it feels like
1. In Basecamp, you comment on a card: **“@Clawdito the date picker is off by one — please fix.”**
2. On your laptop, the agent wakes up, reads the card and its thread, figures out
which repo this is, and starts working.
3. A few minutes later, **Clawdito replies on the same card** with what it did
(and, ideally, a PR link). If it hit a wall, it replies with the error and
@mentions you.
You never leave Basecamp. The agent runs locally, as you, with your tools.
### The one-time setup
You need three things in place:
1. **The runtime** — clone the repo and install dependencies:
```bash
git clone https://github.com/basecamp/basecamp-local-agent-connector
cd basecamp-local-agent-connector
bin/setup # mise install (if you use mise) + bundle install + checks for the `basecamp` and `tailscale` CLIs
```
You also need [Tailscale](https://tailscale.com) with **Funnel enabled** for
your tailnet (Basecamp has to reach your machine over the public internet) and
Ruby 3.4+ (`mise.toml` pins it for [mise](https://mise.jdx.dev) users).
2. **An agent user + its local profile.** The agent is a *real Basecamp user*
(e.g. a bot account named “Clawdito”) that you can @mention. The connector
talks to Basecamp through the [`basecamp` CLI](https://basecamp.com/cli),
which supports named **profiles** — and the agent name you pass must match a
local profile authenticated **as that agent user**:
```bash
basecamp profile create clawdito # the profile must exist before you can log in to it
basecamp auth login --profile clawdito # log in as the Clawdito account
basecamp me --profile clawdito # verify it's Clawdito, not you
```
Your own login is the default profile (the **operator** — the only person
allowed to trigger the agent). The agent and the operator **must be different
Basecamp users**, otherwise the agent’s own replies would trigger it again.
3. **The skill** — install it into Claude Code:
```bash
npx skills add basecamp/basecamp-local-agent-connector -g -a claude-code
```
Install it user-level (`-g`) so it is available in whichever repo you start
Claude from; a project-level install lands in the current directory only.
Start a new Claude session afterwards so it picks the skill up.
(Or just run Claude Code from a clone of this repo — the skill is
auto-discovered via `.claude/skills`.)
**Updating:** the install is a snapshot copy fetched from GitHub, so editing
this repo does *not* change an installed skill. After changes land on `main`,
refresh with:
```bash
npx skills update -g # or without -g for a project-level install
```
### Using it
In Claude Code, run `/basecamp-connect` and say who should watch what.
**There's no syntax to memorize** — the skill reads plain English. It needs an
**agent** (an `@profile`) and at least one **project** — or, for a GitHub-only
run, just a **repo**, with no agent and no project. Everything else has a sane
default and can be said in passing.
```
/basecamp-connect @Clawdito on BC5 Calendar
/basecamp-connect watch BC5.1 and On Call as @Clawdito
/basecamp-connect @Clawdito — projects BC5.1, On Call, and 20361308
/basecamp-connect use @Clawdito to watch https://3.basecamp.com/2914079/projects/41746046
/basecamp-connect @Clawdito on Queenbee, with jorge as the operator
/basecamp-connect @Clawdito on On Call, and let anyone on the project trigger it
/basecamp-connect @Clawdito on BC5.1, and let marie@37signals.com trigger it too # admin operators only
/basecamp-connect @Clawdito on BC5.1, with rob@37signals.com as an operator and anyone at 37signals.com as a participant
/basecamp-connect @Clawdito on BC5.1 plus PR reviews on basecamp/bc3
/basecamp-connect @Clawdito on On Call, poll chat every 30s, skip boosts
/basecamp-connect watch PR reviews on basecamp/bc3
```
> [!TIP]
> **You only have to say it once.** Every successful connection is stored in
> `~/.config/basecamp-connect/last.json` — agent, projects, trust, and polling —
> so invoking `/basecamp-connect` with nothing after it picks those up. It shows
> you what it remembered and asks before reconnecting, so the usual second
> session is just `/basecamp-connect` and a yes.
The flag form still works too, if you prefer typing it that way —
`/basecamp-connect @Clawdito --project "BC5 Calendar" --project "On Call"` — and
it's what gets handed to `bin/connect` underneath either way. Skip it: say what
you want.
Then go to Basecamp and @mention `@Clawdito` in a comment, message, or card with
what you want done. Each time you do, the agent runs and replies.
A few things worth knowing about what you can ask for:
- **A project can be its exact name, a URL, or an ID.** A name is resolved to
its id once, at launch, and must match exactly one project: a near miss or a
shared name stops the launch and names the candidates, rather than watching a
project you didn't mean.
- **Several projects, one connector.** One webhook per project, all multiplexed
onto a single funnel path, so the same `@agent` watches every project you named
at once. Add as many as you like.
- **You alone can trigger it, unless you say otherwise.** The agent acts with
your full machine authority, so widening the trust set hands that authority to
more people — deliberate, never incidental. Two kinds of people can be added:
**operators**, named one by one, whose word authorizes the agent as yours
does, and **participants** — a whole domain or project — who can ask it
things but whose word authorizes nothing. Ask in the same sentence ("let
anyone on the project ask it things", "let Marie operate it too"). **One
caveat that decides which to pick:** unless you're a Basecamp
account admin, the API hands you colleagues' email addresses masked, so the
two email-keyed flags add nobody. Read [Trust modes](#trust-modes) before
relying on any of them.
- **GitHub PR reviews ride the same server.** Ask for a repo ("plus PR reviews on
basecamp/bc3") and review events arrive on the same funnel. A webhook watches
the whole repo, so **only reviews on pull requests you opened reach the
agent** — a review on a colleague's PR is somebody else's work, with no branch
of yours behind it, and is dropped. Of the reviews that are on your PRs, only
**your** approvals (the login `gh` is signed in as) reach the agent as
`approved`; someone else's approval is dropped, while their requested changes
and comments still come through. The other thing filtered out is the agent
talking to itself: it posts under **your** account, so a comment review from your login
whose body and every inline comment start with the 🤖 prefix agents put on
their PR comments is its own reply, and never reaches you. If the body or any
one of those comments doesn't start with 🤖, the whole review comes through,
agent parts and all.
- **Two shapes are valid, and that's the whole requirement.** An agent and at
least one project, for watching Basecamp; or a repo on its own, for a
GitHub-only run — no agent, no project. You can also have both at once.
### More than one connector
Two connectors on the same agent and project is always a mistake: Basecamp
delivers each event to both, so one mention dispatches two agents and gets two
replies. `bin/connect` now refuses to start beside a live run that overlaps,
names the other run's pid, and exits. `--allow-duplicate` overrides it if you
really mean to.
Each run records itself in `~/.config/basecamp-connect/runs/<pid>.json` — what it
watches, and the funnel paths it owns — and removes that file at teardown. So
you can always ask what's running:
```bash
bin/connect --status
```
That is also the answer to *"is this leftover webhook safe to delete?"* — a
question that used to be a guess, and once cost a running connector eleven of
its webhooks. If a registration's `payload_url` ends in a path `--status` names,
it belongs to a live run. **Don't delete a webhook you can't attribute:** it may
be another session's, another machine's, or an older build's (those register
under `/hook/<secret>` rather than `/bc5/<secret>`), and deleting one leaves that
connector silently deaf to Basecamp while it goes on polling chat.
`--status` also consults the process table, because the registry only knows runs
that started with it. A connector from an older build records nothing, so it
would otherwise read as "nothing running" while holding live webhooks — which is
exactly how the damage happened. Any `bin/connect` process the registry can't
account for is now called out by pid, with its paths marked unknown.
Genuine orphans clean themselves up. A run killed with `SIGKILL` never tears
down, so its entry stays behind with the paths it owned — and the next
`bin/connect` start deletes exactly those webhooks, on the projects and repos
*that* run watched rather than only the ones you are starting now, and forgets
the entry once every one of them is accounted for. A path nobody recorded is
never touched.
### Stopping (and why it matters)
While running, the connector exposes a **public URL** (via Tailscale Funnel) and
registers a **real webhook** on each watched project. **Always stop it when
you’re done** — stopping deletes every webhook and unmounts its funnel paths
automatically. In Claude Code, ending the skill does this; from a terminal, press
**Ctrl-C**. Nothing is left running or exposed.
While it runs, it also keeps those registrations alive. Basecamp **deactivates
a webhook after 10 failed deliveries** (`Webhook::DeliveryJob` in bc3: about
four to five hours of backoff), and says nothing — the registration stays listed, but
nothing is delivered to it again. A laptop asleep overnight, a Tailscale funnel
path that dropped, or a run whose every delivery answered `503` all end that
way, and the chat and boost pollers keep working through it, so the connector
looks healthy while every mention goes unheard. Every `--webhook-check` seconds
(default 300) the connector re-reads each webhook it registered and reactivates
any Basecamp switched off (or re-registers one deleted out from under it),
remounts any of its funnel paths the funnel has lost, and reports both loudly on
STDERR. The events of the gap itself are gone — bc3 does not redeliver past a
deactivation — so a `DEACTIVATED` line in the log is worth reading.
Deactivation is the loud failure; the quiet one is a **single delivery that never
arrived**. Basecamp records it (`response.code: 0` — the connection failed, the
funnel re-establishing or the network dropping for a second), leaves the webhook
active, and never mentions it again; the connector logs nothing, because it never
received the request. So the same check also **reconciles the delivery history**:
Basecamp keeps the last deliveries on each webhook, with the exact body it POSTed
and the code it got back, and any delivery of the last hour that did not answer
2xx is replayed through the very same pipeline a live delivery takes — same
authorization, same corroborating re-fetch, same per-event suppression, so a
recovered trigger fires exactly once even if Basecamp retries it too. Recoveries
are logged; a failed delivery older than that hour, or one whose time or body
can't be read, is **not** replayed, and says so in the log (`MISSED and NOT
recovered`) with the recording's URL, so you can hand it over yourself.
---
## How it works
```
You, in Basecamp Your machine
──────────────── ─────────────────────────────────────────
"@Clawdito fix the ┌───> bin/connect (the bridge, Ruby)
calendar bug" ──webhook┘ • WEBrick server on a secret local path
• exposed publicly via Tailscale Funnel
• filter: authored by operator + @mentions agent
• re-fetches & verifies the event vs Basecamp API
• prints trusted events to STDOUT (one JSON/line)
│
▼
/basecamp-connect (the driver, a Claude skill)
• boosts the recording `On it!` as the agent (the ack)
• resolves the local repo from the project
• dispatches a background agent in that repo
(which gathers context via the `basecamp` CLI)
• replies on the card as the agent (--profile)
```
Two halves, deliberately separated:
1. **`bin/connect`** — the **bridge**. A small Ruby process: it opens the public
endpoint, registers the webhooks, and does the *security-critical* filtering
and verification. It emits only trusted events as NDJSON and touches nothing
else.
2. **`/basecamp-connect`** — the **driver**. A Claude Code skill that runs the
bridge, reads its output, acks each event with a boost as the agent, turns it
into a background-agent task in the right repo, and posts the reply.
The bridge is dumb-and-safe; the driver is smart-and-contextual. You can run
`bin/connect` on its own to see exactly what would be dispatched.
**The bridge doesn't know or care what reads it.** It writes one trusted event
per line to STDOUT and stops there, so any agent that can run a process and read
its output can be the driver — the `basecamp` CLI does the replying, and it takes
a `--profile`, not an agent. The driver shipped here is a Claude Code skill
because that's what it was built against, not because the protocol needs one.
---
## Security mechanisms
What `bin/connect` has in place, at a glance:
- **Operator-only triggering by default** — with no trust flags, an event acts
only when its creator is the operator (the CLI default profile, or
`--operator <profile>`), matched by email or account Person id (bc3 redacts
other users' emails from non-admin viewers, so the id is the key that always
works). Trust can be **deliberately broadened** per run — see
[Trust modes](#trust-modes) below.
- **Agent-self exclusion** — the agent's own identity never authorizes, in any
mode, matched by email *and* Person id. Even when trust is broadened to a
domain or project the agent belongs to, its own posts cannot re-trigger it.
- **Every event says whose it is** — `role` on each emitted line is
`operator` (you, or someone named with `--allow`) or `participant` (admitted
only by `--allow-domain` / `--allow-project`). The bridge labels; the watcher
decides what a participant's request may lead to. See
[Operators and participants](#operators-and-participants).
- **Assignments stay operator-only** — assigning the agent a card/todo is
higher-privilege (the assigner's identity is not corroborated), so the named
`--allow` operators trigger by assignment only when
`--allow-assignments-from-authorized` opts them in, and participants never
do.
- **Mention gating** — the recording must contain a real Basecamp mention
*attachment* (`application/vnd.basecamp.mention`) for the agent user, matched by
the agent's Person id encoded in the mention SGID. A mention typed into a
**draft** counts from the moment the draft is published: Basecamp relays no
event while a recording is drafted and never re-relays its creation once it
goes live, so the publication (`*_active`) is the delivery the connector acts
on — and a recording Basecamp still marks `drafted` never emits.
- **Subscription gating** — a new comment with *no* mention still triggers when
the agent subscribes to the commented-on recording (a card/thread it
participates in). Subscription is a live API fact, so it is confirmed by
re-fetching the parent's subscribers and matching the agent's Person id — never
taken from the payload. The comment author is gated exactly like a mention
(operator by default, or the active trust mode's authors, participants
included).
- **Boost gating** — a boost on the agent's work triggers only when a fresh
fetch of the **agent's own received-boosts feed** contains it: boosts never
arrive by webhook (polling that feed is the delivery mechanism), and the feed
files a boost under the person it was aimed at, so membership is both the
existence proof and the targeting proof. The booster is gated exactly like a
mention author — matched by Person id, since the agent's view of the feed
redacts other users' emails — and the emitted booster/content come from the
fetch, never from a payload. A boost is an operator's trigger only — on the
agent's work it reads as approval, which a participant cannot give, so a
participant's boost is ignored (with a line on STDERR). Named `--allow`
operators are matched by email, which that redaction hides, so in practice
boosts stay yours alone.
- **API corroboration** — every event is re-fetched from the Basecamp API and the
**authoritative fetched copy is what gets acted on**, never the raw POST body.
For a mention the fetched recording carries the authoritative creator *and*
content, so both the author and the mention are re-checked against it. An
assignment corroborates the agent's live assignee state but keeps the POST's
claimed assigner — see the assignment caveat under [Trust modes](#trust-modes).
- **Secret webhook path** — the server accepts only `POST /bc5/<secret>`, where
`<secret>` is a fresh 128-bit random token generated per run; every other path
returns 404.
- **Localhost binding** — WEBrick listens only on `127.0.0.1`; the sole public
ingress is the Tailscale Funnel over HTTPS.
- **Replay de-duplication** — events are de-duplicated by id within a run.
- **No reply loop** — the agent's own identity never authorizes in any mode (an
explicit guard on email and Person id), so replies posted as the agent can't
re-trigger the connector even under `domain`/`project` trust where the agent
shares the domain or is a project member; in operator mode they also simply
fail the author check. Startup refuses to run if the agent and operator
resolve to the same user.
- **Ephemeral exposure** — the funnel and per-project webhooks exist only while
the process runs and are torn down on exit.
---
## Trust & security model
A webhook payload is attacker-influenceable text that flows into an agent which
can run commands. `bin/connect` emits an event only when **all** of these hold:
1. **Authored by an authorized user.** By default that means *you* alone (the
CLI default profile, or `--operator <profile>`), matched by email or account
Person id: a third party who can comment in the project cannot make your
agent do anything.
Trust modes (below) can deliberately extend this to named colleagues, a
domain, or the whole project membership — and for a **mention** the check is
applied **twice**: once on the claimed webhook payload as a cheap pre-filter,
and again on the corroborated event, so authorization binds to the author
Basecamp actually recorded, never to forgeable POST text. (An **assignment**
corroborates the agent's assignee state but not the assigner — see the
assignment caveat under [Trust modes](#trust-modes).)
2. **Targets the agent.** The event must reach the agent one of four ways:
a real Basecamp mention *attachment* (`application/vnd.basecamp.mention`)
naming it (not loose text that happens to contain the name); an assignment
adding it to a card/todo; a **new comment on a recording the agent
subscribes to**; or a **boost on the agent's work**. Mentions are re-checked
on the corroborated recording, so a forged mention paired with a real
un-mentioning recording is dropped; subscription is re-fetched from the live
subscribers API and stamped by the verifier, so a comment the agent doesn't
actually subscribe to is dropped the same way; a boost is stamped only when
the verifier finds it in a fresh fetch of the agent's own received-boosts
feed — the feed files a boost under the person it was aimed at, so
membership is the targeting fact.
3. **Corroborated by Basecamp.** The recording is re-fetched from the Basecamp
API and confirmed. For a mention that means it exists **with the claimed
creator and the claimed mention** — so a forged POST cannot survive. For an
assignment it means the agent is really among the recording's current
assignees; the assigner's identity is not independently corroborated, so
there the secret URL path — a fresh 128-bit token per run — is the gate that
stops a forged operator-assignment, not corroboration.
For a mention, the content acted on is the **authoritative copy fetched from
Basecamp**, never the raw POST body.
**No reply loop.** Replies are posted *as the agent*, a different user than the
operator. The agent's own identity **never authorizes, in any mode** — an
explicit guard, matched by email and Person id, refuses agent-authored events
before any mode is consulted. So even under `--trust domain` (where the agent's
email may share the domain) or `--trust project` (where the agent is a member),
its own replies can never re-trigger the connector. (`bin/connect` refuses to
start if the agent and operator resolve to the same Basecamp user — a
configuration in which nothing can trigger. The usual cause is `BASECAMP_PROFILE`
pinned to the agent's profile with no `--operator`: the CLI resolves an
unflagged call through that variable before the default profile, so the
operator becomes the agent.)
### Trust modes
Who may drive the agent is a per-run, explicit choice. The agent acts with the
operator's full machine authority, so broadening trust means handing that
authority to more people — the startup log prints the operators, the
participant set, and who may assign, so it is never implicit.
| Mode | Who triggers | CLI | Keyed on |
|------|--------------|-----|----------|
| `operator` *(default)* | You only. No flags = exactly this. | — | your email **or** Person id |
| `allowlist` | You + the named emails, as operators. | `--allow marie@37signals.com` (repeatable or comma-separated; implies the mode, or `--trust allowlist`) | the author's email |
| `domain` | Any author whose email is at a listed domain, as a participant. | `--allow-domain 37signals.com` (repeatable), or bare `--trust domain` for the 37signals.com default | the author's email |
| `project` | Any corroborated non-client author of a recording the operator's account can read (client users excluded, fail-closed), as a participant. | `--allow-project` or `--trust project` | the author's Person id |
`--allow` combines with either participant set: `--allow rob@37signals.com
--allow-domain 37signals.com` makes Rob an operator and every other
37signals.com author a participant. The two participant sets don't combine with
each other.
**The `Keyed on` column decides whether a mode can fire at all.** Basecamp shows
a person's real email address only to themselves and to account admins —
`Person::Ability#can_see_email_address_of?` is `self == person || admin?`. Every
trust decision is re-made on the recording the verifier re-fetches with *your*
CLI profile, so unless you are an admin on that account, a colleague's address
arrives masked:
```
$ basecamp show <a colleague's message> --json | jq .data.creator
{ "id": 51659243, "name": "Rob Zolkos", "email_address": "r••••••••@•••.•••", "client": false }
```
`allowlist` compares that string against the email you listed and never matches.
`domain` parses `•••.•••` out of it as the domain and never matches. Both fail
closed and silently — the event is simply dropped as unauthorized, with no hint
that a masked address is why. As an account admin you see real addresses and both
modes work as written.
`project` is keyed on the Person id, which every viewer can see, so it works
regardless of admin status. It is also the loosest of the three — read the limit
below before choosing it.
Every mode implicitly includes the operator and excludes the agent itself. In
`project` mode, membership is proven by corroboration: only project members can
post in a project, and every event is re-fetched from the Basecamp API before it
acts — a person who cannot post there cannot produce a corroborated recording.
**Client (external) users are excluded fail-closed**: the corroborated recording
must positively report `creator.client == false`; an absent or non-boolean flag
is treated as untrusted, so a recording representation that omits it cannot slip
a client author through.
One limit of `project` mode is worth stating plainly, because it matters only
against the forged-POST-with-leaked-secret-path threat (a normal Basecamp
delivery is unaffected): **corroboration proves the recording exists with that
author, not that it lives in a *watched* project.** The API re-fetch follows the
URL in the payload, and the operator's CLI can read recordings beyond the
watched projects. So `project` mode trusts any corroborated non-client author in
*any* project the operator's account can see, not strictly the watched ones.
Prefer `allowlist`/`domain` when you need the trust set pinned to specific
people.
**Assignments are operator-only in every mode** unless
`--allow-assignments-from-authorized` opts the named `--allow` operators in;
participants never trigger by assignment. An assignment
is corroborated by the agent really being among the card's assignees — but the
*assigner's* identity is **not** independently verifiable: the verifier confirms
live assignee state and preserves the event's claimed creator. Against a forged
POST on a leaked secret path, that means the "operator-only" guarantee for the
assignment trigger rests on the secret path, not on corroboration, in a way the
mention trigger does not. Bear that in mind before opting assignments in, and
prefer the mention trigger when the author must be cryptographically pinned to
the recording.
### Operators and participants
Opening a project to a whole domain is how a colleague's question reaches the
agent at all — without it their mention is dropped, silently, like a
stranger's. But a domain is not a list of people you would hand your machine
to. So every emitted line carries `role`:
- **`operator`** — you, or someone you named with `--allow`. Their word
authorizes the agent the way yours does.
- **`participant`** — admitted only by `--allow-domain` or `--allow-project`.
They reach the agent by mention and by commenting on a thread it follows;
never by assignment or boost, both of which the bridge drops (and says so
on STDERR, since the person meant something by it).
The bridge only labels. What a participant's request may lead to is the
watcher's policy: the shipped skill answers, researches, files and drafts for a
participant, and stages anything irreversible or outward-facing — a merge, a
deploy, a production write, a message to a customer — for an operator's word
in the same thread. The role is settled on the corroborated author, so a
payload claiming an operator's email on a participant's recording emits as the
participant.
Participants' text is less trusted input. The agent can be asked to do anything
a participant can phrase, and runs with your machine authority; the guarantee
that a participant cannot get a merge or deploy out of it is the watcher
honouring `role`, not anything the bridge enforces. Widen to a domain only with
a watcher that does.
---
## Internal command: `bin/connect`
The bridge. Run it directly to watch a project and print trusted events; the
skill runs exactly this under the hood.
```bash
bin/connect @Clawdito --project "BC5 Calendar"
bin/connect @Clawdito --project "BC5 Calendar" --project "HEY Triage"
bin/connect @Clawdito --project Queenbee --operator jorge --port 4567
```
| Argument / flag | Meaning | Default |
|-----------------|---------|---------|
| `@AGENT` | Agent user / local `basecamp` profile to watch for and reply as. Leading `@` optional; lowercased to the profile name. **Required**, validated at startup. | — |
| `--project` | Basecamp project name, URL, or ID. **Required**, repeatable. | — |
| `--operator` | Profile whose user is allowed to trigger. Also the profile every call not made as the agent runs under — corroborating fetches, chat polling, webhook registration. | CLI default profile |
| `--gh-operator` | GitHub login the review loop is about (with `--repo`): reviews on pull requests opened by anyone else are dropped, and only this login's `approved` reviews are actionable. Any other reviewer's `approved` review is dropped; `changes_requested` and `commented` pass from anyone. The one exception: a `commented` review by *that* login whose body and every inline comment start with 🤖 is the dispatched agent's own reply and is dropped — anything written in it without the marker, and it passes like anyone else's. | the login `gh` is authenticated as |
| `--trust` | Trust mode: `operator`, `allowlist`, `project`, or `domain`. Usually inferred from the value flags below. | `operator` |
| `--allow` | Author email to trust as an **operator** (repeatable or comma-separated). Implies `--trust allowlist`, or combines with a participant set. | — |
| `--allow-domain` | Email domain whose authors are **participants** (repeatable or comma-separated). Implies `--trust domain`. | `37signals.com` under bare `--trust domain` |
| `--allow-project` | Any corroborated non-client author of a recording the operator's account can read is a **participant**. Implies `--trust project`. | off |
| `--allow-assignments-from-authorized` | Let the `--allow` operators trigger via assignment, not just the operator. Participants never do. | off — assignments are the operator's only |
| `--types` | Comma-separated Basecamp event types to subscribe to. `Chat::Line` selects Campfire coverage — chat has no webhooks, so the connector polls each watched project's chats for it. | `Comment,Message,Kanban::Card,Kanban::Step,Todo,Chat::Line` |
| `--chat-poll` | Campfire poll interval, in seconds. | `15` |
| `--boost-poll` | Received-boosts poll interval, in seconds. Boosts have no webhooks, so the connector polls the agent's own received-boosts feed for them. | `60` |
| `--no-boosts` | Don't poll the agent's received-boosts feed (no boost trigger). | polling on |
| `--webhook-check` | How often, in seconds, to re-check that each registered webhook is still active and its funnel path still mounted, putting back whichever isn't, and to reconcile each webhook's delivery history so a delivery that never arrived is replayed. Basecamp deactivates a webhook after 10 failed deliveries. | `300` |
| `--port` | Local port for the webhook server. | an unused high port |
**What it does, in order:**
1. **Resolve agent & operator.** Validates the agent name maps to a usable local
profile (`basecamp me --profile <agent>`); if not, it aborts with `Create it
with basecamp profile create <agent>… then run basecamp auth login --profile
<agent>…`. Resolves the operator identity (refreshing an expired token once). Warns if agent == operator. With
`--repo`, also resolves the operator's GitHub login (`gh api user`, or
`--gh-operator`) — the author of the pull requests whose reviews are
emitted, the only reviewer whose approvals are emitted, and the login whose all-🤖
comment reviews are dropped as the agent's own replies — and aborts with `Run gh auth login, or pass --gh-operator LOGIN` if it
can't.
2. **Open the endpoint.** Starts a WEBrick server on `127.0.0.1:<port>` that only
accepts `POST /bc5/<random-secret>`; everything else is 404. One server + one
secret path serves every watched project.
3. **Expose it.** `tailscale funnel --set-path` mounts each of the connector's
paths (`/bc5/<secret>`, plus `/gh/<secret>` when watching repos) on this
host's funnel, publishing the server at a public `https://<host>.ts.net` URL.
Only those paths are touched, so funnel paths other tools mounted keep
working.
4. **Register webhooks.** Creates one webhook per project (with retry on transient
failures), recording their IDs for cleanup. Also starts the **boost poller**
(unless `--no-boosts`): boosts have no webhooks, so the agent's own
received-boosts feed is fetched every `--boost-poll` seconds and each new
boost runs the same pipeline as a webhook delivery. The first fetch is a
baseline — history is never dispatched.
5. **Listen.** For each delivery, on the request thread: pre-filter
(authorized author + mentions agent + actionable kind), de-duplicate by
event id, verify against the Basecamp API, re-check that the
**corroborated** author is authorized, and **print the trusted event as one
line of NDJSON** to STDOUT. Dropped/diagnostic lines go to STDERR. The
delivery is answered with the verdict: `200` once the event is settled
(emitted, dropped, or a duplicate), `503` when Basecamp could not be asked —
the connector re-runs the CLI a few times first (its keyring probe loses a
race under concurrent invocations and reports stale credentials; bc3 may
answer 5xx mid-deploy), and a `503` makes bc3's delivery job redeliver the
event with backoff rather than settling it as uncorroborated. Which
failures count as the CLI's plight rather than Basecamp's answer is a
fixed list of codes and messages (`Client::TRANSIENT_CODES`,
`TRANSIENT_API_ERROR`), widened by the `retryable` field of the CLI's
`-j` error envelope from the release that adds it (pending in
basecamp-cli): a failure stamped `retryable: true` is retried whatever
its code, while `false` — the CLI's stamp for its verdicts and for
anything it never classified, the keyring race included — leaves the list
in force. A delivery that stays unanswerable for all of bc3's 10 attempts
(~4.3h — a revoked credential looks just like the race) makes bc3
deactivate the webhook. The webhook check (`--webhook-check`, see
[Stopping](#stopping-and-why-it-matters)) reactivates it within one
interval, but a credential still broken just fails the next ten deliveries
too: fix it (`basecamp auth status --profile <agent>`). The connector logs
that remedy with every `503`.
**Emitted event (STDOUT, one JSON object per line):**
```json
{"event_id":99001,"kind":"comment_created","created_at":"…",
"creator":{"id":100,"name":"Jorge Manrubia","email_address":"jorge@…"},
"recording":{"id":456,"type":"Comment","app_url":"…","url":"…",
"content":"<p>… <bc-attachment content-type=\"application/vnd.basecamp.mention\">…Clawdito…</bc-attachment> fix X</p>",
"parent":{…},"bucket":{"id":222,"name":"BC5 Calendar"}},
"trigger":{"mentioned":true,"subscribed":false}}
```
`trigger` is the connector's own verdict on why the event targets the agent,
settled on the re-fetched recording: `mentioned` when its content carries a
mention attachment for the agent's Person id, `subscribed` when a
`comment_created` fired because the agent subscribes to the commented-on
recording. A `comment_created` is exactly one of the two. An assignment or a
boost is a directive by `kind` alone: `subscribed` is `false` for both, and
`mentioned` is a fact about the content (an assigned card whose description
mentions the agent reads `true`; a boost is a reaction, not content, so the
boost path settles no mention verdict and it always reads `false`). A watcher
reads `trigger` to tell a directive from
followed-thread activity instead of decoding the mention markup itself.
**Teardown.** On `SIGINT`/`SIGTERM` it deletes **every** registered webhook
(best-effort, reporting any it couldn’t) and unmounts its own funnel paths
(`tailscale funnel --set-path <path> off` — never `funnel reset`, which would
also tear down other tools' paths), then stops the server. The mounted paths and
webhooks live only for the lifetime of the process. If it
is ever `SIGKILL`ed, clean up manually:
```bash
basecamp webhooks list --project "<project>" # find leftovers
basecamp webhooks delete <id> --project "<project>"
tailscale funnel status # find leftover /bc5/… and /gh/… paths
tailscale funnel --set-path /bc5/<secret> off
```
### Useful `basecamp` CLI commands
```bash
basecamp me [--profile <name>] # who a profile is
basecamp webhooks list --project "<project>" -j # webhooks on a project
basecamp comment <recording-url> "…" --profile <agent> # post as the agent
```
---
## Configuration
- **Agent profile** — a local `basecamp` CLI profile authenticated as the agent
user. The mention target *and* the reply identity (`basecamp comment …
--profile <agent>`).
- **Operator** — the user allowed to trigger; defaults to the CLI default
profile, override with `--operator <profile>`. Must differ from the agent.
Every call not made as the agent is made under this profile, so
`--operator` also decides whose credentials corroborate events and register
webhooks — a `BASECAMP_PROFILE` in the environment does not.
- **Trust mode** — who beyond the operator may trigger, as operator or
participant; defaults to nobody. See [Trust modes](#trust-modes).
- **Project → repo mapping** — [`config/project_repos.toml`](config/project_repos.toml)
maps Basecamp project-name tokens to local repo paths. The skill uses it to
decide where to run each agent; if nothing matches, it asks you.
- **Event types** — `--types` (default `Comment,Message,Kanban::Card,Kanban::Step,Todo,Chat::Line`).
`Chat::Line` is Campfire coverage: Basecamp delivers no chat webhooks, so the
connector polls each watched project's chats and runs new lines through the
same trust gate as webhook events. A watched project with chat disabled says
so once and is then left out of the poll for the rest of the run; restart to
re-check it.
- **Boost polling** — `--boost-poll` interval in seconds (default 60), or
`--no-boosts` to disable the boost trigger. Boosts have no webhooks, so the
connector polls the agent's own received-boosts feed — an account-wide,
agent-scoped surface (a boost triggers wherever the agent's boosted work
lives, not only in watched projects).
- **Webhook check** — `--webhook-check` interval in seconds (default 300):
how often each registered webhook is re-read and reactivated if Basecamp
deactivated it, the funnel paths remounted if lost, and each webhook's
delivery history reconciled so a delivery that never arrived is replayed
(within a one-hour lookback; anything older is logged, not replayed).
- **Port** — `--port` (default: an unused high port).
---
## Development
```bash
bin/setup # or: bundle install
bundle exec rake test # minitest suite
bundle exec rubocop # 37signals house style
```
The code is a small [Zeitwerk](https://github.com/fxn/zeitwerk)-autoloaded gem
under `lib/basecamp_agent_connector/`. Transport-agnostic pieces live at the top
level; the two transports are namespaced under `Basecamp::` and `GitHub::`.
External commands (`basecamp`, `gh`, `tailscale`) are reached through an
injectable **command runner**, so the test suite stubs that one subprocess
boundary rather than mocking the gem’s own classes.
```
bin/connect # shim → Connector.start(ARGV) — Basecamp and/or GitHub
lib/basecamp_agent_connector/
connector # unified: one multi-route server on shared-funnel paths, mounts each transport's bridge
command_runner # shared: runs subprocesses; the seam tests stub
server # shared: WEBrick server, path→handler routes; answers the handler's status, raw body + headers
tunnel # shared: mounts/unmounts our own paths on the host's Tailscale Funnel
emitter # shared: NDJSON writer
basecamp/ # Basecamp:: — the Basecamp webhook transport
bridge # one route: secret path, register webhooks, handler, teardown
client # thin wrapper over the `basecamp` CLI (JSON in/out, profiles)
identity # resolve a Basecamp user by profile (agent / operator)
webhooks # register / delete webhooks across projects (with retry)
event # payload value object + the filter predicates
verifier # authoritative re-fetch + corroboration
pipeline # pre-filter → dedup → verify → emit
github/ # GitHub:: — the PR review-loop transport
bridge # one route: secret path + HMAC, register repo hooks, handler, teardown
client # thin wrapper over the `gh` CLI (JSON in/out)
webhooks # register / delete repo webhooks (with retry)
webhook_signature # constant-time X-Hub-Signature-256 HMAC verify
review_event # pull_request_review payload value object
review_verifier # re-fetch the review + inline comments
review_pipeline # verify signature → filter → dedup → re-fetch → emit
skills/basecamp-connect/SKILL.md # the /basecamp-connect skill
config/project_repos.toml # project → repo mapping
test/ # minitest, mirrors lib/
docs/spec.md # full design & decisions
```
See [`docs/spec.md`](docs/spec.md) for the complete design and the rationale
behind each decision.
## License
[MIT](LICENSE.txt).