gatebound-support
by carnufex
README.md
# gatebound-support
Python/FastAPI backend for Gatebound's ElevenLabs support agent: MCP tools for the agent,
the Discord ticket flow, the post-call webhook, and the knowledge-base sync CLI.
`docs/SPEC.md` is the contract this service is built against — read it for exact env names,
endpoint paths, tool shapes, and the ticket flow. `docs/ARCHITECTURE.md` has the component
map, trust boundaries and failure modes; `docs/DECISIONS.md` the reasoning (no database
access from this stack, MCP instead of webhook tools, ticket links instead of agent-created
tickets, knowledge base synced from Git, cheapest models while iterating). This file is just
the "how do I run it" quick start.
Live: agent "Gatebound Support" in the text-first widget on
[gatebound.rosenvall.se](https://gatebound.rosenvall.se); service at
`gatebound-support.rosenvall.se`, deployed from the private homelab GitOps repo.
## ElevenLabs agent as code (`elevenlabs/`)
Everything about the agent lives in the ElevenLabs CLI project layout and is pushed, never
edited in the UI:
```powershell
$env:ELEVENLABS_API_KEY = "..."
cd elevenlabs
python scripts/build_agent_config.py # prompt + settings -> agent_configs/Gatebound-Support.json
elevenlabs agents push # create/update the agent
elevenlabs tests push # the six behaviour tests in test_configs/
python scripts/run_agent_tests.py # run them, table + exit code (add --verbose for transcripts)
python scripts/sync_agent_refs.py # mirror live knowledge_base + mcp_server_ids into the config
$env:MCP_SECRET = "..."; uv run python scripts/probe_mcp.py # talk to the deployed /mcp like ElevenLabs does
```
The MCP server entry and its bearer secret are registered once per workspace (see
`docs/DECISIONS.md`); the agent references it by id in `mcp_server_ids`.
## Run locally
```powershell
uv sync
copy .env.example .env # edit as needed; every integration defaults to "unset" (disabled)
uv run gatebound-support serve
```
The server listens on `PORT` (default 8080). With everything left `unset` it still starts and
answers `/healthz` and `/status`; the ticket flow works without Discord (tickets land as
`pending_manual`), and the MCP endpoint at `/mcp` rejects all calls until `MCP_SECRET` is set.
## Tests
```powershell
uv run pytest -q
uv run ruff check .
```
## kb-sync
Syncs a directory of markdown files into an ElevenLabs agent's knowledge base (SPEC §7):
```powershell
uv run gatebound-support kb-sync --dir path\to\docs --manifest path\to\kb-manifest.json --agent <agent_id>
uv run gatebound-support kb-sync --dir ... --manifest ... --agent ... --dry-run # print the plan, write nothing
uv run gatebound-support kb-sync --dir ... --manifest ... --agent ... --check # exit 1 on drift, for CI/CronJob
```
Requires `ELEVENLABS_API_KEY`.
## Discord voice
`gatebound-support voicebot` is a second, separate long-lived process (own container
command, own Kubernetes Deployment — same image, `command: ["gatebound-support",
"voicebot"]`, no HTTP service) that runs the Discord gateway connection and bridges voice:
- **`/support` or `/call`** (from anywhere — the player does *not* need to already be in a
voice channel): the bot creates a **private temporary voice channel** (`support-<display
name>`, in the same category as the support text channel if it has one) that only the
player, the bot, and roles with Administrator/Manage Guild can see or join — a support
call must never be overhearable or joinable by other guild members. The player gets an
ephemeral reply with a link to the channel ("join it and I'll pick up"); if they're
already in a voice channel and the bot has Move Members, it pulls them straight in
instead. A ticket + private Discord thread is opened for the call up front (source
`discord_voice`). The ElevenLabs Conversational AI session — the same agent the website
widget talks to, over the SDK's `Conversation` + a custom `AudioInterface` (see
`src/gatebound_support/voicebot/`) — only starts once the player actually joins that
channel; if nobody joins within 2 minutes the channel is deleted and "Call not started"
is posted to the ticket thread. The post-call transcript webhook posts the summary and
transcript to the thread when a real call ends, exactly like the website ticket flow.
- **`/hangup`**: ends the current call early.
- The call also ends when the player leaves the private channel, the agent says goodbye
(its own `end_call` tool), or after `VOICE_MAX_MINUTES` (default 15) — in every case the
temporary voice channel is deleted and "Call ended (`<duration>`)" is posted to the
ticket thread. On startup the bot also sweeps for and deletes any leftover empty
`support-*` channels from a previous crash/restart.
- One call per Discord server at a time, from the moment `/support` reserves the guild's
slot (channel creation, waiting for join, and the call itself) — a second `/support`
while busy gets an ephemeral "the line is busy" reply.
- **`/ticket`**, and a persistent **"Open a ticket"** button pinned in the support channel,
open a text ticket via a modal (category + description) without going through the
ElevenLabs agent at all — same private-thread flow, source `discord_text`. One open
ticket per Discord user at a time; opening a second one just links back to the first.
- **`/close`**, a persistent **"Close ticket"** button on every ticket's opening post and on
the voice call's "Call ended" message, and reacting with ✅ or 🔒 to any bot-authored
message inside a ticket thread, all close the ticket the same way: only the ticket's own
Discord user or a member with Manage Threads on the parent channel may do it (everyone
else gets an ephemeral refusal, or — for a reaction, which has no ephemeral channel — a
short message posted in the thread). Closing calls the internal API (status → `closed`),
ends an active voice call for that ticket first if there is one, posts "Ticket closed by
`<mention>`.", renames the thread to `[closed] <old name>`, and archives + locks it. A
closed ticket never blocks its owner from opening a new one.
- **Reaction shortcuts** on the pinned instructions message (an alternative to typing
slash commands): react, and the bot removes your reaction once it's acted on it.
| Reaction | Action |
|---|---|
| 🎫 | Same as `/ticket`, but without a modal (reactions can't open one): category `other`, summary "Opened by reaction; the player describes the problem in the thread" — the player is asked to describe the problem in the thread itself. Already has an open ticket → a reminder is posted there instead of opening a new one. |
| 📞 | Same as `/support`: creates the private voice channel, ticket and thread, and waits for the join — since there's no ephemeral reply for a reaction, the "join `<channel>` and I'll pick up" text is posted as the thread's first message instead. |
A per-user in-flight guard ignores a reaction that arrives while that user's previous
ticket-creation/call-start reaction is still being handled, so double-tapping doesn't
double-fire.
The bot talks to this same service's `/internal` API (bearer `MCP_SECRET`, not part of the
public contract in `docs/SPEC.md`) to create, update, and close tickets, since it's a
separate process from the one holding SQLite.
**Discord bot permissions needed** (added when inviting/authorizing the bot, or updating its
existing OAuth2 scopes): `View Channel`, `Send Messages`, `Create Private Threads`, `Send
Messages in Threads`, `Manage Threads` (needed for the text ticket flow *and* to let
Manage-Threads staff close any ticket, not just their own); plus, for voice, **`Connect`**,
**`Speak`**, **`Use Voice Activity`**, and — **permanent, not optional** — **`Manage
Channels`** and **`Manage Roles`** (needed to create the private per-call voice channel and
set its permission overwrites every time `/support` runs). **`Move Members`** is optional:
when granted, a player already in a voice channel is pulled straight into their private call
channel instead of having to click the link themselves. The reaction shortcuts additionally
want **`Manage Messages`** so the bot can remove a player's reaction from the pinned message
after acting on it — without it the reaction still triggers the action, it just isn't
auto-removed (a `Forbidden` from the removal attempt is caught and ignored).
**Cost note:** ElevenLabs bills voice conversation minutes at a materially higher rate than
text conversations — a `/support` call is not "free" the way a widget chat message is.
Run it locally the same way as `serve`:
```powershell
uv run gatebound-support voicebot
uv run gatebound-support voicebot --check # validates settings + opus + imports, no connection, exits 0/1
```
## Docker
```powershell
docker build --platform linux/amd64 -t gatebound-support:dev .
docker run --rm -p 8080:8080 --env-file .env gatebound-support:dev
```
Runs as uid 1000, writes SQLite to `DATA_DIR` (default `/data` in the container).
## Environment variables
See `docs/SPEC.md` sections 1 and 8 for the full list and what each one is for. Quick
reference (all default to `"unset"`, meaning that integration is disabled):
| Variable | Purpose |
|---|---|
| `PORT`, `DATA_DIR`, `LOG_LEVEL` | runtime |
| `PUBLIC_BASE_URL`, `WEB_API_BASE_URL`, `WEB_PUBLIC_URL` | this service's own URL, the web app's internal API, and its public URL |
| `SUPPORT_API_TOKEN` | shared secret for calls to the web support API |
| `SUPPORT_IDENTITY_SECRET` | HS256 key for the player identity JWT |
| `MCP_SECRET` | bearer secret ElevenLabs sends on `/mcp` |
| `ELEVENLABS_API_KEY`, `ELEVENLABS_WEBHOOK_SECRET`, `ELEVENLABS_AGENT_ID` | ElevenLabs API access, post-call webhook HMAC key, and agent id |
| `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID`, `DISCORD_SUPPORT_CHANNEL_ID`, `DISCORD_STAFF_WEBHOOK_URL` | the ticket flow's Discord integration (`DISCORD_BOT_TOKEN`/`DISCORD_GUILD_ID`/`DISCORD_SUPPORT_CHANNEL_ID` are also what the voicebot process uses) |
| `VOICE_MAX_MINUTES` | voicebot: max length of a `/support` call in minutes (default 15) |
| `SUPPORT_INTERNAL_URL` | voicebot: in-cluster base URL of this service's own `/internal` API (default assumes the standard `gatebound-support` namespace/service names) |
Copy `.env.example` to `.env` and fill in what you need; everything else can stay `unset`.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues