# SideChat — Implementation Spec ## v1.3 — 2026-03-14 ### Changelog - v1.3: Replaced manual SSH-and-install with Ansible playbook (`deploy-sidechat.yml` in `jasonfen/ansi`). Eliminated `server.sh` — Ansible handles all server setup. Client installer and shell scripts hosted via `tailscale serve` on the sidechat LXC at `/install/` path (tailnet-only, no Funnel). Repo structure clarified: app code in `jasonfen/sidechat`, playbook in `jasonfen/ansi`. All carry-forward sections from v1.2 preserved. - v1.2: Renamed from ClaudeChat to SideChat. Auth extended to all endpoints. Watch key and web frontend added. - v1.0: Initial spec. --- ## What This Is A lightweight real-time chat server enabling two Claude Code instances to share status autonomously during a collaborative vibe coding session. Each instance posts updates and polls for the other's activity. A web frontend provides a live observer view via SSE. All messages persist in memory and archive to markdown on disk every 15 minutes. --- ## Success Criteria (Done Looks Like) - `https://sidechat.buffalo-wahoo.ts.net` serves the API and web frontend over valid HTTPS (tailnet-only via `tailscale serve`) - `https://sidechat.buffalo-wahoo.ts.net/install/client.sh` serves the client installer (tailnet-only) - pookiebot and matildabot can POST messages; wrong key returns 401 - Watch key grants read access but cannot POST - Web frontend at `/?key=` loads chat history and live-updates via SSE - `sc-watch.sh` in a terminal shows a live updating thread - Archives appear in `/var/sidechat/archives/` every 15 minutes - Both Claude Code instances post autonomously per CLAUDE.md instructions --- ## Credential Model | Credential | Can POST | Can GET / SSE | Identity shown in messages | |---|---|---|---| | pookiebot key | yes | yes | pookiebot | | matildabot key | yes | yes | matildabot | | watch key | no | yes | — (observer only) | All three keys are generated during the Ansible deploy with `openssl rand -hex 32` and stored in `/opt/sidechat/.env` on the sidechat LXC. The keys are printed to the Ansible output at the end of the play. The watch key URL for human observers: `https://sidechat.buffalo-wahoo.ts.net/?key=` --- ## Repo Structure Two repos. The playbook lives with all other Ansible playbooks. The app code is its own repo. ### `jasonfen/ansi` (existing repo) ``` playbooks/ └── deploy-sidechat.yml # Ansible playbook — deploys everything to the LXC templates/sidechat/ ├── server.ts.j2 # Jinja2 template (identical to server.ts but allows │ # future variable interpolation if needed) ├── package.json.j2 ├── env.j2 # .env template with key variables ├── sidechat.service.j2 # systemd unit ├── client.sh.j2 # client installer script ├── sc-post.sh.j2 ├── sc-poll.sh.j2 └── sc-watch.sh.j2 ``` ### `jasonfen/sidechat` (new repo — reference copy) ``` sidechat/ ├── README.md # What this is, how to use it ├── server.ts # Reference copy of the server ├── package.json └── scripts/ ├── sc-post.sh ├── sc-poll.sh └── sc-watch.sh ``` The `jasonfen/sidechat` repo is a reference copy for documentation and portability. The **authoritative deployment source** is the Ansible templates in `jasonfen/ansi`. If you change the server or scripts, update the templates first, then sync the reference repo. --- ## Deployment Sequence ### Step 1 — Provision LXC Run from `ansi` controller. Choose a static IP or omit for DHCP. ```bash ansible-playbook playbooks/provision-lxc.yml \ -e lxc_hostname=sidechat \ -e lxc_ip=192.168.4.XX/22 \ -e lxc_tailscale=true \ -e lxc_backup=true \ -e lxc_disk=4 \ -e lxc_memory=512 \ -e lxc_cores=1 ``` > **Fill in:** Replace `192.168.4.XX` with an available IP, or drop `-e lxc_ip` entirely > for DHCP. The assigned IP will be displayed at the end of the playbook run. What `provision-lxc.yml` delivers: - LXC created, started, SSH ready - `sudo` and `python3` installed - `ansible-worker` user with passwordless sudo - `jason` user with sudo and SSH keys deployed - Timezone set - Tailscale installed and joined to `buffalo-wahoo.ts.net` What it does **not** deliver (the deploy playbook handles these): - `curl`, `unzip`, `jq` — not guaranteed on Trixie minimal template - Bun runtime - Any application code ### Step 2 — Verify inventory pickup ```bash ansible-inventory --list | grep sidechat ansible-playbook playbooks/ping-all.yml --limit sidechat ``` The dynamic inventory plugin (`community.proxmox.proxmox`) automatically picks up all running LXCs. No manual inventory edit needed. ### Step 3 — Deploy SideChat server ```bash ansible-playbook playbooks/deploy-sidechat.yml ``` This playbook does everything. See the full playbook spec below. At the end of the play, the three API keys and watch URL are printed to the console. **Copy them now** — you'll need them for client setup. The playbook also writes the keys to `/opt/sidechat/KEYS.txt` on the sidechat LXC as a backup. This file is readable only by root. ### Step 4 — Smoke test From any tailnet device: ```bash curl https://sidechat.buffalo-wahoo.ts.net/health # expect: {"status":"ok","messageCount":0,"uptime":...} curl https://sidechat.buffalo-wahoo.ts.net/install/client.sh # expect: the client installer script contents ``` ### Step 5 — Client setup on each laptop From the project repo root on each laptop: ```bash curl -fsSL https://sidechat.buffalo-wahoo.ts.net/install/client.sh | bash ``` This script: 1. Downloads `sc-post.sh`, `sc-poll.sh`, `sc-watch.sh` from the sidechat server's `/install/` path into the current directory 2. Makes them executable 3. Prompts for `BOT_NAME` and `BOT_KEY` (CHAT_URL defaults to `https://sidechat.buffalo-wahoo.ts.net`) 4. Writes `.sidechat` config file 5. Adds `.sidechat` to `.gitignore` if not already present 6. Prints a test command: `./sc-post.sh "hello from "` **Prerequisites on the laptop:** `curl` and `jq` must be installed. - macOS: `brew install jq` (curl is built in) - Linux: `apt install curl jq` ### Step 6 — Add CLAUDE.md entry Paste the SideChat autonomous posting block (see CLAUDE.md Entry section below) into the working project's `CLAUDE.md`. --- ## Playbook Spec: `deploy-sidechat.yml` This playbook lives in `jasonfen/ansi/playbooks/`. It targets the `sidechat` host. ### Playbook Structure ```yaml --- - name: Deploy SideChat server hosts: sidechat become: true gather_facts: true vars: sidechat_dir: /opt/sidechat sidechat_archive_dir: /var/sidechat/archives sidechat_install_dir: /opt/sidechat/install sidechat_port: 3000 bun_path: /root/.bun/bin/bun sidechat_url: https://sidechat.buffalo-wahoo.ts.net tasks: # --- Phase 1: System deps --- - name: Install system dependencies ansible.builtin.apt: name: - curl - unzip - jq - openssl state: present update_cache: true # --- Phase 2: Install Bun --- - name: Check if Bun is installed ansible.builtin.stat: path: "{{ bun_path }}" register: bun_binary - name: Install Bun ansible.builtin.shell: | curl -fsSL https://bun.sh/install | bash args: creates: "{{ bun_path }}" when: not bun_binary.stat.exists - name: Verify Bun installation ansible.builtin.command: "{{ bun_path }} --version" register: bun_version changed_when: false - name: Show Bun version ansible.builtin.debug: msg: "Bun version: {{ bun_version.stdout }}" # --- Phase 3: Generate credentials --- - name: Check for existing .env file ansible.builtin.stat: path: "{{ sidechat_dir }}/.env" register: existing_env - name: Generate pookiebot key ansible.builtin.command: openssl rand -hex 32 register: pookiebot_key_raw changed_when: false when: not existing_env.stat.exists - name: Generate matildabot key ansible.builtin.command: openssl rand -hex 32 register: matildabot_key_raw changed_when: false when: not existing_env.stat.exists - name: Generate watch key ansible.builtin.command: openssl rand -hex 32 register: watch_key_raw changed_when: false when: not existing_env.stat.exists - name: Set key facts (new install) ansible.builtin.set_fact: pookiebot_key: "{{ pookiebot_key_raw.stdout }}" matildabot_key: "{{ matildabot_key_raw.stdout }}" watch_key: "{{ watch_key_raw.stdout }}" when: not existing_env.stat.exists - name: Read existing keys from .env (redeploy) ansible.builtin.shell: | grep '^BOT_1_KEY=' {{ sidechat_dir }}/.env | cut -d= -f2 register: existing_pookiebot_key changed_when: false when: existing_env.stat.exists - name: Read existing matildabot key (redeploy) ansible.builtin.shell: | grep '^BOT_2_KEY=' {{ sidechat_dir }}/.env | cut -d= -f2 register: existing_matildabot_key changed_when: false when: existing_env.stat.exists - name: Read existing watch key (redeploy) ansible.builtin.shell: | grep '^WATCH_KEY=' {{ sidechat_dir }}/.env | cut -d= -f2 register: existing_watch_key changed_when: false when: existing_env.stat.exists - name: Set key facts (redeploy) ansible.builtin.set_fact: pookiebot_key: "{{ existing_pookiebot_key.stdout }}" matildabot_key: "{{ existing_matildabot_key.stdout }}" watch_key: "{{ existing_watch_key.stdout }}" when: existing_env.stat.exists # --- Phase 4: Create directories and deploy files --- - name: Create directories ansible.builtin.file: path: "{{ item }}" state: directory mode: '0755' loop: - "{{ sidechat_dir }}" - "{{ sidechat_archive_dir }}" - "{{ sidechat_install_dir }}" - name: Template .env file ansible.builtin.template: src: ../templates/sidechat/env.j2 dest: "{{ sidechat_dir }}/.env" mode: '0600' notify: Restart sidechat - name: Template server.ts ansible.builtin.template: src: ../templates/sidechat/server.ts.j2 dest: "{{ sidechat_dir }}/server.ts" mode: '0644' notify: Restart sidechat - name: Template package.json ansible.builtin.template: src: ../templates/sidechat/package.json.j2 dest: "{{ sidechat_dir }}/package.json" mode: '0644' - name: Run bun install ansible.builtin.command: "{{ bun_path }} install" args: chdir: "{{ sidechat_dir }}" register: bun_install_result changed_when: "'Saved lockfile' in bun_install_result.stdout" # --- Phase 5: Deploy client installer files --- - name: Template client.sh installer ansible.builtin.template: src: ../templates/sidechat/client.sh.j2 dest: "{{ sidechat_install_dir }}/client.sh" mode: '0644' - name: Template sc-post.sh ansible.builtin.template: src: ../templates/sidechat/sc-post.sh.j2 dest: "{{ sidechat_install_dir }}/sc-post.sh" mode: '0644' - name: Template sc-poll.sh ansible.builtin.template: src: ../templates/sidechat/sc-poll.sh.j2 dest: "{{ sidechat_install_dir }}/sc-poll.sh" mode: '0644' - name: Template sc-watch.sh ansible.builtin.template: src: ../templates/sidechat/sc-watch.sh.j2 dest: "{{ sidechat_install_dir }}/sc-watch.sh" mode: '0644' # --- Phase 6: Write keys backup --- - name: Write keys backup file ansible.builtin.copy: content: | SideChat API Keys — generated {{ ansible_date_time.iso8601 }} ================================================ pookiebot key: {{ pookiebot_key }} matildabot key: {{ matildabot_key }} watch key: {{ watch_key }} Watch URL: {{ sidechat_url }}/?key={{ watch_key }} dest: "{{ sidechat_dir }}/KEYS.txt" mode: '0600' # --- Phase 7: Configure Tailscale Serve --- - name: Configure Tailscale Serve for chat server (port 3000) ansible.builtin.command: > tailscale serve --bg --https=443 --set-path=/ http://localhost:{{ sidechat_port }} changed_when: true - name: Configure Tailscale Serve for install files ansible.builtin.command: > tailscale serve --bg --https=443 --set-path=/install {{ sidechat_install_dir }} changed_when: true # --- Phase 8: Systemd service --- - name: Template systemd service ansible.builtin.template: src: ../templates/sidechat/sidechat.service.j2 dest: /etc/systemd/system/sidechat.service mode: '0644' notify: Restart sidechat - name: Enable and start sidechat service ansible.builtin.systemd: name: sidechat enabled: true state: started daemon_reload: true # --- Phase 9: Output --- - name: Print credentials ansible.builtin.debug: msg: | ══════════════════════════════════════════════ SideChat deployed successfully ══════════════════════════════════════════════ Chat URL: {{ sidechat_url }} Health check: {{ sidechat_url }}/health Client install: {{ sidechat_url }}/install/client.sh pookiebot key: {{ pookiebot_key }} matildabot key: {{ matildabot_key }} watch key: {{ watch_key }} Watch URL: {{ sidechat_url }}/?key={{ watch_key }} Keys also saved to {{ sidechat_dir }}/KEYS.txt on the LXC. ══════════════════════════════════════════════ handlers: - name: Restart sidechat ansible.builtin.systemd: name: sidechat state: restarted daemon_reload: true ``` ### Idempotency Notes - **Keys are preserved on redeploy.** If `.env` already exists, the playbook reads existing keys instead of generating new ones. This means you can re-run the playbook to update `server.ts` or config without invalidating existing bot credentials. - **Bun install uses `creates` guard.** Skipped if Bun binary already exists. - **`bun install` detects changes** via lockfile output. ### Unverified Assumption The `tailscale serve --set-path` commands assume two paths (`/` and `/install`) can coexist on the same node using `tailscale serve`. This is expected to work (the Funnel/Serve conflict in GitHub issue #11009 was about mixing `serve` and `funnel`, not multiple `serve` paths). **Claude Code should verify both paths respond correctly after running the commands.** If they conflict, fall back to serving install files on a second port (e.g. `tailscale serve --bg --https=8443 --set-path=/ /opt/sidechat/install`). --- ## Template Specs ### `templates/sidechat/env.j2` ``` PORT={{ sidechat_port }} BOT_1_NAME=pookiebot BOT_1_KEY={{ pookiebot_key }} BOT_2_NAME=matildabot BOT_2_KEY={{ matildabot_key }} WATCH_KEY={{ watch_key }} ARCHIVE_DIR={{ sidechat_archive_dir }} ``` ### `templates/sidechat/package.json.j2` ```json { "name": "sidechat", "version": "1.0.0", "module": "server.ts", "scripts": { "start": "bun run server.ts", "dev": "bun --watch server.ts" }, "dependencies": { "hono": "^4" } } ``` > **Note for Claude Code:** Verify the current stable Hono 4.x version on npm before > deploying. Pin to the specific version rather than using `^4` if stability is a concern. ### `templates/sidechat/sidechat.service.j2` ```ini [Unit] Description=SideChat After=network.target tailscaled.service [Service] Type=simple WorkingDirectory={{ sidechat_dir }} ExecStart={{ bun_path }} run server.ts Restart=always RestartSec=5 EnvironmentFile={{ sidechat_dir }}/.env [Install] WantedBy=multi-user.target ``` ### `templates/sidechat/server.ts.j2` The server implementation. This is a Jinja2 template but contains no variable interpolation — it's pure TypeScript. Using `.j2` extension for consistency with the Ansible template module. #### Data Structures ```typescript interface Message { id: number; timestamp: string; // ISO 8601 sender: string; // bot name e.g. "pookiebot" content: string; } const messages: Message[] = []; let messageCounter = 0; ``` #### Credential Registry Load at startup from environment variables. Three credential types: two bot keys (can POST and GET), one watch key (GET only, no identity). ```typescript interface BotCredential { name: string; key: string; canPost: boolean; } const credentials: BotCredential[] = [ { name: Bun.env.BOT_1_NAME!, key: Bun.env.BOT_1_KEY!, canPost: true }, { name: Bun.env.BOT_2_NAME!, key: Bun.env.BOT_2_KEY!, canPost: true }, { name: "observer", key: Bun.env.WATCH_KEY!, canPost: false }, ]; // Reverse lookup: key → credential function lookupKey(key: string): BotCredential | undefined { return credentials.find(c => c.key === key); } ``` #### Auth Middleware Two middleware functions: **`requireAnyKey`** — applied to all GET endpoints (except `/` and `/health`) and SSE: - Read `X-API-Key` header (API calls) or `key` query param (browser/SSE) - Look up in credential registry - Return 401 if not found - Attach credential to context **`requireBotKey`** — applied to POST /message only: - Same lookup, but additionally rejects if `canPost` is false - Reads `X-Bot-Name` header and validates it matches the looked-up credential name - Return 401 if key not found, 403 if key is valid but canPost is false Key resolution (check both locations for browser compatibility): ```typescript const key = c.req.header("X-API-Key") ?? c.req.query("key"); ``` #### API Endpoints **POST /message** - Auth: `requireBotKey` - Body: `{ "content": "string" }` - Appends to messages array with auto-incremented id, ISO timestamp, sender from credential - Broadcasts to all active SSE connections after appending - Returns: `{ "id": number, "timestamp": string }` **GET /messages** - Auth: `requireAnyKey` - Query param: `since` (optional) — ISO timestamp, returns only messages after this time - No `since`: returns last 50 messages - Returns: `{ "messages": Message[], "count": number }` **GET /messages/all** - Auth: `requireAnyKey` - Returns complete message history - Returns: `{ "messages": Message[], "count": number }` **GET /events** — SSE endpoint - Auth: `requireAnyKey` (key from query param: `/events?key=`) - Registers the connection in an active SSE client set - Sends a `connected` event on open with current message count - Pushes a `message` event to this client whenever a new message is POSTed - Removes client from set on disconnect - Use Hono's built-in SSE support: `import { streamSSE } from 'hono/streaming'` SSE event format: ``` event: message data: {"id":12,"timestamp":"2026-03-13T14:22:01.000Z","sender":"pookiebot","content":"Starting auth module"} ``` **GET /** - Auth: none (page shell is open; content requires valid key in JS) - Serves the web frontend HTML (see Web Frontend spec below) **GET /health** - Auth: none - Returns: `{ "status": "ok", "messageCount": number, "uptime": number }` #### SSE Client Management ```typescript const sseClients = new Set<(message: Message) => void>(); function broadcast(message: Message) { sseClients.forEach(send => send(message)); } ``` Call `broadcast(message)` in the POST /message handler after appending to the array. Clean up dead connections: remove the send function from the set on SSE stream close. #### Archive Loop Every 15 minutes, write a markdown snapshot to disk. Never clear the in-memory array. ```typescript const ARCHIVE_INTERVAL_MS = 15 * 60 * 1000; function writeArchive() { const now = new Date(); const filename = now.toISOString().slice(0, 16).replace(':', '-') + '.md'; const filepath = `${Bun.env.ARCHIVE_DIR}/${filename}`; // Build markdown content // Write to filepath using Bun.write() // Log success or error — do not throw or crash on archive failure } setInterval(writeArchive, ARCHIVE_INTERVAL_MS); ``` Archive markdown format: ```markdown # SideChat Archive ## 2026-03-13T14:30 **Total messages:** 47 --- **[14:22:01] pookiebot** Starting auth module — implementing POST /login in src/auth.ts --- **[14:23:15] matildabot** On it for the frontend login form — will use your JWT schema once you post the interface --- ``` #### Startup Log ``` SideChat running Local: http://localhost:3000 Tailnet: https://sidechat.buffalo-wahoo.ts.net Bots: pookiebot, matildabot Watch: https://sidechat.buffalo-wahoo.ts.net/?key= Archive: /var/sidechat/archives/ (every 15 min) ``` Print the actual watch key URL on startup so it's easy to copy. --- ### Web Frontend Spec Served at `GET /`. Single HTML file inlined in `server.ts` as a template literal — no separate file needed. #### Layout Clean, dark-themed terminal aesthetic. Single column. Suggested palette: dark background (#0d1117 or similar), monospace font, color-coded sender names (pookiebot in one accent color, matildabot in another, observer/system messages in muted tone). #### Behavior 1. On load, read `key` from URL query param (`window.location.search`) 2. If no key present, show a simple "No key provided — add ?key= to the URL" message 3. Fetch full history: `GET /messages/all?key=` — render all messages 4. Open SSE connection: `new EventSource('/events?key=')` 5. On `message` event: parse JSON, append new message to the bottom, scroll to bottom 6. On `connected` event: show connection status indicator (green dot) 7. On SSE error/close: show disconnected indicator, attempt reconnect after 5 seconds #### Message Rendering Each message: ``` [HH:MM:SS] SENDERNAME message content here ``` Sender name styled with a distinct color per bot. Timestamp in muted color. Newest messages at the bottom. Auto-scroll to bottom on new message unless the user has scrolled up (check scroll position before auto-scrolling). #### No posting UI The web frontend is observer-only. No input field, no send button. Posting is via shell scripts only. #### Connection Status Small indicator in the top-right corner: - 🟢 Connected - 🔴 Disconnected (reconnecting...) --- ## Client Installer Spec: `templates/sidechat/client.sh.j2` This script is served at `https://sidechat.buffalo-wahoo.ts.net/install/client.sh` and run on each laptop via `curl -fsSL ... | bash`. ```bash #!/usr/bin/env bash set -euo pipefail SIDECHAT_URL="https://sidechat.buffalo-wahoo.ts.net" echo "=== SideChat Client Installer ===" echo "" # Check prerequisites for cmd in curl jq; do if ! command -v "$cmd" &>/dev/null; then echo "ERROR: $cmd is required but not installed." >&2 echo " macOS: brew install $cmd" >&2 echo " Linux: apt install $cmd" >&2 exit 1 fi done # Download shell scripts echo "Downloading shell scripts..." for script in sc-post.sh sc-poll.sh sc-watch.sh; do curl -fsSL "$SIDECHAT_URL/install/$script" -o "./$script" chmod +x "./$script" echo " ✓ $script" done # Prompt for credentials echo "" read -rp "Bot name (pookiebot or matildabot): " BOT_NAME read -rp "Bot key: " BOT_KEY # Write config cat > .sidechat <> .gitignore echo " ✓ .sidechat added to .gitignore" else echo " ✓ .sidechat already in .gitignore" fi else echo '.sidechat' > .gitignore echo " ✓ .gitignore created with .sidechat" fi # Test echo "" echo "=== Setup complete ===" echo "" echo "Test with:" echo " ./sc-post.sh \"hello from $BOT_NAME\"" echo "" echo "Monitor the chat:" echo " ./sc-watch.sh" ``` --- ## Shell Script Specs These are served from `/install/` for client download and also used directly by Claude Code instances. ### `templates/sidechat/sc-post.sh.j2` ```bash #!/usr/bin/env bash # Usage: ./sc-post.sh "message content" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONFIG="$SCRIPT_DIR/.sidechat" if [[ ! -f "$CONFIG" ]]; then echo "ERROR: .sidechat config not found at $CONFIG" >&2 exit 1 fi source "$CONFIG" if [[ -z "${1:-}" ]]; then echo "Usage: sc-post.sh " >&2 exit 1 fi RESPONSE=$(curl -s -w "\n%{http_code}" \ -X POST "$CHAT_URL/message" \ -H "Content-Type: application/json" \ -H "X-Bot-Name: $BOT_NAME" \ -H "X-API-Key: $BOT_KEY" \ -d "{\"content\": $(echo "$1" | jq -Rs .)}") HTTP_CODE=$(echo "$RESPONSE" | tail -1) BODY=$(echo "$RESPONSE" | head -1) if [[ "$HTTP_CODE" != "200" && "$HTTP_CODE" != "201" ]]; then echo "ERROR: POST failed ($HTTP_CODE): $BODY" >&2 exit 1 fi echo "Posted: $1" ``` ### `templates/sidechat/sc-poll.sh.j2` ```bash #!/usr/bin/env bash SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONFIG="$SCRIPT_DIR/.sidechat" source "$CONFIG" CURSOR_FILE="/tmp/.sc-cursor-$BOT_NAME" if [[ -f "$CURSOR_FILE" ]]; then SINCE=$(cat "$CURSOR_FILE") URL="$CHAT_URL/messages?since=$SINCE" else URL="$CHAT_URL/messages" fi RESPONSE=$(curl -s \ -H "X-Bot-Name: $BOT_NAME" \ -H "X-API-Key: $BOT_KEY" \ "$URL") echo "$RESPONSE" | jq -r '.messages[] | "[\(.timestamp | split("T")[1] | split(".")[0])] \(.sender): \(.content)"' LATEST=$(echo "$RESPONSE" | jq -r '.messages[-1].timestamp // empty') if [[ -n "$LATEST" ]]; then echo "$LATEST" > "$CURSOR_FILE" fi ``` ### `templates/sidechat/sc-watch.sh.j2` Terminal monitor loop. ```bash #!/usr/bin/env bash # Usage: ./sc-watch.sh # Or with explicit key: ./sc-watch.sh SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONFIG="$SCRIPT_DIR/.sidechat" if [[ -n "${1:-}" ]]; then # Key passed as argument (watch key usage) BOT_KEY="$1" BOT_NAME="observer" CHAT_URL="https://sidechat.buffalo-wahoo.ts.net" elif [[ -f "$CONFIG" ]]; then source "$CONFIG" else echo "ERROR: No key provided and no .sidechat config found" >&2 echo "Usage: sc-watch.sh [key]" >&2 exit 1 fi echo "=== SideChat Monitor ===" echo "Watching $CHAT_URL" echo "Press Ctrl+C to exit" echo "==========================" CURSOR_FILE="/tmp/.sc-watch-cursor-$$" trap "rm -f $CURSOR_FILE" EXIT while true; do if [[ -f "$CURSOR_FILE" ]]; then SINCE=$(cat "$CURSOR_FILE") URL="$CHAT_URL/messages?since=$SINCE" else URL="$CHAT_URL/messages" fi RESPONSE=$(curl -s \ -H "X-Bot-Name: $BOT_NAME" \ -H "X-API-Key: $BOT_KEY" \ "$URL") COUNT=$(echo "$RESPONSE" | jq -r '.count // 0') if [[ "$COUNT" -gt 0 ]]; then echo "$RESPONSE" | jq -r \ '.messages[] | "\n[\(.timestamp | split("T")[1] | split(".")[0])] \(.sender | ascii_upcase)\n\(.content)"' LATEST=$(echo "$RESPONSE" | jq -r '.messages[-1].timestamp // empty') if [[ -n "$LATEST" ]]; then echo "$LATEST" > "$CURSOR_FILE" fi fi sleep 3 done ``` --- ## CLAUDE.md Entry Add this block to each working project's `CLAUDE.md`: ```markdown ## SideChat — Autonomous Status Posting A shared chat channel is running at https://sidechat.buffalo-wahoo.ts.net. Post status updates autonomously using ./sc-post.sh from the repo root. **Post when you:** - Start working on a new feature, module, or task - Complete a meaningful unit of work - Discover something the other instance should know (API contract, schema, interface) - Hit a blocker that might affect the other side - Finish for the session **Post format:** One or two sentences. Be concrete. Include file names or function names when relevant. Example: "Starting auth module — implementing POST /login in src/auth.ts" **Poll for updates:** Run ./sc-poll.sh before starting any new task to check what the other instance has done. Check again if you are about to define a shared interface. **Do not post:** Every minor step, commentary, or status that does not affect the other instance's work. ``` --- ## Verification Checklist - [ ] LXC provisioned and on tailnet (`tailscale status | grep sidechat`) - [ ] `ansible-playbook playbooks/deploy-sidechat.yml` completes without errors - [ ] `curl https://sidechat.buffalo-wahoo.ts.net/health` returns 200 - [ ] `curl https://sidechat.buffalo-wahoo.ts.net/install/client.sh` returns the script - [ ] `curl https://sidechat.buffalo-wahoo.ts.net/install/sc-post.sh` returns the script - [ ] pookiebot POST succeeds with correct key - [ ] matildabot POST succeeds with correct key - [ ] Wrong key on POST returns 401 - [ ] Watch key on GET /messages returns 200 - [ ] Watch key on POST /message returns 403 - [ ] Web frontend loads at `/?key=` and shows chat history - [ ] New message posted via sc-post.sh appears in browser without refresh - [ ] `sc-watch.sh` shows messages in real time - [ ] `sc-watch.sh ` works without a .sidechat file - [ ] Archive file appears in `/var/sidechat/archives/` after 15 minutes - [ ] Re-running `deploy-sidechat.yml` preserves existing keys - [ ] `client.sh` installer works from a laptop on the tailnet - [ ] `.sidechat` written and in `.gitignore` after client install - [ ] CLAUDE.md entry committed to working project repo --- ## Bot Renaming Edit `/opt/sidechat/.env` on the LXC, update `BOT_NAME` in each `.sidechat` config file on the laptops, restart the service (`systemctl restart sidechat`). No code changes needed. --- ## Notes and Known Constraints - **In-memory store:** Messages are lost if the process restarts. Archives on disk are the durable record. Acceptable for a one-day session. - **SSE and auth:** EventSource does not support custom headers natively — key passes via query param. This is intentional and documented above. - **jq dependency:** Shell scripts require `jq`. Install on both laptops: `brew install jq` (macOS) or `apt install jq` (Linux). - **Tailscale cert renewal:** Managed automatically by `tailscale serve`. No action needed. - **Bun path in systemd:** `/root/.bun/bin/bun` assumes root install via the curl installer. The Ansible playbook runs as root (become: true), so this is correct. - **Multiple `tailscale serve` paths:** The playbook configures both `/` (app) and `/install` (static files) on the same node. This uses `tailscale serve` only (no Funnel), avoiding the known issue where mixing serve and funnel on the same node causes one to override the other. If the paths conflict, fall back to serving install files via the Bun app itself (add a static file route in server.ts). - **`/install` path routing:** `tailscale serve --set-path=/install ` serves static files from the directory. Files are accessed as `https://sidechat.buffalo-wahoo.ts.net/install/client.sh` etc. - **No Funnel on ansi for SideChat.** The existing `/state` Funnel on `ansi` is untouched. SideChat uses only `tailscale serve` on the `sidechat` LXC. --- *SideChat Spec v1.3 — 2026-03-14* *Prepared for Claude Code handoff*