DEV Community

Cover image for I Turned Claude Code Into a Personal AI You Can Reach From Anywhere Using Webhooks
Abraham Gonzalez
Abraham Gonzalez

Posted on

I Turned Claude Code Into a Personal AI You Can Reach From Anywhere Using Webhooks

I've spent the last year paying per-token overages on Claude Code because I keep hitting my weekly limit.

That's not a complaint. That's context.

I use Claude Code the way most developers eventually do — as a headless collaborator running in the background while I do other things. The problem is "other things" often means I'm not at my computer. I'd come back 40 minutes later to find Claude had been waiting on me for 35 of them.

So I built a notification system on top of it. And in doing so, I accidentally built something that looks a lot like OpenClaw — just in the other direction.

This post is the conceptual walkthrough. I'm keeping the actual implementation to myself for now — but the primitives are public, the webhook docs are public, and anyone can build this. Here's how I think about it.


The Insight

OpenClaw (the gateway that connects Claude to messaging apps like Telegram and WhatsApp) is doing something conceptually simple:

event comes in → invoke Claude → route output somewhere
Enter fullscreen mode Exit fullscreen mode

That's the whole thing. The magic is in the plumbing.

Claude Code has hooks.

Claude Code's lifecycle exposes 14 hook events — SessionStart, PreToolUse, PostToolUse, Notification, Stop, SessionEnd, and more. Each one fires at a specific point and delivers a JSON payload to any shell command you configure. Which means the same primitive exists, just flipped:

Claude Code does something → hook fires → your command runs → route it somewhere
Enter fullscreen mode Exit fullscreen mode

If you add a response path — your phone sends a message back, your server picks it up and spawns a new claude session — you've closed the loop. You've built a personal Claude Code gateway. One that reaches you wherever you are.


What I Actually Built

Here's the architecture:

┌─────────────────────────────────────────────────┐
│                  Claude Code                     │
│  (running locally, headless or in terminal)      │
└────────────────────┬────────────────────────────┘
                     │ hook events (JSON via stdin)
                     ▼
┌─────────────────────────────────────────────────┐
│              Node.js Server (local)              │
│  - receives hook payloads                        │
│  - checks context (am I at my desk?)             │
│  - routes notifications                          │
│  - manages browser blocker state                 │
└──────────┬──────────────────────────┬────────────┘
           │ push notification        │ browser signal
           ▼                          ▼
      📱 Phone                  🖥️ Browser extension
  (only when away)          (blocks tabs when idle)
Enter fullscreen mode Exit fullscreen mode

Three components. Let's go through each.


Part 1: Catching the Hooks

Claude Code lets you configure hooks in ~/.claude/settings.json. When a hook event fires, Claude Code pipes a JSON payload to your command's stdin. The simplest thing to do with that payload is forward it to a local server.

Since each hook is configured separately per event type, I use separate routes so the server knows exactly what fired without needing to parse the event type out of the payload:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "curl -s -X POST http://localhost:4242/hook/notification -H 'Content-Type: application/json' -d @-"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "curl -s -X POST http://localhost:4242/hook/stop -H 'Content-Type: application/json' -d @-"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "curl -s -X POST http://localhost:4242/hook/pretooluse -H 'Content-Type: application/json' -d @-"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The -d @- tells curl to read the request body from stdin — which is exactly where Claude Code pipes the event payload. The Content-Type: application/json header lets Express parse it automatically.

Here's the server:

import express from "express";

const app = express();
app.use(express.json());

let sessionActive = false;

app.post("/hook/notification", async (req, res) => {
  await handleNotification();
  res.json({ ok: true });
});

app.post("/hook/stop", async (req, res) => {
  sessionActive = false;
  await handleStop();
  res.json({ ok: true });
});

app.post("/hook/pretooluse", (req, res) => {
  sessionActive = true;
  res.json({ ok: true });
});

app.get("/status", (req, res) => {
  res.json({ sessionActive });
});

app.listen(4242, () => console.log("Hook server running on :4242"));
Enter fullscreen mode Exit fullscreen mode

Part 2: Context Detection (The Important Part)

The whole point is to only notify me when it matters. If I'm at my desk, Claude Code's session panel is right in front of me. I don't need a push to my phone.

On macOS, ioreg exposes the system's HID idle time in nanoseconds. One awk pipe gives you a clean number:

import { execSync } from "child_process";

function isScreenIdle(): boolean {
  try {
    // HIDIdleTime is in nanoseconds. 60 seconds = 60_000_000_000 ns
    const idleNs = parseInt(
      execSync(
        "ioreg -c IOHIDSystem | awk '/HIDIdleTime/ {print $NF; exit}'",
        { encoding: "utf8" }
      ).trim(),
      10
    );
    return idleNs > 60_000_000_000;
  } catch {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

If the screen has been idle for over a minute, I'm not there. Route the notification.


Part 3: Push Notifications

I use ntfy.sh — a free, open-source push notification service. You subscribe to a topic on your phone, your server publishes to it. No account needed for basic use, just pick a long random topic name and treat it like a secret.

const NTFY_TOPIC = "your-long-random-private-topic";

async function notify(title: string, body: string) {
  await fetch(`https://ntfy.sh/${NTFY_TOPIC}`, {
    method: "POST",
    headers: {
      Title: title,
      Priority: "default",
      Tags: "robot",
    },
    body,
  });
}

async function handleNotification() {
  if (!isScreenIdle()) return;
  await notify("Claude Code", "Claude needs your attention");
}

async function handleStop() {
  if (!isScreenIdle()) return;
  await notify("Claude Code — Done", "Session finished. Come back.");
}
Enter fullscreen mode Exit fullscreen mode

The Notification hook fires when Claude is waiting on you — needs input, hit a permission prompt, something needs attention. The Stop hook fires when the session ends. Both are the signals I care about.

Install the ntfy app on your phone, subscribe to your topic. Done.


Part 4: Browser Tab Blocker

The PreToolUse hook fires every time Claude Code is about to call a tool — which means as long as a session is active and running, the server keeps sessionActive = true. When Stop fires, it flips to false.

A lightweight browser extension polls /status every 30 seconds. When sessionActive is false, it adds a blur overlay and a redirect prompt to sites on my blocklist. When Claude Code fires up again, they unblock.

You can build the extension in about 50 lines of vanilla JS using the Chrome Extensions Manifest V3 API.


The Full Loop (Where This Goes Next)

Right now this is one-directional — Claude Code talks to me. The natural next step is closing the loop:

📱 Phone → send message to server
server → spawn `claude -p` session with that prompt
Claude Code → runs → hooks fire back → notification
Enter fullscreen mode Exit fullscreen mode

The spawning part is straightforward. The -p / --print flag runs Claude Code non-interactively — it takes a prompt, runs it, prints the result, and exits:

import { spawn } from "child_process";

function runClaudeSession(prompt: string) {
  const proc = spawn("claude", ["-p", prompt], {
    stdio: "inherit",
    detached: true,
  });
  proc.unref();
}
Enter fullscreen mode Exit fullscreen mode

Add an inbound route to your server (an ntfy webhook, a Telegram bot, whatever messaging surface you prefer), wire it to runClaudeSession, and now you can kick off Claude Code tasks from your phone while you're away. Come back to a finished PR. Or a first draft. Or a bug fixed.

That's OpenClaw. Trigger + reaction. Built from the Claude Code side. I'm not publishing mine — but there's nothing stopping you from building yours.


Why This Framing Matters

Most people think of Claude Code as a terminal tool. That's limiting.

The hook system makes it an agent with an event bus. The primitives for building a personal AI gateway are already there — 14 lifecycle events, JSON payloads on stdin, any shell command you want to run. OpenClaw proved the pattern works in one direction. Claude Code's hooks let you do it in the other.

The community is already building these bridges independently. The ecosystem is moving fast.

The primitives are already there. The hook docs are public. The rest is plumbing — and that part I'll leave to you.


This started as a LinkedIn post — discussion is happening there if you want to pick it apart.

Top comments (0)