cd /news/ai-agents/software-factory-agent-instruction · home topics ai-agents article
[ARTICLE · art-52075] src=gist.github.com ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Software Factory Agent Instruction

A developer built a headless agent that polls Basecamp for @mentions on cards, hands the card thread to Claude CLI for processing, and marks notifications as read. The agent runs locally via a systemd user timer every 30 seconds, uses SQLite for deduplication, and requires Claude CLI with --dangerously-skip-permissions for headless operation.

read10 min views2 publishedJul 4, 2026

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.

── more in #ai-agents 4 stories · sorted by recency
── more on @basecamp 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/software-factory-age…] indexed:0 read:10min 2026-07-04 ·