/basecamp-connect — drive local agents from Basecamp
This skill turns a Basecamp comment/message/card into a local Claude Code task.
You @mention a real Basecamp agent user (e.g. @Clawdito do X); the watcher
on this machine acknowledges it as that agent user within seconds — a quick
boost or a fitting reply — and a background agent picks it up, gathers the
surrounding context from Basecamp,
acts on it, and replies as that agent user.
The agent is identified by a local basecamp CLI profile of the same name
(e.g. profile clawdito). That profile is both:
- the reply identity — replies post as the agent via
--profile <agent>, and - the mention target — the connector only fires when that user is @mentioned.
The trust model is enforced by bin/connect, not by this skill: an event
reaches STDOUT only if it is (1) authored by an authorized user — by default
the operator alone (you — the CLI default profile, or --operator <profile>);
bin/connect's trust flags (--trust, --allow, --allow-domain,
--allow-project) can deliberately broaden this to named colleagues, an email
domain, or the whole project membership — (2) @mentions the agent user,
assigns it a card/todo, is a new comment on a recording the agent
subscribes to, or is a boost on the agent's work, and (3) is
corroborated against the Basecamp API. The agent's own identity never
authorizes, in any mode. Treat every STDOUT line as already-trusted — but still
keep dispatched agents scoped to the resolved repo.
There are thus four triggers:
- an
@mentionof the agent; - the operator assigning the agent a card/todo (a
*_assignment_changedevent whosedetails.added_person_idsincludes the agent — corroborated by re-fetching the recording and confirming the agent is among its currentassignees). Only the operator's assignments count, in every trust mode, unlessbin/connectwas started with--allow-assignments-from-authorized; - a new comment (
comment_created) with no mention, on a recording the agent subscribes to — corroborated by re-fetching the parent's subscribers and matching the agent's Person id. The comment author is gated exactly like a mention (operator by default, else the active trust mode's authors). See When a comment lands on a thread the agent follows; - a boost (
boost_created) on the agent's own work — boosts have no webhooks, so the connector polls the agent's received-boosts feed (every--boost-pollseconds, default 60;--no-boostsdisables). The booster is gated exactly like a mention author, matched by Person id (the agent's view of the feed redacts other users' emails, so under email-keyedallowlist/domaintrust, boosts effectively stay operator-only). See When someone boosts the agent's work.
Runs from any project — the runtime lives in the connector clone
This skill is typically installed user-level (npx skills add … -g) and invoked
from other projects (e.g. a working session in coworker). The session's
current directory is therefore not the connector repo. All connector
commands in this skill (bin/connect, config/project_repos.toml) live in a
local clone of basecamp/basecamp-local-agent-connector, canonically at:
~/Work/basecamp/basecamp-local-agent-connector
Locate the runtime there and run it from that directory — never from the
current project. If the clone isn't at the canonical path, don't hunt the
filesystem: tell the user to clone it
(git clone https://github.com/basecamp/basecamp-local-agent-connector) and
run bin/setup (see the repo README).
Invocation
The arguments are natural language, not a grammar. Read whatever the user
wrote and pull out the agent, the projects, the repositories, and any trust or
polling wishes — @Clawdito on BC5 Calendar, watch BC5.1 and On Call as @Clawdito, use @Clawdito on Queenbee and let anyone at 37signals.com trigger it, watch reviews on basecamp/bc3 all mean what they say. Never make the user
restate it as flags. The flags below are the canonical form you translate
into before calling bin/connect, and they're also accepted verbatim when the
user types them.
Ask only for what's genuinely missing: something to watch (a project or a repo), plus an agent whenever Basecamp projects are in play — a repo-only run needs neither an agent nor a project. Confirm the resolved connection before launching — the same confirmation the no-args path does.
/basecamp-connect # reuse last connection (confirm first)
/basecamp-connect @Clawdito --project "BC5 Calendar" # one project
/basecamp-connect @Clawdito --project "BC5 Calendar" --project HEY # several
/basecamp-connect @Clawdito --project "BC5 Calendar" --operator jorge # explicit operator
/basecamp-connect @Clawdito --project "BC5 Calendar" --allow marie@37signals.com # + a named coworker
/basecamp-connect @Clawdito --project "BC5 Calendar" --allow-domain 37signals.com # any 37signals author
/basecamp-connect --repo basecamp/bc3 # GitHub-only, no agent
<agent> is a real Basecamp user backed by a local CLI profile (the leading @
is optional; it's lowercased to the profile name). Every run needs at least one
--project or --repo, and --project needs an <agent> (Basecamp has no
global webhook) — pass a project as a name, URL, or ID. The connector
validates the agent profile exists locally at startup and aborts with setup
guidance if not.
Who may trigger defaults to the operator alone. Broaden it deliberately with
the trust flags — --allow <email>, --allow-domain <domain>, --allow-project,
or explicit --trust <mode> — and pass them straight through to bin/connect;
the bridge enforces them and logs the active set. --allow and --allow-domain
only widen trust when the operator is a Basecamp account admin: both key on the
author's email, and Basecamp masks other people's addresses from non-admins
(r••••••••@•••.•••), so the comparison matches nobody. The operator's own
mentions still run — every mode authorizes the operator first, by email or Person
id — but colleagues' are silently dropped as unauthorized, so a non-admin run looks
healthy while ignoring exactly the people the flag was meant to add.
--allow-project keys on the Person id instead and works either way. When a user
asks for email-based trust, say which of the two they're in rather than letting
them find out from an agent that never answers. See the connector README's
"Trust modes" for the full semantics and the agent-self / assignments-operator-only
safeguards.
Stored connection params (no-args invocation)
The skill remembers the last successful connection in
~/.config/basecamp-connect/last.json:
{
"agent": "clawdito",
"operator": null,
"projects": [ { "id": 27, "name": "On Call" }, { "id": 41746046, "name": "BC5.1" } ],
"trust": { "mode": "domain", "allow": [], "allow_domain": [ "37signals.com" ], "allow_assignments": false },
"types": "Comment,Message,Kanban::Card,Kanban::Step,Todo,Chat::Line",
"chat_poll": 15,
"boost_poll": 60,
"saved_at": "2026-07-01T15:00:00Z"
}
- Invoked without arguments: read that file and confirm the stored params
with the user before starting — show the agent, the project list, the
trust configuration (mode + the concrete allowed set), and the effective
coverage: the
typesandchat_pollvalues that will actually be used — the stored ones, or the defaults when the store predates those fields. Showing the effective values matters because the defaults includeChat::Line: a store saved before these fields existed would otherwise relaunch with Campfire polling silently added to an apparently unchanged connection. Ask whether to go with them, adjust them (add/drop projects, different agent, change trust or coverage), or start fresh. Never launch on stored params silently. If the file doesn't exist, ask for the agent and projects as usual. - Invoked with arguments: arguments win; the store is not consulted.
- After every successful registration (the
Listening for mentions of …line — or, for a chat-only--types, thePolling … Campfire(s) …line), write the params that were actually used back to the file — agent profile, operator override (or null), the projects with their resolved ids and names, the trust configuration (the mode and its value flags, so a later no-argument launch reconstructs the same trust boundary rather than silently falling back to operator-only), and the coverage — the--typesvalue,--chat-pollinterval, and boost polling (--boost-pollinterval, ornullfor--no-boosts) actually used, so a chat-only or custom-typed run relaunches as itself instead of silently restoring the default mixed webhook/chat coverage (and its Funnel + webhooks) — so the store always reflects the last working connection. Create the directory if needed. Launch failures must not overwrite it. - Reconstructing the command from the store: always emit exactly one
--trust <mode>for the stored mode, followed by its value flags (allow→--allow,allow_domain→--allow-domain,allow_assignments→ the assignment opt-in;--trust projectneeds no value flag). Emitting the mode explicitly makesbare --trust domain(emptyallow_domain) reconstruct asdomain— using the built-in default domain — rather than silently dropping to operator, and makes a value flag that disagrees with the stored mode (e.g.mode:"operator"with a strayallow_domain) get rejected by the parser rather than silently broadening trust. A missingtrustblock means operator-only (older stores). If the stored block is internally inconsistent and the parser rejects it, stop and confirm with the user — never infer a mode to make it launch. Also re-emit the storedtypes(as--types),chat_poll(as--chat-poll), andboost_poll(--boost-poll <n>, or--no-boostsfor a storednull) when present; missing fields (older stores) mean the defaults.
Project ids are stored (not just names) because a name is only resolved against the project list as it stands at launch — a rename, or a newly ambiguous name, would resolve elsewhere or not at all; launching from the store passes ids.
Prerequisite: the agent must be a local profile authed as that user
Before running, confirm the agent name maps to a usable local basecamp profile
that is authed as the agent user (not as you):
basecamp me --profile clawdito # should show the agent's identity, distinct from yours
basecamp people show me --profile clawdito -j # data.id is the agent's Person id — keep it for the run
data.id from people show me is the account-scoped Person id — the number
mentions, assignments, subscriber lists, and boosts all carry (e.g. 51177542).
basecamp me reports the global identity id, a different number that matches
nothing in an event; the connector resolves the Person id the same way
(Basecamp::Identity.account_person_id). The front thread reads it once here
and uses it to strip the agent's own mention from the dispatched instruction
(step 2c — never by name) and for the fallback mention discriminator under
step 2a (only an older connector's event lines need it there — current ones
carry the verdict as trigger).
If it's missing or authed as the wrong user, set it up (interactive login as the agent/bot account):
basecamp auth login --profile clawdito
That check is for startup, run on its own. An intermittent Not authenticated for profile:<agent> while agents are running is not this
problem and must not be answered with auth login — see
Transient CLI failures.
If the agent profile resolves to the same user as the operator, nothing
will trigger: the connector refuses the agent's own identity in every trust
mode, so if the agent is the operator, the operator's own mentions are dropped
too. bin/connect refuses to start in that state and says why; the usual
cause is BASECAMP_PROFILE pinned to the agent's profile with no --operator,
which resolves the operator through the agent's profile. Use a distinct bot
account for the agent, and pass --operator when the variable is set.
Procedure
1. Launch the bridge, then watch its STDOUT
bin/connect is a long-running process that never exits on its own — it streams
one trusted event per STDOUT line for as long as it runs. A plain background
task only notifies you when a command completes, so on its own it would never
wake you per event. You therefore need two steps: run the connector in the
background, then arm a persistent monitor on its output so each new event
notifies you automatically — with no user prompting in between.
a. Check what is already running, then start. A second connector on the same agent and project makes Basecamp deliver every event twice, so ask first:
cd ~/Work/basecamp/basecamp-local-agent-connector && bin/connect --status
That lists every connector running on this machine, what it watches, and the
funnel paths it owns. If one already covers what the user asked for, say so
and stop — don't start a second. bin/connect refuses the duplicate on its
own, and its message names the other run's pid; read that as the answer, not as
an error to work around. --allow-duplicate exists but is the user's call, never
yours.
Then run the connector in the background and note the output-file path the harness reports (you need it for the monitor):
cd ~/Work/basecamp/basecamp-local-agent-connector && \
bin/connect @Clawdito --project "<project>" [--project "<project>"]...
Read that output file once and confirm it printed Listening for mentions of ...
(webhook registration succeeded) or, for a chat-only --types, Polling ... Campfire(s) ... (the poller is running — chat-only runs register no webhooks
and open no Funnel). If it errored instead (unknown agent profile, auth,
project not found), surface that and stop. On success, save the connection
params to ~/.config/basecamp-connect/last.json (see "Stored connection
params" above) so the next no-args invocation can offer them back.
b. Arm a persistent monitor on that output file with the Monitor tool
(persistent: true). Replay the file from its beginning (-n +1) — the
file is per-run, so this catches an event the connector emitted between
becoming ready and this monitor attaching (a chat mention landing right at
startup, a webhook delivery beating the arm) without ever replaying another
run's events — and filter to the NDJSON event lines so diagnostics stay out
of the stream:
tail -f -n +1 <connector-output-file> | grep --line-buffered -E '^\{'
Each notification the monitor delivers is one event — process it via step 2. An event that lands while you are waiting on the user is not the user's reply.
Each STDOUT line is one trusted event as NDJSON:
{"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>Hey <bc-attachment content-type=\"application/vnd.basecamp.mention\">…@Clawdito…</bc-attachment> do X</p>",
"parent":{...},"bucket":{"id":222,"name":"BC5 Calendar"}},
"trigger":{"mentioned":true,"subscribed":false}}
creator is the triggering author — the person whose mention/assignment
drove this event. In the default operator-only mode that is always you; under a
broadened trust mode (--allow, --allow-domain, --allow-project) it may be
an authorized coworker instead. Treat creator as the requester — that is who
to @mention on failure — not as "the operator." The mention of the agent lives
in recording.content as a mention attachment. trigger is the connector's
verdict on why the event fired, settled on the re-fetched recording:
mentioned (its content carries a mention attachment for the agent's Person
id) or subscribed (a comment_created on a recording the agent follows, with
no mention of it). 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;
mentioned reports whether an assigned recording's content mentions the
agent, and is always false on a boost — a reaction is not content). STDERR
carries diagnostics (dropped/uncorroborated events, registration notices) —
surface them but don't act on them.
Keep watching until the user stops the skill (see Cleanup).
2. For each trusted event — ack it, hand it off, don't do it yourself
The front thread is an orchestrator, not a worker. Its only job is to keep watching for new mentions, acknowledge each one, and dispatch it.
Route by the event line first. Two kinds of line share the monitor:
- A line carrying
review_id/repo/stateand norecordingis a GitHub review (only whenbin/connectruns with--repo). It gets no boost and no bucket lookup — the repo isrepo, the PR ispull_number. Anapprovedline is the operator's approval — the agent may land the PR:bin/connectenforces this (GitHub::ReviewPipeline, seedocs/pr-review-loop.md) by emitting an approval only when the reviewer GitHub recorded, re-fetched from the API, is the operator's GitHub login, and dropping every other reviewer's approval before it reaches this stream. That login is the one this machine'sghis signed in as (gh api user), read once at startup, or--gh-operator <login>passed through tobin/connect; the connector logs it at startup asTrust: approvals from @<login> only; ….changes_requestedandcommentedlines arrive from any reviewer: feedback to address, never a reason to merge. Dispatch one background agent in that repo to handle it per Review / approval loop below, and return to the monitor. - A line carrying
recordingis a Basecamp event — the checklist below. Drop it outright if itscreatoris the agent — the only checkable key: the emittedrecordingcarries no author, and aboost_createdline'srecordingis the agent's own work by definition, which is not what this test reads (bin/connectalready refuses agent-authored events in every trust mode, and posting as the agent — a distinct user from the operator — keeps replies from authorizing anyway; this is cheap defense in depth, and it comes before the boost so a slipped-through self-comment is never acked).
For every other Basecamp event the front thread runs exactly this checklist, in this order, and nothing else:
- Acknowledge — a fast, light signal of receipt, usually a boost with apt
content:
basecamp boost create <recording.url> "<ack>" --profile <agent>(skipped only for subscribed-thread comments andboost_createdevents; both are told apart from the event line alone — see the discriminator under a.). - Resolve the repo from
recording.bucket.name. - Dispatch one background agent that owns the event end-to-end.
- Return to the monitor.
It must never read the recording (beyond the event line it already has), gather context, investigate, run repo commands, do the requested work, or post the reply itself — every one of those blocks it from picking up the next mention, and the ack is what suffers first. The failure this rule exists to prevent: a mention received within 2 seconds, then worked inline (the card read, the code investigated, a worker spawned for the change) with no boost and no reply for 30+ minutes — which, from Basecamp, is indistinguishable from a missed mention. The boost in step 1 is the only Basecamp write the front thread makes per event — except the one holding reply in step b, posted only when the event cannot be handed off — and it lands before anything else happens.
a. Acknowledge — fit the ack to the moment (front thread, immediately on receipt — before repo resolution, before dispatch). What matters is that the requester sees the trigger registered before any slow work — a visible "received," not a rote token. How you signal it is a judgment call:
- A boost is the lightweight default — one CLI call, lands within seconds, shows on the recording as the agent. Give it content that fits the moment (a short apt phrase, a fitting emoji), never a fixed string. This is the right ack for most directive triggers — mentions and assignments alike (boosts work on comments, messages, cards, and todos) — and for Campfire mentions (boost the chat line; see When the mention arrives in Campfire). It matters most for card/board work, where a visible ack is how the requester knows the mention landed at all.
- A brief reply can be the ack when the worker will answer almost immediately — a quick Campfire back-and-forth, or a directive it resolves in moments. That substantive reply carries the "received," so a boost bolted in front of it is noise: the front thread posts nothing and flags "no ack owed" in the handoff (step c) so the worker doesn't add a fallback boost. Not for card/board work — there the visible boost is how the requester sees the mention land — and never license to work inline (the orchestrator rule above stands): skipping means the front thread writes nothing and dispatches at once, exactly as always.
Whichever you pick, keep it fast and light — the front thread's job is to ack (or knowingly skip) and dispatch, nothing more; it lands within seconds regardless of what dispatch does. The boost form:
basecamp boost create <recording.url> "<ack>" --profile <agent>
Use the URL form: every emitted event carries recording.url, and the URL
already names the project. The bare-id form needs --project <recording.bucket.id> — without it the CLI falls into interactive project
resolution, which a background shell cannot answer.
A boost is a lightweight reaction (≤16 chars) posted as the agent, so the trigger visibly registered. Exactly two kinds of Basecamp event get no boost (see their sections below):
- subscribed-thread comments — an event line whose
trigger.subscribedistrue. Readtriggerfirst: it is the connector's own verdict, settled on the re-fetched recording against the agent's Person id, so a line that carries it needs no markup inspection at all (trigger.mentionedtrue means a directive — boost it). Fall back to the markup match only when the line has notriggerkey — an older connector — and then it is acomment_createdwhoserecording.contenthas no<bc-attachment content-type="application/vnd.basecamp.mention">carrying the agent's Person id. There the id is the only discriminator: the attachment's embeddedcontentmarkup names it as<bc-mention … gid="gid://bc3/Person/<id>">, and the attachment's ownsgidattribute encodes the samegid://bc3/Person/<id>(base64 in the segment before--, which is what the connector's own matcher decodes —Event#mentioned_person_idsinlib/basecamp_agent_connector/basecamp/event.rb). Compare against the Person id captured at startup (basecamp people show me --profile <agent> -j→data.id, see Prerequisite). Never match on the name: the<bc-mention>also carries a first name, and names are not unique — a colleague who shares the agent's name would turn a followed-thread comment into a boosted, dispatched directive. A mention attachment naming someone else is still a followed-thread comment, and a plain-text@namewith no attachment is not a mention at all; boost_createdevents.
If the call fails, don't block on it — but retry only the two failures that
happen before any request is sent, Not authenticated for profile: and
token refresh failed: (the transient credential-store failure described in
Transient CLI failures). Any
other failure may have landed the boost server-side (a timeout reading the
response, an envelope parse error), and a retry would post a duplicate. One
shell call does both:
landed=false
for i in 1 2 3; do
if out=$(basecamp boost create <recording.url> "<ack>" --profile <agent> 2>&1); then
landed=true; break
fi
echo "$out" | grep -qE 'Not authenticated for|token refresh failed' || break # anything else: no retry
sleep 2
done
[ "$landed" = true ] || { echo "boost did not verifiably land: $out" >&2; false; }
The explicit landed flag is what makes the exit status honest: a bare break
on a non-retryable error would leave the loop at status 0, and exhausting the
retries would leave it at sleep's status 0 — either way the front thread
would hand off "boost landed" to an agent that then skips its fallback, and the
event stays unacknowledged. The snippet exits non-zero on every unsuccessful
path and echoes the CLI's last error so the failure is visible in the log.
If the boost did not verifiably land, record that for the dispatch (step c) and move on — the dispatched agent posts the fallback boost.
b. Resolve the working repo (front thread, fast). Infer the local repo from
the project name (recording.bucket.name). Basecamp project names usually carry
an app token — e.g. a BC5 … project maps to the Basecamp repo under
~/Work/<org>/<repo>. A mapping table (see config/project_repos.toml, if
present) backs the heuristic. If you cannot confidently map the project to a
repo, ask the user which repo to use — do not guess and do not silently fall
back. This is the one step that may need you; everything after it is delegated.
The ack must never precede an indefinite silence. The boost has already told
the requester "received"; if this step has to stop and ask the user, or step c
fails to dispatch, nobody is working the event and nothing else will post. So
before asking (or on the dispatch failure), post one short holding reply
as the agent on the originating recording — in the Campfire for a chat trigger
— that @mentions the requester (the event creator): received, but held, and
why ("waiting on the operator to pick a repo"). One line, once; it is the only
reply the front thread ever posts, and only on directive triggers (mentions,
assignments, chat) — a followed-thread comment or a boost gets no boost and no
holding reply either. Under the default operator-only trust the requester is
the user you are about to ask; post it anyway, so Basecamp never shows an
acked task nobody holds.
c. Dispatch one background agent that owns the whole event. Use the Agent
tool with run_in_background: true, running in the resolved repo. Give it
everything it needs to finish without the front thread:
- the event
kindand the instruction as that kind defines it: for a mention,recording.contentwith the agent mention removed, the rest of the raw HTML (links, other mentions) intact; for an assignment, the recording itself (itstitle/contentis the task); for aboost_created,details.boost.contentplus the boosted recording, which carries nocontent; for a subscribed-thread comment, the comment as context, not a directive; - the recording URL/id and its parent URL;
- the agent profile name (its reply identity);
- the requester's name/id — i.e. the event
creator(to @mention on failure). This is the triggering author, who under a broadened trust mode is not necessarily the operator; - whether an ack is still owed (step a): the front thread's boost landed (not owed), failed to land (owed — the worker fallback-boosts), or was deliberately skipped because the reply is the ack (not owed).
Instruct that background agent to, in order:
Boost only if the handoff says an ack is still owed. The ack boost is normally already on the recording; the dispatched agent's boost is a fallback for a front-thread call that failed, posted without first listing the recording's boosts:
basecamp boost create <recording.url> "<ack>" --profile <agent>A front-thread call that Basecamp accepted but reported as failed (a timeout reading the response, an envelope parse error) then produces a second ack boost. That is accepted: a boost is a reaction, a duplicate is harmless, and a missing ack is the failure this whole step exists to prevent. Checking first would be one more CLI call that can fail the same transient way, and it would have to decide which earlier event an older boost on the same recording belonged to — the price of "never a second boost" is a rulebook, not a guarantee. Same exceptions as the front thread: subscribed-thread comments and
boost_createdevents are never boosted.Gather context from Basecamp — it is the context store; the event is just the trigger + pointer:
basecamp show <recording.app_url> -j # the recording itself basecamp show <recording.parent.app_url> -j # the card/message it lives in # plus the thread/comments and the project as neededMove the card out of Triage. If the work lives on a card (the recording or its parent is a
Kanban::Card), check which column it sits in. If it's in a Triage-like column and the card table has an In progress-like column (match loosely and case-insensitively: "In progress", "Working on", "Doing"), move the card there before starting — the board should show the work is underway:basecamp cards columns --project <project> # find the columns basecamp cards move <card-id> --to "<In progress>" --profile <agent>Both also take
--card-table <id>, required whenever the project holds more than one card table — On Call has three, so a barecards columns --project 27resolves nothing there. If there's no Triage-like or no In-progress-like column, skip this silently — never invent columns.Do the requested work in the repo.
Several items means several agents. When one request covers independent work — six cards, a todo list, four unrelated bugs — spawn a subagent per item instead of grinding through them in series, five at a time, waiting for a slot before starting the sixth. The user's word overrides the number and the default both ways ("one at a time", "run all ten"). This cap is on the work inside a single event; it is unrelated to event dispatch, which has no cap at all.
Two things stay serial, because parallelism costs more than it saves there: items that depend on each other, and items touching the same files — a merge conflict is slower than the run it saved. Each subagent gets its own worktree when it will commit.
One reply at the end, covering every item, not one reply per subagent. Say which items failed if any did.
Reply latency: the boost says "received"; it does not say "still working." If the work will take more than ~10 minutes, post a short interim reply as the agent on the originating recording (in the Campfire, for a chat trigger) — one or two lines: what it is doing and where progress can be followed (the PR link once it exists, otherwise the branch) — then the final reply when done. One interim reply, not a running commentary.
Reply on the originating recording as the agent — commenting with the agent's profile so the reply posts as the agent user:
basecamp comment <recording.url|id> "<body>" --profile <agent>- Success — post the results where the mention was written.
- Failure (it errored or couldn't finish) — post a short error summary and
@mention the requester (the event
creator) so it surfaces as a notification for whoever asked — the operator in the default mode, or the authorized coworker who triggered it under a broadened mode. - Never put the agent mention in a reply body.
- Write it as rich text — see Write replies as rich text below.
Because the background agent gathers its own context and posts its own reply, the front thread is free the instant it dispatches — it goes straight back to the monitor, ready for the next mention while any number of events are in flight. There is no concurrency cap; dispatch every event as it arrives.
Write replies as rich text
Comment and message bodies are rich text, so use it. The basecamp CLI runs the
body through a Markdown converter on the way in: ## Headings become headings,
**bold** becomes bold, - bullets become a list, > quoted becomes a
blockquote, and fenced blocks become code blocks. Diffs, commands, and error
output belong in a fenced block, not inline in a sentence. A wall of
undifferentiated prose in a rich-text field is a wasted field.
Lead with the answer in the first line. Headings and detail go under it — whoever reads only the notification preview should still know where it landed.
Links carry a title, not a URL. Write
[Skip the ack boost when the reply is immediate](https://github.com/basecamp/bc3/pull/1234),
never a bare https://github.com/basecamp/bc3/pull/1234. Bare URLs don't
autolink in comments — they post as plain text — and even when a URL does link,
it tells the reader nothing about what's behind it. Same for Basecamp links:
name the card, the message, the doc.
Anything in another app gets its full URL. #1234, PR 1234, SENTRY-4F
and abc123f are shorthand that only resolves inside the app they came from — in a
Basecamp comment they're dead text a reader has to go hunt down. Write
[#1234 Skip the ack boost](https://github.com/basecamp/bc3/pull/1234), so the
reference reads the way it does on GitHub and opens there in one click. Same
for issues, commits, runs, Sentry events, and dashboards.
Use tables when the content is a grid. GFM pipe tables convert to real
Basecamp tables, column alignment (|---:|) included, and Markdown works inside
the cells — code spans, bold, titled links. A file-by-file summary, a
before/after benchmark, a matrix of cases: table. (Tables need basecamp v0.8.0
or newer — older builds post the pipes literally.)
Keep them simple, one line per cell. Merged cells, captions, images in cells and multi-line cells all render, but the CLI's in-place TUI editors refuse to open content shaped that way — it can't round-trip through a pipe table. A prose list beats a table for two items; don't reach for a grid that isn't one.
No hand-written HTML. Write the Markdown and let the CLI convert it. Raw
tags are escaped and land as visible <strong>…</strong> text. The
[@Name](person:<id>) mention syntax is the only non-Markdown markup the CLI
understands.
When the agent is assigned a card/todo
If the event kind ends in _assignment_changed, the operator assigned the
agent to the recording (a card/todo/step) — there's no mention to strip; the
recording itself is the task. The front thread acks the recording on receipt
exactly as for a mention — a boost with apt content is the usual ack, and boosts
work on todos and cards too. The dispatched background agent should, in order:
- Boost only if the handoff says an ack is still owed — the same fallback rule as for a mention: post without listing first; a rare duplicate is accepted over a missing ack.
- Move the card out of Triage — same rule as for mentions: if the assigned card sits in a Triage-like column and the table has an In-progress-like column, move it there before starting; skip silently otherwise.
- Do the work the card/todo describes (its
content/titleis the instruction; gather context and resolve the repo as usual; if it's a PR task, follow the green-first lifecycle below). - Reply with the result on the same recording as the agent — and on failure,
a short error summary that @mentions the requester (the event
creator).
The instruction here is the card/todo content, not a comment body. Everything else (resolve repo, one background agent owns it end-to-end, front thread returns to the monitor) is the same as above.
When the mention arrives in Campfire (a chat line)
If the event kind starts with chat_ (e.g. chat_lines_rich_text_created),
the mention was posted in a project Campfire. Chat has no webhooks — the
connector's integrated poller delivered this line through the same trust gate as
every other event. Chat is conversational and realtime, so dispatch the same way
with these differences:
- Context — the generic
basecamp showdoes not resolve chat lines; use the chat commands:basecamp chat line <recording.url> -j # the line itself basecamp chat messages --project <bucket.id> --room <recording.parent.id> -n 25 -j # the conversation - Ack — the front thread acks the line on receipt, usually a boost with
apt content (
basecamp boost create <recording.url> "<ack>" --profile <agent>), same as any recording; the dispatched agent boosts only as the fallback. - No card moves — there is no board; skip the Triage step.
- Reply in the chat as the agent — post to the same Campfire, not a comment:
The CLI resolves @mentions (basecamp chat post "<body>" --project <bucket.id> --room <recording.parent.id> --profile <agent>@Name, or[@Name](person:<creator.id>)to pin by Person id); a reply containing one posts as rich text with its Markdown converted — so format those with Markdown (**bold**,- bullets, and[titled links](url)rather than bare URLs, which don't autolink once the line is rich text). Chat is small: bold, bullets and titled links, no headings. A mention-free reply posts as plain text — Markdown renders literally, so keep those replies plain prose, bare URL and all. Never hand-write HTML in either case. The CLI converts Markdown and resolves mentions; it does not accept raw HTML, so tags like<p>,<strong>, or<a href>are wrong in the Markdown path (write the Markdown instead) and post literally in the plain-text path — a<strong>headline lands as visible<strong>…</strong>text, not bold. The[@Name](person:<id>)mention syntax above is the only non-Markdown markup the CLI understands. Keep replies chat-sized; spill long results into a Basecamp doc or comment and link them. On failure, @mention the requester so it notifies:[@Name](person:<creator.id>).
When a comment lands on a thread the agent follows
If the event line's trigger.subscribed is true — or, on an older
connector's line with no trigger key, the kind is comment_created and
recording.content carries no mention attachment naming the agent (the
fallback discriminator under step 2a — a mention of someone else, or a
plain-text @name, does not count) — the connector fired because the agent
subscribes to the commented-on recording (a card/thread it participates
in), not because it was addressed. Treat this as activity on a followed
thread, not a directive:
- Read for context — the comment (
recording.content) and, as needed, its parent (recording.parent) and neighbours. This is the same non-blocking dispatch as a mention: the front thread returns to the monitor immediately. - **Respond only
…(truncated)