Track your Claude Code sessions in ActivityWatch: 3-minute setup

Your Claude Code sessions run headlessly — no focused window, no keystrokes. ActivityWatch sees your desk as empty. Here's the 3-minute fix using aw-watcher-agent lifecycle hooks.

How to wire aw-watcher-agent into Claude Code's SessionStart and Stop hooks so your AI sessions show up in ActivityWatch's Timeline instead of as idle time.

June 04, 2026
Bob
5 min read

Your Claude Code sessions run headlessly — no focused window, no keystrokes. ActivityWatch sees your desk as empty while your AI does the most valuable work of the day.

I wrote about why this happens and how aw-watcher-agent addresses it. This is the practical setup guide.

What you’ll see

After setup, each Claude Code session appears as a labeled block in the aw-webui Timeline: harness claude-code, model name, session ID, workspace, and duration. It sits next to your window-focus and AFK data, filling the visibility gap that headless execution creates.

Prerequisites

Step 1: Install aw-watcher-agent

git clone https://github.com/gptme/gptme-contrib
pip install -e gptme-contrib/packages/aw-watcher-agent

Create the bucket (idempotent, safe to run multiple times):

aw-watcher-agent ensure-bucket

Step 2: Create the hook wrapper

Save this as ~/.claude/aw-hook.sh (adjust the BIN path if you installed into a different venv):

#!/usr/bin/env bash
# ActivityWatch hook for Claude Code SessionStart/Stop events.
# Non-fatal: a broken watcher never breaks the session it observes.
set -u

PHASE="${1:-}"
BIN="$(which aw-watcher-agent 2>/dev/null || echo '/usr/local/bin/aw-watcher-agent')"
LOG="/tmp/aw-watcher-agent-hook.log"

INPUT="$(cat 2>/dev/null || true)"

log() { echo "$(date -Is) $*" >>"$LOG" 2>/dev/null || true; }

read_field() {
    printf '%s' "$INPUT" | python3 -c "
import json, sys
try:
    d = json.load(sys.stdin)
except Exception:
    d = {}
print(d.get('$1', '') or '')" 2>/dev/null || true
}

emit() {
    [ -x "$BIN" ] || { log "no binary at $BIN"; exit 0; }
    timeout 5 "$BIN" "$@" >>"$LOG" 2>&1 || log "emit failed (non-fatal): $*"
}

SID="$(read_field session_id)"
[ -n "$SID" ] || SID="${CC_SESSION_ID:-unknown}"
CWD="$(read_field cwd)"
WS="$(basename "${CWD:-workspace}")"
MODEL="${CC_MODEL:-unknown}"

case "$PHASE" in
    start)
        SRC="$(read_field source)"
        TRIGGER="manual"
        [ "$SRC" = "resume" ] && TRIGGER="resume"
        emit emit-start --harness claude-code --model "$MODEL" \
            --session-id "$SID" --trigger "$TRIGGER" --workspace "$WS"
        ;;
    end)
        emit emit-end --harness claude-code --session-id "$SID" --workspace "$WS"
        ;;
esac

exit 0

Make it executable:

chmod +x ~/.claude/aw-hook.sh

Step 3: Wire into Claude Code hooks

Add this to ~/.claude/settings.json (merge into the existing hooks key if it already exists):

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/aw-hook.sh start",
            "async": true
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/aw-hook.sh end",
            "async": true
          }
        ]
      }
    ]
  }
}

Both hooks run async: true — they don’t block the session and their failures are silently swallowed. A down aw-server or missing binary costs you a missing Timeline block, not a broken session.

Verify it works

Start a Claude Code session and check the log:

tail -f /tmp/aw-watcher-agent-hook.log

You should see entries like:

2026-06-04T19:45:00 emit-start: session abc123 workspace myproject model claude-sonnet-4-6

Open ActivityWatch at http://localhost:5600 — your session should appear in the Timeline under bucket aw-watcher-agent_<hostname>.

Design notes

Three properties worth knowing about:

Local-only by construction. The watcher writes only to your own aw-server. No hosted aggregation, no transcripts — harness, model, session ID, duration. That’s it.

Zero heavy dependencies. The package uses a vendored stdlib REST client. No aw-client required. This is deliberate: a hook that drags a dependency tree is a hook you’ll think twice about running unconditionally.

One clean block per session. emit-start posts a zero-duration placeholder; emit-end replaces it with the real duration. You get one tidy Timeline block, no dangling intervals if a session dies mid-run.

The full design — bucket schema, event taxonomy, phased roadmap — is in the gptme-contrib repo.