HOWTO: Connect Parallel Hours MCP to Claude Code with Automatic Task Tracking
Last Updated: April 2026
This guide walks you through connecting the Parallel Hours MCP server to Claude Code in any repository. Once set up, Claude will automatically:
- Warn you when no timer is running at session start
- Increment your AI prompt count on every message
- Log token usage (input, output, cache) when a session ends
- Create and transition tasks as you work GitHub issues
The full setup takes about 15 minutes. It is repeatable across any repository you own.
What You'll Set Up
| Component | What it does |
|---|---|
.mcp.json |
Tells Claude Code where the Parallel Hours MCP server is and how to authenticate |
CLAUDE.md |
Instructs Claude to check for timers and start one if missing |
.claude/hooks/ |
Three Python scripts that fire automatically on session events |
.claude/settings.json |
Registers the hooks with Claude Code |
.claude/commands/ |
Optional slash commands for starting/ending sessions |
Prerequisites
Before starting, you need:
- A Parallel Hours account — free tier is sufficient. Sign up at parallelhours.io.
- Claude Code — the CLI or desktop app, version 1.x or later.
uv— the fast Python package manager. Install with:
bash curl -LsSf https://astral.sh/uv/install.sh | sh
uvis used both to run the MCP server (uvx) and to execute the hook scripts with their dependencies.- A Parallel Hours project — create one from the web UI. Note the project key (e.g.,
MYPROJECT). - GitHub CLI (
gh) — optional, needed only if you want slash commands that create GitHub issues.
Step 1: Create a Personal Access Token (PAT)
- Log in to Parallel Hours and go to Account → API Tokens.
- Click New Token, give it a name (e.g.,
claude-code-myrepo), and choose an expiry (90 days is a good default). - Copy the token — it is shown once only.
- Add it to your shell environment. The recommended approach is via your shell profile or a
.envfile that is gitignored:
bash
# In ~/.zshrc, ~/.bashrc, or a project-local .env (gitignored)
export TKPI_PAT="ph_your_token_here"
export TKPI_BASE_URL="https://parallelhours.io"
export TKPI_PROJECT="MYPROJECT" # your project key
Reload your shell after adding these:
bash
source ~/.zshrc # or ~/.bashrc
Security note: Never commit your PAT to git. The hook scripts read it from the environment at runtime.
Environment variable gotcha: GUI vs CLI launch
~/.zshrc exports are only visible to processes started from a terminal. If you launch Claude Code from your desktop (Dock, Spotlight, Finder, or the desktop app), it starts outside your shell and will not see those exports.
Fix — macOS: Use ~/.zprofile (or ~/.bash_profile) instead of ~/.zshrc. Login-shell files are sourced by GUI apps via the macOS PAM layer:
# ~/.zprofile (sourced by login shells — picked up by GUI apps)
export TKPI_PAT="ph_your_token_here"
export TKPI_BASE_URL="https://parallelhours.io"
export TKPI_PROJECT="MYPROJECT"
If that still doesn't work (common on newer macOS), set the vars at the launchd level so every app inherits them:
launchctl setenv TKPI_PAT "ph_your_token_here"
launchctl setenv TKPI_BASE_URL "https://parallelhours.io"
launchctl setenv TKPI_PROJECT "MYPROJECT"
These persist until the next reboot. Add them to a login item script or ~/.zprofile with a launchctl setenv call to make them permanent.
Fix — Linux / WSL: Add the exports to ~/.profile (sourced by display managers) rather than ~/.bashrc (terminal-only):
# ~/.profile
export TKPI_PAT="ph_your_token_here"
export TKPI_BASE_URL="https://parallelhours.io"
export TKPI_PROJECT="MYPROJECT"
Quick check: Open Claude Code however you normally do and run this in a conversation:
What is the value of the TKPI_PAT environment variable?
If Claude says it's empty or undefined, the env vars aren't reaching Claude Code and you need one of the fixes above.
Step 2: Configure the MCP Server
Create .mcp.json at the root of your repository:
{
"mcpServers": {
"parallelhours": {
"command": "uvx",
"args": ["parallelhours-mcp"],
"env": {
"TKPI_PAT": "${TKPI_PAT}",
"TKPI_BASE_URL": "https://parallelhours.io",
"TKPI_PROJECT": "MYPROJECT"
}
}
}
}
Replace MYPROJECT with your actual project key.
The ${TKPI_PAT} syntax passes your shell environment variable into the MCP server. Claude Code resolves these at startup — the raw token never appears in the file.
Verify the connection:
# In a Claude Code session, check that the tools are visible:
# Type a prompt and look for mcp__parallelhours__* tool suggestions,
# or run this in the Claude conversation:
# "Call mcp__parallelhours__list_projects and show me the output"
Step 3: Add Instructions to CLAUDE.md
CLAUDE.md (at the repo root) tells Claude how to behave in this project. Add a time tracking section:
## Session Time Tracking — REQUIRED
**Before writing any code or editing any file:**
1. Call `mcp__parallelhours__get_active_timers` to check whether a timer is running.
2. If no timer is running: ask the user which issue to work on, then start a timer.
3. If a timer is running: proceed normally.
**At session end:** stop the active timer and log AI usage.
- Always use `mcp__parallelhours__*` tools. Never use `curl` or direct REST calls.
- MCP server config: `.mcp.json`. PAT: env var `TKPI_PAT`.
This is the minimal version. If you adopt the full /session-start and /session-end skill workflow (see Step 5), you can reference those commands instead.
Step 4: Install the Claude Code Hooks
Hooks are shell commands that Claude Code executes automatically on events like session start, each user prompt, and session end. Create three hook scripts.
4a — Directory structure
.claude/
hooks/
session_start_timer_check.py
prompt_counter.py
stop_token_logger.py
settings.json
4b — Hook 1: Session start — warn if no timer is running
.claude/hooks/session_start_timer_check.py
#!/usr/bin/env python3
"""SessionStart hook — warn if no time-kpi timer is running."""
import json
import os
import sys
import httpx
PAT = os.environ.get("TKPI_PAT", "")
BASE_URL = os.environ.get("TKPI_BASE_URL", "https://parallelhours.io").rstrip("/")
if not PAT:
sys.exit(0)
try:
resp = httpx.get(
f"{BASE_URL}/mcp/v1/timers/active/",
headers={"Authorization": f"Bearer {PAT}", "Content-Type": "application/json"},
timeout=5,
)
if resp.status_code == 200:
timers = resp.json().get("timers", [])
if not timers:
print(json.dumps({
"systemMessage": (
"Parallel Hours: No timer running. "
"Start a timer before writing code so this session is tracked."
)
}))
except Exception:
pass # Never block session start on hook failure.
sys.exit(0)
4c — Hook 2: Prompt counter — increment on every user message
.claude/hooks/prompt_counter.py
#!/usr/bin/env python3
"""UserPromptSubmit hook — auto-increment prompt_count on the active timer."""
import json
import os
import sys
import httpx
PAT = os.environ.get("TKPI_PAT", "")
BASE_URL = os.environ.get("TKPI_BASE_URL", "https://parallelhours.io").rstrip("/")
if not PAT:
sys.exit(0)
try:
payload = json.load(sys.stdin)
except Exception:
sys.exit(0)
session_id = payload.get("session_id", "")
headers = {"Authorization": f"Bearer {PAT}", "Content-Type": "application/json"}
try:
params = {"session_id": session_id} if session_id else {}
resp = httpx.get(f"{BASE_URL}/mcp/v1/timers/active/", headers=headers, params=params, timeout=5)
if resp.status_code != 200:
sys.exit(0)
timers = resp.json().get("timers", [])
if not timers and session_id:
resp2 = httpx.get(f"{BASE_URL}/mcp/v1/timers/active/", headers=headers, timeout=5)
if resp2.status_code == 200:
timers = resp2.json().get("timers", [])
if not timers:
sys.exit(0)
timer_id = timers[-1]["timer_id"]
httpx.post(f"{BASE_URL}/mcp/v1/timers/{timer_id}/prompt/", headers=headers, timeout=5)
except Exception:
pass # Never block the prompt on hook failure.
sys.exit(0)
4d — Hook 3: Stop logger — parse transcript and log token usage
.claude/hooks/stop_token_logger.py
#!/usr/bin/env python3
"""Stop hook — parse JSONL transcript and log token usage to the active timer."""
import json
import os
import sys
from datetime import datetime, timezone
import httpx
PAT = os.environ.get("TKPI_PAT", "")
BASE_URL = os.environ.get("TKPI_BASE_URL", "https://parallelhours.io").rstrip("/")
if not PAT:
sys.exit(0)
try:
payload = json.load(sys.stdin)
except Exception:
sys.exit(0)
transcript_path = payload.get("transcript_path", "")
session_id = payload.get("session_id", "")
headers = {"Authorization": f"Bearer {PAT}", "Content-Type": "application/json"}
def _find_timer(session_id: str) -> dict | None:
try:
params = {"session_id": session_id} if session_id else {}
resp = httpx.get(f"{BASE_URL}/mcp/v1/timers/active/", headers=headers, params=params, timeout=5)
if resp.status_code == 200:
timers = resp.json().get("timers", [])
if timers:
return timers[-1]
if session_id:
resp2 = httpx.get(f"{BASE_URL}/mcp/v1/timers/active/", headers=headers, timeout=5)
if resp2.status_code == 200:
timers = resp2.json().get("timers", [])
if timers:
return timers[-1]
except Exception:
pass
return None
def _parse_transcript(path: str, since_iso: str) -> dict:
since_dt = None
if since_iso:
try:
since_dt = datetime.fromisoformat(since_iso.replace("Z", "+00:00"))
except ValueError:
pass
totals = {
"input_tokens": 0,
"output_tokens": 0,
"cache_read_tokens": 0,
"cache_write_tokens": 0,
"tool_call_count": 0,
"model_id": "",
}
seen_ids: set[str] = set()
try:
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
if msg.get("isSidechain") or msg.get("isApiErrorMessage"):
continue
msg_id = msg.get("uuid") or msg.get("id") or msg.get("msg_id")
if msg_id:
if msg_id in seen_ids:
continue
seen_ids.add(msg_id)
if since_dt:
ts_raw = msg.get("timestamp")
if ts_raw:
try:
ts = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
if ts < since_dt:
continue
except ValueError:
pass
usage = msg.get("usage") or (msg.get("message", {}) or {}).get("usage")
if usage and isinstance(usage, dict):
totals["input_tokens"] += usage.get("input_tokens", 0) or 0
totals["output_tokens"] += usage.get("output_tokens", 0) or 0
totals["cache_read_tokens"] += usage.get("cache_read_input_tokens", 0) or 0
totals["cache_write_tokens"] += usage.get("cache_creation_input_tokens", 0) or 0
content = msg.get("content") or (msg.get("message", {}) or {}).get("content", [])
if isinstance(content, list):
totals["tool_call_count"] += sum(
1 for block in content
if isinstance(block, dict) and block.get("type") == "tool_use"
)
model = msg.get("model") or (msg.get("message", {}) or {}).get("model", "")
if model:
totals["model_id"] = model
except Exception:
pass
return totals
try:
timer = _find_timer(session_id)
if not timer:
sys.exit(0)
timer_id = timer["timer_id"]
start_time = timer.get("start_time", "")
stats = _parse_transcript(transcript_path, start_time) if transcript_path else {}
if not stats or (
stats["input_tokens"] == 0
and stats["output_tokens"] == 0
and stats["cache_read_tokens"] == 0
and stats["cache_write_tokens"] == 0
):
sys.exit(0)
body: dict = {
"ai_tool": "claude",
"mode": "delegated",
"prompt_count": 0, # tracked separately by prompt_counter.py
"input_tokens": stats["input_tokens"],
"output_tokens": stats["output_tokens"],
"notes": "auto-logged by Stop hook",
}
if stats["cache_read_tokens"]:
body["cache_read_tokens"] = stats["cache_read_tokens"]
if stats["cache_write_tokens"]:
body["cache_write_tokens"] = stats["cache_write_tokens"]
if stats["tool_call_count"]:
body["tool_call_count"] = stats["tool_call_count"]
if stats["model_id"]:
body["model_id"] = stats["model_id"]
httpx.post(f"{BASE_URL}/mcp/v1/timers/{timer_id}/ai-event/", headers=headers, json=body, timeout=5)
except Exception:
pass
sys.exit(0)
4e — Register hooks in settings.json
.claude/settings.json
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "uv run --with httpx python \"${CLAUDE_PROJECT_DIR}/.claude/hooks/session_start_timer_check.py\""
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "uv run --with httpx python \"${CLAUDE_PROJECT_DIR}/.claude/hooks/prompt_counter.py\""
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "uv run --with httpx python \"${CLAUDE_PROJECT_DIR}/.claude/hooks/stop_token_logger.py\""
}
]
}
]
}
}
${CLAUDE_PROJECT_DIR} is a variable that Claude Code resolves to the root of your project automatically — no hardcoded paths needed.
Alternative: If your project already has a Python virtualenv with
httpxinstalled, you can replaceuv run --with httpx pythonwith<path-to-venv>/bin/python3.
Step 5: Add Slash Commands (Optional but Recommended)
Slash commands give Claude structured instructions for starting and ending a tracked session. Create two files:
.claude/commands/session-start.md — instruct Claude to:
1. Check if the working tree is clean
2. Ask which GitHub issue to work on (or create one)
3. Create a feature branch (feat/<issue-N>-<description>)
4. Find or create the matching task in Parallel Hours (project_key="MYPROJECT")
5. Transition the task to in_progress
6. Call mcp__parallelhours__start_timer(task_id=..., notes="...")
7. Report the timer ID and branch
.claude/commands/session-end.md — instruct Claude to:
1. Call mcp__parallelhours__get_active_timers to find the running timer
2. Ask for a prompt count if not already tracked
3. Call mcp__parallelhours__stop_timer(timer_id=...)
4. Call mcp__parallelhours__log_ai_event(...) with prompt count and any token info
5. Optionally transition the task to in_review or done
6. Confirm session summary to the user
You invoke these in Claude Code by typing /session-start 42 (where 42 is the GitHub issue number) or /session-end.
The content of these files is natural language — Claude reads and follows them as instructions. Keep them concise and specific about which MCP tool calls to make and in what order.
Step 6: Add a .gitignore Entry
Make sure your PAT and any local overrides never reach git:
# .gitignore
.env
.env.local
.claude/settings.local.json
.claude/settings.local.json is where you can store machine-specific hook overrides without affecting teammates.
How It All Works Together
Developer opens Claude Code
│
▼
SessionStart hook fires
→ calls GET /mcp/v1/timers/active/
→ if no timer: Claude sees systemMessage warning
│
▼
Developer types /session-start 42
→ Claude creates branch feat/42-my-feature
→ Claude calls mcp__parallelhours__create_task or finds existing
→ Claude calls mcp__parallelhours__start_timer
│
▼
Developer asks Claude to implement feature
→ UserPromptSubmit hook fires on each message
→ calls POST /mcp/v1/timers/{id}/prompt/
→ prompt_count increments automatically
│
▼
Developer types /session-end
→ Claude calls mcp__parallelhours__stop_timer
→ Stop hook fires when session ends
→ parses JSONL transcript for token counts
→ calls POST /mcp/v1/timers/{id}/ai-event/
→ input/output/cache tokens logged automatically
Verification Checklist
After completing setup, verify each piece works:
- [ ]
uvx parallelhours-mcpruns without error in your terminal - [ ] Opening a Claude Code session shows a timer warning (if no timer is running)
- [ ] Typing a message in Claude Code increments the prompt count on your active timer (check the Parallel Hours web UI)
- [ ] Starting and stopping a session logs token usage to the timer
- [ ] The Parallel Hours KPI dashboard shows data from your session
Troubleshooting
Hooks aren't firing
- Confirm .claude/settings.json is at the repo root (not in a subdirectory)
- Restart Claude Code after adding or editing settings.json
- Check that uv is on your PATH: which uv
"No timer running" warning persists after starting a timer
- The SessionStart hook only fires once per session (at open). The warning won't disappear mid-session — it's a reminder, not a live status.
- Verify the timer started by checking the Parallel Hours web UI or calling mcp__parallelhours__get_active_timers in Claude.
MCP tools not available in Claude
- Confirm .mcp.json is at the repo root
- Confirm TKPI_PAT is exported in your shell environment
- Run uvx parallelhours-mcp directly to confirm the package installs correctly
- Restart Claude Code
Token counts look wrong
- The Stop hook only processes tokens from the active timer's start_time forward — it won't double-count prior sessions
- If your session is very long and spans multiple Claude conversations, each session logs independently
PAT expired or revoked
- All hooks exit silently (code 0) if the PAT is missing or the API returns an error — they never block Claude
- Generate a new PAT from the Parallel Hours web UI and update your environment variable
AGENTS.md Alternative
If you work with multiple AI agents (not just Claude Code), replace CLAUDE.md with AGENTS.md at the repo root. The content is identical — most agents (Claude, Gemini CLI, OpenCode) read AGENTS.md and follow its instructions.
# AGENTS.md
## Time Tracking
Before writing any code, check for an active Parallel Hours timer:
1. Call `mcp__parallelhours__get_active_timers`.
2. If no timers are running, ask the user which task to work on and start one.
3. At session end, stop the timer and log AI usage.
The hooks in .claude/settings.json are Claude Code-specific — other agents won't use them. For other agents, you will need to manually start and stop timers or add equivalent hooks using that agent's configuration system.
What Gets Tracked
Once fully set up, Parallel Hours will automatically track:
| Metric | Source |
|---|---|
| Wall-clock time | Timer start/stop |
| AI prompt count | UserPromptSubmit hook |
| Input tokens | Stop hook (transcript parse) |
| Output tokens | Stop hook (transcript parse) |
| Cache read/write tokens | Stop hook (transcript parse) |
| Tool call count | Stop hook (transcript parse) |
| Model used | Stop hook (transcript parse) |
This data feeds the KPI dashboard so you can see AI concurrency ratio, actual vs. estimated time, and per-task AI cost.
Questions or issues? Open a ticket at parallelhours.io or visit the community forum.