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.
| Concept | Claude Hooks | Codex Hooks |
|---|---|---|
| Configuration | claude_hooks.py entry-points registered at startup | codex_hooks.json handlers registered with the Codex daemon |
| Lifecycle events | on_message, on_tool_use, on_response, on_error | on_input, on_output, on_error, on_done |
| Transport | In-process function calls (same Python runtime) | Unix socket or named pipe messages (separate process OK) |
| Persistent process needed | No — hooks run in the same process as the model | Yes — 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:
| Strategy | How to implement |
|---|---|
| Primary/secondary | Claude drains input_q; Codex observes output_q and appends a code suggestion only when the output contains a code block |
| Round-robin | A thin dispatcher alternates which model’s on_message hook drains the queue |
| Topic-based | The relay_input skill inspects the Slack message for keywords (/code, @codex) and routes to the appropriate queue |
| Fan-out | Both 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:
- Skill layer owns the Slack connection (Socket Mode, approval gate, graceful shutdown) — unchanged from the previous design.
relay_inputskill pushes inbound Slack messages onto a sharedasyncio.Queue.- Claude’s
on_messagehook drains the queue and prepends messages to the current turn;on_responsepushes replies onto the output queue. - Codex’s
on_input/on_outputhooks mirror the same pattern for the Codex daemon, which runs as a persistent subprocess. post_outputskill consumes the output queue and callschat.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.