@stage-labs/metro 0.1.0-beta.0 → 0.1.0-beta.10

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.
@@ -0,0 +1,213 @@
1
+ /** Telegram station: long-poll bot API; sends agent-style markdown as HTML with plain-text fallback. */
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { errMsg, log } from '../log.js';
6
+ import { mintId } from '../history.js';
7
+ import { mdToTelegramHtml } from './telegram-md.js';
8
+ import { inlineKeyboard, tgSendRich } from './telegram-upload.js';
9
+ import { Line, } from './index.js';
10
+ import { STATE_DIR } from '../paths.js';
11
+ const API_BASE = 'https://api.telegram.org';
12
+ const NO_PREVIEW = { link_preview_options: { is_disabled: true } };
13
+ const MAX_BYTES = 20 * 1024 * 1024;
14
+ const token = () => {
15
+ const t = process.env.TELEGRAM_BOT_TOKEN;
16
+ if (!t)
17
+ throw new Error('TELEGRAM_BOT_TOKEN is not set');
18
+ return t;
19
+ };
20
+ async function tg(method, body, opts = {}) {
21
+ const signals = [AbortSignal.timeout(opts.timeoutMs ?? 30_000)];
22
+ if (opts.signal)
23
+ signals.push(opts.signal);
24
+ const res = await fetch(`${API_BASE}/bot${token()}/${method}`, {
25
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
26
+ body: JSON.stringify(body), signal: AbortSignal.any(signals),
27
+ });
28
+ const json = (await res.json());
29
+ if (!json.ok)
30
+ throw new Error(`telegram ${method}: ${json.description ?? 'unknown error'}`);
31
+ return json.result;
32
+ }
33
+ const isParseError = (err) => errMsg(err).includes("can't parse entities");
34
+ const isNoopEdit = (err) => errMsg(err).includes('message is not modified');
35
+ const targetOf = (line) => {
36
+ const t = Line.parseTelegram(line);
37
+ if (!t)
38
+ throw new Error(`not a telegram line: ${line}`);
39
+ return t;
40
+ };
41
+ function attachmentTags(m) {
42
+ const out = [];
43
+ if (m.photo?.length)
44
+ out.push('[image]');
45
+ if (m.document?.mime_type?.startsWith('image/'))
46
+ out.push('[image]');
47
+ else if (m.document)
48
+ out.push(`[file: ${m.document.file_name ?? m.document.file_id}]`);
49
+ if (m.voice)
50
+ out.push('[voice]');
51
+ if (m.audio)
52
+ out.push('[audio]');
53
+ return out;
54
+ }
55
+ const CAPS = {
56
+ in: ['text', 'image'], out: ['text'],
57
+ features: ['reply', 'send', 'edit', 'react', 'download', 'fetch'],
58
+ };
59
+ export class TelegramStation {
60
+ name = 'telegram';
61
+ capabilities = CAPS;
62
+ pollOffset = 0;
63
+ pollAbort = null;
64
+ messageHandler = () => { };
65
+ offsetFile = join(STATE_DIR, 'telegram-offset.json');
66
+ /** Snapshot recent inbounds in memory so `metro download <line> <id>` can resolve them. */
67
+ recent = new Map();
68
+ onMessage(handler) { this.messageHandler = handler; }
69
+ async start() {
70
+ await tg('deleteWebhook', { drop_pending_updates: false }).catch(() => { });
71
+ const persisted = Number(existsSync(this.offsetFile) ? readFileSync(this.offsetFile, 'utf8').trim() : 0) || 0;
72
+ if (persisted > 0)
73
+ this.pollOffset = persisted;
74
+ else {
75
+ /* First run: anchor on latest update id (-1 returns most recent without consuming). */
76
+ const initial = await tg('getUpdates', { offset: -1, timeout: 0 });
77
+ this.pollOffset = initial.length ? initial[0].update_id + 1 : 0;
78
+ this.saveOffset();
79
+ }
80
+ log.info({ offset: this.pollOffset }, 'telegram station: polling started');
81
+ this.pollAbort = new AbortController();
82
+ void this.pollLoop();
83
+ }
84
+ async stop() { this.pollAbort?.abort(); this.pollAbort = null; }
85
+ async getMe() {
86
+ return tg('getMe', {});
87
+ }
88
+ async send(line, text, opts) {
89
+ const { chatId, topicId } = targetOf(line);
90
+ const base = { chat_id: chatId };
91
+ if (topicId !== undefined)
92
+ base.message_thread_id = topicId;
93
+ if (opts?.replyTo)
94
+ base.reply_parameters = { message_id: Number(opts.replyTo) };
95
+ return tgSendRich(token(), tg, base, text, opts);
96
+ }
97
+ async edit(line, messageId, text, opts) {
98
+ const { chatId } = targetOf(line);
99
+ const base = { chat_id: chatId, message_id: Number(messageId), ...NO_PREVIEW };
100
+ if (opts?.buttons)
101
+ base.reply_markup = opts.buttons.length ? inlineKeyboard(opts.buttons) : { inline_keyboard: [] };
102
+ try {
103
+ await tg('editMessageText', { ...base, text: mdToTelegramHtml(text), parse_mode: 'HTML' });
104
+ }
105
+ catch (err) {
106
+ if (isNoopEdit(err))
107
+ return;
108
+ if (!isParseError(err))
109
+ throw err;
110
+ log.warn({ err: errMsg(err) }, 'telegram: HTML edit rejected, retrying plain');
111
+ try {
112
+ await tg('editMessageText', { ...base, text });
113
+ }
114
+ catch (e) {
115
+ if (!isNoopEdit(e))
116
+ throw e;
117
+ }
118
+ }
119
+ }
120
+ async react(line, messageId, emoji) {
121
+ await tg('setMessageReaction', {
122
+ chat_id: targetOf(line).chatId, message_id: Number(messageId),
123
+ reaction: emoji ? [{ type: 'emoji', emoji }] : [],
124
+ });
125
+ }
126
+ async download(line, messageId, outDir) {
127
+ /* Telegram has no "get message by id" — resolve from the in-memory snapshot. */
128
+ const { chatId } = targetOf(line);
129
+ const m = this.recent.get(`${chatId}:${messageId}`);
130
+ if (!m)
131
+ return [];
132
+ const refs = [];
133
+ if (m.photo?.length)
134
+ refs.push({ id: m.photo[m.photo.length - 1].file_id, mime: 'image/jpeg' });
135
+ if (m.document?.mime_type?.startsWith('image/'))
136
+ refs.push({ id: m.document.file_id, mime: m.document.mime_type });
137
+ const out = [];
138
+ for (const [i, { id, mime }] of refs.entries()) {
139
+ try {
140
+ const file = await tg('getFile', { file_id: id });
141
+ const res = await fetch(`${API_BASE}/file/bot${token()}/${file.file_path}`, { signal: AbortSignal.timeout(30_000) });
142
+ if (!res.ok)
143
+ throw new Error(`status ${res.status}`);
144
+ const buf = Buffer.from(await res.arrayBuffer());
145
+ if (buf.byteLength > MAX_BYTES) {
146
+ log.warn({ id, size: buf.byteLength }, 'telegram: attachment too large');
147
+ continue;
148
+ }
149
+ const path = join(outDir, `${chatId}-${messageId}-${i}.${mime.split('/')[1] ?? 'bin'}`);
150
+ await writeFile(path, buf);
151
+ out.push({ path, mediaType: mime });
152
+ }
153
+ catch (err) {
154
+ log.warn({ err: errMsg(err), id }, 'telegram: attachment fetch failed');
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+ /** Bot API has no history endpoint — only the in-memory snapshot is reachable. */
160
+ async fetch() { return []; }
161
+ saveOffset() {
162
+ try {
163
+ writeFileSync(this.offsetFile, String(this.pollOffset));
164
+ }
165
+ catch (err) {
166
+ log.warn({ err: errMsg(err) }, 'telegram offset save failed');
167
+ }
168
+ }
169
+ async pollLoop() {
170
+ const signal = this.pollAbort?.signal;
171
+ const body = { timeout: 25, allowed_updates: ['message'] };
172
+ while (this.pollAbort && !this.pollAbort.signal.aborted) {
173
+ try {
174
+ const updates = await tg('getUpdates', { offset: this.pollOffset, ...body }, { timeoutMs: 60_000, signal });
175
+ for (const u of updates) {
176
+ this.pollOffset = u.update_id + 1;
177
+ this.dispatchUpdate(u);
178
+ }
179
+ if (updates.length)
180
+ this.saveOffset();
181
+ }
182
+ catch (err) {
183
+ if (this.pollAbort?.signal.aborted)
184
+ break;
185
+ log.warn({ err: errMsg(err) }, 'telegram poll error; backing off');
186
+ await new Promise(r => setTimeout(r, 2000));
187
+ }
188
+ }
189
+ }
190
+ dispatchUpdate(u) {
191
+ const m = u.message;
192
+ if (!m?.chat?.id || typeof m.message_id !== 'number' || m.from?.is_bot)
193
+ return;
194
+ const text = [m.text ?? m.caption, ...attachmentTags(m)].filter(Boolean).join(' ');
195
+ if (!text)
196
+ return;
197
+ const topicId = m.is_topic_message ? m.message_thread_id : undefined;
198
+ const fromName = m.from?.username ? `@${m.from.username}` : m.from?.first_name;
199
+ log.info({ from: fromName, chat: m.chat.id, topic: topicId, text: text.slice(0, 80) }, 'telegram: inbound');
200
+ if (this.recent.size >= 50) {
201
+ const first = this.recent.keys().next().value;
202
+ if (first)
203
+ this.recent.delete(first);
204
+ }
205
+ this.recent.set(`${m.chat.id}:${m.message_id}`, m);
206
+ this.messageHandler({
207
+ id: mintId(), ts: new Date((m.date ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(),
208
+ station: 'telegram', line: Line.telegram(m.chat.id, topicId), messageId: String(m.message_id),
209
+ lineName: topicId === undefined ? (m.chat.title ?? m.chat.first_name ?? undefined) : undefined,
210
+ from: Line.user('telegram', m.from?.id ?? 'unknown'), fromName, text, payload: m,
211
+ });
212
+ }
213
+ }
@@ -0,0 +1,99 @@
1
+ /** Receive-only HTTP station. Each registered endpoint = one path `/wh/<id>` → InboundMessage on stdout. */
2
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
3
+ import { createServer } from 'node:http';
4
+ import { errMsg, log } from '../log.js';
5
+ import { mintId } from '../history.js';
6
+ import { findEndpoint, listEndpoints, webhookPort } from '../webhooks.js';
7
+ import { Line } from './index.js';
8
+ /** Synthesize an `event` tag from common provider-specific headers (GitHub, Intercom). */
9
+ function pickEvent(headers) {
10
+ return headers['x-github-event'] ?? headers['x-intercom-topic'] ?? 'event';
11
+ }
12
+ /** Constant-time signature check against `X-Hub-Signature-256: sha256=<hex>` (GitHub/Intercom style). */
13
+ function verifySig(secret, raw, header) {
14
+ if (!header?.startsWith('sha256='))
15
+ return false;
16
+ const given = Buffer.from(header.slice(7), 'hex');
17
+ const want = createHmac('sha256', secret).update(raw).digest();
18
+ return given.length === want.length && timingSafeEqual(given, want);
19
+ }
20
+ async function readBody(req) {
21
+ const chunks = [];
22
+ for await (const c of req)
23
+ chunks.push(c);
24
+ return Buffer.concat(chunks);
25
+ }
26
+ export class WebhookStation {
27
+ name = 'webhook';
28
+ server = null;
29
+ handler = null;
30
+ onMessage(h) { this.handler = h; }
31
+ start() {
32
+ const port = webhookPort();
33
+ return new Promise((resolve, reject) => {
34
+ this.server = createServer((req, res) => this.handle(req, res).catch(err => {
35
+ log.warn({ err: errMsg(err) }, 'webhook handler error');
36
+ if (!res.headersSent)
37
+ res.writeHead(500).end();
38
+ }));
39
+ this.server.on('error', reject);
40
+ this.server.listen(port, '127.0.0.1', () => {
41
+ log.info({ port, endpoints: listEndpoints().length }, 'webhook station ready');
42
+ resolve();
43
+ });
44
+ });
45
+ }
46
+ async stop() {
47
+ const srv = this.server;
48
+ if (!srv)
49
+ return;
50
+ await new Promise(resolve => srv.close(() => resolve()));
51
+ this.server = null;
52
+ }
53
+ async handle(req, res) {
54
+ const m = req.url?.match(/^\/wh\/([A-Za-z0-9_-]+)/);
55
+ if (!m) {
56
+ res.writeHead(404).end();
57
+ return;
58
+ }
59
+ const endpointId = m[1];
60
+ const endpoint = findEndpoint(endpointId);
61
+ if (!endpoint) {
62
+ res.writeHead(404).end();
63
+ return;
64
+ }
65
+ if (req.method === 'GET') {
66
+ res.writeHead(200).end(`metro webhook ${endpointId} ready\n`);
67
+ return;
68
+ }
69
+ if (req.method !== 'POST') {
70
+ res.writeHead(405).end();
71
+ return;
72
+ }
73
+ const raw = await readBody(req);
74
+ const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(',') : v ?? '']));
75
+ if (endpoint.secret && !verifySig(endpoint.secret, raw, headers['x-hub-signature-256'])) {
76
+ log.warn({ endpoint: endpointId }, 'webhook signature mismatch — rejecting');
77
+ res.writeHead(401).end('signature mismatch');
78
+ return;
79
+ }
80
+ let body = raw.toString('utf8');
81
+ try {
82
+ body = JSON.parse(body);
83
+ }
84
+ catch { /* keep as string */ }
85
+ const line = Line.webhook(endpointId);
86
+ this.handler?.({
87
+ id: mintId(),
88
+ ts: new Date().toISOString(),
89
+ station: 'webhook',
90
+ line,
91
+ lineName: endpoint.label,
92
+ from: line,
93
+ messageId: headers['x-github-delivery'] || headers['x-request-id'] || randomUUID(),
94
+ text: `${pickEvent(headers)} ${req.method} ${req.url}`,
95
+ payload: { headers, body },
96
+ });
97
+ res.writeHead(200).end('ok');
98
+ }
99
+ }
package/dist/tunnel.js ADDED
@@ -0,0 +1,64 @@
1
+ /** Cloudflared tunnel manager. Prefers token-from-env so a missing local credentials JSON does not block startup. */
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { STATE_DIR } from './paths.js';
6
+ import { errMsg, log } from './log.js';
7
+ const FILE = join(STATE_DIR, 'tunnel.json');
8
+ const RESTART_DELAY_MS = 2_000;
9
+ export const loadTunnelConfig = () => existsSync(FILE)
10
+ ? JSON.parse(readFileSync(FILE, 'utf8'))
11
+ : null;
12
+ export function saveTunnelConfig(c) { writeFileSync(FILE, JSON.stringify(c, null, 2)); }
13
+ /** Fetch the tunnel's auth token. Null when CLI is unavailable, not logged in, or no such tunnel. */
14
+ function fetchTunnelToken(name) {
15
+ const r = spawnSync('cloudflared', ['tunnel', 'token', name], { encoding: 'utf8' });
16
+ if (r.status !== 0)
17
+ return null;
18
+ const token = r.stdout.trim();
19
+ return token.length > 0 ? token : null;
20
+ }
21
+ export class Tunnel {
22
+ cfg;
23
+ port;
24
+ child = null;
25
+ closed = false;
26
+ /** `undefined` = unresolved; `null` = resolved but unavailable (CLI missing / not logged in / no tunnel). */
27
+ token = undefined;
28
+ constructor(cfg, port) {
29
+ this.cfg = cfg;
30
+ this.port = port;
31
+ }
32
+ get hostname() { return this.cfg.hostname; }
33
+ start() {
34
+ if (this.closed)
35
+ return;
36
+ if (this.token === undefined)
37
+ this.token = fetchTunnelToken(this.cfg.name);
38
+ const mode = this.token ? 'token' : 'named';
39
+ log.info({ name: this.cfg.name, hostname: this.cfg.hostname, port: this.port, mode }, 'cloudflared tunnel starting');
40
+ /** `--no-autoupdate` is a global cloudflared flag — must come before the `tunnel` subcommand. */
41
+ const args = ['--no-autoupdate', 'tunnel', 'run', '--url', `http://127.0.0.1:${this.port}`];
42
+ /** Token form resolves the tunnel from TUNNEL_TOKEN so the trailing name arg must be omitted. */
43
+ if (!this.token)
44
+ args.push(this.cfg.name);
45
+ const env = this.token
46
+ ? { ...process.env, TUNNEL_TOKEN: this.token }
47
+ : process.env;
48
+ this.child = spawn('cloudflared', args, { stdio: ['ignore', 'pipe', 'pipe'], env });
49
+ this.child.stderr?.on('data', d => log.debug({ cloudflared: d.toString().trim() }, 'cloudflared'));
50
+ this.child.on('exit', code => {
51
+ this.child = null;
52
+ if (this.closed)
53
+ return;
54
+ log.warn({ code }, 'cloudflared exited; restarting');
55
+ setTimeout(() => this.start(), RESTART_DELAY_MS);
56
+ });
57
+ this.child.on('error', err => log.warn({ err: errMsg(err) }, 'cloudflared spawn error'));
58
+ }
59
+ stop() {
60
+ this.closed = true;
61
+ this.child?.kill();
62
+ this.child = null;
63
+ }
64
+ }
@@ -0,0 +1,41 @@
1
+ /** Webhook endpoint registry: persists `(id, label, secret?)` for each receive endpoint. */
2
+ import { randomBytes } from 'node:crypto';
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { STATE_DIR } from './paths.js';
6
+ const FILE = join(STATE_DIR, 'webhooks.json');
7
+ /** Local listener port — `127.0.0.1` only; expose publicly via Cloudflare tunnel. */
8
+ export const webhookPort = () => Number(process.env.METRO_WEBHOOK_PORT) || 8420;
9
+ function read() {
10
+ if (!existsSync(FILE))
11
+ return { endpoints: [] };
12
+ try {
13
+ return JSON.parse(readFileSync(FILE, 'utf8'));
14
+ }
15
+ catch {
16
+ return { endpoints: [] };
17
+ }
18
+ }
19
+ function write(s) { writeFileSync(FILE, JSON.stringify(s, null, 2)); }
20
+ export const listEndpoints = () => read().endpoints;
21
+ export const findEndpoint = (id) => read().endpoints.find(e => e.id === id);
22
+ export function addEndpoint(label, secret) {
23
+ const s = read();
24
+ /** 16-char URL-safe id (~96 bits — collision-proof for any reasonable count). */
25
+ const ep = {
26
+ id: randomBytes(12).toString('base64url'), label, createdAt: new Date().toISOString(),
27
+ ...(secret ? { secret } : {}),
28
+ };
29
+ s.endpoints.push(ep);
30
+ write(s);
31
+ return ep;
32
+ }
33
+ export function removeEndpoint(id) {
34
+ const s = read();
35
+ const before = s.endpoints.length;
36
+ s.endpoints = s.endpoints.filter(e => e.id !== id);
37
+ if (s.endpoints.length === before)
38
+ return false;
39
+ write(s);
40
+ return true;
41
+ }
package/docs/agents.md ADDED
@@ -0,0 +1,224 @@
1
+ # Metro: a guide for coding agents
2
+
3
+ You are running inside a session that has **launched `metro`** in the background. Metro emits a live stream of JSON events from Discord, Telegram, third-party webhooks (GitHub, Intercom, …), and other agents on its stdout. Your job is to consume that stream and post replies back via subcommands.
4
+
5
+ ## Starting the bridge
6
+
7
+ The launch mechanics differ by runtime — pick the one that matches yours.
8
+
9
+ ### Claude Code
10
+
11
+ ```
12
+ Bash(command: "metro", run_in_background: true)
13
+ ```
14
+
15
+ Then attach `Monitor` to its stdout. Each line is one JSON event you act on.
16
+
17
+ ### Codex
18
+
19
+ ```
20
+ shell(command: "METRO_CODEX_RC=ws://127.0.0.1:8421 metro", run_in_background: true)
21
+ ```
22
+
23
+ Don't watch its stdout — Codex has no Monitor equivalent. Metro pushes each event into your thread via JSON-RPC `turn/start`, so events arrive as user input on your next turn. The user must have a daemon and TUI running for this to work:
24
+
25
+ ```
26
+ codex app-server --listen ws://127.0.0.1:8421 # daemon (terminal 1)
27
+ codex --remote ws://127.0.0.1:8421 # TUI (this session — terminal 2)
28
+ ```
29
+
30
+ Run `metro doctor` if anything seems off.
31
+
32
+ ## Event shape
33
+
34
+ Every event is a **history entry** — the same record that's appended to `history.jsonl`. Fields: `kind` (`inbound`/`notification`/`outbound`/`edit`/`react`), `id` (`msg_…`), `ts`, `station`, `line` (conversation), `lineName?`, `from` (participant URI), `fromName?`, `to`, `text`, `messageId?` (platform-side id; inbound/outbound only), `payload?` (raw platform message; inbound only).
35
+
36
+ ```json
37
+ {"kind":"inbound","id":"msg_aB3xY7zP","ts":"2026-05-14T12:00:00Z","station":"telegram","line":"metro://telegram/-100…/247","lineName":"infra","from":"metro://telegram/user/12345","fromName":"@alice","to":"metro://claude/user/9bfc7af0-…","messageId":"4567","text":"hello [image]","payload":{"message_id":4567,"chat":{"id":-100,"type":"supergroup","is_forum":true},"from":{"id":12345,"username":"alice"},"text":"hello","entities":[{"type":"mention","offset":0,"length":6}],"photo":[{"file_id":"…"}],"reply_to_message":{"message_id":4500,"text":"earlier","from":{"id":99,"username":"bob"}}}}
38
+ ```
39
+
40
+ ```json
41
+ {"kind":"notification","id":"msg_pQ4r5sT0","ts":"…","station":"claude","line":"metro://claude/9bfc7af0-…/50b00d11-…","from":"metro://codex/user/8119ecb1-…","to":"metro://claude/9bfc7af0-…/50b00d11-…","text":"deploy succeeded"}
42
+ ```
43
+
44
+ ### `payload` by station
45
+
46
+ `payload` is the platform's native message shape. Narrow on `event.station`:
47
+
48
+ - **`discord`** — discord.js `Message.toJSON()`: camelCase fields (`channelId`, `guildId`, `content`, `author`, `mentions: { users[], roles[], everyone }`, `attachments[]`, `reference`, …). Collections come back as **arrays of IDs**. `referencedMessage` (also `toJSON()`-shaped) is added inline on replies (auto-fetched).
49
+ - **`telegram`** — raw Bot API `Message` (snake_case): `{ message_id, chat, from, text, caption, entities[], photo[], document, voice, audio, reply_to_message, … }`. `reply_to_message` is inline on replies.
50
+ - **`webhook`** — `{ headers: Record<string,string>, body: <parsed JSON | raw string> }`. Narrow further on the provider — GitHub sets `headers['x-github-event']` (`push`, `pull_request`, `issues`, …) and includes a `repository`/`sender` in body; Intercom sets `x-intercom-topic` etc. `text` is a short summary; full event is always in `payload.body`.
51
+
52
+ Use `payload` for anything the envelope doesn't surface — mentions, reply chains, embeds, stickers, entities.
53
+
54
+ Both `from` and `to` are **participant URIs** (the conversation lives in `line`): `metro://<station>/user/<id>` for a person, `metro://claude/user/<orgId>` for a Claude Code agent (orgId = stable Anthropic-account UUID), `metro://codex/user/<accountId>` for a Codex agent (accountId = stable ChatGPT-account UUID), `metro://<station>/<channelId>` as a fallback `to` when sending to a group with no single recipient.
55
+
56
+ When **you** call `metro send`/`reply`/`edit`/`react`, metro auto-stamps `from` to your runtime — `metro://claude/user/<orgId>` (when `$CLAUDECODE` is set; orgId comes from `claude auth status --json`) or `metro://codex/user/<accountId>` (when `$METRO_CODEX_RC`/`$CODEX_HOME` is set; accountId comes from `$CODEX_HOME/auth.json`, `tokens.account_id`). Both identities are account-scoped, not install-scoped: switch accounts with `claude auth login` / `codex login` and the next event uses the new id (within ~5 s for the daemon, immediately for one-shot CLI calls). Override with `--from=<uri>` or `$METRO_FROM`. When replying/reacting, `to` is auto-set to the original sender (history lookup).
57
+
58
+ - `kind: "inbound"` — a human (or another bot) posted on a chat platform **or a third-party service POSTed to a registered webhook endpoint** (`station: "webhook"`, `payload: { headers, body }`).
59
+ - `kind: "notification"` — another agent called `metro send` against your agent line. This is how Codex pings Claude Code and vice versa.
60
+
61
+ `text` may include `[image]` / `[voice]` / `[audio]` / `[file: <name>]` placeholders alongside the real text — non-image attachments are opaque markers, images can be materialized via `metro download`.
62
+
63
+ ## Required flow on every event
64
+
65
+ 1. **Echo `event.display` verbatim as your first chat output.** Every event ships a pre-rendered chat-bubble in `event.display` — bold header (icon + station + sender) and a markdown blockquote body. Paste it as-is before any commentary or tool calls. Monitor's notification chip is a CLI-only UI and won't surface visibly in VSCode/Cursor, so the agent's own echo is the only cross-surface signal:
66
+
67
+ ```
68
+ **📩 telegram · @bonustrack**
69
+ > Hey
70
+ ```
71
+
72
+ The format is centralized in metro's dispatcher (`formatDisplay()` in `src/history.ts`) — don't compose your own.
73
+
74
+ 2. **Decide and act** using the subcommands below.
75
+
76
+ ## Detecting "is this for me?"
77
+
78
+ Derive from `payload`. Bot id per station is in `$METRO_STATE_DIR/bot-ids.json` (`{discord:"<userId>", telegram:"<userId>"}`).
79
+
80
+ - **`discord`** — DM if `payload.guildId == null`; otherwise pinged if `payload.mentions.users.includes(<bot-id>)`.
81
+ - **`telegram`** — DM if `payload.chat.type === 'private'`; otherwise pinged if any entity in `payload.entities` (or `caption_entities`) is `{type:"mention"}` matching `@<bot-username>`, or `{type:"text_mention", user:{id:<bot-id>}}`.
82
+ - **`webhook`** — every POST is for you (you registered the endpoint). Route on `payload.headers['x-github-event']` / `x-intercom-topic` etc. to know which provider event it is.
83
+
84
+ Default for chat: only reply on DM or ping; otherwise stay silent or `metro react` to ack. Webhooks just consume — no ack mechanism.
85
+
86
+ ## Subcommands
87
+
88
+ | Action | Command |
89
+ |---|---|
90
+ | Quote-reply (threads under original) | `metro reply <line> <messageId> <text>` |
91
+ | Send a fresh message (no reply context) | `metro send <line> <text>` |
92
+ | Edit a message you previously sent | `metro edit <line> <messageId> <text>` |
93
+ | Reaction (empty emoji clears it) | `metro react <line> <messageId> <emoji>` |
94
+ | Download `[image]` attachments | `metro download <line> <messageId> [--out=<dir>]` |
95
+ | Recent-message lookback (Discord only) | `metro fetch <line> [--limit=20]` |
96
+ | Cross-agent ping | `metro send <agent-line> <text> [--from=<line>]` |
97
+ | Register webhook endpoint | `metro webhook add <label> [--secret=<hmac-secret>]` |
98
+ | List / remove webhook endpoints | `metro webhook list` · `metro webhook remove <id>` |
99
+ | Configure Cloudflare named tunnel | `metro tunnel setup <tunnel-name> <hostname>` |
100
+
101
+ `reply` / `send` / `edit` accept multi-line text via stdin (heredoc). Webhooks are receive-only — there's no `reply` for them, just consume the event.
102
+
103
+ ### Rich content flags
104
+
105
+ `send` and `reply` accept these flags; `edit` accepts `--buttons` only.
106
+
107
+ - `--image=<path>` — local image. **Repeatable** for albums: `--image=a.png --image=b.png` (or comma-separated). Up to 10 / message. Text becomes the caption (on the first image for albums).
108
+ - `--document=<path>` — local file (PDF, log, csv, …). Same repeat/comma syntax.
109
+ - `--voice=<path>` — single voice message (`.ogg` Opus or `.mp3`). On Telegram renders as a voice bubble; on Discord uploaded as audio attachment.
110
+ - `--buttons='[[{"text":"…","url":"…"}]]'` — inline URL-button keyboard (2D rows × buttons). Pass `'[]'` to `edit` to clear.
111
+
112
+ ```bash
113
+ metro send <line> "screenshot" --image=/tmp/build.png
114
+ metro send <line> "before/after" --image=/tmp/before.png --image=/tmp/after.png
115
+ metro reply <line> <id> "voice note" --voice=/tmp/note.ogg
116
+ metro send <line> "approve?" --buttons='[[{"text":"Open PR","url":"https://github.com/x/y/pull/1"}]]'
117
+ ```
118
+
119
+ Limits: 20 MB / file. Telegram albums are single-type (photos OR documents per album); mixing kinds still works — metro splits into two messages. Buttons are dropped on multi-attachment Telegram sends. URL buttons only for now.
120
+
121
+ Append `--json` to any command for a single JSON line you can parse.
122
+
123
+ ## When to use `reply` vs `send`
124
+
125
+ - **`reply`** — responding to a specific inbound message. Threads under it. Default when handling an `inbound` event.
126
+ - **`send`** — initiating without a triggering message: a long task you kicked off finished, a follow-up the user asked you to deliver later, or posting to an agent line (`metro://claude/...`, `metro://codex/...`) to notify a peer.
127
+
128
+ ## Universal message IDs
129
+
130
+ The `id` field on every event and `metro history` row is metro's **universal ID** (`msg_<8 chars>`). It works anywhere a `<message_id>` is expected — `metro reply`, `edit`, `react`, `download` — and resolves to the platform's own id via the history file. Use it for chaining commands or referring back across stations.
131
+
132
+ ## `metro history` — read the universal message log
133
+
134
+ Every inbound, outbound, edit, react, and notification is appended to `$METRO_STATE_DIR/history.jsonl` automatically.
135
+
136
+ ```bash
137
+ metro history --limit=20 # recent 20, newest first
138
+ metro history --line=metro://discord/123 # only this conversation
139
+ metro history --kind=inbound --since=2026-05-14 # inbounds since that day
140
+ metro history --station=telegram --text=deploy # all Telegram entries containing "deploy"
141
+ metro history --from='@alice' --json # everything from alice, JSON
142
+ ```
143
+
144
+ Filters: `--limit` (default 50), `--line`, `--station`, `--kind` (`inbound`/`outbound`/`edit`/`react`/`notification`), `--from`, `--text`, `--since` (ISO), `--json`.
145
+
146
+ ## Discovery
147
+
148
+ ### `metro lines`
149
+
150
+ ```
151
+ $ metro lines
152
+ 2m ago metro://discord/1234567890 infra
153
+ 5m ago metro://telegram/-100123/42 design-review
154
+ ```
155
+
156
+ Lines sorted by recency. Use when the user says "the Telegram channel" or "that PR thread."
157
+
158
+ ### `metro stations`
159
+
160
+ ```
161
+ $ metro stations
162
+ ✓ discord chat in: text+image · out: text · features: reply, send, edit, react, download, fetch
163
+ DISCORD_BOT_TOKEN
164
+ ✓ telegram chat in: text+image · out: text · features: reply, send, edit, react, download, fetch
165
+ TELEGRAM_BOT_TOKEN
166
+ ✓ claude agent in: text · out: text · features: send, notify
167
+ account: 9bfc7af0-… · seen 1 agent, 2 sessions
168
+ seen: 9bfc7af0-… · sessions: 2
169
+ ✗ codex agent in: text · out: text · features: send, notify
170
+ set METRO_CODEX_RC=ws://… to push
171
+ ✓ webhook service in: text · out: – · features: –
172
+ 2 endpoints · base https://webhook.example.com
173
+ ```
174
+
175
+ `✓` = ready (env/runtime detected), `✗` = configured-but-broken or runtime not detected, `·` = informational. The detail line under each agent row shows the resolved account id plus the per-agent count of sessions metro has observed — pull addressable agent lines from those.
176
+
177
+ ## Webhooks (receiving HTTP events)
178
+
179
+ When the user wants metro to receive events from a third-party service (GitHub PRs, Intercom conversations, Fireflies meetings, …):
180
+
181
+ 1. **One-time tunnel setup** (only needed once per machine): `metro tunnel setup <tunnel-name> <hostname>`. Requires `cloudflared` on PATH (`brew install cloudflared`) and a Cloudflare account + domain on Cloudflare DNS. Run `cloudflared tunnel login` first if you haven't.
182
+ 2. **Register an endpoint**: `metro webhook add <label> [--secret=<shared-secret>]`. Prints the public URL — paste it into the provider's webhook settings. For GitHub specifically, set **Content type: `application/json`** (form-encoded won't parse into `payload.body`).
183
+ 3. **Run the daemon**: `metro`. With at least one endpoint registered, metro auto-binds the HTTP listener (port 8420, override `METRO_WEBHOOK_PORT`) and spawns `cloudflared tunnel run` if `tunnel.json` exists.
184
+
185
+ Each POST becomes an inbound event:
186
+
187
+ ```json
188
+ {"kind":"inbound","station":"webhook","line":"metro://webhook/<id>","lineName":"github",
189
+ "from":"metro://webhook/<id>","to":"metro://claude/user/<orgId>",
190
+ "messageId":"<x-github-delivery>","text":"push POST /wh/<id>",
191
+ "payload":{"headers":{"x-github-event":"push",…},"body":{"ref":"refs/heads/main",…}}}
192
+ ```
193
+
194
+ `text` is a short summary; the real event lives in `payload.body`. Use `payload.headers['x-github-event']` (or `x-intercom-topic` etc.) to narrow on provider event type. If you set `--secret`, metro verifies `X-Hub-Signature-256` and rejects bad signatures with 401 — agents see only authenticated events.
195
+
196
+ ## Image attachments
197
+
198
+ When an inbound has an `[image]` tag in `text`:
199
+
200
+ 1. `metro download <line> <messageId>` → prints absolute paths.
201
+ 2. `Read` each path with your `Read` tool — the image enters your context as a vision input.
202
+ 3. Reply normally via `metro reply`.
203
+
204
+ ## Cross-agent notification
205
+
206
+ Both agents can post to each other's **agent line** — `metro://claude/<agent-id>/<session-id>` or `metro://codex/<agent-id>/<session-id>`. `<agent-id>` is the peer's stable account id (cross-device); `<session-id>` is one conversation. Discover both by running `metro stations` (which lists every agent + session metro has seen), or by reading `$METRO_STATE_DIR/agent-registry.json` directly. The daemon re-emits the post on its stdout stream (and pushes via codex-rc if configured), so the peer agent sees it as a notification:
207
+
208
+ ```bash
209
+ metro send metro://claude/9bfc7af0-…/50b00d11-… "build green, ready to ship"
210
+ metro send metro://claude/9bfc7af0-…/50b00d11-… "build green" --from=metro://codex/user/8119ecb1-… # override sender
211
+ ```
212
+
213
+ This requires the metro daemon to be running on the machine. Without a daemon, agent-line sends error with a clear message.
214
+
215
+ ## Don'ts
216
+
217
+ - ❌ Spawning a second metro daemon — there's one per machine (lockfile-enforced).
218
+ - ❌ `metro send` to a line that isn't in `metro lines` unless the user gave it to you explicitly.
219
+ - ❌ Narrating the tool ("I'll now use metro reply to…"). The tool call is already visible.
220
+
221
+ ## Further reading
222
+
223
+ - URI scheme: [`uri-scheme.md`](uri-scheme.md)
224
+ - Source: https://github.com/bonustrack/metro