Broadcast your Claude Code session live. Viewers watch messages, tool calls and responses in real time in a browser and can ask questions that reach your…

<img width="1624" height="984" alt="image" src="https://github.com/user-attachments/assets/6dfccd9a-1413-4f07-86bf-780a81dbe526" />
Broadcast your Claude Code session live. Viewers watch the full conversation — messages, tool calls, and responses — unfold in real-time in a browser. They can send text questions that surface automatically in Claude's context. Everything runs through a Claude Code mod: no MCP server, no settings hooks, no manual tool calls during a session.
cloudflared — no Cloudflare account requiredbrew install cloudflare/cloudflare/cloudflared
Build the server binary:
git clone https://github.com/xdm67x/claudecast
cd claudecast
cargo build --release
# binary is at target/release/claudecast
Load the mod for a session:
claude --plugin-dir ./mod
By default the mod spawns claudecast from your PATH. If the binary is not on your PATH, configure its location in the plugin options:
claude plugin configure claudecast --values-stdin <<'EOF'
{"binaryPath": "/absolute/path/to/claudecast"}
EOF
Type / in Claude Code to see the claudecast commands. No Claude turn is needed — /cast runs immediately, even mid-turn.
| Command | Description |
|---|---|
/cast | Starts the server and Cloudflare tunnel, returns the public URL. |
/cast-viewers | Shows how many viewers are watching. |
/cast-stop | Ends the session and disconnects all viewers. |
Run /cast. The mod spawns the local server (if it isn't already running), opens a trycloudflare.com tunnel, prints the public URL, and shows a live status line under the prompt:
[claudecast active — public URL: https://xyz.trycloudflare.com]
User messages, tool calls and your responses are broadcast automatically.
From that point everything is automatic:
prompt.submit hook and pushed to the viewer feed.tool.call hook and shown as expandable cards, with the result the model reads.turn.complete hook.get_interactions call needed. The questions leave the queue once surfaced.Run /cast-stop. All connected viewers receive a session-ended notification and the tunnel is torn down. The local server dies with the Claude Code session, and a cast is also stopped automatically when the session ends.
Claude Code
└── claudecast mod (hooks/register.js)
│ /cast, /cast-stop, /cast-viewers commands
│ prompt.submit, tool.call, turn.complete, session.end events
│
▼ (HTTP on 127.0.0.1:3000)
claudecast server (Rust binary, Axum)
│
▼
cloudflared tunnel
│
▼
Viewer browsers (/feed SSE + /messages polling)
Mod (mod/ — a Claude Code plugin):
| Event | Action |
|---|---|
session.start | Registers the /cast, /cast-stop, /cast-viewers commands |
prompt.submit | Broadcasts the user message + surfaces viewer questions as context |
tool.call | Broadcasts the tool name, input, and output after the tool runs |
turn.complete | Broadcasts the assistant's final response |
session.end | Stops the cast so no viewer hangs on a dead feed |
Server:
--port, default 3000). It serves the viewer page (assets/viewer.html), the feed (/feed SSE, /messages polling), and accepts viewer questions (/interact)./cast/start, /cast/stop) and feed input (/user-message, /assistant-message, /tool-event, /pending-questions) are reachable from localhost only — the tunnel exposes just the viewer side, so a public viewer can never inject messages or stop your cast./messages?since=N every second to receive new feed entries incrementally.cargo test # server unit tests
claude plugin validate ./mod # static validation of the mod
cd mod && claude plugin test # mod hook tests (no model, no network)hooks/register.js 241 lines1// claudecast mod: broadcasts this session live to viewers over a public URL.
2//
3// The claudecast Rust binary runs a local HTTP server (viewer page + feed) and
4// the Cloudflare tunnel. This mod replaces the old MCP server and settings hooks:
5// /cast, /cast-stop, /cast-viewers user commands (no Claude turn needed)
6// prompt.submit broadcast user messages + surface viewer questions
7// tool.call broadcast tool calls and their results
8// turn.complete broadcast assistant responses
9//
10// Module state is shared between the hooks of this session.
11
12const MAX_OUTPUT = 4000
13
14let active = false // this session is broadcasting
15let base = 'http://127.0.0.1:3000'
16let binary = 'claudecast'
17let port = 3000
18
19export function register(on, options = {}) {
20 port = options.port ?? 3000
21 binary = options.binaryPath ?? 'claudecast'
22 base = 'http://127.0.0.1:' + port
23
24 on('session.start', async ($, e, next) => {
25 await $.command.register({
26 name: 'cast',
27 description: 'Start broadcasting this session live and show the public viewer URL',
28 immediate: true,
29 })
30 await $.command.register({
31 name: 'cast-stop',
32 description: 'Stop the live broadcast and disconnect all viewers',
33 immediate: true,
34 })
35 await $.command.register({
36 name: 'cast-viewers',
37 description: 'Show how many viewers are watching the live cast',
38 immediate: true,
39 })
40 return next(e)
41 })
42
43 on('command.run', { command: 'cast' }, async ($) => {
44 let up
45 try {
46 up = await ensureServer($)
47 } catch (err) {
48 return { text: 'claudecast: ' + err + '. ' + spawnHelp() }
49 }
50 if (!up) {
51 return { text: 'claudecast: server did not come up on ' + base + '. ' + spawnHelp() }
52 }
53 let start
54 try {
55 start = await post($, '/cast/start', {})
56 } catch (err) {
57 return { text: 'claudecast: failed to start the cast: ' + err }
58 }
59 const url = start.public_url || ''
60 if (!url) {
61 return {
62 text:
63 'claudecast: no tunnel URL. Is cloudflared installed? ' +
64 '(brew install cloudflare/cloudflare/cloudflared)',
65 }
66 }
67 active = true
68 $.ui.status('claudecast live · ' + url)
69 return {
70 text:
71 '[claudecast active — public URL: ' + url + ']\n' +
72 'Share this URL with your audience. User messages, tool calls and your responses are ' +
73 'broadcast automatically from now on. Viewer questions are injected into your context ' +
74 'at the start of each turn — treat them as part of the conversation when relevant.',
75 }
76 })
77
78 on('command.run', { command: 'cast-stop' }, async ($) => {
79 if (!active) return { text: 'No active cast. Run /cast to start one.' }
80 try {
81 await post($, '/cast/stop', {})
82 } catch (err) {
83 return { text: 'claudecast: stop failed: ' + err }
84 }
85 active = false
86 $.ui.status(undefined)
87 return { text: 'claudecast stopped. Viewers have been disconnected.' }
88 })
89
90 on('command.run', { command: 'cast-viewers' }, async ($) => {
91 if (!active) return { text: 'No active cast. Run /cast to start one.' }
92 let status
93 try {
94 status = await get($, '/status')
95 } catch (err) {
96 return { text: 'claudecast: server unreachable: ' + err }
97 }
98 const n = status.viewer_count ?? 0
99 const url = status.public_url || base
100 return { text: n + (n === 1 ? ' viewer' : ' viewers') + ' watching · ' + url }
101 })
102
103 // Broadcast the user's message, then surface pending viewer questions as
104 // context only Claude reads — the questions leave the queue once surfaced.
105 on('prompt.submit', async ($, e, next) => {
106 if (!active) return next(e)
107 try {
108 await post($, '/user-message', { text: e.text })
109 } catch {
110 // Broadcasting must never block the prompt.
111 }
112 let questions = []
113 try {
114 questions = await get($, '/pending-questions')
115 } catch {
116 // No questions to surface.
117 }
118 if (Array.isArray(questions) && questions.length > 0) {
119 const block =
120 'Viewer questions from the audience watching this session live ' +
121 '(answer them as part of your reply when relevant):\n' +
122 questions.map((q) => '- ' + q).join('\n')
123 return next({ ...e, context: [...(e.context ?? []), block] })
124 }
125 return next(e)
126 })
127
128 // Broadcast every tool call after it runs, with the result the model reads.
129 on('tool.call', async ($, e, next) => {
130 if (!active) return next(e)
131 const result = await next(e)
132 try {
133 const input = {}
134 for (const [key, value] of Object.entries(e)) {
135 if (key !== 'tool' && key !== 'tool_use_id' && key !== 'consent' && key !== 'agentId') {
136 input[key] = value
137 }
138 }
139 const output =
140 typeof result?.text === 'string' ? result.text : JSON.stringify(result?.result ?? '')
141 await post($, '/tool-event', {
142 name: e.tool,
143 input,
144 output: String(output).slice(0, MAX_OUTPUT),
145 })
146 } catch {
147 // Broadcasting must never disturb the tool result.
148 }
149 return result
150 })
151
152 // Broadcast Claude's final response for the turn (main conversation only —
153 // subagent turns carry agentId and stay off the feed).
154 on('turn.complete', async ($, e, next) => {
155 const result = await next(e)
156 if (active && !e.agentId && e.answer) {
157 try {
158 await post($, '/assistant-message', { text: e.answer })
159 } catch {
160 // Broadcasting must never disturb the turn.
161 }
162 }
163 return result
164 })
165
166 // End the cast when the session goes away, so no viewer hangs on a dead feed.
167 on('session.end', async ($, e, next) => {
168 if (active) {
169 active = false
170 try {
171 await post($, '/cast/stop', {})
172 } catch {
173 // Session is ending either way.
174 }
175 }
176 return next(e)
177 })
178}
179
180// --- HTTP helpers against the local claudecast server ---
181
182async function get($, path) {
183 const res = await $.http.fetch(base + path)
184 if (!res.ok) throw new Error('GET ' + path + ' -> ' + res.status)
185 return JSON.parse(res.text)
186}
187
188async function post($, path, body) {
189 const res = await $.http.fetch(base + path, {
190 method: 'POST',
191 headers: { 'content-type': 'application/json' },
192 body: JSON.stringify(body),
193 })
194 if (!res.ok) throw new Error('POST ' + path + ' -> ' + res.status + ' ' + res.text)
195 return res.text ? JSON.parse(res.text) : {}
196}
197
198// Ensure the local claudecast server is up: reuse a running one, spawn it when
199// not. Returns true once /status answers.
200async function ensureServer($) {
201 if (await serverUp($)) return true
202 try {
203 // The loop is the child's life: consume it in the background so the server
204 // stays up for the rest of the session and dies with it.
205 const child = $.process.spawn({ argv: [binary, '--port', String(port)] })
206 void (async () => {
207 try {
208 for await (const _piece of child) {
209 // discard the server's output
210 }
211 } catch {
212 // The server exited; the /status polling below reports it.
213 }
214 })()
215 } catch (err) {
216 throw new Error('cannot spawn the server: ' + err)
217 }
218 for (let i = 0; i < 50; i++) {
219 await $.clock.sleep(100)
220 if (await serverUp($)) return true
221 }
222 return false
223}
224
225async function serverUp($) {
226 try {
227 await $.http.fetch(base + '/status')
228 return true
229 } catch {
230 return false
231 }
232}
233
234function spawnHelp() {
235 return (
236 'Build it with cargo build --release in the claudecast repo, then either put ' +
237 'target/release/claudecast on your PATH, set binaryPath in the plugin options, ' +
238 'or start it yourself with: ' + binary + ' --port ' + port
239 )
240}
241