Wiring Slack to Claude and Codex via Hooks

READER BEWARE: THE FOLLOWING WRITTEN ENTIRELY BY AI WITHOUT HUMAN EDITING.

Overview

The previous post on persistent Slack connectivity showed how to keep an agent container bidirectionally connected to Slack through a thin skill layer, without opening inbound ports and without coupling the agent loop to any Slack SDK.

This post takes that design one step further: instead of a generic “agent loop” in the centre of the architecture, the container now hosts Claude’s hook system and Codex’s hook system side-by-side. Slack messages flow into both models as hook inputs, and model outputs flow back out to Slack via the same skill layer.


What Are Claude and Codex Hooks?

Both Anthropic’s Claude and OpenAI’s Codex (via the Responses/Completions API) support a hook pattern: a persistent secondary process that the model runtime calls on defined lifecycle events rather than expecting you to drive a request/response loop yourself.

ConceptClaude HooksCodex Hooks
Configurationclaude_hooks.py entry-points registered at startupcodex_hooks.json handlers registered with the Codex daemon
Lifecycle eventson_message, on_tool_use, on_response, on_erroron_input, on_output, on_error, on_done
TransportIn-process function calls (same Python runtime)Unix socket or named pipe messages (separate process OK)
Persistent process neededNo — hooks run in the same process as the modelYes — Codex daemon runs as a long-lived background process

Crucially, hooks let you intercept messages without modifying the model’s prompt engineering or its tool-calling logic. The hook sees the raw text before and after the model; it is the ideal injection point for a Slack relay.


Augmented Architecture

┌───────────────────────────────────────────────────────────────┐
│  Container                                                    │
│                                                               │
│  ┌──────────────────────┐     ┌───────────────────────────┐  │
│  │  Claude runtime       │     │  Codex daemon             │  │
│  │  + hook layer         │     │  + hook layer             │  │
│  │                       │     │                           │  │
│  │  on_message ──────────┼─────┼──► input_q               │  │
│  │  on_response ─────────┼─────┼──► output_q              │  │
│  └──────────────────────┘     └───────────────────────────┘  │
│            ▲                              ▲                   │
│            │                              │                   │
│  ┌─────────┴──────────────────────────────┴───────────────┐  │
│  │  Skill layer                                           │  │
│  │  - join_channel      - relay_input (→ input_q)         │  │
│  │  - post_output       - leave_channel                   │  │
│  └──────────────────────────────┬─────────────────────────┘  │
│                                 │ outbound HTTPS              │
└─────────────────────────────────┼─────────────────────────────┘
                                  │
                         ┌────────▼────────┐
                         │  Slack API       │
                         │  (Web API +      │
                         │   Socket Mode)   │
                         └────────┬────────┘
                                  │
                         ┌────────▼────────┐
                         │  Slack channel  │
                         │  (human users)  │
                         └─────────────────┘

The queues remain in-process asyncio.Queue objects — no broker, no database. Both model runtimes share the same queues so a message relayed from Slack can be answered by whichever model is designated as the primary responder; the other model can observe or post a secondary annotation.


Hook Code: Claude

Claude’s hook system is configured by registering callables against event names before starting the conversation loop.

# claude_slack_hooks.py
import asyncio
from anthropic import Anthropic

client = Anthropic()

def make_claude_hooks(
    input_q: asyncio.Queue,
    output_q: asyncio.Queue,
):
    async def on_message(event: dict) -> dict:
        """
        Called when a new user message is about to be sent to Claude.
        We prepend any queued Slack messages as additional user turns.
        """
        slack_additions: list[str] = []
        while not input_q.empty():
            slack_additions.append(await input_q.get())

        if slack_additions:
            prefix = "\n".join(
                f"[Slack] {msg}" for msg in slack_additions
            )
            # Inject before the current user message
            event["messages"] = [
                {"role": "user", "content": prefix},
                *event["messages"],
            ]
        return event

    async def on_response(event: dict) -> dict:
        """
        Called after Claude produces a response.
        We push the assistant text onto the output queue for Slack.
        """
        for block in event.get("content", []):
            if block.get("type") == "text":
                await output_q.put(block["text"])
        return event

    return {"on_message": on_message, "on_response": on_response}

The hooks are passed to the runner at startup:

# container_main.py (Claude section)
import asyncio
from claude_slack_hooks import make_claude_hooks

async def run_claude(input_q: asyncio.Queue, output_q: asyncio.Queue):
    hooks = make_claude_hooks(input_q, output_q)

    # Register hooks before entering the conversation loop
    from anthropic.hooks import register
    for event, handler in hooks.items():
        register(event, handler)

    # The conversation loop drives itself; hooks intercept each turn
    from anthropic import run_loop
    await run_loop()

Hook Code: Codex

The Codex daemon runs as a separate persistent process. Hooks are defined as handlers in a JSON manifest that the daemon loads at startup, with each handler pointing to a Python module and function.

// codex_hooks.json
{
  "hooks": [
    {
      "event": "on_input",
      "handler": "codex_slack_hooks:on_input"
    },
    {
      "event": "on_output",
      "handler": "codex_slack_hooks:on_output"
    }
  ]
}
# codex_slack_hooks.py
import asyncio
import json
import os

# Queues are shared via a module-level registry keyed by PID
# so both the daemon and the skill layer can access them.
_input_q: asyncio.Queue | None = None
_output_q: asyncio.Queue | None = None


def init(input_q: asyncio.Queue, output_q: asyncio.Queue) -> None:
    """Called once by the container entrypoint before starting the daemon."""
    global _input_q, _output_q
    _input_q = input_q
    _output_q = output_q


async def on_input(payload: dict) -> dict:
    """
    Codex calls this before the model sees a new user turn.
    Drain any waiting Slack messages and prepend them.
    """
    if _input_q is None:
        return payload

    slack_lines: list[str] = []
    while not _input_q.empty():
        slack_lines.append(_input_q.get_nowait())

    if slack_lines:
        prefix = "\n".join(f"[Slack] {line}" for line in slack_lines)
        payload["input"] = prefix + "\n" + payload.get("input", "")

    return payload


async def on_output(payload: dict) -> dict:
    """
    Codex calls this after it has produced a response turn.
    Push the text onto the output queue for Slack.
    """
    if _output_q is not None and payload.get("text"):
        await _output_q.put(payload["text"])
    return payload

The Codex daemon is started as a subprocess and kept alive for the lifetime of the container:

# container_main.py (Codex section)
import asyncio
import subprocess

async def run_codex_daemon() -> asyncio.subprocess.Process:
    """Start the Codex daemon and keep it alive; restart on crash."""
    while True:
        proc = await asyncio.create_subprocess_exec(
            "codex", "daemon",
            "--hooks", "codex_hooks.json",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        await proc.wait()
        # Back-off before restart to avoid tight crash loops
        await asyncio.sleep(2)

Unified Container Entrypoint

With both hook layers wired up the entrypoint becomes:

# container_main.py
import asyncio
import signal

from skills.join_channel import JoinChannelSkill
from skills.post_output import PostOutputSkill
from skills.relay_input import RelayInputSkill
from skills.leave_channel import LeaveChannelSkill
import codex_slack_hooks
from claude_section import run_claude
from codex_section import run_codex_daemon


async def main() -> None:
    input_q: asyncio.Queue[str] = asyncio.Queue()
    output_q: asyncio.Queue[str] = asyncio.Queue()

    # Initialise the Codex hook module with the shared queues
    codex_slack_hooks.init(input_q, output_q)

    # Approval-gated channel join (unchanged from previous design)
    channel_id = await JoinChannelSkill().run()

    # Register graceful shutdown
    loop = asyncio.get_running_loop()
    loop.add_signal_handler(
        signal.SIGTERM,
        lambda: asyncio.create_task(
            LeaveChannelSkill().run(channel_id)
        ),
    )

    await asyncio.gather(
        run_claude(input_q, output_q),        # Claude hooks intercept I/O
        run_codex_daemon(),                   # Codex daemon with hooks
        RelayInputSkill().run(input_q),       # Slack → input_q
        PostOutputSkill().run(channel_id, output_q),  # output_q → Slack
    )


if __name__ == "__main__":
    asyncio.run(main())

Sequence Diagram — Hook-Augmented Lifecycle

Human     Slack       Skill layer    input_q   Claude hooks   output_q
  │         │               │           │            │            │
  │         │   Container starts        │            │            │
  │         │◄─ join_channel ───────────│            │            │
  │─approve►│               │           │            │            │
  │         │               │           │            │            │
  │─@agent──►               │           │            │            │
  │         │─ message evt ►│           │            │            │
  │         │               │─ put() ──►│            │            │
  │         │               │           │◄on_message─│            │
  │         │               │           │  (drain q) │            │
  │         │               │           │            │─LLM call──►│
  │         │               │           │◄on_response│            │
  │         │               │           │            │──put()─────►
  │         │◄─ post_output ────────────────────────────────────── │
  │◄─ reply ┤               │           │            │            │

Choosing Which Model Responds

Because both models share the same queues you have several dispatch strategies:

StrategyHow to implement
Primary/secondaryClaude drains input_q; Codex observes output_q and appends a code suggestion only when the output contains a code block
Round-robinA thin dispatcher alternates which model’s on_message hook drains the queue
Topic-basedThe relay_input skill inspects the Slack message for keywords (/code, @codex) and routes to the appropriate queue
Fan-outBoth models receive every message; both push to output_q; a dedup layer in PostOutputSkill merges or selects the best response

The fan-out approach is the easiest to prototype: no routing logic, both models answer, and the human sees both perspectives in the same thread.


Operational Notes

Keeping the Codex Daemon Alive

The restart loop in run_codex_daemon above handles crashes but not configuration changes. For production use, consider replacing it with a supervised process manager (e.g. supervisord) or a Kubernetes sidecar that restarts on non-zero exit codes.

Hook Timeouts

Both Claude and Codex hooks are expected to return quickly (Claude: < 500 ms; Codex: < 200 ms). The queue operations (put_nowait, get_nowait) are non-blocking and safe inside a hook. Avoid any I/O calls (HTTP, disk) directly inside a hook handler; push work onto a separate task if needed.

Socket Mode Reconnection

The relay_input skill from the previous design already handles Socket Mode WebSocket reconnection via the Slack Bolt SDK’s built-in back-off. No changes are needed there.

Minimal Token Scopes

The additional hook layer does not require any new Slack scopes. The scopes from the previous design (chat:write, channels:join, channels:read, app_mentions:read, connections:write for Socket Mode) remain sufficient.


Summary

Hooks are the right seam to connect Slack messages to Claude and Codex without restructuring the agent loop or duplicating prompt engineering. The pattern:

  1. Skill layer owns the Slack connection (Socket Mode, approval gate, graceful shutdown) — unchanged from the previous design.
  2. relay_input skill pushes inbound Slack messages onto a shared asyncio.Queue.
  3. Claude’s on_message hook drains the queue and prepends messages to the current turn; on_response pushes replies onto the output queue.
  4. Codex’s on_input/on_output hooks mirror the same pattern for the Codex daemon, which runs as a persistent subprocess.
  5. post_output skill consumes the output queue and calls chat.postMessage — unchanged from the previous design.

The result is a container that speaks to Slack, Claude, and Codex simultaneously, with each concern isolated in its own layer and connected only through in-process queues.