Build a headless agent on this server that polls Basecamp for @mentions on cards, hands the card thread to claude -p
, and marks the notification read. Local only, no public webhook. Poll cadence is 30 seconds via a systemd user timer.
systemd user timer (every 30s)
-> bc-agent.sh
-> flock (single instance)
-> basecamp notifications --agent (filter: type Mention or Assignment, AND app_url contains /card_tables/cards/)
-> for each NEW mention (dedup via SQLite):
-> extract card_id + bucket_id from subscription_url
-> insert SQLite row status='processing'
-> fetch card + comments: basecamp cards show <id> ; basecamp comments list <id>
-> assemble prompt: PROMPT.md (workflow instructions) + card/thread JSON
-> (cd $WORKDIR && claude -p --dangerously-skip-permissions <PROMPT>) (blocking; runs in the repo; Claude reads thread, acts, and posts back)
-> basecamp notifications read <id> (mark read)
-> update SQLite status='success' | 'failed' (from claude exit code)
Known values for this install:
-
Basecamp account id:
<your-id> -
Notification payload shape: top-level
.unreads[]
array - Mention object fields used:
id
,type
("Mention"
),app_url
,subscription_url
subscription_url
pattern:.../buckets/{BUCKET_ID}/recordings/{CARD_ID}/subscription.json
- The
recording_id
insubscription_url
is the CARD id (not the comment id), which is whatbasecamp cards show
needs.
Run each check. All must pass before continuing.
basecamp --version
basecamp auth status
basecamp accounts list --agent # confirm id 6237538 is present
claude --version
gh --version
gh auth status
git config --get user.name
git config --get user.email
command -v sqlite3 || echo "MISSING: install sqlite3"
command -v flock || echo "MISSING: install util-linux"
which claude basecamp sqlite3 flock gh git
Install the compound-engineering plugin into Claude Code (one time):
claude /plugin marketplace add https://github.com/EveryInc/every-marketplace
claude /plugin install compound-engineering
Confirm the target repo has a CLAUDE.md
at its root. The compound phase reads and updates it every cycle. If the repo has none, create a minimal one before enabling the timer.
Critical prerequisite to verify manually: ** claude -p must run bash, edit files, download attachments, and use git, gh, and basecamp without interactive approval.** The agent runs headless, so any permission prompt would hang the run. This install uses
--dangerously-skip-permissions
as the default (see the script and the note below), which removes those prompts. Confirm end to end:
claude -p --dangerously-skip-permissions "Run the shell command: basecamp accounts list --agent --account 6237538 and summarize the result."
Global state, not tied to any repo.
mkdir -p "$HOME/bc-agent"
mkdir -p "$HOME/.local/share/bc-agent"
mkdir -p "$HOME/.config/bc-agent"
sqlite3 "$HOME/.local/share/bc-agent/state.db" "CREATE TABLE IF NOT EXISTS processed_mentions (
notification_id TEXT PRIMARY KEY,
card_id TEXT,
bucket_id TEXT,
status TEXT, -- processing | success | failed
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT
);"
Setup agent: stop here and ask the user. This is a one-time value. Prompt them:
"What is the absolute path to the project repository this bot should work in? For example
/home/jarpis/projects/my-app
."
Each claude -p
invocation runs with this directory as its working directory, so Claude has the codebase, CLAUDE.md
, and project context available whether the mention is a code task or just a question about the thread.
Validate the answer before writing it (must be an absolute path that exists), then write the config file:
WORKDIR="/home/jarpis/projects/my-app"
[ -d "$WORKDIR" ] || { echo "Path does not exist: $WORKDIR"; exit 1; }
cat > "$HOME/.config/bc-agent/config" <<EOF
WORKDIR="$WORKDIR"
EOF
If the path is not a git repo, that is fine, warn the user but proceed. Claude just runs from that directory.
Copy the provided PROMPT.md
(the agent's operating instructions: attachment handling, question vs implementation routing, and the brainstorm to plan to work to review to compound to PR loop) to ~/bc-agent/PROMPT.md
. The script reads it on every run and appends the live card context before calling Claude.
cp /path/to/PROMPT.md "$HOME/bc-agent/PROMPT.md"
test -f "$HOME/bc-agent/PROMPT.md" && echo "PROMPT.md in place"
Create ~/bc-agent/bc-agent.sh
. Adjust the PATH
line to match which claude basecamp
from step 1.
#!/usr/bin/env bash
set -uo pipefail
ACCOUNT="your-id"
DB="$HOME/.local/share/bc-agent/state.db"
LOCK="/tmp/bc-agent.lock"
LOG="$HOME/.local/share/bc-agent/agent.log"
CONFIG="$HOME/.config/bc-agent/config"
export PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
[ -f "$CONFIG" ] && . "$CONFIG"
if [ -z "${WORKDIR:-}" ] || [ ! -d "$WORKDIR" ]; then
printf '%s WORKDIR unset or missing (%s); edit %s\n' \
"$(date -u +%FT%TZ)" "${WORKDIR:-<unset>}" "$CONFIG" >>"$LOG"
exit 0
fi
exec 9>"$LOCK"
flock -n 9 || exit 0
log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >>"$LOG"; }
sqlite3 "$DB" "CREATE TABLE IF NOT EXISTS processed_mentions (
notification_id TEXT PRIMARY KEY,
card_id TEXT, bucket_id TEXT, status TEXT,
created_at TEXT DEFAULT (datetime('now')), updated_at TEXT);"
MENTIONS=$(basecamp notifications --agent --account "$ACCOUNT" --jq '
.unreads[]?
| select(.type == "Mention" or .type == "Assignment")
| select(.app_url | contains("/card_tables/cards/"))
| [.id, .subscription_url, .app_url, .title] | @tsv
') || { log "notifications fetch failed"; exit 0; }
[ -z "$MENTIONS" ] && exit 0
while IFS=$'\t' read -r NOTIF_ID SUB_URL APP_URL TITLE; do
[ -z "$NOTIF_ID" ] && continue
SEEN=$(sqlite3 "$DB" "SELECT 1 FROM processed_mentions WHERE notification_id='$NOTIF_ID' LIMIT 1;")
[ -n "$SEEN" ] && continue
BUCKET_ID=$(printf '%s' "$SUB_URL" | sed -E 's|.*/buckets/([0-9]+)/.*|\1|')
CARD_ID=$(printf '%s' "$SUB_URL" | sed -E 's|.*/recordings/([0-9]+)/.*|\1|')
sqlite3 "$DB" "INSERT INTO processed_mentions
(notification_id, card_id, bucket_id, status, updated_at)
VALUES ('$NOTIF_ID','$CARD_ID','$BUCKET_ID','processing', datetime('now'));"
log "processing mention $NOTIF_ID card $CARD_ID bucket $BUCKET_ID :: $TITLE"
CARD=$(basecamp cards show "$CARD_ID" --agent --in "$BUCKET_ID" --account "$ACCOUNT" 2>>"$LOG")
COMMENTS=$(basecamp comments list "$CARD_ID" --agent --in "$BUCKET_ID" --account "$ACCOUNT" 2>>"$LOG")
if [ -z "$CARD" ]; then
log "card fetch failed for $CARD_ID"
sqlite3 "$DB" "UPDATE processed_mentions SET status='failed', updated_at=datetime('now') WHERE notification_id='$NOTIF_ID';"
basecamp notifications read "$NOTIF_ID" --account "$ACCOUNT" --agent >/dev/null 2>&1
continue
fi
PROMPT_TEMPLATE=$(cat "$HOME/bc-agent/PROMPT.md")
PROMPT=$(cat <<EOF
$PROMPT_TEMPLATE
card_id: $CARD_ID
bucket_id: $BUCKET_ID
account: $ACCOUNT
Card JSON:
$CARD
Comment thread JSON (oldest first):
$COMMENTS
EOF
)
( cd "$WORKDIR" && claude -p --dangerously-skip-permissions "$PROMPT" ) >>"$LOG" 2>&1
RC=$?
basecamp notifications read "$NOTIF_ID" --account "$ACCOUNT" --agent >/dev/null 2>&1
if [ "$RC" -eq 0 ]; then
sqlite3 "$DB" "UPDATE processed_mentions SET status='success', updated_at=datetime('now') WHERE notification_id='$NOTIF_ID';"
log "success $NOTIF_ID"
else
sqlite3 "$DB" "UPDATE processed_mentions SET status='failed', updated_at=datetime('now') WHERE notification_id='$NOTIF_ID';"
log "failed $NOTIF_ID rc=$RC"
fi
done <<< "$MENTIONS"
Make it executable:
chmod +x "$HOME/bc-agent/bc-agent.sh"
Notes on choices already settled:
- SQLite is the source of truth for dedup, not Basecamp read status. A row is inserted as
processing
before any work, so a crash never causes reprocessing. - The
--jq
filter emits one TSV line per mention. Two mentions on different cards produce two loop iterations, so two separateclaude -p
runs. flock -n
on fd 9 gives single-instance behavior and releases automatically on exit, including crashes, so no stale lockfile. If a run is still busy when the next 30s tick fires, the new run exits immediately and picks up the backlog on the next free tick.- Empty inbox returns
.unreads
asnull
, not[]
. The filter uses.unreads[]?
so this normal state yields empty output and exits quietly rather than erroring every 30 seconds. - Caveat on failure detection: the
|| { log ...; exit 0; }
guard only fires ifbasecamp notifications
exits non-zero. If the CLI ever prints an error envelope but still exits 0 (for example on expired auth), a real failure would look like an empty inbox and pass silently. Before trusting this in production, verify what a failednotifications
call returns (break auth and check the exit code). If it exits 0 on failure, add an explicit check that the output parses and contains anunreads
key, and alert on its absence.
Use user-level units so they inherit the bot's Basecamp and Claude auth from $HOME
.
Create ~/.config/systemd/user/bc-agent.service
:
[Unit]
Description=Basecamp mention agent (oneshot)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=%h/bc-agent/bc-agent.sh
Create ~/.config/systemd/user/bc-agent.timer
:
[Unit]
Description=Run Basecamp mention agent every 30s
[Timer]
OnBootSec=30
OnUnitActiveSec=30
AccuracySec=1s
Persistent=false
[Install]
WantedBy=timers.target
AccuracySec=1s
matters. The systemd default accuracy is 1 minute, which would smear a 30s cadence. OnUnitActiveSec=30
schedules the next run 30s after the previous run finished.
loginctl enable-linger "$USER"
systemctl --user daemon-reload
systemctl --user enable --now bc-agent.timer
systemctl --user list-timers | grep bc-agent
~/bc-agent/bc-agent.sh
tail -f ~/.local/share/bc-agent/agent.log
sqlite3 ~/.local/share/bc-agent/state.db \
"SELECT notification_id, card_id, status, updated_at FROM processed_mentions ORDER BY created_at DESC LIMIT 10;"
journalctl --user -u bc-agent.service -f
End-to-end check: @mention the bot user in a card comment, wait up to ~30s, confirm a reply comment appears and a success
row lands in SQLite.
Retry a stuck or failed item manually:
sqlite3 ~/.local/share/bc-agent/state.db "SELECT * FROM processed_mentions WHERE status='failed';"
sqlite3 ~/.local/share/bc-agent/state.db "DELETE FROM processed_mentions WHERE notification_id='<ID>';"
Posting model (settled: agentic). Claude posts its own reply and takes actions (branches, commits, PR) via the tools it runs. This requires headless tool use, enabled here with--dangerously-skip-permissions
. Git is the safety net: work happens on abc/<slug>
branch, never onmain
. -
PR timing (settled: PR last). Work commits and pushes to the feature branch but opens no PR. Review runs on the branch diff, then compound documents, then the PR is opened as the final step. This reorders the compound-engineering plugin's native flow (which opens the PR during work). To use the native flow instead, have Claude open the PR at the end of work and run review against the PR. -
Execution model.claude -p
runs blocking, so mentions in one tick are processed sequentially and a long autonomous run (work plus review plus compound) holds the lock. That is expected and safe: other mentions wait and are picked up on the next free tick. A shared repo working directory makes concurrent runs unsafe anyway (two Claudes editing the same tree), so blocking is the right default here. -
Failure visibility.failed
rows are recorded but nothing alerts you. Consider a daily check that greps forstatus='failed'
and pings you (Basecamp chat post, email, etc.). -
Run duration. The post-approval run can take many minutes (review spawns many agents). There is no wall-clock limit in the script; the lock simply stays held. If you later want a ceiling, wrap the Claude call intimeout <seconds>
and treat a timeout as afailed
row.