Hooks
What agent-office runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add W17ant/Claude-Office > /plugin install agent-office@agent-office
Ships with agent-office. Installing the plugin gets these hooks.
What fires, and when
PreToolUse
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
PostToolUse
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
PostToolUseFailure
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
SubagentStart
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
SubagentStop
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
SessionEnd
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
UserPromptSubmit
Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
Stop
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
PermissionRequest
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
Notification
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
StopFailure
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
PreCompact
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
PostCompact
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
PostToolBatch
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
CwdChanged
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
PostModelSwitch
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
TaskCreated
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
TaskCompleted
bash "${CLAUDE_PLUGIN_ROOT}/hooks/agent-tracker.sh"
In the plugin's words
How agent-office describes its own hook set.
Forwards Claude Code hook events to the Agent Office server (http://localhost:3334/event, or AGENT_OFFICE_EVENT_URL / AGENT_OFFICE_PORT). Every entry runs hooks/agent-tracker.sh, which exits 0 silently when the server is down or when the event is one it does not act on. The same
Where it lives
- hooks/agent-tracker.shRunsGitHub
Read the script
#!/usr/bin/env bash # ============================================================================= # agent-tracker.sh — Claude Code Hook → Agent Office bridge # # Claude Code passes hook event JSON on stdin. # This script inspects the event and forwards relevant activity to the # Agent Office server at http://localhost:3334/event (AGENT_OFFICE_PORT or # AGENT_OFFICE_EVENT_URL move it; see SERVER_URL below). # # Hook events handled: # PreToolUse — detect Agent tool calls, MCP tool calls, tool activity # PostToolUse — detect Agent/MCP completion; any other call's finish # (main thread or worker) lowers the permission hand # that very call raised # PostToolUseFailure — a tool call errored (a non-zero Bash exit lands here # and gets NO PostToolUse); shown as a failed status # SubagentStart — a subagent exists; ties the office agent to its agent_id # SubagentStop — a subagent finished (the completion signal for background agents) # SessionStart — a Claude Code session began (startup/resume/compact/clear/fork) # SessionEnd — that session exited # UserPromptSubmit — the boss's prompt (or a background worker waking Claude) # Stop — Claude's turn ended: its reply + the background work still running # PermissionRequest — Claude is waiting for a permission answer # Notification — permission_prompt / idle_prompt / quota_* / agent_needs_input # StopFailure — the turn ended on an API error # PreCompact / # PostCompact — context compaction started / finished # PostToolBatch — a batch of tool calls finished, on the main thread or # inside a worker (lowers a permission hand that thread # raised) # CwdChanged — the session cd'd somewhere else (project tag follows) # PostModelSwitch — the session runs another model now # TaskCreated / # TaskCompleted — the session's task list changed (the TaskCreate tool, # TaskUpdate to completed); the office reads the list # itself from ~/.claude/tasks/<session_id>/, these only # make it look at once # # Every event carries the session it came from: sessionId (session_id), # project (basename of cwd), cwd and transcriptPath. Two terminals are two # sessions, and the server keeps their agents apart with these. The events # of the second group describe the session's own Claude (its "main" record # on the server) rather than a worker. # # UserPromptSubmit is special: whatever a hook prints on stdout is injected # into Claude's context and exit 2 blocks the prompt. This script captures # the Python's stdout into a variable and only ever exits 0, so nothing of # the office leaks into the conversation. # # PermissionRequest is the one event that may print: what a PermissionRequest # hook writes on stdout answers the dialog. When the office is allowed to # answer (the marker file $AGENT_OFFICE_HOME/answer-permissions exists — the # server keeps it in step with its answerPermissions setting) the event is # POSTed in the foreground, the server's reply names a pending permission, and # the script long-polls GET /permission/<id> until the office clicks (the # server's hookOutput is printed verbatim) or the server says the dialog was # answered in the terminal, expired, or is unknown (nothing is printed). If # the server cannot be reached at any point, exit 0 at once: the terminal # dialog is in charge. Every other event is POSTed in the background and # never waits. # # Background agents (run_in_background): the Agent tool returns straight away # with status "async_launched", so PostToolUse only links the office agent to # the subagent's agentId. The agent leaves on SubagentStop (or a # SubagentHandback tool call, on Claude Code versions that have one). # Tool calls made inside a subagent carry that subagent's agent_id, which is # forwarded so the server knows which office agent is doing the work. # # Signals: a Bash command that deploys, builds, tests or pushes is classified # (classify_command, "Signals" in the Python below, with its pattern table) # and its start, success or failure travels as `signals` on the event the # call sends anyway — deploy_start / deploy_success / deploy_fail, build_*, # test_*, push / push_done / push_failed — so the office can show a deploy # shipping, open an incident when one fails, and cheer a push to main. # # The payload shapes handled below are the ones Claude Code 2.1.300 was # observed sending (see tests/hook-script.test.ts for verbatim captures). # # Usage (configured in ~/.claude/settings.json hooks section): # { "type": "command", "command": "/path/to/agent-tracker.sh" } # ============================================================================= # Where the office listens: AGENT_OFFICE_EVENT_URL wins, else the port from # AGENT_OFFICE_PORT (the same knob the office scripts take), default 3334. # The default host is 127.0.0.1, not 'localhost': 'localhost' can resolve to # ::1 first (macOS tries IPv6 for it), which would let a squatter on [::1] # win the name even while the real office runs on 127.0.0.1. SERVER_URL="${AGENT_OFFICE_EVENT_URL:-http://127.0.0.1:${AGENT_OFFICE_PORT:-3334}/event}" # Read the hook payload from stdin PAYLOAD=$(cat) # Auth token (optional — read from file if present). The token is NEVER put on # curl's command line (every local user can read argv in `ps` / /proc), and # the token file is stable across restarts, so a leak would stay valid. The # hook instead SIGNS each request with it (sign_request below): the token stays # in this variable and this process's memory, both of which only its owner can # read. It travels to the signing python through the environment, never argv. TOKEN="" TOKEN_FILE="${AGENT_OFFICE_HOME:-$HOME/.agent-office}/auth-token" if [ -f "$TOKEN_FILE" ]; then TOKEN=$(ca - hooks/install-hooks.shGitHub
Read the script
#!/usr/bin/env bash # ============================================================================= # install-hooks.sh — Add Agent Office hook to ~/.claude/settings.json # ($CLAUDE_CONFIG_DIR/settings.json when that is set, the # file Claude Code itself reads then) # # This script: # 1. Refuses a settings file that is not valid JSON (line and column of the # error, nothing changed), then backs it up under a name no earlier # backup has # 2. Registers agent-tracker.sh for PreToolUse (spawns + tool activity), # PostToolUse (completions), PostToolUseFailure (failed tool calls), # SubagentStart (subagent ids), SubagentStop (subagents finishing), # SessionStart and SessionEnd (one session pill per Claude Code terminal), # and the main-thread events that show what the session's own Claude is # doing: UserPromptSubmit (the prompt), Stop (the reply), PermissionRequest # and Notification (waiting on you), StopFailure (API errors), PreCompact # and PostCompact (compaction), PostToolBatch (a permission answered), # CwdChanged (project tag follows a cd), PostModelSwitch (model) # (--status-line-only skips this step) # 3. Registers status-line.sh as the statusLine (+ subagentStatusLine) # command — the payroll / electricity / memory meters — chaining any # status line you already had (--no-status-line skips this step) # 4. Prints next steps # # Safe to re-run: each hook event is checked separately, so an older install # gets any missing entries added, and an entry that still points at a checkout # you have since moved, re-cloned or deleted is repointed at this one. Hooks # that are not ours are never touched. An entry is ours when one of its shell # words is a path ending in hooks/agent-tracker.sh (hooks/status-line.sh for # the status line), the layout of every checkout and of the plugin; a command # that merely contains the name (my-status-line.sh, ~/.claude/status-line.sh) # is someone else's. # # The agent-office plugin registers the same hooks from its own copy, so a # plugin user wants the status line alone: --status-line-only (npm run # install-status-line). A plain install with the plugin enabled warns that # every event would then arrive twice. # # --uninstall takes it all back out: every hook entry whose command runs # agent-tracker.sh goes (whatever checkout it points at), a statusLine / # subagentStatusLine that runs status-line.sh is put back to the command it # chained (saved in $AGENT_OFFICE_HOME/statusline-prev and # subagent-statusline-prev) or removed, and the saved files are deleted. # # Usage: # bash hooks/install-hooks.sh [--no-status-line | --status-line-only] # bash hooks/install-hooks.sh --uninstall [--no-status-line | --status-line-only] # ============================================================================= set -euo pipefail # --no-status-line: register the hooks only (no payroll / electricity / # memory meters); with --uninstall, leave the status line # alone # --status-line-only: register the status line only (no hooks), for the # plugin install; with --uninstall, leave the hooks alone # --uninstall: remove what this script registered (see above) HOOKS=1 STATUS_LINE=1 UNINSTALL=0 USAGE="Usage: bash hooks/install-hooks.sh [--no-status-line | --status-line-only] bash hooks/install-hooks.sh --uninstall [--no-status-line | --status-line-only]" for arg in "$@"; do case "$arg" in --no-status-line|--no-statusline) STATUS_LINE=0 ;; --status-line-only|--statusline-only) HOOKS=0 ;; --uninstall) UNINSTALL=1 ;; -h|--help) echo "$USAGE" exit 0 ;; *) echo "Unknown option: $arg (try --no-status-line, --status-line-only or --uninstall)" >&2 exit 1 ;; esac done if [ "$HOOKS" = "0" ] && [ "$STATUS_LINE" = "0" ]; then echo "--no-status-line and --status-line-only together leave nothing to do" >&2 echo "$USAGE" >&2 exit 1 fi # Claude Code reads $CLAUDE_CONFIG_DIR/settings.json when that is set (the # server and npm run doctor follow it too), ~/.claude/settings.json otherwise CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" SETTINGS_FILE="$CLAUDE_DIR/settings.json" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TRACKER_SCRIPT="$SCRIPT_DIR/agent-tracker.sh" HOOK_COMMAND="bash \"$TRACKER_SCRIPT\"" STATUS_SCRIPT="$SCRIPT_DIR/status-line.sh" OFFICE_HOME="${AGENT_OFFICE_HOME:-$HOME/.agent-office}" # Colours RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' RESET='\033[0m' echo "" echo -e "${CYAN}╔═══════════════════════════════════════════╗${RESET}" if [ "$UNINSTALL" = "1" ]; then echo -e "${CYAN}║ Agent Office — Hook Uninstaller ║${RESET}" else echo -e "${CYAN}║ Agent Office — Hook Installer ║${RESET}" fi echo -e "${CYAN}╚═══════════════════════════════════════════╝${RESET}" echo "" # --------------------------------------------------------------------------- # A settings file that is not valid JSON is refused, never replaced: one stray # comma in a hand-edited file would otherwise cost every permission rule, env # var, model choice and plugin in it. The parse error is printed with its line # and column and nothing is written — no backup either, since this runs first. # A file holding only whitespace counts as {} (there is nothing in it to # lose). Every Python step below that parses the file refuses the same way. # --------------------------------------------------------------------------- check_settings_json() { local why [ -f "$SETTINGS_FILE" ] || return 0 if ! why=$(python3 - "$SETTINGS_FILE" 2>&1 <<'PYEOF' import json, sys try: with open(sys.argv[1], 'r') as f: text = f.read() if text.strip(): json.loads(text) except json.JSONDecodeError as e: print(f'line {e.lineno - hooks/status-line.shGitHub
Read the script
#!/usr/bin/env bash # ============================================================================= # status-line.sh — Claude Code statusLine → Agent Office meters bridge # # Claude Code runs the configured `statusLine.command` on every state change # of an interactive session (new assistant message, /compact, permission-mode # change, ...) with a JSON object on stdin (Claude Code 2.1.300, see # https://code.claude.com/docs/en/statusline): # session_id, transcript_path, cwd, version, model{id,display_name}, # workspace{current_dir,project_dir}, cost{total_cost_usd,total_duration_ms, # total_api_duration_ms,total_lines_added,total_lines_removed}, # context_window{total_input_tokens,total_output_tokens,context_window_size, # used_percentage,current_usage}, exceeds_200k_tokens, effort{level}, # rate_limits{five_hour{used_percentage,resets_at},seven_day{...},spend_limit{...}} # (rate_limits only for Pro/Max subscribers, and only after the first API # response; used_percentage may be null early in the session) # # This script POSTs a compact summary to the office (POST /status — the # payroll / electricity / memory wall panel) and prints the status line that # Claude Code displays: # default <model> · ctx <used>% · $<cost> # chained the output of the statusLine command that was configured before # install-hooks.sh wrapped it (saved in # $AGENT_OFFICE_HOME/statusline-prev); the default line is printed # when that command prints nothing # # `--subagents` makes it the `subagentStatusLine` command. Claude Code then # sends {...base hook fields, columns, tasks:[{id, agentType, status, # description, label, startTime, model, effort, contextWindowSize, tokenCount, # tokenSamples, cwd}]} once per refresh tick while subagents are visible. The # per-worker token counts are forwarded as an agent_status and nothing is # printed, which keeps Claude Code's default rows (or the chained previous # command's rows, saved in $AGENT_OFFICE_HOME/subagent-statusline-prev). # # Bash 3.2 compatible; one python3 -I call (no cwd imports, no PYTHON* env); # curl runs in the background with a 1 s cap, so a stopped server costs # nothing and never blocks the status line. # ============================================================================= # Where the office listens: AGENT_OFFICE_STATUS_URL wins, else the port from # AGENT_OFFICE_PORT (the same knob as agent-tracker.sh and the office # scripts), default 3334 SERVER_URL="${AGENT_OFFICE_STATUS_URL:-http://127.0.0.1:${AGENT_OFFICE_PORT:-3334}/status}" OFFICE_HOME="${AGENT_OFFICE_HOME:-$HOME/.agent-office}" MODE="main" PREV_FILE="$OFFICE_HOME/statusline-prev" if [ "${1:-}" = "--subagents" ]; then MODE="subagents" PREV_FILE="$OFFICE_HOME/subagent-statusline-prev" fi PAYLOAD=$(cat) if [ -z "$PAYLOAD" ]; then exit 0 fi # Auth token (written by the server on start; optional — the same lookup as # agent-tracker.sh). The token is NEVER put on curl's argv, where every local # user can read it in `ps`: this script SIGNS its POST with the token instead # (sign_headers below) and sends the signature as a header. The token stays in # this variable and this process's memory (owner-readable only), reaching the # signing python through the environment, never argv. TOKEN="" TOKEN_FILE="$OFFICE_HOME/auth-token" if [ -f "$TOKEN_FILE" ]; then TOKEN=$(cat "$TOKEN_FILE" 2>/dev/null) fi # Given a method and the request URL (via the environment, so the token never # reaches argv) and the body on stdin, print "<timestamp><US><signature>", # the HMAC-SHA256(token, method\npath\ntimestamp\nbody) the server verifies. # Nothing is printed when there is no token (the POST then goes out unsigned # and the server answers 401, which the backgrounded call ignores). PY_SIGN=' import os, sys, time, hashlib token = os.environ.get("OFFICE_TOKEN", "") if not token: sys.exit(0) def _hmac_sha256_hex(key, msg): # HMAC-SHA256 per RFC 2104 on hashlib: the hmac module costs ~5 ms to import if len(key) > 64: key = hashlib.sha256(key).digest() key = key.ljust(64, b"\0") inner = hashlib.sha256(bytes(b ^ 0x36 for b in key) + msg).digest() return hashlib.sha256(bytes(b ^ 0x5c for b in key) + inner).hexdigest() def _url_path(url): # Path and query of the URL, as urlsplit gives them, without importing urllib.parse rest = url.split("://", 1)[-1].split("#", 1)[0] i = rest.find("/") return rest[i:] if i >= 0 else "/" method = os.environ.get("OFFICE_METHOD", "") path = _url_path(os.environ.get("OFFICE_URL", "")) body = sys.stdin.buffer.read() ts = str(int(time.time() * 1000)) msg = (method + "\n" + path + "\n" + ts + "\n").encode() + body sig = _hmac_sha256_hex(token.encode(), msg) sys.stdout.write(ts + "\x1f" + sig) ' # Prints "<timestamp><US><signature>" for a request. $1 method, $2 URL; body on stdin. sign_headers() { OFFICE_TOKEN="$TOKEN" OFFICE_METHOD="$1" OFFICE_URL="$2" python3 -I -c "$PY_SIGN" } # One Python pass. Line 1 is the event to POST (empty = nothing to send), the # rest is the default status line. The payload travels through the # environment, never through the Python source. OUT=$(STATUS_PAYLOAD="$PAYLOAD" STATUS_MODE="$MODE" python3 -I - <<'PYEOF' import json, os, sys try: sys.stdout.reconfigure(encoding='utf-8', errors='replace') except Exception: pass def num(v): # A finite number, or None (bool is not a number here) if isinstance(v, bool) or not isinstance(v, (int, float)): return None if v != v or v in (float('inf'), float('-inf')): return None return v def text(v, limit=120): if isinstance(v, bool) or not isinstance(v, (str, int, float)): return '' return str(v)[:limit] try: d = json.loads(os.environ.get('STATUS_PAYLOAD', '') or '{}') if not isinstance(d, dict): d = {} except Exception: d = {} mode = os.environ.get('STATUS_MODE', 'main') session_id = text(d.get('session_id'), 200) #
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
A pixel art virtual office that visualizes your AI agents working in real-time. Watch Claude Code agents spawn, sit at desks, take coffee breaks, and chat in a Slack-inspired office chat panel — all rendered in an isometric pixel art office.

