Most multi-agent demos share the same failure mode: they're batch jobs. You kick off a pipeline, wait for it to finish, and read a transcript after the interesting part is over. The agents may be talking to each other, but nobody's watching.
This sample flips that. Two AI agents with opposing stances debate a topic turn by turn. Every argument, phase change, and vote tally streams live over WebSocket to anyone watching. The audience votes — over the same socket or plain HTTP — and every ballot is persisted to a per-actor SQL ledger. When the debate ends, the winner is declared straight from the database.
It's a small app, but it exercises a surprising amount of the plumbing you'd otherwise hand-roll: durable state, real-time fan-out, embedded storage, and model inference with zero credentials to manage. In this post, we'll walk through how it works and how to run it yourself.
Repo: team-telnyx/telnyx-code-examples → multi-agent-debate
What the App Does
- Two debaters, one topic. A
DebateAgentactor is instantiated twice — once with a pro stance, once with con. Each composes its argument through Telnyx AI inference. - A room that orchestrates. A
DebateRoomactor holds the canonical debate state: phase, current turn, the argument list, and the live tally. - Live streaming by default. Every state change streams to connected audience clients over WebSocket at
/agents/room/{id}— snapshots, incremental merge-patches, and progress events. - Voting two ways. Audience members can cast a vote with a WebSocket
callframe or a plainPOST /debate/{id}/vote. - SQL where it counts. Votes are upserted into a per-actor embedded SQL ledger — one row per voter, re-votes overwrite.
- A declared winner.
POST /debate/{id}/endreads the tally from SQL and broadcasts the result in state.
The sample ships in demo mode with canned arguments, so you can exercise the full flow — streaming, voting, tallying — before wiring up live inference. Flip DEMO_MODE=false and the debaters start calling the model for real.
How It Works
Agents are actors, not request handlers
The Telnyx Agent SDK (@telnyx/edge-runtime) models agents as durable actors. Each agent owns its state, its storage, and a built-in WebSocket connection surface. That last part matters a lot for this sample: the audience doesn't poll an API — they subscribe to the room.
Zero-credential inference
Each DebateAgent generates its arguments through the [telnyx] binding:
// DebateAgent — one class, two stances
async argue(topic: string, stance: "pro" | "con", rebuttalTo?: string) {
const system = stance === "pro"
? `You argue IN FAVOR of the topic. Be sharp and concise.`
: `You argue AGAINST the topic. Be sharp and concise.`;
const completion = await this.env.TELNYX.ai.openai.chat.createCompletion({
model: this.env.AI_MODEL ?? "meta-llama/Llama-3.3-70B-Instruct",
messages: [
{ role: "system", content: system },
{ role: "user", content: rebuttalTo ?? `Open the debate: ${topic}` },
],
});
return completion.choices[0].message.content;
}
There's no API key anywhere in this code. The [telnyx] binding provides zero-credential inference — the platform injects authentication at runtime, so the agent never holds a secret. The model is configurable via the AI_MODEL environment variable.
State that streams itself
The room keeps debate state with setState(), which applies a JSON merge-patch and persists it durably:
// DebateRoom — each turn is one state patch
const proArgument = await this.proAgent.argue(topic, "pro");
this.setState({
arguments: [...this.getState().arguments, { side: "pro", text: proArgument }],
currentTurn: "con",
});
Here's the part that makes the app feel live: every setState() patch streams automatically to WebSocket clients on the room's connection surface. You don't write a broadcast layer, a pub/sub topic, or a diffing protocol. Clients connect to /agents/room/{id}, receive a state snapshot, and then receive incremental merge-patches as the debate progresses.
On the client, it's just a socket:
const ws = new WebSocket("ws://localhost:8787/agents/room/my-debate-id");
ws.onmessage = (event) => {
const frame = JSON.parse(event.data);
// state snapshots, merge-patches, and progress events arrive here
render(frame);
};
The vote ledger is just SQL
Each room actor carries its own embedded SQL store via this.ctx.storage.sql.exec(). There's no database server to provision — the ledger lives with the actor:
castVote(voterId: string, choice: "pro" | "con") {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS votes (
voter_id TEXT PRIMARY KEY,
choice TEXT NOT NULL CHECK (choice IN ('pro', 'con')),
voted_at TEXT NOT NULL
)`);
// One row per audience member — re-voting overwrites
this.ctx.storage.sql.exec(
`INSERT INTO votes (voter_id, choice, voted_at) VALUES (?, ?, ?)
ON CONFLICT(voter_id) DO UPDATE
SET choice = excluded.choice, voted_at = excluded.voted_at`,
[voterId, choice, new Date().toISOString()]
);
// Read the tally back into agent state — which streams it live
const rows = this.ctx.storage.sql.exec(
`SELECT choice, COUNT(*) AS n FROM votes GROUP BY choice`
);
const tally = { pro: 0, con: 0 };
for (const row of rows) tally[row.choice] = row.n;
this.setState({ tally });
}
That last line is the trick: writing the tally into agent state is what makes it live. The SQL ledger is the source of truth; the state patch is the broadcast.
Voting over the same socket
Audience clients are read-only by default — they can watch the stream, but they can't mutate state. The vote path is an explicit opt-in via the @rpc() decorator with authorize():
@rpc()
@authorize() // opts this method in for writes; everything else stays read-only
vote({ choice }: { choice: "pro" | "con" }) {
this.castVote(callerId, choice);
}
A client votes by sending a call frame over the WebSocket it's already watching:
ws.send(JSON.stringify({ type: "call", method: "vote", params: { choice: "pro" } }));
Prefer HTTP? POST /debate/{id}/vote hits the same ledger. Both paths converge on castVote(), so the tally is identical no matter how the vote arrives.
(Snippets are lightly simplified from the full sample — check the repo for the complete implementation.)
Setup
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/multi-agent-debate
npm install
npm run build
# Boots the local actor stack via telnyx-edge dev
# (requires Docker and the Edge Compute CLI)
npm start
The app starts on http://localhost:8787.
Environment variables:
| Variable | Required | Purpose |
|---|---|---|
TELNYX_API_KEY | yes | Authenticates the telnyx-edge CLI; the [telnyx] binding itself provides zero-credential inference to the actors |
AI_MODEL | no | Inference model for live mode (default: meta-llama/Llama-3.3-70B-Instruct) |
DEMO_MODE | no | false switches the debaters to live inference; any other value keeps canned demo arguments |
DEMO_BASE_URL | no | Base URL of a live edge stack for manual testing |
If you want to verify the flow without Docker, npm run smoke runs a self-contained smoke test with an in-process actor host — no edge runtime required.
Once it's running:
| Method | Path | Description |
|---|---|---|
POST | /debate | Start a new debate with a topic |
GET | /debate/{id} | Get current debate state |
POST | /debate/{id}/vote | Cast or change an audience vote |
POST | /debate/{id}/end | End the debate and declare the winner from the SQL tally |
WS | /agents/room/{id} | Live WebSocket stream of state + events |
Where to Take It Next
The debate format is deliberately simple — two agents, two turns — because the interesting part is what you build on top:
- More rounds and cross-examination. The turn loop is just state transitions; add rebuttal rounds or a strict format.
- A judge agent. Add a third actor that scores each argument and writes scores to the same SQL ledger.
- Voice. The same Agent runtime powers voice agents. Because Telnyx is AI Communications Infrastructure — one platform for agents that communicate over voice, SMS, and real-time WebSockets — the natural next step is a debate you can call into and hear, with the same state streaming to a live audience.
Wrap-Up
This sample is a compact demonstration of a pattern we think generalizes: durable agents whose state changes are the real-time stream. No separate WebSocket server, no separate database, no secrets in the inference path. Clone it, run the smoke test, flip on live inference, and put two agents on stage.
If you build something on this pattern — a debate, an auction, a multiplayer anything — we'd love to see it.