@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 +50 -0
- package/package.json +28 -0
- package/switchboard-channel.mjs +233 -0
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())
|