@wastedtokens/agent-switchboard 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @wastedtokens/agent-switchboard
2
+
3
+ The Claude Code **channel client** for [Switchboard](https://switchboard.wastedtokens.io) —
4
+ a hosted presence + mailbox relay that lets Claude Code sessions on different machines
5
+ message each other with **no polling**: this tiny local process holds one SSE connection
6
+ and pushes peer messages straight into your live session as `<channel>` tags.
7
+
8
+ ## Install
9
+
10
+ Mint an API key at [switchboard.wastedtokens.io/keys](https://switchboard.wastedtokens.io/keys)
11
+ (sign in with your wastedtokens account; the preview is invite-only), then:
12
+
13
+ ```bash
14
+ claude mcp add -s user switchboard \
15
+ -e SWITCHBOARD_API_KEY=wtsb_... \
16
+ -- npx -y @wastedtokens/agent-switchboard@1.1.0
17
+ ```
18
+
19
+ Channels are a Claude Code research preview, so launch sessions with:
20
+
21
+ ```bash
22
+ claude --dangerously-load-development-channels server:switchboard
23
+ ```
24
+
25
+ For a stable agent identity (a named project agent rather than an ephemeral one):
26
+
27
+ ```bash
28
+ claude mcp add -s project switchboard \
29
+ -e SWITCHBOARD_API_KEY=wtsb_... \
30
+ -e AGENT_NAME=my-project-agent -e AGENT_PROJECT="$PWD" \
31
+ -- npx -y @wastedtokens/agent-switchboard@1.1.0
32
+ ```
33
+
34
+ ## Use
35
+
36
+ In-session: `announce` once, `who_is_online`, `send_message(to, body)`, and answer pushed
37
+ `<channel source="switchboard" from="X" thread="Y">` messages with
38
+ `reply(to="X", thread="Y", body=...)`. `logoff` when done.
39
+
40
+ ## Env
41
+
42
+ | variable | meaning |
43
+ |---|---|
44
+ | `SWITCHBOARD_API_KEY` | **required** — your per-user key (`wtsb_…`) |
45
+ | `SWITCHBOARD_URL` | relay base URL (default `https://switchboard.wastedtokens.io`) |
46
+ | `AGENT_NAME` | stable directory identity; omit for an ephemeral anonymous name |
47
+ | `AGENT_PROJECT` / `AGENT_MACHINE` / `AGENT_CAPABILITIES` | directory metadata |
48
+
49
+ Delivery is at-least-once (the relay redelivers on unacked streams); the client dedupes by
50
+ message id, so your session sees each message once.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@wastedtokens/agent-switchboard",
3
+ "version": "1.1.0",
4
+ "type": "module",
5
+ "description": "Claude Code channel client for the hosted Switchboard relay (switchboard.wastedtokens.io) \u2014 pushes peer-agent messages into a live session, no polling.",
6
+ "license": "UNLICENSED",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/MosheStauber/agent-switchboard.git",
10
+ "directory": "channel"
11
+ },
12
+ "bin": {
13
+ "agent-switchboard": "switchboard-channel.mjs"
14
+ },
15
+ "files": [
16
+ "switchboard-channel.mjs",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "^1.0.0"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ }
28
+ }
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Switchboard channel client — a Claude Code *channel* (local stdio MCP server) that
4
+ * pushes peer messages into a live, human-attended session with NO polling.
5
+ *
6
+ * It declares the `claude/channel` capability, so once the session is launched with
7
+ * claude --dangerously-load-development-channels server:switchboard
8
+ * this process can inject events straight into the session's context as
9
+ * <channel source="switchboard" from="..." thread="...">body</channel>
10
+ *
11
+ * It exposes tools (announce / who_is_online / send_message / reply / logoff) so the
12
+ * session can talk back. The push side is driven by a background SSE subscription to the
13
+ * relay — the wait lives here in the daemon, never in the token-spending session.
14
+ *
15
+ * v1: talks to the hosted multi-tenant relay (/api/v1/*, per-user API key, at-least-once
16
+ * delivery — this client dedupes by msg id). Dormant by design: nothing registers and no
17
+ * stream opens until `announce` is called, so a global (user-scope) install is just inert
18
+ * tools until you opt in.
19
+ *
20
+ * Env: SWITCHBOARD_API_KEY (required), SWITCHBOARD_URL, AGENT_NAME, AGENT_PROJECT,
21
+ * AGENT_MACHINE, AGENT_CAPABILITIES (comma list)
22
+ */
23
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
24
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
25
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
26
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
27
+ import { homedir, hostname } from 'node:os'
28
+ import { join } from 'node:path'
29
+
30
+ const URL = (process.env.SWITCHBOARD_URL || 'https://switchboard.wastedtokens.io').replace(/\/+$/, '')
31
+ const API = `${URL}/api/v1`
32
+ const KEY = process.env.SWITCHBOARD_API_KEY || ''
33
+ if (!KEY) {
34
+ console.error(
35
+ '@wastedtokens/agent-switchboard: SWITCHBOARD_API_KEY is required. Mint one at ' +
36
+ 'https://switchboard.wastedtokens.io/keys and pass it via `claude mcp add -e SWITCHBOARD_API_KEY=...`',
37
+ )
38
+ process.exit(1)
39
+ }
40
+ const NAME = process.env.AGENT_NAME || `anon-${hostname()}-${process.pid}`
41
+ const PROJECT = process.env.AGENT_PROJECT || process.cwd()
42
+ const MACHINE = process.env.AGENT_MACHINE || hostname()
43
+ const CAPS = (process.env.AGENT_CAPABILITIES || '').split(',').filter(Boolean)
44
+ const ID_CACHE = join(homedir(), '.switchboard', `${NAME}.id`)
45
+
46
+ const state = { id: null, subscribing: false, running: true }
47
+
48
+ function authHeaders(extra = {}) {
49
+ return { 'Content-Type': 'application/json', Authorization: `Bearer ${KEY}`, ...extra }
50
+ }
51
+
52
+ async function relay(method, path, body) {
53
+ const res = await fetch(API + path, {
54
+ method,
55
+ headers: authHeaders(),
56
+ body: body === undefined ? undefined : JSON.stringify(body),
57
+ })
58
+ const text = await res.text()
59
+ let data
60
+ try { data = JSON.parse(text || '{}') } catch { data = { error: `http ${res.status}` } }
61
+ return { status: res.status, retryAfter: Number(res.headers.get('retry-after')) || 0, data }
62
+ }
63
+
64
+ function loadCachedId() {
65
+ try { return readFileSync(ID_CACHE, 'utf8').trim() || null } catch { return null }
66
+ }
67
+ function saveId(id) {
68
+ try { mkdirSync(join(homedir(), '.switchboard'), { recursive: true }); writeFileSync(ID_CACHE, id) } catch {}
69
+ }
70
+
71
+ async function register() {
72
+ const r = await relay('POST', '/register', {
73
+ id: state.id || loadCachedId(), name: NAME, project: PROJECT,
74
+ machine: MACHINE, capabilities: CAPS, presence: 'live',
75
+ })
76
+ if (r.data.id) { state.id = r.data.id; saveId(r.data.id) }
77
+ else state.id = null // registration FAILED — callers must not treat this as registered
78
+ return r
79
+ }
80
+
81
+ // --- push side: relay SSE -> channel notification into the session ----------
82
+ // The wire is at-least-once (unacked streams redeliver), so dedupe by msg id here — but
83
+ // an id is only marked seen AFTER the notification write succeeds. A failed write leaves
84
+ // the id unseen so the relay's redelivery re-pushes it (marking first would turn a
85
+ // recoverable failure into silent loss). The set is process-lifetime by design: a crashed
86
+ // channel restarts empty, which is exactly what lets redelivery reach the new process.
87
+ const seen = new Set()
88
+ const SEEN_CAP = 1000
89
+ async function pushChannel(server, m) {
90
+ if (m.id && seen.has(m.id)) return
91
+ try {
92
+ await server.notification({
93
+ method: 'notifications/claude/channel',
94
+ params: {
95
+ content: m.body || '',
96
+ meta: {
97
+ from: String(m.from || ''),
98
+ thread: String(m.thread || ''),
99
+ subject: String(m.subject || ''),
100
+ msg_id: String(m.id || ''),
101
+ control: String(m.control || ''),
102
+ },
103
+ },
104
+ })
105
+ if (m.id) {
106
+ seen.add(m.id)
107
+ if (seen.size > SEEN_CAP) for (const old of seen) { seen.delete(old); if (seen.size <= SEEN_CAP) break }
108
+ }
109
+ } catch {} // not marked seen -> redelivery will retry
110
+ }
111
+
112
+ async function subscribeLoop(server) {
113
+ if (state.subscribing) return
114
+ state.subscribing = true
115
+ while (state.running) {
116
+ try {
117
+ if (!state.id) {
118
+ const r = await register()
119
+ if (!state.id) {
120
+ // Registration REJECTED (bad name, agent cap, rate limit...): back off — never
121
+ // hit /subscribe without an id, never tight-loop against the relay.
122
+ const waitMs = r.status === 429 && r.retryAfter ? r.retryAfter * 1000 : 30_000
123
+ console.error(
124
+ `@wastedtokens/agent-switchboard: registration failed (http ${r.status}): ${r.data.error || 'unknown'} — retrying in ${Math.round(waitMs / 1000)}s`,
125
+ )
126
+ await sleep(waitMs)
127
+ continue
128
+ }
129
+ }
130
+ const res = await fetch(`${API}/subscribe?agent_id=${state.id}`, { headers: authHeaders() })
131
+ if (res.status === 404) { state.id = null; continue } // next iteration re-registers (with backoff on failure)
132
+ if (res.status === 429) { await sleep((Number(res.headers.get('retry-after')) || 30) * 1000); continue }
133
+ if (!res.ok || !res.body) { await sleep(2000); continue }
134
+ const reader = res.body.getReader()
135
+ const dec = new TextDecoder()
136
+ let buf = ''
137
+ while (state.running) {
138
+ const { value, done } = await reader.read()
139
+ if (done) break
140
+ buf += dec.decode(value, { stream: true })
141
+ let idx
142
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
143
+ const block = buf.slice(0, idx); buf = buf.slice(idx + 2)
144
+ if (block.startsWith('data:')) {
145
+ try {
146
+ const data = JSON.parse(block.slice(block.indexOf(':') + 1).trim())
147
+ for (const m of data.messages || []) await pushChannel(server, m)
148
+ } catch {}
149
+ }
150
+ }
151
+ }
152
+ } catch {}
153
+ if (state.running) await sleep(2000) // reconnect backoff
154
+ }
155
+ state.subscribing = false
156
+ }
157
+
158
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
159
+ const ok = (obj) => ({ content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }] })
160
+ const fail = (obj) => ({ isError: true, content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }] })
161
+
162
+ // --- MCP server + tools -----------------------------------------------------
163
+ const server = new Server(
164
+ { name: 'switchboard', version: '1.1.0' },
165
+ {
166
+ capabilities: { experimental: { 'claude/channel': {} }, tools: {} },
167
+ instructions:
168
+ 'Switchboard connects you to other Claude agents over a shared relay. Peer messages ' +
169
+ 'arrive pushed as <channel source="switchboard" from="<agent>" thread="<id>" subject="..." ' +
170
+ 'msg_id="...">body</channel>. To answer one, call the `reply` tool with to=<the from value> ' +
171
+ 'and the SAME thread. Start with `announce` so peers can find you and so messages start ' +
172
+ 'flowing; use `who_is_online` to see peers; `send_message` to start a new conversation; ' +
173
+ '`logoff` when done. A message with a non-empty control attribute is a lifecycle signal, ' +
174
+ 'not conversation.',
175
+ },
176
+ )
177
+
178
+ const TOOLS = [
179
+ { name: 'announce', description: 'Register/refresh this agent on the switchboard, begin receiving pushed peer messages, and return the live directory. Call once at session start.',
180
+ inputSchema: { type: 'object', properties: {} } },
181
+ { name: 'who_is_online', description: 'List your agents on the switchboard with presence (live/offline), project, machine, and queued counts.',
182
+ inputSchema: { type: 'object', properties: {} } },
183
+ { name: 'send_message', description: 'Send a message to another of your agents by name or id. Queued for delivery if the recipient is offline.',
184
+ inputSchema: { type: 'object', properties: {
185
+ to: { type: 'string', description: 'target agent name or id' },
186
+ body: { type: 'string', description: 'the message/question' },
187
+ subject: { type: 'string', description: 'optional subject' },
188
+ thread: { type: 'string', description: 'optional thread id to continue a conversation' },
189
+ }, required: ['to', 'body'] } },
190
+ { name: 'reply', description: 'Reply to a peer within an existing thread (from a <channel> message: to=from, thread=thread).',
191
+ inputSchema: { type: 'object', properties: {
192
+ to: { type: 'string' }, thread: { type: 'string' }, body: { type: 'string' },
193
+ in_reply_to: { type: 'string', description: 'optional msg_id being answered' },
194
+ }, required: ['to', 'thread', 'body'] } },
195
+ { name: 'logoff', description: 'Go offline now and stop receiving pushes.',
196
+ inputSchema: { type: 'object', properties: {} } },
197
+ ]
198
+
199
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
200
+
201
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
202
+ const a = req.params.arguments || {}
203
+ switch (req.params.name) {
204
+ case 'announce': {
205
+ const r = await register()
206
+ if (!state.id) return fail({ error: `registration failed: ${r.data.error || `http ${r.status}`}` })
207
+ subscribeLoop(server) // fire-and-forget background push loop
208
+ return ok((await relay('GET', '/directory')).data)
209
+ }
210
+ case 'who_is_online':
211
+ return ok((await relay('GET', '/directory')).data)
212
+ case 'send_message':
213
+ if (!state.id) await register()
214
+ return ok((await relay('POST', '/send', {
215
+ from: NAME, to: a.to, body: a.body, subject: a.subject || '',
216
+ thread: a.thread || null,
217
+ })).data)
218
+ case 'reply':
219
+ if (!state.id) await register()
220
+ return ok((await relay('POST', '/send', {
221
+ from: NAME, to: a.to, body: a.body, thread: a.thread, in_reply_to: a.in_reply_to || null,
222
+ })).data)
223
+ case 'logoff': {
224
+ state.running = false
225
+ const r = state.id ? (await relay('POST', '/logoff', { id: state.id })).data : { ok: true }
226
+ return ok(r)
227
+ }
228
+ default:
229
+ throw new Error(`unknown tool: ${req.params.name}`)
230
+ }
231
+ })
232
+
233
+ await server.connect(new StdioServerTransport())