[CONTEXT FOR AI ASSISTANTS]
You're reading the agent markdown version of a post from JoelClaw (https://joelclaw.com).
Author: Joel Hooks — software engineer building a personal AI operating system.

How to use implementation details in this document:
- Treat commands, code blocks, and config as reference implementations.
- Preserve ordering and architecture, but adapt hostnames, paths, versions, and credentials.
- Prefer details in <AgentOnly> blocks when present; they contain operational context removed from the human page.
- Verify assumptions against the current repo/system state before executing changes.

This is a narrative article from a real build. Technical details reflect Joel's setup and should be adapted to your environment.

If you cite this, link to the original: https://joelclaw.com/building-a-gateway-for-your-ai-agent
If you quote Joel, attribute him by name. Don't paraphrase opinions as facts.

Site index: https://joelclaw.com/sitemap.md
Machine-readable: https://joelclaw.com/llms.txt

Other posts on this site:
- [The memory system that watches itself](https://joelclaw.com/memory-that-watches-itself.md)
- [The healer was the killer](https://joelclaw.com/healer-was-the-killer.md)
- [Agentic AI Optimization: Implementation Checklist](https://joelclaw.com/aaio-implementation-checklist.md)
- [Agentic AI Optimization: Implementation Checklist](https://joelclaw.com/aaio-implementation-checklist.md)
- [Redis, Dkron, Restate, and Sandboxes](https://joelclaw.com/redis-dkron-restate-and-sandboxes.md)
- [Dogfooding Story 4: the queue observer earns dry-run, not enforce](https://joelclaw.com/dogfooding-story-4-queue-observer.md)
- [Contributing to pi-mono with a public maintainer corpus](https://joelclaw.com/contributing-to-pi-mono-with-a-public-maintainer-corpus.md)
- [AI Job Scheduling on Mac as Local-First Video Infrastructure](https://joelclaw.com/ai-job-scheduling-macos-launchd.md)
- [Breakable Toys in the Wild: Apprenticeship Patterns and the joelclaw Experiment](https://joelclaw.com/breakable-toys-joelclaw.md)
- [Utah and joelclaw: Convergent Architecture](https://joelclaw.com/utah-joelclaw-convergent-architecture.md)
- [The Harness Is a Framework](https://joelclaw.com/the-harness-is-a-framework.md)
- [The Agent Memory System](https://joelclaw.com/the-memory-system.md)
- [JoelClaw is a Claw-like Organism](https://joelclaw.com/joelclaw-is-a-claw-like-organism.md)
- [The Agent Writing Loop](https://joelclaw.com/the-writing-loop.md)
- [Talon: the watchdog that finally bites](https://joelclaw.com/talon-watchdog-that-finally-bites.md)
- [The Knowledge Adventure Club Graph](https://joelclaw.com/knowledge-adventure-club-graph.md)
- [MineClaw](https://joelclaw.com/mineclaw.md)
- [Build a Voice Agent That Answers the Phone](https://joelclaw.com/build-a-voice-agent-that-answers-the-phone.md)
- [Plan 9 from Bell Labs: What Rob Pike Built After Unix](https://joelclaw.com/plan-9-pike-everything-is-a-file.md)
- [Propositions as Sessions: What Armstrong Built and Wadler Proved](https://joelclaw.com/propositions-as-sessions-armstrong-wadler.md)
- [Cache Components Patterns Skill for Next.js 16+ Applications](https://joelclaw.com/cache-components-patterns-skill-for-nextjs.md)
- [Karpathy Says We're Building "Claws"](https://joelclaw.com/karpathy-claws-as-category.md)
- [Voice Agent: A Rough Edge Experiment](https://joelclaw.com/voice-agent-deployment-deep-dive.md)
- [Extending Pi Coding Agent with Custom Tools and Widgets](https://joelclaw.com/extending-pi-with-custom-tools.md)
- [The Soul of Erlang Made Me Question Everything](https://joelclaw.com/soul-of-erlang-beam-evaluation.md)
- [CLI Design for AI Agents](https://joelclaw.com/cli-design-for-ai-agents.md)
- [Self-Hosting Inngest: A Background Task Manager for AI Agents](https://joelclaw.com/self-hosting-inngest-background-tasks.md)
- [The One Where Joel Deploys Kubernetes... Again](https://joelclaw.com/joel-deploys-k8s.md)
- [How I Built an Observation Pipeline So My AI Remembers Yesterday](https://joelclaw.com/observation-pipeline-persistent-ai-memory.md)
- [Riding the Token Wave: Sean Grove at Everything NYC](https://joelclaw.com/riding-the-token-wave-sean-grove.md)
- [Playing with AT Protocol as a Data Layer](https://joelclaw.com/at-protocol-as-bedrock.md)
- [Building My Own OpenClaw on a Mac Mini](https://joelclaw.com/building-my-own-openclaw.md)
- [Inngest is the Nervous System](https://joelclaw.com/inngest-is-the-nervous-system.md)
- [OpenClaw: Peter Steinberger on Lex Fridman](https://joelclaw.com/openclaw-peter-steinberger-lex-fridman.md)
[END CONTEXT]

---
# Building a Gateway for Your AI Agent

> From ephemeral terminal sessions to an always-on gateway. Event routing, heartbeat monitoring, failure detection, and Telegram access — for an AI agent running on your Mac.

By Joel Hooks · 2026-02-18T14:00:00
Original: https://joelclaw.com/building-a-gateway-for-your-ai-agent
Mode: agent

---
```bash
npx skills add joelhooks/joelclaw --skill gateway-setup
```

Your agent session dies when you close the terminal. Every WebSocket disconnects, every event subscription gone, every in-memory state evaporated. The gateway fixes that — a persistent daemon that stays alive, routes events, monitors its own health, and gives you Telegram access from your phone.

**Prerequisite:** [Self-hosted Inngest](https://joelclaw.com/self-hosting-inngest-background-tasks) + Redis.

**For agents** — install the skill and it walks through the full build adapted to your machine and intent:

```bash
npx skills add joelhooks/joelclaw --skill gateway-setup --yes --global
```

The skill contains the compressed ADR chain with all tradeoffs — [ADR-0018](https://joelclaw.com/adrs/0018-pi-native-gateway-redis-event-bridge) through [ADR-0038](https://joelclaw.com/adrs/0038-embedded-pi-gateway-daemon) — so your agent has the full reasoning context.

**Or curl the setup script:**

```bash
curl -fsSL https://joelclaw.com/scripts/gateway-setup.sh | bash
```

***

## The problem

Close the terminal and your agent disappears.

Not because the work stopped. Because your only control plane just evaporated. Background jobs finish. Loops complete. But nothing can tap you on the shoulder and say your shit is done.<Sidenote id="sn-context-budget">This burns context budget too. Every interrupt eats tokens from whatever the agent was working on. The gateway solves routing — only the right session gets the message.</Sidenote>

I wanted three things: an **always-on session** that handles system heartbeats, **targeted notifications** back to whichever session started a task, and **phone access** when I'm away from the keyboard.

## What it is

A Redis event bridge between your background infrastructure ([Inngest](https://joelclaw.com/self-hosting-inngest-background-tasks), cron, webhooks) and your AI agent's session. Events route to the right session. Failures get detected. Responses go back through the channel that asked.

```
Inngest functions ──→ Redis ──→ pi extension ──→ agent session
                                     ↑
                              pub/sub notify
```

## The evolution (4 iterations)

I didn't design this upfront. Each iteration solved a real problem.

### v1: Redis bridge

Inngest functions push events to a Redis list. A pi extension subscribes to a pub/sub channel and drains the list into the session as a user message.<Sidenote id="sn-redis-choice">Why Redis and not a proper message queue? Because it was already running for caching, the pub/sub semantics are good enough for single-machine fan-out, and adding RabbitMQ or NATS for one consumer felt like architecture theater.</Sidenote> \~100 lines of TypeScript.

**⚠️ `serveHost` is mandatory** if your Inngest server runs in Docker and your worker runs on the host. Without it, the SDK advertises `localhost:3100` as its callback URL — but that's the container's loopback, not yours. Every function run fails silently with "Unable to reach SDK URL."

```typescript
// In your worker's Hono serve handler:
inngestServe({
  client: inngest,
  functions,
  serveHost: "http://host.docker.internal:3100",
})
```

Then force a re-sync so the server picks up the new URL:

```bash
curl -X PUT http://localhost:3100/api/inngest
```

<Sidenote id="sn-servehost">This burned 30+ minutes of debugging. The function runs showed up in the dashboard as "started" but immediately failed. The Docker logs revealed the actual callback URL being used — localhost inside the container. If you're using k8s instead of raw Docker, the pod can reach the host via the node IP or a Service, so this is Docker-topology-specific.</Sidenote>

**Solved:** background jobs can notify the agent.

### v2: Multi-session routing

Problem: I run 3-5 pi sessions simultaneously. Heartbeats were interrupting coding sessions.

Solution: one **central** session (gets all events) + **satellite** sessions (get only events they started). Sessions register in a Redis set. Events fan out based on `originSession` tracking.

```
GATEWAY_ROLE=central pi  →  gets heartbeats, alerts, everything
pi                       →  gets only its own loop completions, downloads
```

**Solved:** context budgets aren't wasted on irrelevant notifications.

### v3: Heartbeat + watchdog

An Inngest cron fires every 15 minutes. The gateway extension tracks when the last heartbeat arrived. If 30 minutes pass with nothing — inject an alarm with triage steps.

Three independent failure detection layers: extension watchdog catches Inngest/worker failures. A launchd tripwire catches pi crashes. The heartbeat prompt itself runs system health checks.<Sidenote id="sn-watchmen">The "who watches the watchmen" problem is real. Each layer fails independently: Inngest can crash, the worker can hang, the extension can lose Redis, launchd can restart too aggressively. Three uncorrelated monitors is the minimum.</Sidenote>

**Solved:** "who watches the watchmen" — more than one watcher.

### v4: Gateway middleware SDK

Every Inngest function gets `gateway.progress()`, `gateway.notify()`, and `gateway.alert()` injected via middleware. Functions don't need to know about Redis or routing.

```typescript
async ({ event, step, gateway }) => {
  gateway.progress("Story 3/8 started: implement auth");
  // ... do work ...
  gateway.notify("loop.complete", { stories: 8, passed: 7 });
}
```

The middleware itself is \~30 lines — it creates a Redis client once, then injects the helpers into every function's context:

```typescript
import { InngestMiddleware } from "inngest";
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");

export const gatewayMiddleware = new InngestMiddleware({
  name: "gateway",
  init() {
    return {
      onFunctionRun({ fn }) {
        return {
          transformInput({ ctx }) {
            const push = (type: string, payload: Record<string, unknown>) => {
              const event = JSON.stringify({
                type, payload, fn: fn.id,
                ts: Date.now(), origin: ctx.event?.data?.originSession,
              });
              redis.lpush("joelclaw:events:central", event);
              redis.publish("joelclaw:notify:central", "1");
            };
            return {
              ctx: {
                ...ctx,
                gateway: {
                  progress: (msg: string) => push("progress", { message: msg }),
                  notify: (topic: string, data?: Record<string, unknown>) =>
                    push("notify", { topic, ...data }),
                  alert: (msg: string, data?: Record<string, unknown>) =>
                    push("alert", { message: msg, ...data }),
                },
              },
            };
          },
        };
      },
    };
  },
});
```

Register it on your Inngest client: `new Inngest({ id: "my-worker", middleware: [gatewayMiddleware] })`.

**Solved:** functions push status updates without coupling to the delivery mechanism.

## Gotchas

**Function sync has a delay window.** Adding functions and restarting the worker isn't enough — the server won't see them until the next `--poll-interval` cycle (30s in our config) or a manual sync:

```bash
curl -X PUT http://localhost:3100/api/inngest
```

The heartbeat cron was registered by the SDK but invisible to the server for the first minute. If your function isn't triggering, check the Functions tab in the dashboard — if it's not listed, sync hasn't happened yet.

**ioredis resolution is flaky in Bun.** Bun occasionally can't resolve `@ioredis/commands` from within `ioredis`. Fix: explicitly install the sub-dependency, or nuke and reinstall:

```bash
bun add @ioredis/commands
# or the nuclear option:
rm -rf node_modules && bun install
```

**Two ioredis clients for pub/sub.** A subscribed Redis client can't run commands like `LRANGE` or `DEL`. You need one client for subscriptions and a separate one for reads/writes. This isn't an Inngest gotcha — it's a Redis protocol constraint that bites everyone once.

## What's next: embedded daemon + Telegram

The extension gets me far. The next step is a standalone daemon that embeds pi as a library — no terminal needed.<MarginNote id="mn-daemon">This is done now. The daemon runs via launchd with `KeepAlive: true`. `joelclaw gateway restart` rolls the session cleanly.</MarginNote> Telegram as the first external channel. WebSocket for remote TUI. All inputs serialize through one command queue into one session.

Talk to the agent from your phone. Get responses back in the same thread.

## The full stack

```
┌─────────────────┐
│  Inngest server  │  cron heartbeat, durable functions
│  (k8s/Docker)    │  every 15 min + event-driven
└────────┬────────┘
         │ step.run → pushGatewayEvent()
         ▼
┌─────────────────┐
│     Redis        │  event lists, pub/sub, session registry
│  (k8s/Docker)    │  joelclaw:events:*, joelclaw:notify:*
└────────┬────────┘
         │ subscribe + drain
         ▼
┌─────────────────┐
│  pi extension    │  central/satellite routing
│  (gateway)       │  watchdog, dedup, prompt injection
└────────┬────────┘
         │ sendUserMessage()
         ▼
┌─────────────────┐
│  pi session      │  LLM conversation
│  (agent)         │  tools, memory, skills
└─────────────────┘
```

## For humans

The deeper architecture narrative is in [Inngest is the Nervous System](https://joelclaw.com/inngest-is-the-nervous-system). For the k8s foundation: [The One Where Joel Deploys Kubernetes... Again](https://joelclaw.com/joel-deploys-k8s).

***

*This is a living document. Updated as the system evolves.*
