# Software Factory Agent Instruction

> Source: <https://gist.github.com/aidityasadhakim/a3a1cb7d7a22a089dd54d2a32f607029>
> Published: 2026-07-04 13:36:57+00:00

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.

``` php
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`

in`subscription_url`

is the CARD id (not the comment id), which is what`basecamp cards show`

needs.

Run each check. All must pass before continuing.

```
# Basecamp CLI installed and authenticated as the bot user
basecamp --version
basecamp auth status
basecamp accounts list --agent   # confirm id 6237538 is present

# Claude CLI installed and authenticated
claude --version

# GitHub CLI installed and authenticated (used to open the PR)
gh --version
gh auth status

# git identity configured (commits are made under this identity)
git config --get user.name
git config --get user.email

# sqlite3 present
command -v sqlite3 || echo "MISSING: install sqlite3"

# flock present (util-linux, normally preinstalled)
command -v flock || echo "MISSING: install util-linux"

# Confirm where the tools live (needed for PATH in the script)
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:

```
# Replace with the path the user gave you
WORKDIR="/home/jarpis/projects/my-app"

# Sanity check
[ -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.

```
# PROMPT.md ships alongside this handover; place it here:
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.

``` bash
#!/usr/bin/env bash
set -uo pipefail

# ---- Config ----
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"

# systemd user services get a minimal PATH; make tools reachable.
export PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH"

# ---- Load config (sets WORKDIR) ----
# shellcheck source=/dev/null
[ -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

# ---- Single-instance lock (auto-releases on exit, survives crashes) ----
exec 9>"$LOCK"
flock -n 9 || exit 0

log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >>"$LOG"; }

# ---- Ensure DB exists (idempotent) ----
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);"

# ---- Fetch card triggers as TSV: id, subscription_url, app_url, title ----
# Triggers are @mentions or assignments on a card. `notifications` returns
# .unreads as null (not []) when the inbox is empty, which is the normal state.
# `.unreads[]?` yields nothing on null instead of erroring.
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

# ---- Process each mention (here-string keeps the loop in the current shell) ----
while IFS=$'\t' read -r NOTIF_ID SUB_URL APP_URL TITLE; do
  [ -z "$NOTIF_ID" ] && continue

  # Dedup: skip anything already seen (any status)
  SEEN=$(sqlite3 "$DB" "SELECT 1 FROM processed_mentions WHERE notification_id='$NOTIF_ID' LIMIT 1;")
  [ -n "$SEEN" ] && continue

  # Extract IDs from subscription_url (numeric, safe to interpolate)
  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|')

  # Claim it before doing any work
  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"

  # Fetch the card, then its comment thread. This binary has no --all-comments
  # flag on `cards show`, and `cards show` does not include comments inline, so
  # the thread comes from a separate `comments list` call.
  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

  # Assemble the prompt: static instructions (PROMPT.md) + live card context.
  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
)

  # Hand to Claude (blocking), running inside the project repo so it has full
  # codebase context. Subshell keeps the cd scoped to this call only.
  # --dangerously-skip-permissions is required: the run is headless and must use
  # bash, file edits, git, gh, and basecamp with no approval prompts.
  ( cd "$WORKDIR" && claude -p --dangerously-skip-permissions "$PROMPT" ) >>"$LOG" 2>&1
  RC=$?

  # Mark read immediately after Claude returns
  basecamp notifications read "$NOTIF_ID" --account "$ACCOUNT" --agent >/dev/null 2>&1

  # Record outcome from Claude's exit code
  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 separate`claude -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`

as`null`

, 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 if`basecamp 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 failed`notifications`

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 an`unreads`

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.

```
# Let user services run without an active login session (required on a headless server)
loginctl enable-linger "$USER"

systemctl --user daemon-reload
systemctl --user enable --now bc-agent.timer

# Confirm the timer is scheduled
systemctl --user list-timers | grep bc-agent
# Run once by hand and watch the log
~/bc-agent/bc-agent.sh
tail -f ~/.local/share/bc-agent/agent.log

# Inspect processed rows
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;"

# Watch systemd execution
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:

```
# Find failures
sqlite3 ~/.local/share/bc-agent/state.db "SELECT * FROM processed_mentions WHERE status='failed';"

# Force reprocessing of one notification (delete its row; it must still be unread in Basecamp)
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 a`bc/<slug>`

branch, never on`main`

. -
**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 for`status='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 in`timeout <seconds>`

and treat a timeout as a`failed`

row.
