Playbook 04 · Slack
The handover desk lives in Slack
Goal: every escalation from every Zolva agent, on every channel,
lands as one threaded message in a #cx-escalations Slack channel. A
teammate replies in the thread; that reply becomes the resolution, recorded into the
customer's session and the audit chain via the resume endpoint, so the
agent has it the moment the customer returns.
X-Slack-Signature / X-Slack-Request-Timestamp and the
v0:{timestamp}:{body} HMAC-SHA256 base string
(verifying requests from Slack),
the Events API url_verification handshake, the three-second
acknowledgment window and x-slack-retry-num retry headers
(Events API),
and chat.postMessage arguments, scopes, and threading via the parent
ts
(chat.postMessage reference).
If Slack changes a field, trust their docs over this page.
The shape: Zolva's WebhookBackend delivers each handover ticket,
HMAC-signed, to your desk gateway (one small FastAPI service). The
gateway posts a summary into Slack with chat.postMessage and returns the
message ts as the ticket id, so the Slack thread is the ticket.
Thread replies come back through the Events API; the gateway forwards them, signed, to
the POST /sessions/{agent}/resume endpoint that zolva serve
exposes. Handover, resolution, and resume are all steps on the audit chain.
Prerequisites #
- A Slack app installed in your workspace with the bot scopes
chat:writeandchannels:history, its bot token (xoxb-…), and its signing secret. - Event Subscriptions enabled with the bot event
message.channels; you will set the request URL in step 3. Invite the bot to your escalations channel and note the channel ID (C…). - Python ≥ 3.11 and:
pip install "zolva[dashboard]" fastapi uvicorn httpx(the extra gives youzolva serve). - Environment variables, never inline config:
SLACK_BOT_TOKEN,SLACK_SIGNING_SECRET,SLACK_ESCALATIONS_CHANNEL,ZOLVA_HANDOVER_SECRET,ZOLVA_INBOUND_SECRET, and your model provider key.
Step 1 · Point handover at the desk #
Nothing about your agents changes. The handover backend is one constructor argument on the app, and it serves every agent in the config directory:
"""The bank's AgentApp, escalating to the Slack desk gateway."""
import os
import tools # noqa: F401 your @zolva.tool functions register on import
from zolva import AgentApp, WebhookBackend
from zolva.bridge import get_adapter
app = AgentApp.from_config(
"agents/",
# judge-backed rules (never, refuse_topics) need an adapter at startup
judge=get_adapter("openai"),
judge_model="gpt-5.6-terra",
handover=WebhookBackend(
url="http://localhost:8015/handover", # the desk gateway, step 2
secret=os.environ["ZOLVA_HANDOVER_SECRET"],
),
)
Every escalation path converges here: an agent calling
handoff(to="human-escalation"), a guardrail block, a tool crash, a
provider outage. The backend receives the full Ticket (session id, agent,
reason, transcript, trigger) as an HMAC-signed POST and must answer with a JSON
{"id": …}.
Step 2 · Escalations into Slack #
The desk gateway's first endpoint receives the ticket, posts it to the escalations channel, and remembers which Slack thread belongs to which session:
"""Slack handover desk: escalations in a channel, resolutions from the thread.
Run: uvicorn slack_desk:api --host 0.0.0.0 --port 8015
Point your Slack app's Event Subscriptions request URL at https://your-host/slack/events
"""
import hashlib
import hmac
import json
import logging
import os
import sqlite3
import time
import httpx
from fastapi import BackgroundTasks, FastAPI, Request, Response
from fastapi.responses import JSONResponse, PlainTextResponse
from zolva import SignatureError, sign_payload, verify_zolva_signature
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("slack-desk")
api = FastAPI()
BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"]
SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
DESK_CHANNEL = os.environ["SLACK_ESCALATIONS_CHANNEL"] # channel ID, e.g. C0123456789
HANDOVER_SECRET = os.environ["ZOLVA_HANDOVER_SECRET"]
INBOUND_SECRET = os.environ["ZOLVA_INBOUND_SECRET"]
ZOLVA_URL = os.environ.get("ZOLVA_URL", "http://localhost:8700")
# thread ts -> (session_id, agent); sqlite so a gateway restart keeps open tickets
db = sqlite3.connect("desk.sqlite", check_same_thread=False)
db.execute(
"CREATE TABLE IF NOT EXISTS tickets "
"(thread_ts TEXT PRIMARY KEY, session_id TEXT NOT NULL, agent TEXT NOT NULL)"
)
async def _post_message(payload: dict) -> dict:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(
"https://slack.com/api/chat.postMessage",
json=payload,
headers={"Authorization": f"Bearer {BOT_TOKEN}"},
)
data: dict = r.json()
if not data.get("ok"):
log.error("chat.postMessage failed: %s", data.get("error"))
return data
# -- Handover tickets from Zolva (HMAC-signed by WebhookBackend) --
@api.post("/handover")
async def handover(request: Request) -> JSONResponse:
raw = await request.body()
try:
verify_zolva_signature(
raw,
request.headers.get("X-Zolva-Signature", ""),
request.headers.get("X-Zolva-Timestamp", ""),
HANDOVER_SECRET,
)
except SignatureError:
return JSONResponse({"error": "invalid signature"}, status_code=403)
ticket = json.loads(raw) # Ticket: session_id, agent, reason, transcript, trigger
tail = "\n".join(
f"{m['role']}: {m['content']}" for m in ticket["transcript"][-6:] if m.get("content")
)
data = await _post_message(
{
"channel": DESK_CHANNEL,
"text": (
f":rotating_light: *{ticket['agent']}* escalated `{ticket['session_id']}`\n"
f"*Reason:* {ticket['reason']}\n```{tail}```\n"
"_Reply in this thread to resolve._"
),
}
)
if not data.get("ok"):
return JSONResponse({"error": "slack rejected the message"}, status_code=502)
with db:
db.execute(
"INSERT OR REPLACE INTO tickets VALUES (?, ?, ?)",
(data["ts"], ticket["session_id"], ticket["agent"]),
)
# WebhookBackend records this id as the HandoverRef: the thread is the ticket
return JSONResponse({"id": data["ts"]})
The transcript tail keeps PII exposure deliberate: the desk sees the last six turns,
not the customer's whole history, and the blocked content that triggered the escalation
arrives in trigger even when a guardrail kept it out of the session.
Step 3 · Resolve from the thread #
Same file, second half. Slack's Events API delivers every message in the channel;
the gateway verifies Slack's signature, answers the one-time
url_verification handshake, and treats a human's thread reply as the
resolution:
# -- Slack request signing: v0=HMAC_SHA256("v0:{timestamp}:{body}") --
def _slack_signature_ok(raw: bytes, ts: str, signature: str) -> bool:
if not ts.isdigit() or abs(time.time() - int(ts)) > 300: # replay guard, per Slack docs
return False
base = b"v0:" + ts.encode() + b":" + raw
expected = "v0=" + hmac.new(SIGNING_SECRET.encode(), base, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
@api.post("/slack/events")
async def slack_events(request: Request, background: BackgroundTasks) -> Response:
raw = await request.body()
if not _slack_signature_ok(
raw,
request.headers.get("X-Slack-Request-Timestamp", ""),
request.headers.get("X-Slack-Signature", ""),
):
return Response(status_code=403)
body = json.loads(raw)
if body.get("type") == "url_verification": # one-time handshake when you save the URL
return PlainTextResponse(body.get("challenge", ""))
if request.headers.get("X-Slack-Retry-Num"):
# Slack retries anything slower than 3s; a retried reply must not resume twice
return Response(status_code=200)
event = body.get("event", {})
if (
body.get("type") == "event_callback"
and event.get("type") == "message"
and event.get("channel") == DESK_CHANNEL
and event.get("thread_ts") # only threaded replies, not new messages
and not event.get("bot_id") # never react to our own confirmations
and not event.get("subtype") # skip edits, joins, and other message subtypes
):
background.add_task(_resolve, event["thread_ts"], event.get("text", ""))
return Response(status_code=200) # ack inside Slack's 3-second window; work happens after
async def _resolve(thread_ts: str, resolution: str) -> None:
row = db.execute(
"SELECT session_id, agent FROM tickets WHERE thread_ts = ?", (thread_ts,)
).fetchone()
if row is None:
return # a thread the desk didn't open
session_id, agent = row
body = json.dumps({"session_id": session_id, "resolution": resolution}).encode()
ts, sig = sign_payload(INBOUND_SECRET, body) # zolva serve verifies this signature
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(
f"{ZOLVA_URL}/sessions/{agent}/resume",
content=body,
headers={
"Content-Type": "application/json",
"X-Zolva-Signature": sig,
"X-Zolva-Timestamp": ts,
},
)
if r.status_code != 200:
log.error("resume failed: %s %s", r.status_code, r.text)
return
await _post_message(
{
"channel": DESK_CHANNEL,
"thread_ts": thread_ts, # parent ts threads the confirmation, per Slack docs
"text": ":white_check_mark: Recorded. The agent has this resolution "
"when the customer returns.",
}
)
The resume lands in the session history as a [human teammate] message
and emits a resume step on the bus, so the audit chain shows the full arc:
handover → resolution → resume, with who-said-what preserved.
Step 4 · Run it end to end #
Three processes: the desk gateway, zolva serve, and one signed test
message. The demo channel uses the log adapter because
zolva serve returns the reply in the HTTP response anyway:
channels:
webchat:
adapter: log
agents:
support-agent: [webchat]
uvicorn slack_desk:api --port 8015 &
ZOLVA_INBOUND_SECRET=$ZOLVA_INBOUND_SECRET zolva serve --app app:app --channels channels.yaml --port 8700 &
# a signed inbound message that will escalate (hardship -> human-escalation)
python - <<'EOF'
import json, os, httpx
from zolva import sign_payload
body = json.dumps({"session_id": "demo-1",
"text": "I lost my job and can't make this month's payment"}).encode()
ts, sig = sign_payload(os.environ["ZOLVA_INBOUND_SECRET"], body)
r = httpx.post("http://localhost:8700/channels/webchat/support-agent", content=body,
headers={"Content-Type": "application/json",
"X-Zolva-Signature": sig, "X-Zolva-Timestamp": ts})
print(r.json())
EOF
Watch #cx-escalations: the ticket appears with the transcript tail.
Reply in the thread ("Waived the late fee, set up a 3-month plan, call scheduled"),
and the desk confirms with a checkmark once the resume is recorded. Then
zolva scorecard audit.db shows the handover and resume steps on a
verified chain.
Retries, loops & scopes #
- The 3-second rule: Slack retries any event not acknowledged
with a 2xx within three seconds, up to three times with
x-slack-retry-numset. The gateway acks immediately and does the resume in the background; retried deliveries are dropped before they can resume twice. - Bot-loop protection: the desk's own thread confirmations come
back through the Events API too. Skipping events with a
bot_idor anysubtype(edits, joins) is what keeps the desk from talking to itself. - Private escalations channel: subscribe to
message.groupswith thegroups:historyscope instead ofmessage.channels/channels:history. Everything else is unchanged. - Two signatures, same idea: Slack signs with
v0:{timestamp}:{body}and a five-minute replay window; Zolva signs with{timestamp}.{body}and the same window. Both directions of the desk are authenticated, both use constant-time comparison. - Every agent, every channel: the ticket carries
agentand the channel-namespacedsession_id, so one desk serves your WhatsApp, voice, and webchat deployments at once.