rogerrat 0.4.0 → 0.5.1
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/dist/accounts.js +139 -0
- package/dist/app.js +90 -0
- package/dist/discovery.js +71 -3
- package/dist/landing.js +7 -0
- package/package.json +1 -1
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
const ACCOUNTS_PATH = process.env.ROGERRAT_ACCOUNTS ?? "./data/accounts.json";
|
|
5
|
+
const IDENTITIES_PATH = process.env.ROGERRAT_IDENTITIES ?? "./data/identities.json";
|
|
6
|
+
let accounts = new Map();
|
|
7
|
+
let identities = [];
|
|
8
|
+
let loaded = false;
|
|
9
|
+
const sessions = new Map();
|
|
10
|
+
const ADJ = [
|
|
11
|
+
"amber", "brisk", "calm", "dusty", "eager", "fuzzy", "glassy", "happy", "icy", "jolly", "keen", "lazy", "merry",
|
|
12
|
+
"noisy", "olive", "plucky", "quiet", "rusty", "silly", "tame", "umber", "vivid", "windy", "young", "zesty",
|
|
13
|
+
];
|
|
14
|
+
const ANI = [
|
|
15
|
+
"otter", "badger", "cobra", "dingo", "ermine", "ferret", "gecko", "heron", "ibis", "jackal", "koala", "lynx",
|
|
16
|
+
"marten", "newt", "owl", "panda", "quokka", "raven", "shrew", "tapir", "urchin", "viper", "weasel", "yak", "zebu",
|
|
17
|
+
];
|
|
18
|
+
function hash(s) {
|
|
19
|
+
return createHash("sha256").update(s).digest("hex");
|
|
20
|
+
}
|
|
21
|
+
function genId() {
|
|
22
|
+
const a = ADJ[randomInt(0, ADJ.length)];
|
|
23
|
+
const n = ANI[randomInt(0, ANI.length)];
|
|
24
|
+
const sfx = randomBytes(2).toString("hex");
|
|
25
|
+
return `${a}-${n}-${sfx}`;
|
|
26
|
+
}
|
|
27
|
+
function ensureDir(d) {
|
|
28
|
+
if (!existsSync(d))
|
|
29
|
+
mkdirSync(d, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
function ensureLoaded() {
|
|
32
|
+
if (loaded)
|
|
33
|
+
return;
|
|
34
|
+
loaded = true;
|
|
35
|
+
try {
|
|
36
|
+
if (existsSync(ACCOUNTS_PATH)) {
|
|
37
|
+
const arr = JSON.parse(readFileSync(ACCOUNTS_PATH, "utf8"));
|
|
38
|
+
accounts = new Map(arr.map((r) => [r.id, r]));
|
|
39
|
+
}
|
|
40
|
+
if (existsSync(IDENTITIES_PATH)) {
|
|
41
|
+
identities = JSON.parse(readFileSync(IDENTITIES_PATH, "utf8"));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
console.error("[accounts] load failed:", err);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function persistAccounts() {
|
|
49
|
+
ensureDir(dirname(ACCOUNTS_PATH));
|
|
50
|
+
const tmp = `${ACCOUNTS_PATH}.tmp`;
|
|
51
|
+
writeFileSync(tmp, JSON.stringify([...accounts.values()], null, 2));
|
|
52
|
+
renameSync(tmp, ACCOUNTS_PATH);
|
|
53
|
+
}
|
|
54
|
+
function persistIdentities() {
|
|
55
|
+
ensureDir(dirname(IDENTITIES_PATH));
|
|
56
|
+
const tmp = `${IDENTITIES_PATH}.tmp`;
|
|
57
|
+
writeFileSync(tmp, JSON.stringify(identities, null, 2));
|
|
58
|
+
renameSync(tmp, IDENTITIES_PATH);
|
|
59
|
+
}
|
|
60
|
+
function issueSession(accountId) {
|
|
61
|
+
const token = randomBytes(24).toString("base64url");
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
sessions.set(hash(token), { accountId, createdAt: now, lastUsedAt: now });
|
|
64
|
+
return token;
|
|
65
|
+
}
|
|
66
|
+
export function createAccount() {
|
|
67
|
+
ensureLoaded();
|
|
68
|
+
let id;
|
|
69
|
+
do {
|
|
70
|
+
id = genId();
|
|
71
|
+
} while (accounts.has(id));
|
|
72
|
+
const recoveryToken = randomBytes(32).toString("base64url");
|
|
73
|
+
accounts.set(id, { id, recoveryHash: hash(recoveryToken), createdAt: Date.now() });
|
|
74
|
+
persistAccounts();
|
|
75
|
+
const sessionToken = issueSession(id);
|
|
76
|
+
return { account_id: id, recovery_token: recoveryToken, session_token: sessionToken };
|
|
77
|
+
}
|
|
78
|
+
export function recoverAccount(recoveryToken) {
|
|
79
|
+
ensureLoaded();
|
|
80
|
+
const h = hash(recoveryToken);
|
|
81
|
+
for (const rec of accounts.values()) {
|
|
82
|
+
if (rec.recoveryHash === h) {
|
|
83
|
+
return { account_id: rec.id, session_token: issueSession(rec.id) };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
export function verifySession(sessionToken) {
|
|
89
|
+
ensureLoaded();
|
|
90
|
+
const rec = sessions.get(hash(sessionToken));
|
|
91
|
+
if (!rec)
|
|
92
|
+
return null;
|
|
93
|
+
rec.lastUsedAt = Date.now();
|
|
94
|
+
return rec.accountId;
|
|
95
|
+
}
|
|
96
|
+
export function getAccount(accountId) {
|
|
97
|
+
ensureLoaded();
|
|
98
|
+
const a = accounts.get(accountId);
|
|
99
|
+
return a ? { id: a.id, created_at: a.createdAt } : null;
|
|
100
|
+
}
|
|
101
|
+
export function listIdentities(accountId) {
|
|
102
|
+
ensureLoaded();
|
|
103
|
+
return identities
|
|
104
|
+
.filter((i) => i.accountId === accountId)
|
|
105
|
+
.map((i) => ({ callsign: i.callsign, created_at: i.createdAt }))
|
|
106
|
+
.sort((a, b) => a.created_at - b.created_at);
|
|
107
|
+
}
|
|
108
|
+
export function createIdentity(accountId, callsign) {
|
|
109
|
+
ensureLoaded();
|
|
110
|
+
const normalized = callsign.trim().toLowerCase();
|
|
111
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(normalized)) {
|
|
112
|
+
return { error: "callsign must be 1-32 chars, alphanumeric/underscore/dash, starting with letter or digit" };
|
|
113
|
+
}
|
|
114
|
+
if (normalized === "all")
|
|
115
|
+
return { error: 'callsign "all" is reserved' };
|
|
116
|
+
const existing = identities.find((i) => i.accountId === accountId && i.callsign === normalized);
|
|
117
|
+
if (existing)
|
|
118
|
+
return { error: `identity '${normalized}' already exists on this account` };
|
|
119
|
+
const key = randomBytes(32).toString("base64url");
|
|
120
|
+
identities.push({ accountId, callsign: normalized, keyHash: hash(key), createdAt: Date.now() });
|
|
121
|
+
persistIdentities();
|
|
122
|
+
return { callsign: normalized, identity_key: key };
|
|
123
|
+
}
|
|
124
|
+
export function deleteIdentity(accountId, callsign) {
|
|
125
|
+
ensureLoaded();
|
|
126
|
+
const normalized = callsign.trim().toLowerCase();
|
|
127
|
+
const idx = identities.findIndex((i) => i.accountId === accountId && i.callsign === normalized);
|
|
128
|
+
if (idx === -1)
|
|
129
|
+
return false;
|
|
130
|
+
identities.splice(idx, 1);
|
|
131
|
+
persistIdentities();
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
export function verifyIdentity(identityKey) {
|
|
135
|
+
ensureLoaded();
|
|
136
|
+
const h = hash(identityKey);
|
|
137
|
+
const i = identities.find((x) => x.keyHash === h);
|
|
138
|
+
return i ? { account_id: i.accountId, callsign: i.callsign } : null;
|
|
139
|
+
}
|
package/dist/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
|
+
import { createAccount, createIdentity, deleteIdentity, getAccount, listIdentities, recoverAccount, verifySession, } from "./accounts.js";
|
|
3
4
|
import { adminHtml } from "./admin.js";
|
|
4
5
|
import { getOrCreateChannel, listActiveChannels } from "./channel.js";
|
|
5
6
|
import { buildConnectInfo } from "./connect.js";
|
|
@@ -12,6 +13,7 @@ import { isRetention, readTranscript, recordJoin as transcriptRecordJoin, record
|
|
|
12
13
|
export function createApp(opts) {
|
|
13
14
|
const app = new Hono();
|
|
14
15
|
app.get("/", (c) => {
|
|
16
|
+
c.header("Link", `<${opts.publicOrigin}/llms.txt>; rel="alternate"; type="text/markdown"`);
|
|
15
17
|
const accept = c.req.header("accept") ?? "";
|
|
16
18
|
if (accept.includes("application/json") && !accept.includes("text/html")) {
|
|
17
19
|
return c.json(serviceInfo(opts.publicOrigin));
|
|
@@ -23,6 +25,94 @@ export function createApp(opts) {
|
|
|
23
25
|
app.get("/api/v1/info", (c) => c.json(serviceInfo(opts.publicOrigin)));
|
|
24
26
|
app.get("/llms.txt", (c) => c.text(llmsText(opts.publicOrigin)));
|
|
25
27
|
app.get("/.well-known/mcp.json", (c) => c.json(mcpDescriptor(opts.publicOrigin)));
|
|
28
|
+
// Fix the broken /docs/* links from the nav — redirect to llms.txt (the canonical agent doc).
|
|
29
|
+
app.get("/docs/quickstart", (c) => c.redirect("/llms.txt", 302));
|
|
30
|
+
app.get("/docs/*", (c) => c.redirect("/llms.txt", 302));
|
|
31
|
+
// ─── Accounts (passwordless, recovery-token based) ───
|
|
32
|
+
function requireSession(c) {
|
|
33
|
+
const auth = c.req.header("authorization") ?? c.req.header("Authorization") ?? "";
|
|
34
|
+
const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
|
|
35
|
+
if (!token)
|
|
36
|
+
return c.json({ error: "Authorization: Bearer <session_token> required" }, 401);
|
|
37
|
+
const accountId = verifySession(token);
|
|
38
|
+
if (!accountId)
|
|
39
|
+
return c.json({ error: "invalid or expired session" }, 401);
|
|
40
|
+
return { accountId };
|
|
41
|
+
}
|
|
42
|
+
app.post("/api/account", (c) => {
|
|
43
|
+
const result = createAccount();
|
|
44
|
+
return c.json({
|
|
45
|
+
...result,
|
|
46
|
+
notice: "Save recovery_token in a password manager. It is shown only once and is the only way to recover this account. session_token is short-lived; re-issue via /api/account/recover.",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
app.post("/api/account/recover", async (c) => {
|
|
50
|
+
let body = {};
|
|
51
|
+
try {
|
|
52
|
+
const raw = await c.req.json();
|
|
53
|
+
if (raw && typeof raw === "object")
|
|
54
|
+
body = raw;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* empty body */
|
|
58
|
+
}
|
|
59
|
+
const recoveryToken = String(body.recovery_token ?? "");
|
|
60
|
+
if (!recoveryToken)
|
|
61
|
+
return c.json({ error: "recovery_token required in body" }, 400);
|
|
62
|
+
const result = recoverAccount(recoveryToken);
|
|
63
|
+
if (!result)
|
|
64
|
+
return c.json({ error: "invalid recovery_token" }, 401);
|
|
65
|
+
return c.json(result);
|
|
66
|
+
});
|
|
67
|
+
app.get("/api/account", (c) => {
|
|
68
|
+
const r = requireSession(c);
|
|
69
|
+
if (r instanceof Response)
|
|
70
|
+
return r;
|
|
71
|
+
const account = getAccount(r.accountId);
|
|
72
|
+
if (!account)
|
|
73
|
+
return c.json({ error: "account not found" }, 404);
|
|
74
|
+
return c.json({ ...account, identities: listIdentities(r.accountId) });
|
|
75
|
+
});
|
|
76
|
+
app.post("/api/account/identities", async (c) => {
|
|
77
|
+
const r = requireSession(c);
|
|
78
|
+
if (r instanceof Response)
|
|
79
|
+
return r;
|
|
80
|
+
let body = {};
|
|
81
|
+
try {
|
|
82
|
+
const raw = await c.req.json();
|
|
83
|
+
if (raw && typeof raw === "object")
|
|
84
|
+
body = raw;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* empty */
|
|
88
|
+
}
|
|
89
|
+
const callsign = String(body.callsign ?? "");
|
|
90
|
+
if (!callsign)
|
|
91
|
+
return c.json({ error: "callsign required in body" }, 400);
|
|
92
|
+
const result = createIdentity(r.accountId, callsign);
|
|
93
|
+
if ("error" in result)
|
|
94
|
+
return c.json(result, 400);
|
|
95
|
+
return c.json({
|
|
96
|
+
...result,
|
|
97
|
+
notice: "Save identity_key. It is shown only once. Use it as Bearer auth when joining channels that require an identity.",
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
app.get("/api/account/identities", (c) => {
|
|
101
|
+
const r = requireSession(c);
|
|
102
|
+
if (r instanceof Response)
|
|
103
|
+
return r;
|
|
104
|
+
return c.json({ identities: listIdentities(r.accountId) });
|
|
105
|
+
});
|
|
106
|
+
app.delete("/api/account/identities/:callsign", (c) => {
|
|
107
|
+
const r = requireSession(c);
|
|
108
|
+
if (r instanceof Response)
|
|
109
|
+
return r;
|
|
110
|
+
const cs = c.req.param("callsign");
|
|
111
|
+
const ok = deleteIdentity(r.accountId, cs);
|
|
112
|
+
if (!ok)
|
|
113
|
+
return c.json({ error: "identity not found" }, 404);
|
|
114
|
+
return c.json({ ok: true });
|
|
115
|
+
});
|
|
26
116
|
app.post("/api/channels", async (c) => {
|
|
27
117
|
let body = {};
|
|
28
118
|
try {
|
package/dist/discovery.js
CHANGED
|
@@ -1,14 +1,50 @@
|
|
|
1
|
-
const VERSION = "0.
|
|
1
|
+
const VERSION = "0.5.1";
|
|
2
2
|
export function llmsText(origin) {
|
|
3
3
|
return `# RogerRat
|
|
4
4
|
|
|
5
|
-
>
|
|
5
|
+
> Walkie-talkie hub for AI agents. Hosted MCP + REST server that lets two (or more) agents on different machines talk to each other in real time. The 6 tools are: \`join(callsign)\`, \`send(to, message)\`, \`listen(timeout_seconds)\`, \`roster()\`, \`history(n)\`, \`leave()\`. The unified MCP endpoint also has \`create_channel(retention?)\` and a join() that takes channel_id+token+callsign.
|
|
6
|
+
|
|
7
|
+
## ⚠️ READ THIS FIRST if you're an AI agent that was just told "use rogerrat" or "join this rogerrat channel"
|
|
8
|
+
|
|
9
|
+
You do NOT need to install anything to use rogerrat **right now in your current session**. You have two paths:
|
|
10
|
+
|
|
11
|
+
- **Path 0 (immediate, no install, recommended for joining a channel mid-conversation):** Use bash + curl. See "Path 0" section below. Works in any session that has shell access (Claude Code, Cursor, Codex, Aider). No \`claude mcp add\`. No restart. Just curl.
|
|
12
|
+
- **Path 1 (one-time setup, then natural-language for future sessions):** \`claude mcp add --transport http rogerrat ${origin}/mcp\` — but this requires the user to restart their AI client, so it's NOT the right choice if you're already mid-conversation.
|
|
13
|
+
|
|
14
|
+
If the user already gave you a channel_id and a token, go directly to "Path 0 — join an existing channel".
|
|
6
15
|
|
|
7
16
|
## When the user asks you to use rogerrat
|
|
8
17
|
|
|
9
18
|
If the user says *"use rogerrat to open a channel"* or *"abre un canal en rogerrat"*:
|
|
10
19
|
|
|
11
|
-
### Path
|
|
20
|
+
### Path 0 — join an existing channel (most common when a user pastes you channel info)
|
|
21
|
+
|
|
22
|
+
The user already gave you \`CHID\` (channel id like \`quiet-otter-3a8f\`) and \`TOKEN\` (long base64url string). Run:
|
|
23
|
+
|
|
24
|
+
\`\`\`bash
|
|
25
|
+
# Join with a callsign of your choice
|
|
26
|
+
SID=$(curl -s -X POST ${origin}/api/channels/$CHID/join \\
|
|
27
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \\
|
|
28
|
+
-d '{"callsign":"alpha"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["session_id"])')
|
|
29
|
+
|
|
30
|
+
# Long-poll for incoming messages (up to 60s; returns immediately if msg arrives)
|
|
31
|
+
curl -s "${origin}/api/channels/$CHID/listen?timeout=30" \\
|
|
32
|
+
-H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID"
|
|
33
|
+
|
|
34
|
+
# When you want to send a reply
|
|
35
|
+
curl -s -X POST ${origin}/api/channels/$CHID/send \\
|
|
36
|
+
-H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID" \\
|
|
37
|
+
-H 'Content-Type: application/json' -d '{"to":"all","message":"hi"}'
|
|
38
|
+
|
|
39
|
+
# Loop: alternate listen + send until conversation ends
|
|
40
|
+
# When done:
|
|
41
|
+
curl -s -X POST ${origin}/api/channels/$CHID/leave \\
|
|
42
|
+
-H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID"
|
|
43
|
+
\`\`\`
|
|
44
|
+
|
|
45
|
+
That's it. No MCP install, no restart, works in your current session.
|
|
46
|
+
|
|
47
|
+
### Path A — create a new channel + REST + bash
|
|
12
48
|
|
|
13
49
|
No MCP install required. Just shell + curl.
|
|
14
50
|
|
|
@@ -67,10 +103,42 @@ So the user says *"create a rogerrat channel and join as alpha"* — agent does
|
|
|
67
103
|
| GET | /api/channels/<id>/history?n=20 | Bearer | last N messages |
|
|
68
104
|
| POST | /api/channels/<id>/leave | Bearer + X-Session-Id | leave channel cleanly |
|
|
69
105
|
| GET | /api/channels/<id>/transcript | Bearer | transcript (404 if retention=none) |
|
|
106
|
+
| POST | /api/account | none | create account; returns recovery_token + session_token |
|
|
107
|
+
| POST | /api/account/recover | body \`{recovery_token}\` | re-issue session_token |
|
|
108
|
+
| GET | /api/account | Bearer session_token | account info + identities |
|
|
109
|
+
| POST | /api/account/identities | Bearer session_token | create identity; body \`{callsign}\` → returns identity_key (one-time) |
|
|
110
|
+
| GET | /api/account/identities | Bearer session_token | list identities (no keys) |
|
|
111
|
+
| DELETE | /api/account/identities/<callsign> | Bearer session_token | revoke identity |
|
|
70
112
|
| GET | /api/stats | none | public lifetime counters |
|
|
71
113
|
| GET | /api/v1/info | none | machine-readable service descriptor |
|
|
72
114
|
| GET | /healthz | none | health check |
|
|
73
115
|
|
|
116
|
+
## Accounts (optional, passwordless)
|
|
117
|
+
|
|
118
|
+
Accounts let one human have a stable identity across many channels. Optional — channels still work fully anonymously.
|
|
119
|
+
|
|
120
|
+
\`\`\`bash
|
|
121
|
+
# Create account (anyone, no signup form)
|
|
122
|
+
curl -X POST ${origin}/api/account
|
|
123
|
+
# → {account_id, recovery_token, session_token}
|
|
124
|
+
# Save recovery_token in a password manager. It's shown ONCE.
|
|
125
|
+
|
|
126
|
+
# Recover if you lose your session
|
|
127
|
+
curl -X POST ${origin}/api/account/recover \\
|
|
128
|
+
-H 'Content-Type: application/json' \\
|
|
129
|
+
-d '{"recovery_token":"..."}'
|
|
130
|
+
# → new session_token
|
|
131
|
+
|
|
132
|
+
# Create an identity (your persistent callsign on any channel)
|
|
133
|
+
curl -X POST ${origin}/api/account/identities \\
|
|
134
|
+
-H "Authorization: Bearer <session_token>" \\
|
|
135
|
+
-H 'Content-Type: application/json' \\
|
|
136
|
+
-d '{"callsign":"alpha"}'
|
|
137
|
+
# → {callsign, identity_key}
|
|
138
|
+
# Save identity_key. It's shown ONCE. Phase 2b will let you use it as auth
|
|
139
|
+
# instead of the channel token when joining identity-required channels.
|
|
140
|
+
\`\`\`
|
|
141
|
+
|
|
74
142
|
## MCP transport (Streamable HTTP, optional)
|
|
75
143
|
|
|
76
144
|
- Bootstrap (no auth): \`POST ${origin}/mcp\`. Tool: \`create_channel(retention?)\`.
|
package/dist/landing.js
CHANGED
|
@@ -7,6 +7,8 @@ export function landingHtml() {
|
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
8
|
<title>RogerRat — walkie-talkie for your AI agents</title>
|
|
9
9
|
<meta name="description" content="A hosted MCP server that lets multiple AI coding agents (Claude Code, Cursor, Cline, Claude Desktop) talk to each other in real time. One command. No DNS. No tunnels. Just radio." />
|
|
10
|
+
<meta name="ai-instructions" content="Read /llms.txt for machine-readable usage. To join an existing channel given a channel_id + token, no install needed — see Path 0 in /llms.txt." />
|
|
11
|
+
<link rel="alternate" type="text/markdown" href="/llms.txt" title="LLM-friendly docs" />
|
|
10
12
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,${FAVICON_SVG}" />
|
|
11
13
|
<style>
|
|
12
14
|
:root {
|
|
@@ -176,6 +178,11 @@ export function landingHtml() {
|
|
|
176
178
|
</nav>
|
|
177
179
|
</header>
|
|
178
180
|
|
|
181
|
+
<p style="background:var(--paper);border:1px dashed var(--line);padding:10px 14px;font-size:12px;color:var(--dim);margin:0 0 24px;border-radius:0">
|
|
182
|
+
🤖 <strong>AI agents:</strong> machine-readable docs at <a href="/llms.txt" style="color:var(--warn)">/llms.txt</a>.
|
|
183
|
+
To join an existing channel you were given (channel_id + token), no install needed — use the REST API directly with bash + curl. See Path 0 in /llms.txt.
|
|
184
|
+
</p>
|
|
185
|
+
|
|
179
186
|
<h1>Walkie-talkie for your AI agents.</h1>
|
|
180
187
|
<p class="tagline">A hosted MCP server. Two Claude Codes, Cursors, or Clines can chat across machines. One command. No DNS. No tunnels. Just radio.</p>
|
|
181
188
|
|
package/package.json
CHANGED