{"slug": "software-factory-agent-instruction", "title": "Software Factory Agent Instruction", "summary": "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.", "body_md": "Build a headless agent on this server that polls Basecamp for @mentions on cards, hands the card thread to `claude -p`\n\n, and marks the notification read. Local only, no public webhook. Poll cadence is 30 seconds via a systemd user timer.\n\n``` php\nsystemd user timer (every 30s)\n  -> bc-agent.sh\n    -> flock (single instance)\n    -> basecamp notifications --agent  (filter: type Mention or Assignment, AND app_url contains /card_tables/cards/)\n    -> for each NEW mention (dedup via SQLite):\n        -> extract card_id + bucket_id from subscription_url\n        -> insert SQLite row status='processing'\n        -> fetch card + comments: basecamp cards show <id> ; basecamp comments list <id>\n        -> assemble prompt: PROMPT.md (workflow instructions) + card/thread JSON\n        -> (cd $WORKDIR && claude -p --dangerously-skip-permissions <PROMPT>)   (blocking; runs in the repo; Claude reads thread, acts, and posts back)\n        -> basecamp notifications read <id>   (mark read)\n        -> update SQLite status='success' | 'failed' (from claude exit code)\n```\n\nKnown values for this install:\n\n- Basecamp account id:\n`<your-id>`\n\n- Notification payload shape: top-level\n`.unreads[]`\n\narray - Mention object fields used:\n`id`\n\n,`type`\n\n(`\"Mention\"`\n\n),`app_url`\n\n,`subscription_url`\n\n`subscription_url`\n\npattern:`.../buckets/{BUCKET_ID}/recordings/{CARD_ID}/subscription.json`\n\n- The\n`recording_id`\n\nin`subscription_url`\n\nis the CARD id (not the comment id), which is what`basecamp cards show`\n\nneeds.\n\nRun each check. All must pass before continuing.\n\n```\n# Basecamp CLI installed and authenticated as the bot user\nbasecamp --version\nbasecamp auth status\nbasecamp accounts list --agent   # confirm id 6237538 is present\n\n# Claude CLI installed and authenticated\nclaude --version\n\n# GitHub CLI installed and authenticated (used to open the PR)\ngh --version\ngh auth status\n\n# git identity configured (commits are made under this identity)\ngit config --get user.name\ngit config --get user.email\n\n# sqlite3 present\ncommand -v sqlite3 || echo \"MISSING: install sqlite3\"\n\n# flock present (util-linux, normally preinstalled)\ncommand -v flock || echo \"MISSING: install util-linux\"\n\n# Confirm where the tools live (needed for PATH in the script)\nwhich claude basecamp sqlite3 flock gh git\n```\n\nInstall the compound-engineering plugin into Claude Code (one time):\n\n```\nclaude /plugin marketplace add https://github.com/EveryInc/every-marketplace\nclaude /plugin install compound-engineering\n```\n\nConfirm the target repo has a `CLAUDE.md`\n\nat its root. The compound phase reads and updates it every cycle. If the repo has none, create a minimal one before enabling the timer.\n\nCritical 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\n\n`--dangerously-skip-permissions`\n\nas the default (see the script and the note below), which removes those prompts. Confirm end to end:\n\n```\nclaude -p --dangerously-skip-permissions \"Run the shell command: basecamp accounts list --agent --account 6237538 and summarize the result.\"\n```\n\nGlobal state, not tied to any repo.\n\n```\nmkdir -p \"$HOME/bc-agent\"\nmkdir -p \"$HOME/.local/share/bc-agent\"\nmkdir -p \"$HOME/.config/bc-agent\"\n\nsqlite3 \"$HOME/.local/share/bc-agent/state.db\" \"CREATE TABLE IF NOT EXISTS processed_mentions (\n  notification_id TEXT PRIMARY KEY,\n  card_id         TEXT,\n  bucket_id       TEXT,\n  status          TEXT,          -- processing | success | failed\n  created_at      TEXT DEFAULT (datetime('now')),\n  updated_at      TEXT\n);\"\n```\n\n**Setup agent: stop here and ask the user.** This is a one-time value. Prompt them:\n\n\"What is the absolute path to the project repository this bot should work in? For example\n\n`/home/jarpis/projects/my-app`\n\n.\"\n\nEach `claude -p`\n\ninvocation runs with this directory as its working directory, so Claude has the codebase, `CLAUDE.md`\n\n, and project context available whether the mention is a code task or just a question about the thread.\n\nValidate the answer before writing it (must be an absolute path that exists), then write the config file:\n\n```\n# Replace with the path the user gave you\nWORKDIR=\"/home/jarpis/projects/my-app\"\n\n# Sanity check\n[ -d \"$WORKDIR\" ] || { echo \"Path does not exist: $WORKDIR\"; exit 1; }\n\ncat > \"$HOME/.config/bc-agent/config\" <<EOF\nWORKDIR=\"$WORKDIR\"\nEOF\n```\n\nIf the path is not a git repo, that is fine, warn the user but proceed. Claude just runs from that directory.\n\nCopy the provided `PROMPT.md`\n\n(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`\n\n. The script reads it on every run and appends the live card context before calling Claude.\n\n```\n# PROMPT.md ships alongside this handover; place it here:\ncp /path/to/PROMPT.md \"$HOME/bc-agent/PROMPT.md\"\ntest -f \"$HOME/bc-agent/PROMPT.md\" && echo \"PROMPT.md in place\"\n```\n\nCreate `~/bc-agent/bc-agent.sh`\n\n. Adjust the `PATH`\n\nline to match `which claude basecamp`\n\nfrom step 1.\n\n``` bash\n#!/usr/bin/env bash\nset -uo pipefail\n\n# ---- Config ----\nACCOUNT=\"your-id\"\nDB=\"$HOME/.local/share/bc-agent/state.db\"\nLOCK=\"/tmp/bc-agent.lock\"\nLOG=\"$HOME/.local/share/bc-agent/agent.log\"\nCONFIG=\"$HOME/.config/bc-agent/config\"\n\n# systemd user services get a minimal PATH; make tools reachable.\nexport PATH=\"$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH\"\n\n# ---- Load config (sets WORKDIR) ----\n# shellcheck source=/dev/null\n[ -f \"$CONFIG\" ] && . \"$CONFIG\"\nif [ -z \"${WORKDIR:-}\" ] || [ ! -d \"$WORKDIR\" ]; then\n  printf '%s WORKDIR unset or missing (%s); edit %s\\n' \\\n    \"$(date -u +%FT%TZ)\" \"${WORKDIR:-<unset>}\" \"$CONFIG\" >>\"$LOG\"\n  exit 0\nfi\n\n# ---- Single-instance lock (auto-releases on exit, survives crashes) ----\nexec 9>\"$LOCK\"\nflock -n 9 || exit 0\n\nlog() { printf '%s %s\\n' \"$(date -u +%FT%TZ)\" \"$*\" >>\"$LOG\"; }\n\n# ---- Ensure DB exists (idempotent) ----\nsqlite3 \"$DB\" \"CREATE TABLE IF NOT EXISTS processed_mentions (\n  notification_id TEXT PRIMARY KEY,\n  card_id TEXT, bucket_id TEXT, status TEXT,\n  created_at TEXT DEFAULT (datetime('now')), updated_at TEXT);\"\n\n# ---- Fetch card triggers as TSV: id, subscription_url, app_url, title ----\n# Triggers are @mentions or assignments on a card. `notifications` returns\n# .unreads as null (not []) when the inbox is empty, which is the normal state.\n# `.unreads[]?` yields nothing on null instead of erroring.\nMENTIONS=$(basecamp notifications --agent --account \"$ACCOUNT\" --jq '\n  .unreads[]?\n  | select(.type == \"Mention\" or .type == \"Assignment\")\n  | select(.app_url | contains(\"/card_tables/cards/\"))\n  | [.id, .subscription_url, .app_url, .title] | @tsv\n') || { log \"notifications fetch failed\"; exit 0; }\n\n[ -z \"$MENTIONS\" ] && exit 0\n\n# ---- Process each mention (here-string keeps the loop in the current shell) ----\nwhile IFS=$'\\t' read -r NOTIF_ID SUB_URL APP_URL TITLE; do\n  [ -z \"$NOTIF_ID\" ] && continue\n\n  # Dedup: skip anything already seen (any status)\n  SEEN=$(sqlite3 \"$DB\" \"SELECT 1 FROM processed_mentions WHERE notification_id='$NOTIF_ID' LIMIT 1;\")\n  [ -n \"$SEEN\" ] && continue\n\n  # Extract IDs from subscription_url (numeric, safe to interpolate)\n  BUCKET_ID=$(printf '%s' \"$SUB_URL\" | sed -E 's|.*/buckets/([0-9]+)/.*|\\1|')\n  CARD_ID=$(printf '%s' \"$SUB_URL\"   | sed -E 's|.*/recordings/([0-9]+)/.*|\\1|')\n\n  # Claim it before doing any work\n  sqlite3 \"$DB\" \"INSERT INTO processed_mentions\n    (notification_id, card_id, bucket_id, status, updated_at)\n    VALUES ('$NOTIF_ID','$CARD_ID','$BUCKET_ID','processing', datetime('now'));\"\n  log \"processing mention $NOTIF_ID card $CARD_ID bucket $BUCKET_ID :: $TITLE\"\n\n  # Fetch the card, then its comment thread. This binary has no --all-comments\n  # flag on `cards show`, and `cards show` does not include comments inline, so\n  # the thread comes from a separate `comments list` call.\n  CARD=$(basecamp cards show \"$CARD_ID\" --agent --in \"$BUCKET_ID\" --account \"$ACCOUNT\" 2>>\"$LOG\")\n  COMMENTS=$(basecamp comments list \"$CARD_ID\" --agent --in \"$BUCKET_ID\" --account \"$ACCOUNT\" 2>>\"$LOG\")\n  if [ -z \"$CARD\" ]; then\n    log \"card fetch failed for $CARD_ID\"\n    sqlite3 \"$DB\" \"UPDATE processed_mentions SET status='failed', updated_at=datetime('now') WHERE notification_id='$NOTIF_ID';\"\n    basecamp notifications read \"$NOTIF_ID\" --account \"$ACCOUNT\" --agent >/dev/null 2>&1\n    continue\n  fi\n\n  # Assemble the prompt: static instructions (PROMPT.md) + live card context.\n  PROMPT_TEMPLATE=$(cat \"$HOME/bc-agent/PROMPT.md\")\n  PROMPT=$(cat <<EOF\n$PROMPT_TEMPLATE\n\ncard_id: $CARD_ID\nbucket_id: $BUCKET_ID\naccount: $ACCOUNT\n\nCard JSON:\n$CARD\n\nComment thread JSON (oldest first):\n$COMMENTS\nEOF\n)\n\n  # Hand to Claude (blocking), running inside the project repo so it has full\n  # codebase context. Subshell keeps the cd scoped to this call only.\n  # --dangerously-skip-permissions is required: the run is headless and must use\n  # bash, file edits, git, gh, and basecamp with no approval prompts.\n  ( cd \"$WORKDIR\" && claude -p --dangerously-skip-permissions \"$PROMPT\" ) >>\"$LOG\" 2>&1\n  RC=$?\n\n  # Mark read immediately after Claude returns\n  basecamp notifications read \"$NOTIF_ID\" --account \"$ACCOUNT\" --agent >/dev/null 2>&1\n\n  # Record outcome from Claude's exit code\n  if [ \"$RC\" -eq 0 ]; then\n    sqlite3 \"$DB\" \"UPDATE processed_mentions SET status='success', updated_at=datetime('now') WHERE notification_id='$NOTIF_ID';\"\n    log \"success $NOTIF_ID\"\n  else\n    sqlite3 \"$DB\" \"UPDATE processed_mentions SET status='failed', updated_at=datetime('now') WHERE notification_id='$NOTIF_ID';\"\n    log \"failed $NOTIF_ID rc=$RC\"\n  fi\n\ndone <<< \"$MENTIONS\"\n```\n\nMake it executable:\n\n```\nchmod +x \"$HOME/bc-agent/bc-agent.sh\"\n```\n\nNotes on choices already settled:\n\n- SQLite is the source of truth for dedup, not Basecamp read status. A row is inserted as\n`processing`\n\nbefore any work, so a crash never causes reprocessing. - The\n`--jq`\n\nfilter emits one TSV line per mention. Two mentions on different cards produce two loop iterations, so two separate`claude -p`\n\nruns. `flock -n`\n\non 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\n`.unreads`\n\nas`null`\n\n, not`[]`\n\n. The filter uses`.unreads[]?`\n\nso this normal state yields empty output and exits quietly rather than erroring every 30 seconds. - Caveat on failure detection: the\n`|| { log ...; exit 0; }`\n\nguard only fires if`basecamp notifications`\n\nexits 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 failed`notifications`\n\ncall returns (break auth and check the exit code). If it exits 0 on failure, add an explicit check that the output parses and contains an`unreads`\n\nkey, and alert on its absence.\n\nUse user-level units so they inherit the bot's Basecamp and Claude auth from `$HOME`\n\n.\n\nCreate `~/.config/systemd/user/bc-agent.service`\n\n:\n\n```\n[Unit]\nDescription=Basecamp mention agent (oneshot)\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart=%h/bc-agent/bc-agent.sh\n```\n\nCreate `~/.config/systemd/user/bc-agent.timer`\n\n:\n\n```\n[Unit]\nDescription=Run Basecamp mention agent every 30s\n\n[Timer]\nOnBootSec=30\nOnUnitActiveSec=30\nAccuracySec=1s\nPersistent=false\n\n[Install]\nWantedBy=timers.target\n```\n\n`AccuracySec=1s`\n\nmatters. The systemd default accuracy is 1 minute, which would smear a 30s cadence. `OnUnitActiveSec=30`\n\nschedules the next run 30s after the previous run finished.\n\n```\n# Let user services run without an active login session (required on a headless server)\nloginctl enable-linger \"$USER\"\n\nsystemctl --user daemon-reload\nsystemctl --user enable --now bc-agent.timer\n\n# Confirm the timer is scheduled\nsystemctl --user list-timers | grep bc-agent\n# Run once by hand and watch the log\n~/bc-agent/bc-agent.sh\ntail -f ~/.local/share/bc-agent/agent.log\n\n# Inspect processed rows\nsqlite3 ~/.local/share/bc-agent/state.db \\\n  \"SELECT notification_id, card_id, status, updated_at FROM processed_mentions ORDER BY created_at DESC LIMIT 10;\"\n\n# Watch systemd execution\njournalctl --user -u bc-agent.service -f\n```\n\nEnd-to-end check: @mention the bot user in a card comment, wait up to ~30s, confirm a reply comment appears and a `success`\n\nrow lands in SQLite.\n\nRetry a stuck or failed item manually:\n\n```\n# Find failures\nsqlite3 ~/.local/share/bc-agent/state.db \"SELECT * FROM processed_mentions WHERE status='failed';\"\n\n# Force reprocessing of one notification (delete its row; it must still be unread in Basecamp)\nsqlite3 ~/.local/share/bc-agent/state.db \"DELETE FROM processed_mentions WHERE notification_id='<ID>';\"\n```\n\n-\n**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`\n\n. Git is the safety net: work happens on a`bc/<slug>`\n\nbranch, never on`main`\n\n. -\n**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. -\n**Execution model.**`claude -p`\n\nruns 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. -\n**Failure visibility.**`failed`\n\nrows are recorded but nothing alerts you. Consider a daily check that greps for`status='failed'`\n\nand pings you (Basecamp chat post, email, etc.). -\n**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 in`timeout <seconds>`\n\nand treat a timeout as a`failed`\n\nrow.", "url": "https://wpnews.pro/news/software-factory-agent-instruction", "canonical_source": "https://gist.github.com/aidityasadhakim/a3a1cb7d7a22a089dd54d2a32f607029", "published_at": "2026-07-04 13:36:57+00:00", "updated_at": "2026-07-09 04:40:59.270268+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "large-language-models"], "entities": ["Basecamp", "Claude", "SQLite", "GitHub CLI", "systemd", "Every Inc."], "alternates": {"html": "https://wpnews.pro/news/software-factory-agent-instruction", "markdown": "https://wpnews.pro/news/software-factory-agent-instruction.md", "text": "https://wpnews.pro/news/software-factory-agent-instruction.txt", "jsonld": "https://wpnews.pro/news/software-factory-agent-instruction.jsonld"}}