Skip to content
Monitoring
Hook

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.

From plugin
agent-office
2921 command19 hooks
Install
> /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"
Read hooks/hooks.json

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.

Ships withagent-office

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.

Get the whole plugin
Stats
296
Stars
76
Forks
Active
Maintenance
TypeScript
Language
MIT
License
11d ago
Last commit
7mo ago
Created
6h ago
Added

Repo: W17ant/Claude-Office