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.

Verification note. The Slack API calls in this playbook were checked against Slack's official documentation before publication: request signing with 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:write and channels: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 you zolva 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:

app.py
"""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_desk.py
"""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_desk.py (continued)
# -- 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.yaml
channels:
  webchat:
    adapter: log
agents:
  support-agent: [webchat]
shell
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-num set. 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_id or any subtype (edits, joins) is what keeps the desk from talking to itself.
  • Private escalations channel: subscribe to message.groups with the groups:history scope instead of message.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 agent and the channel-namespaced session_id, so one desk serves your WhatsApp, voice, and webchat deployments at once.

← All playbooks