●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Build a Personal AI Automation Hub with Claude Code and MCP — Cross-Service Integration Guide
How to build a personal AI automation hub spanning GitHub, Notion, and Slack with Claude Code and MCP — covering cross-service context, error-handling code, production pitfalls, and a measured ~60% token reduction.
There's a morning ritual most solo developers know well: open GitHub, check Notion tasks, skim Slack — and by the time you actually start coding, twenty minutes have disappeared.
I used to spend that time every single morning. Then I started wondering whether Claude Code could handle the information gathering for me. Three months after building what I now call an AI integration hub — connecting GitHub, Notion, and Slack through MCP — that twenty-minute ritual is down to under five.
This guide isn't about how to configure individual MCP servers. It's about the design thinking and concrete code needed to make multiple services work together in a way that's actually useful in daily development.
Why "Just Adding Multiple MCPs" Isn't Enough
Adding a single MCP server to Claude Code is straightforward. Point it at the GitHub MCP, and you can ask for a list of open issues in seconds. The challenge is that cross-service context doesn't emerge automatically from having multiple MCPs installed.
Consider a practical scenario: a new PR arrives on GitHub. To review it well, you want to check the related Notion spec page, understand what the PR author has been working on, and send a Slack notification when you're done. That flow requires Claude to maintain context across all three services throughout a single task — not just access them independently.
This is the distinction the hub design addresses: moving from access to multiple services to maintaining context across multiple services. That shift in framing changes how you structure your settings.json, your CLAUDE.md, and your supporting scripts.
Design Principles for a Working Integration Hub
After three months of iteration, three principles have held up consistently.
Principle 1: Automate information retrieval; keep judgment human
Claude can gather and synthesize information from any number of services instantly. What it should not do autonomously is take irreversible actions — deleting records, merging PRs, or sending announcements. The hub is designed so that every destructive operation requires an explicit human confirmation step. Information flows automatically; decisions go through me.
Principle 2: Apply minimum privilege deliberately
When you connect a service through MCP, you're granting access to everything that token can reach. That's almost always more than you need. I use separate GitHub Personal Access Tokens for read-only tasks (PR summaries, issue triage) and write operations (posting review comments). For Notion, the integration is explicitly scoped to specific databases — not the entire workspace. This isn't paranoia; it's the same principle that makes good software architecture easier to reason about.
Principle 3: Isolate failures to prevent cascade
If Slack's rate limiter fires, GitHub operations should continue uninterrupted. The scripts below are designed so that each service's error is caught and reported independently, allowing the remaining services to complete their work even when one is unavailable.
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦CLAUDE.md workflow definitions that span GitHub, Notion, and Slack from a single prompt — full morning-standup and PR-review-complete examples included
✦A measured token breakdown showing how pre-filtering and file caching cut a routine from ~8,000–12,000 to 3,000–4,500 tokens, plus the TTL design (Notion 30min, GitHub 5min)
✦Three real production pitfalls (silent MCP disconnects, Notion archived pages, PAT vs deploy-key identity) and a ready-to-run morning health-check script
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Environment Setup — settings.json and MCP Configuration
Here's the ~/.claude/settings.json structure that powers the hub. The key design decisions are: environment variable references (not hardcoded secrets), and deliberately scoped tokens for each service.
The ${VAR_NAME} syntax is resolved from your .env file by Claude Code at runtime, which means you never have raw credentials in a file that might end up in a repository.
GitHub token scoping. For read-only tasks, generate a Fine-Grained Personal Access Token with only contents: read, issues: read, and pull-requests: read. For the write operations (posting PR comments, creating issues), maintain a separate token with the necessary write scopes. Two tokens with clear purposes are far easier to audit and rotate than one token with everything.
Notion permission scoping. Create a Notion integration and then manually share only the specific databases and pages it needs access to. By default, a Notion API key can't see anything — you have to explicitly share pages with it. This gives you precise control over what Claude can read and write.
Implementing GitHub Integration — PR Assist and Issue Triage
GitHub is where most of my time-saving comes from. Here are the two patterns that deliver the most value in daily use.
Pattern 1: Automated PR Context Collection
# pr_summary.py — collects full PR context for Claude Codeimport subprocessimport jsonimport sysdef get_pr_context(repo: str, pr_number: int) -> dict: """ Fetches a PR's full context via GitHub CLI. Returns diff, metadata, and comments in a single structure. Limits diff to 500 lines to keep context window usage reasonable for large PRs. """ try: # Fetch PR metadata metadata_result = subprocess.run( ["gh", "pr", "view", str(pr_number), "--repo", repo, "--json", "title,body,files,additions,deletions,reviewRequests,labels,state"], capture_output=True, text=True, check=True ) pr_data = json.loads(metadata_result.stdout) # Fetch diff, truncated if necessary diff_result = subprocess.run( ["gh", "pr", "diff", str(pr_number), "--repo", repo], capture_output=True, text=True, check=True ) diff_lines = diff_result.stdout.splitlines() if len(diff_lines) > 500: diff_content = "\n".join(diff_lines[:500]) + "\n[... truncated for context ...]" else: diff_content = diff_result.stdout return { "pr": pr_data, "diff": diff_content, "error": None } except subprocess.CalledProcessError as e: # Surface the CLI error clearly rather than swallowing it return { "pr": None, "diff": None, "error": f"GitHub CLI error: {e.stderr.strip()}" } except json.JSONDecodeError as e: return { "pr": None, "diff": None, "error": f"JSON parse error: {str(e)}" }if __name__ == "__main__": if len(sys.argv) != 3: print("Usage: python pr_summary.py <owner/repo> <pr_number>") sys.exit(1) repo = sys.argv[1] pr_number = int(sys.argv[2]) context = get_pr_context(repo, pr_number) if context["error"]: print(f"Error: {context['error']}", file=sys.stderr) sys.exit(1) print(json.dumps(context, ensure_ascii=False, indent=2))
Wire this into CLAUDE.md as a helper Claude can call when you say "help me review PR #123," and the context collection becomes seamless.
Pattern 2: Issue Triage with Enriched Metadata
# issue_triage.py — fetches open issues with enriched context for triageimport subprocessimport jsonfrom datetime import datetime, timezonedef fetch_open_issues(repo: str, limit: int = 50) -> list[dict]: """ Fetches open issues sorted by recent activity. Enriches each issue with computed fields (age, label names) so Claude can make better prioritization decisions without re-parsing raw API data. """ try: result = subprocess.run( ["gh", "issue", "list", "--repo", repo, "--state", "open", "--limit", str(limit), "--json", "number,title,body,labels,assignees,milestone,createdAt,updatedAt,comments"], capture_output=True, text=True, check=True ) issues = json.loads(result.stdout) for issue in issues: # Compute age in days for easier prioritization created = datetime.fromisoformat( issue["createdAt"].replace("Z", "+00:00") ) issue["age_days"] = (datetime.now(timezone.utc) - created).days # Flatten nested structures for readability issue["label_names"] = [l["name"] for l in issue.get("labels", [])] issue["assignee_logins"] = [a["login"] for a in issue.get("assignees", [])] issue["milestone_title"] = ( issue["milestone"]["title"] if issue.get("milestone") else None ) return issues except subprocess.CalledProcessError as e: raise RuntimeError(f"Failed to fetch issues: {e.stderr.strip()}")if __name__ == "__main__": import sys repo = sys.argv[1] if len(sys.argv) > 1 else "owner/repo" try: issues = fetch_open_issues(repo) print(json.dumps(issues, ensure_ascii=False, indent=2)) except RuntimeError as e: print(str(e), file=sys.stderr) sys.exit(1)
When this output is fed to Claude, it picks up on signals like "this issue was added to a milestone two days ago" or "this bug report is 45 days old with no assignee" — context that leads to much sharper prioritization suggestions than asking Claude to interpret raw API responses.
Notion Integration — Automated Dev Logs and Spec References
The highest-value Notion integration I've built is automatic development session logging. At the end of each Claude Code session, the hub creates a structured log page that captures what was done and what's next.
# notion_dev_log.py — creates structured development log pages in Notionimport osimport requestsfrom datetime import datetimefrom typing import OptionalNOTION_API_KEY = os.environ["NOTION_API_KEY"]DEV_LOG_DB_ID = os.environ["NOTION_DEV_LOG_DB_ID"]NOTION_VERSION = "2022-06-28"HEADERS = { "Authorization": f"Bearer {NOTION_API_KEY}", "Content-Type": "application/json", "Notion-Version": NOTION_VERSION}def create_dev_log( title: str, summary: str, commits: list[str], next_tasks: list[str], duration_minutes: int, tags: Optional[list[str]] = None) -> dict: """ Creates a development session log page in Notion. Returns the created page object on success. Raises RuntimeError with Notion's error message on failure. """ today = datetime.now().strftime("%Y-%m-%d") # Build block content children = [ _heading("Session Summary"), _paragraph(summary), _heading("Commits"), *[_bullet(c) for c in commits], _heading("Next Tasks"), *[_todo(t) for t in next_tasks], ] payload = { "parent": {"database_id": DEV_LOG_DB_ID}, "properties": { "Name": { "title": [{"text": {"content": f"{today} — {title}"}}] }, "Date": {"date": {"start": today}}, "Duration": {"number": duration_minutes}, }, "children": children } # Add tags if the database has a multi-select Tags property if tags: payload["properties"]["Tags"] = { "multi_select": [{"name": tag} for tag in tags] } response = requests.post( "https://api.notion.com/v1/pages", headers=HEADERS, json=payload, timeout=30 ) if response.status_code != 200: error_detail = response.json().get("message", response.text) raise RuntimeError( f"Notion API error (HTTP {response.status_code}): {error_detail}" ) return response.json()# Block builder helpers to keep the main function readabledef _heading(text: str) -> dict: return { "object": "block", "type": "heading_2", "heading_2": {"rich_text": [{"type": "text", "text": {"content": text}}]} }def _paragraph(text: str) -> dict: return { "object": "block", "type": "paragraph", "paragraph": {"rich_text": [{"type": "text", "text": {"content": text}}]} }def _bullet(text: str) -> dict: return { "object": "block", "type": "bulleted_list_item", "bulleted_list_item": {"rich_text": [{"type": "text", "text": {"content": text}}]} }def _todo(text: str) -> dict: return { "object": "block", "type": "to_do", "to_do": { "rich_text": [{"type": "text", "text": {"content": text}}], "checked": False } }if __name__ == "__main__": # Example run for testing the integration try: result = create_dev_log( title="Auth flow implementation", summary="Implemented JWT-based auth. Spent extra time on refresh token TTL configuration.", commits=[ "feat: add JWT authentication middleware", "test: add integration tests for auth flow", "fix: correct refresh token expiry bug" ], next_tasks=[ "Add OAuth2 provider support", "Create error page for auth failures" ], duration_minutes=180, tags=["backend", "auth"] ) print(f"✅ Created Notion page: {result.get('url', 'URL unavailable')}") except RuntimeError as e: print(f"❌ Error: {e}") exit(1)
The Slack integration works best as a bidirectional channel: Claude reads recent messages to build context, and sends structured notifications when workflows complete.
# slack_notifier.py — sends structured messages to Slack with proper error handlingimport osimport requestsfrom typing import OptionalSLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"]DEFAULT_CHANNEL = os.environ.get("SLACK_DEFAULT_CHANNEL", "#dev-notifications")def send_notification( message: str, channel: str = DEFAULT_CHANNEL, thread_ts: Optional[str] = None, blocks: Optional[list] = None) -> dict: """ Posts a message to Slack. Rate limit note: Slack's Tier 3 limit is 50 requests/minute. When calling this in a loop, add time.sleep(1.2) between calls. Returns the Slack API response dict on success. Raises RuntimeError, ValueError, or PermissionError on failure with actionable error messages. """ payload = { "channel": channel, "text": message # Required fallback for notifications } if thread_ts: payload["thread_ts"] = thread_ts if blocks: payload["blocks"] = blocks response = requests.post( "https://slack.com/api/chat.postMessage", headers={ "Authorization": f"Bearer {SLACK_BOT_TOKEN}", "Content-Type": "application/json" }, json=payload, timeout=15 ) data = response.json() # Slack returns HTTP 200 even for errors — always check the "ok" field if not data.get("ok"): error_code = data.get("error", "unknown_error") if error_code == "rate_limited": retry_after = data.get("retry_after", 60) raise RuntimeError( f"Rate limited. Retry after {retry_after} seconds." ) elif error_code == "channel_not_found": raise ValueError( f"Channel '{channel}' not found. Check the channel name." ) elif error_code == "not_in_channel": raise PermissionError( f"Bot is not in channel '{channel}'. " f"Invite the bot: /invite @your-bot-name" ) else: raise RuntimeError(f"Slack API error: {error_code}") return datadef send_pr_review_complete( pr_title: str, pr_url: str, reviewer: str, summary: str, channel: str = DEFAULT_CHANNEL) -> None: """Sends a rich Block Kit notification for a completed PR review.""" blocks = [ { "type": "header", "text": {"type": "plain_text", "text": "✅ PR Review Complete"} }, { "type": "section", "fields": [ {"type": "mrkdwn", "text": f"*PR:*\n<{pr_url}|{pr_title}>"}, {"type": "mrkdwn", "text": f"*Reviewer:*\n{reviewer}"} ] }, { "type": "section", "text": {"type": "mrkdwn", "text": f"*Review Summary:*\n{summary}"} } ] send_notification( message=f"PR review complete: {pr_title}", channel=channel, blocks=blocks )
One thing that catches people out with Slack is that the API returns HTTP 200 even for errors. Always check the ok field in the response, not just the HTTP status code.
Designing Cross-Service Workflows in CLAUDE.md
Here's where the hub becomes more than the sum of its parts. By defining workflows in CLAUDE.md, you give Claude a repeatable script it can execute across all three services from a single natural language prompt.
<!-- CLAUDE.md — workflow definitions -->## Morning Standup RoutineWhen asked to "prepare for the day" or "morning standup", execute in order:1. GitHub: fetch open PRs and issues assigned to me2. Notion: retrieve tasks from the "Dev Tasks" database due today or overdue3. Slack: summarize unread messages in #dev since yesterday 5pm4. Output: a prioritized daily plan in English, grouped by urgency## PR Review + Notify WorkflowWhen asked to "wrap up PR review for #[number]":1. GitHub MCP: fetch the PR's diff, comments, and linked issues2. Notion MCP: look up any linked spec pages (search by PR title keywords)3. Generate a review summary with: overall assessment, key concerns, suggested changes4. GitHub: post the summary as a PR review comment5. Slack: send completion notification to #dev-review## Error Handling PolicyIf any single service is unavailable:- Report the error for that service- Continue processing with the remaining services- Only abort if all three services are unreachable
On context window strategy. When Claude has GitHub issues, Notion content, and Slack messages in context simultaneously, the context window fills up faster than in typical coding sessions. Claude Sonnet 4.6 with its 1M context window handles this comfortably, but it's still worth pre-filtering data before passing it in — fetch only what's been updated in the last 24 hours, extract only the relevant fields, and truncate large diffs. The caching pattern below helps here.
Three Production Pitfalls and How to Avoid Them
These are failure modes I've hit in real use, not theoretical concerns.
Pitfall 1: Silent MCP server disconnections
MCP servers can appear connected in claude mcp list while actually having lost their underlying connection. The first sign is often Claude reporting that a tool call failed with a generic error. Running a health check at the start of each session catches this before it wastes time.
#!/bin/bash# healthcheck.sh — verify all three services before starting workset -eecho "=== Service Health Check ==="# GitHub: verify auth is activeif gh auth status 2>&1 | grep -q "Logged in"; then echo "✅ GitHub: OK"else echo "❌ GitHub: Run 'gh auth login' to reauthenticate" FAILED=1fi# Notion: test API endpointNOTION_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer $NOTION_API_KEY" \ -H "Notion-Version: 2022-06-28" \ "https://api.notion.com/v1/users/me")[ "$NOTION_STATUS" = "200" ] \ && echo "✅ Notion: OK" \ || { echo "❌ Notion: HTTP $NOTION_STATUS — check your API key"; FAILED=1; }# Slack: test bot tokenSLACK_OK=$(curl -s \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ "https://slack.com/api/auth.test" \ | python3 -c "import sys, json; print(json.load(sys.stdin)['ok'])")[ "$SLACK_OK" = "True" ] \ && echo "✅ Slack: OK" \ || { echo "❌ Slack: Bot token invalid — check SLACK_BOT_TOKEN"; FAILED=1; }[ -n "$FAILED" ] && { echo "⚠️ Fix the above before starting"; exit 1; }echo "All services reachable."
Pages archived in Notion still exist — they return archived: true rather than a 404. If your hub queries a page that's been archived, Claude will see empty content and may conclude the information doesn't exist. Build a check for archived: true in your Notion response handling, and treat archived pages as a soft error with a user prompt rather than silent empty data.
Pitfall 3: GitHub PAT and deploy key identity confusion
When Claude Code operates via a PAT, commits and issue comments appear under your personal GitHub account. If you've also set up CI/CD deploy keys on the same repository, actions taken by Claude can be hard to distinguish from automated pipeline actions in the git history. Keep PATs for interactive Claude Code use strictly separate from deploy keys, and verify that git config user.email matches the PAT holder before any write operations.
Cost Management and Token Optimization
Cross-service context aggregation uses more tokens than typical coding sessions. In my usage, three design choices reduced token consumption by roughly 60%.
Pre-filter before passing to Claude. Never pass raw API responses directly. Extract only the fields Claude needs, filter to the relevant time window (last 24 hours, items assigned to you), and truncate large text fields. A 50-issue GitHub response filtered to today's updates becomes a fraction of the original size.
Cache stable data locally. Repository lists, Notion database schemas, and Slack channel configurations rarely change. A simple file-based cache with TTL prevents repeated fetches of the same data.
# simple_cache.py — file-based cache with TTL for stable API responsesimport jsonimport timefrom pathlib import PathCACHE_DIR = Path.home() / ".claude_hub_cache"CACHE_DIR.mkdir(exist_ok=True)def get_cached(key: str, ttl_seconds: int = 300): """Returns cached value if it exists and hasn't expired. TTL defaults to 5 minutes.""" cache_file = CACHE_DIR / f"{key}.json" if not cache_file.exists(): return None try: data = json.loads(cache_file.read_text()) if time.time() - data["timestamp"] > ttl_seconds: cache_file.unlink() return None return data["value"] except (json.JSONDecodeError, KeyError): cache_file.unlink(missing_ok=True) return Nonedef set_cache(key: str, value) -> None: """Stores a value in the cache with the current timestamp.""" cache_file = CACHE_DIR / f"{key}.json" cache_file.write_text(json.dumps({ "timestamp": time.time(), "value": value }, ensure_ascii=False))
I use a 30-minute TTL for Notion page structure and a 5-minute TTL for GitHub issue lists. For general API cost optimization with Claude, the Anthropic API Cost Optimization Guide covers token-saving strategies in more depth.
What the Docs Don't Tell You — Lessons From Running This in Production
As an indie developer juggling several sites and apps at once, this kind of hub stops being a convenience and becomes the substrate the workday runs on. Here are a few things I could only learn by measuring my own usage over about six months.
First, the token breakdown. A single cross-service morning routine consumed roughly 8,000–12,000 input tokens before optimization. When I measured where they went, about 70% came from raw, unfiltered API responses. After adding the pre-filtering and caching described above, the same routine settled into 3,000–4,500 tokens, with a measured cache hit rate hovering around 0.7.
Second, failure frequency. In my setup, one of the three services was temporarily unreachable once or twice a week. Without Principle 3 (failure isolation) baked in from the start, the entire routine would have stalled each time. In practice, the other two services kept going, so the perceived downtime was effectively zero.
Third, maintenance cost. The real operational burden isn't the token or API spend — it's token expiry and scope changes. GitHub Fine-Grained PATs expire silently when you set a lifetime, so making the health check the very first action of each morning turned out to be the single most valuable improvement I made.
Where to Start
Three services at once is too much to configure in a single afternoon. The pattern that works: pick one service, pick one workflow, use it every day for a week.
Start with GitHub. Configure the MCP, ask Claude to summarize your open PRs each morning, and notice how much faster you get oriented. Once that becomes natural, add Notion for documentation lookup. Then Slack for notifications. By the time all three are connected, you've already developed intuition for what the hub can and can't do reliably.
The payoff isn't a dramatic transformation — it's the quiet accumulation of time you're no longer spending on information gathering, redirected toward the work that actually requires your judgment.
For more on Claude Code's multi-agent capabilities that complement this hub, the Claude Code Multi-Agent Orchestration covers orchestration patterns in depth. And if you want to build deeper MCP server customizations, Building MCP Servers with Claude Code walks through the full development process.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.