polygram 0.8.0-rc.56 → 0.8.0-rc.58

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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "polygram",
12
- "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
12
+ "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
13
13
  "category": "integration",
14
14
  "source": "./",
15
15
  "homepage": "https://github.com/shumkov/polygram"
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.8.0-rc.56",
5
- "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
4
+ "version": "0.8.0-rc.58",
5
+ "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
8
8
  "openclaw",
@@ -0,0 +1,75 @@
1
+ /**
2
+ * rc.58: validate the `document`/`photo`/etc file param on IPC sends
3
+ * before relaying to Telegram, so agents get a clear error instead
4
+ * of Telegram's cryptic "Wrong port number specified" rejection.
5
+ *
6
+ * Discovery: 2026-05-05 — agent generated an Artisan invoice .docx,
7
+ * tried to deliver via polygram-ipc with `document: 'http://...'`
8
+ * pointing at a localhost HTTP server it spun up. Telegram rejected
9
+ * because (1) it can't reach the agent's Mac, (2) port format was
10
+ * malformed. The rejection error read "Wrong port number specified"
11
+ * which gave neither agent nor operator a useful clue.
12
+ *
13
+ * What's accepted:
14
+ * - { source: '/abs/path' } envelope — preferred for local files
15
+ * - public HTTPS URL — Telegram fetches it
16
+ * - Telegram file_id — short alphanumeric string, no scheme/slashes
17
+ *
18
+ * What's rejected at the IPC layer (clear error returned to caller):
19
+ * - localhost / 127.0.0.1 URLs (Telegram can't reach)
20
+ * - http:// URLs (Telegram requires https)
21
+ * - bare absolute paths (must wrap in { source: ... })
22
+ * - non-https schemes
23
+ *
24
+ * What's PASSED THROUGH (caller's responsibility):
25
+ * - { source: '/abs/path' } — grammy handles multipart upload
26
+ * - https URL string — Telegram validates reachability itself
27
+ * - file_id-shaped string — Telegram resolves
28
+ * - Buffer / Stream / other grammy InputFile shapes
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ const FILE_PARAM_BY_METHOD = {
34
+ sendDocument: 'document',
35
+ sendPhoto: 'photo',
36
+ sendAudio: 'audio',
37
+ sendAnimation: 'animation',
38
+ sendVideo: 'video',
39
+ sendVoice: 'voice',
40
+ };
41
+
42
+ /**
43
+ * @param {string} method - IPC method name (e.g. 'sendDocument')
44
+ * @param {object} params - the params payload
45
+ * @returns {string|null} human-readable error if the file param is
46
+ * malformed, null if the params look fine (or the method has no
47
+ * file param to check).
48
+ */
49
+ function validateIpcFileParam(method, params = {}) {
50
+ const fileParam = FILE_PARAM_BY_METHOD[method];
51
+ if (!fileParam) return null;
52
+ const val = params[fileParam];
53
+ if (typeof val !== 'string') return null; // envelope/Buffer/etc — pass through
54
+ if (val.length === 0) return `polygram IPC: ${fileParam} is empty`;
55
+
56
+ const looksUrl = /^(https?|ftp):\/\//i.test(val);
57
+ const isLocalhost = /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(\b|[:/?#])/i.test(val);
58
+ const isAbsPath = val.startsWith('/');
59
+
60
+ if (isLocalhost) {
61
+ return `polygram IPC: localhost URLs unreachable from Telegram (got: ${val.slice(0, 80)}). Use { source: '/abs/path' } for local files, or a publicly reachable HTTPS URL.`;
62
+ }
63
+ if (looksUrl && !/^https:\/\//i.test(val)) {
64
+ return `polygram IPC: only HTTPS URLs supported for ${fileParam} (got: ${val.slice(0, 80)}). Use https://, { source: '/abs/path' } for local files, or a Telegram file_id.`;
65
+ }
66
+ if (isAbsPath && !looksUrl) {
67
+ return `polygram IPC: bare file path not accepted (got: ${val.slice(0, 80)}). Wrap it: { ${fileParam}: { source: '${val}' } } so grammy uploads as multipart.`;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ module.exports = {
73
+ validateIpcFileParam,
74
+ FILE_PARAM_BY_METHOD,
75
+ };
@@ -648,11 +648,58 @@ class ProcessManagerSdk {
648
648
 
649
649
  let idleTimer = null;
650
650
  let maxTimer = null;
651
+ let visibilityTimer = null;
651
652
  let activated = false;
652
653
 
654
+ // rc.58: visibility heartbeat for long silent tool runs.
655
+ //
656
+ // The SDK can stay silent for minutes when the agent is running
657
+ // a single long Bash/Playwright tool (gcloud auth + Chrome
658
+ // OAuth login dance, multi-step Playwright session, etc.).
659
+ // During the silence:
660
+ // - polygram receives no onChunk, no onToolUse, no onResult
661
+ // - reactor's stall (30s) and freeze (180s) timers fire IF
662
+ // the reactor was in a STALL_PROMOTABLE state at last
663
+ // heartbeat — but if it was just stopped or in AUTOSTEERED
664
+ // terminal state, no visible signal fires
665
+ // - user sees "stuck" with no emoji/typing change for minutes
666
+ //
667
+ // This timer pings the head pending's reactor every 30s while
668
+ // the pending is in flight. It does NOT change the reactor's
669
+ // visible state (heartbeat is idempotent — re-arms timers
670
+ // without flushing) — but it ensures the stall/freeze cycle
671
+ // keeps producing visible promotions even if SDK silence
672
+ // would otherwise let the reactor go fully quiet.
673
+ //
674
+ // Discovery: 2026-05-05 09:58:38 → 10:10:36 in Shumabit@UMI —
675
+ // 12-min silent gap during a Playwright/Chrome OAuth dance,
676
+ // ZERO reactor-state events logged for that chat in that
677
+ // window. User reported "no typing, no reactions". Adding
678
+ // this heartbeat fixes the silent-window class of UX gap
679
+ // without changing agent behavior.
680
+ const VISIBILITY_HEARTBEAT_MS = 30 * 1000;
681
+ const armVisibilityTimer = () => {
682
+ if (visibilityTimer) clearInterval(visibilityTimer);
683
+ visibilityTimer = setInterval(() => {
684
+ if (!entry.pendingQueue.includes(pending)) {
685
+ // Pending no longer in queue → resolved/rejected/dropped.
686
+ // Defensive: clear ourselves even though clearTimers()
687
+ // SHOULD have been called.
688
+ if (visibilityTimer) { clearInterval(visibilityTimer); visibilityTimer = null; }
689
+ return;
690
+ }
691
+ const r = pending.context?.reactor;
692
+ if (r && typeof r.heartbeat === 'function') {
693
+ try { r.heartbeat(); } catch { /* defensive */ }
694
+ }
695
+ }, VISIBILITY_HEARTBEAT_MS);
696
+ visibilityTimer.unref?.();
697
+ };
698
+
653
699
  const clearTimers = () => {
654
700
  if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
655
701
  if (maxTimer) { clearTimeout(maxTimer); maxTimer = null; }
702
+ if (visibilityTimer) { clearInterval(visibilityTimer); visibilityTimer = null; }
656
703
  };
657
704
 
658
705
  const pending = {
@@ -728,6 +775,8 @@ class ProcessManagerSdk {
728
775
  maxTurnMs,
729
776
  );
730
777
  pending.maxTimer = maxTimer;
778
+ // rc.58: arm the visibility heartbeat at activation.
779
+ armVisibilityTimer();
731
780
  try { context?.onActivate?.(); }
732
781
  catch (err) { this.logger.error?.(`[${entry.label}] onActivate: ${err.message}`); }
733
782
  };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * rc.57: pure resolver for the boot-replay window in milliseconds.
3
+ *
4
+ * Lifted out of polygram.js's main() so the derivation rule can be
5
+ * unit-tested without spinning up the daemon.
6
+ *
7
+ * Precedence:
8
+ * 1. config.bot.replayWindowMs — explicit operator override (any
9
+ * positive integer in ms).
10
+ * 2. Auto-derive from max(maxTurn) × 1.2 across all configured chats
11
+ * (and defaults.maxTurn). Reasoning: if a chat allows turns up to
12
+ * maxTurn seconds, an interrupted turn could be that old when
13
+ * polygram restarts; replay window should outlast it. ×1.2 adds
14
+ * buffer.
15
+ * 3. If no maxTurn is configured anywhere, return undefined (db.js
16
+ * uses its 3-min default).
17
+ *
18
+ * Floor at 3 min (legacy default — never tighter than what we shipped
19
+ * before). Cap at 2h (sanity bound — replaying anything older is
20
+ * almost certainly stale work the user already moved on from).
21
+ *
22
+ * Discovery: msg 151 in Shumabit@UMI thread :24 (chat -1003369922517)
23
+ * was sent 2026-05-05 01:55:14, polygram restarted for rc.56 at
24
+ * 02:17 (22 min later). Pre-rc.57 the 3-min default discarded msg 151
25
+ * as too old; the agent's 7-hour Xero-template-build task was
26
+ * abandoned silently. Shumabit@UMI has maxTurn=3600 (60 min); 1.2×
27
+ * = 72 min replay window now keeps long turns alive across deploys.
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ const FLOOR_MS = 3 * 60 * 1000; // 3 min
33
+ const CAP_MS = 2 * 60 * 60 * 1000; // 2 h
34
+ const BUFFER = 1.2; // ×
35
+
36
+ function resolveReplayWindowMs(config) {
37
+ const explicit = Number(config?.bot?.replayWindowMs);
38
+ if (Number.isInteger(explicit) && explicit > 0) return explicit;
39
+ const chatMaxes = Object.values(config?.chats || {})
40
+ .map((c) => Number(c?.maxTurn) || 0);
41
+ const defaultMax = Number(config?.defaults?.maxTurn) || 0;
42
+ const maxTurnSec = Math.max(0, ...chatMaxes, defaultMax);
43
+ if (maxTurnSec === 0) return undefined;
44
+ const derivedMs = Math.round(maxTurnSec * BUFFER * 1000);
45
+ return Math.max(FLOOR_MS, Math.min(CAP_MS, derivedMs));
46
+ }
47
+
48
+ module.exports = {
49
+ resolveReplayWindowMs,
50
+ FLOOR_MS,
51
+ CAP_MS,
52
+ BUFFER,
53
+ };
@@ -19,7 +19,12 @@
19
19
  * handful of 401s, not worth persisting.
20
20
  */
21
21
 
22
- const DEFAULT_INTERVAL_MS = 4000;
22
+ // rc.58: tightened from 4000 → 3000ms. Telegram's typing indicator
23
+ // expires after ~5s; a 4s interval left a 1s margin where any tick
24
+ // delay (event loop pressure, network blip, sandbox throttling) could
25
+ // produce a brief visible flicker. 3s gives 2s margin and reduces
26
+ // flicker risk on long silent SDK turns.
27
+ const DEFAULT_INTERVAL_MS = 3000;
23
28
  const DEFAULT_MAX_CONSECUTIVE_401 = 10;
24
29
  const DEFAULT_MAX_BACKOFF_MS = 300_000; // 5 min — matches OpenClaw
25
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.56",
3
+ "version": "0.8.0-rc.58",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc-client.js",
6
6
  "bin": {
package/polygram.js CHANGED
@@ -66,6 +66,8 @@ const { createReactionManager, classifyToolName } = require('./lib/status-reacti
66
66
  const { createMediaGroupBuffer } = require('./lib/media-group-buffer');
67
67
  const { classify: classifyError, isTransientHttpError } = require('./lib/error-classify');
68
68
  const { createAutoResumeTracker, isAutoResumable } = require('./lib/auto-resume');
69
+ const { resolveReplayWindowMs } = require('./lib/replay-window');
70
+ const { validateIpcFileParam } = require('./lib/ipc-file-validator');
69
71
  const {
70
72
  createStore: createApprovalsStore,
71
73
  matchesAnyPattern: matchesApprovalPattern,
@@ -1404,6 +1406,13 @@ async function handleSendOverIpc(req) {
1404
1406
  throw new Error(`chat not owned by ${BOT_NAME}: ${chatId}`);
1405
1407
  }
1406
1408
 
1409
+ // rc.58: catch the most common file-upload mistakes before Telegram
1410
+ // rejects with a confusing error. validateIpcFileParam returns a
1411
+ // human-readable error string when the file param is malformed,
1412
+ // null otherwise.
1413
+ const fileParamErr = validateIpcFileParam(method, params);
1414
+ if (fileParamErr) throw new Error(fileParamErr);
1415
+
1407
1416
  const sendRes = await tg(bot, method, params, {
1408
1417
  source: source || 'ipc',
1409
1418
  botName: BOT_NAME,
@@ -3654,7 +3663,13 @@ async function pollBot(bot) {
3654
3663
  const chatId = m.chat.id.toString();
3655
3664
  const chatConfig = config.chats[chatId];
3656
3665
  const threadId = m.message_thread_id?.toString();
3657
- const topicName = threadId && chatConfig?.topics?.[threadId] ? chatConfig.topics[threadId] : threadId;
3666
+ // rc.57: use getTopicName() helper which handles BOTH legacy string
3667
+ // form and rc.48 object form. Pre-rc.57 the direct lookup
3668
+ // `chatConfig.topics[threadId]` template-literal'd into "[object Object]"
3669
+ // because rc.48 topics are objects like {name:"Music",agent:"...",...}.
3670
+ const topicName = threadId
3671
+ ? (chatConfig ? getTopicName(chatConfig, threadId) : threadId)
3672
+ : null;
3658
3673
  const chatLabel = chatConfig?.name || chatId;
3659
3674
  const label = topicName ? `${chatLabel}/${topicName}` : chatLabel;
3660
3675
  console.log(`[${BOT_NAME}] ← ${label}: ${(m.text || m.caption || '(media)').slice(0, 60)}`);
@@ -4166,18 +4181,25 @@ async function main() {
4166
4181
  // Boot replay: re-dispatch any inbound turns that were interrupted by
4167
4182
  // the previous polygram's shutdown or crash. These are rows marked
4168
4183
  // 'dispatched', 'processing', or 'replay-pending' (set by the SIGTERM
4169
- // handler) — all within the last `replayWindowMs` (default 3 min) so
4170
- // we don't resurrect ancient work. Override via
4171
- // `config.bot.replayWindowMs` for ops tuning. Dedupe against
4172
- // already-sent outbound replies in case the previous instance DID
4173
- // answer before dying.
4184
+ // handler) — all within the last `replayWindowMs` so we don't
4185
+ // resurrect ancient work. Dedupe against already-sent outbound
4186
+ // replies in case the previous instance DID answer before dying.
4187
+ //
4188
+ // rc.57: auto-derive replayWindowMs from max(maxTurn) * 1.2 when not
4189
+ // explicitly set. Pre-rc.57 the default was 3 min — but chats with
4190
+ // long agent tasks (Shumabit@UMI maxTurn=3600 = 60 min) would have
4191
+ // their interrupted turns silently dropped because the turn was
4192
+ // typically older than 3 min when polygram restarted. Discovery
4193
+ // context: msg 151 in Shumabit@UMI thread :24 on 2026-05-05 was
4194
+ // sent at 01:55:14, polygram restarted for rc.56 at 02:17 (22 min
4195
+ // later). msg 151 was 'replay-pending' but boot-replay's 3-min
4196
+ // window discarded it; the agent's 7-hour Xero task was abandoned.
4197
+ // Auto-derive: 1.2 × max(chatConfig.maxTurn) across all chats,
4198
+ // floored at 3 min (legacy default), capped at 2 hours (sanity).
4174
4199
  try {
4175
4200
  const chatIds = Object.keys(config.chats);
4176
4201
  if (chatIds.length > 0) {
4177
- const replayWindowMs = (() => {
4178
- const v = Number(config.bot?.replayWindowMs);
4179
- return (Number.isInteger(v) && v > 0) ? v : undefined; // undefined → use db.js default
4180
- })();
4202
+ const replayWindowMs = resolveReplayWindowMs(config);
4181
4203
  const candidates = db.getReplayCandidates({ chatIds, ...(replayWindowMs && { olderThanMs: replayWindowMs }) });
4182
4204
  let replayed = 0;
4183
4205
  let skipped = 0;
@@ -0,0 +1,154 @@
1
+ ---
2
+ name: polygram-send
3
+ description: Send messages, photos, documents, or stickers from a script (or from agent Bash) into the same Telegram chat the bot is in. Use when an agent or cron job needs to post a file, photo, or out-of-band text to a polygram chat — typically Xero invoices (PDF/.docx), generated reports, screenshots, or status pings tied to long-running work. Do NOT use for normal turn replies (those go through the agent's text reply, polygram delivers automatically). Do NOT use Telegram MCP — polygram-send is the supported path.
4
+ ---
5
+
6
+ # polygram-send
7
+
8
+ Polygram exposes a per-bot Unix socket at `/tmp/polygram-<bot>.sock`. Authorized callers (same UID, with the per-bot secret) can send Telegram API methods through it. Polygram routes them to the live bot connection, logs them in the `messages` table, and applies markdown / chunking / dedup.
9
+
10
+ This is the **supported path** for any agent or script that needs to deliver a file or out-of-band message into a polygram-managed chat. Telegram MCP is **not** wired up; agents that try to call Telegram directly will hit auth or routing issues.
11
+
12
+ ## When to use
13
+
14
+ - Agent generated a file (PDF, .docx, image, etc.) and the user asked for it in chat
15
+ - Cron job needs to post a status update to the relevant chat
16
+ - Long-running work needs an out-of-turn nudge (e.g. "build finished, here's the artifact")
17
+
18
+ ## When NOT to use
19
+
20
+ - Your normal text reply to the user — return text from the turn, polygram delivers it through the streamed reply path. Don't double-send.
21
+ - Anything that needs the message attributed to "user" — IPC always sends as the bot.
22
+ - The Telegram MCP — explicitly NOT supported. Polygram blocks outbound message routing through anything other than its own bridge.
23
+
24
+ ## Allowed methods
25
+
26
+ Polygram's IPC accepts (and only these):
27
+
28
+ ```
29
+ sendMessage
30
+ sendPhoto
31
+ sendDocument
32
+ sendSticker
33
+ sendChatAction
34
+ editMessageText
35
+ setMessageReaction
36
+ ```
37
+
38
+ Anything else → `method not allowed` rejection.
39
+
40
+ ## Usage from Bash (one-liner pattern)
41
+
42
+ The lightest path is a `node -e` invocation that requires `polygram/lib/ipc-client`:
43
+
44
+ ```bash
45
+ node -e '
46
+ const { tell } = require("/Users/shumabit/.npm-global/lib/node_modules/polygram/lib/ipc-client");
47
+ (async () => {
48
+ const r = await tell("shumabit", "sendDocument", {
49
+ chat_id: "-1003369922517",
50
+ message_thread_id: 24,
51
+ document: { source: "/absolute/path/to/invoice.docx" },
52
+ caption: "Artisan April invoice — ฿3,562.00",
53
+ }, { source: "agent:xero-invoice" });
54
+ console.log(JSON.stringify(r));
55
+ })().catch(e => { console.error(e.message); process.exit(1); });
56
+ '
57
+ ```
58
+
59
+ Replace `shumabit` with `umi-assistant` or `shumorobot` for the other daemons. The `polygram` package path is wherever it was installed globally (run `npm root -g` to confirm).
60
+
61
+ ## Critical: how to pass `document` (and `photo`)
62
+
63
+ For **sendDocument** and **sendPhoto**, the `document` (or `photo`) field has THREE valid forms. Pass the wrong one and Telegram rejects.
64
+
65
+ ### ✅ Local file path via `{ source: "/abs/path" }` — preferred
66
+
67
+ ```js
68
+ document: { source: "/Users/shumabit/work/invoice.docx" }
69
+ ```
70
+
71
+ This uses grammy's `InputFile` envelope. Polygram → grammy → multipart upload → Telegram. Works for any local file ≤50 MB. **Use this for any file you just generated.**
72
+
73
+ ### ✅ Public HTTPS URL — for files already hosted publicly
74
+
75
+ ```js
76
+ document: "https://cdn.example.com/invoice.pdf"
77
+ ```
78
+
79
+ Telegram fetches the URL directly. Must be **publicly reachable from Telegram's servers** (i.e. a public CDN, S3 bucket, or hosted endpoint). NOT localhost. NOT IP addresses Telegram can't reach. NOT HTTP — must be HTTPS.
80
+
81
+ ### ✅ Telegram `file_id` — for files Telegram has already seen
82
+
83
+ ```js
84
+ document: "BAADAQADwAADBREAAVoQOnEZmRRJpmcE"
85
+ ```
86
+
87
+ Re-use a file that polygram (or any bot) previously uploaded. Cheapest by far — no upload cost.
88
+
89
+ ### ❌ Common failures
90
+
91
+ ```js
92
+ // WRONG: localhost URL — Telegram can't reach your Mac
93
+ document: "http://localhost:8080/file.pdf"
94
+
95
+ // WRONG: HTTP (not HTTPS) URL — Telegram requires HTTPS for URL form
96
+ document: "http://cdn.example.com/file.pdf"
97
+
98
+ // WRONG: malformed port (double colon, alpha chars)
99
+ document: "http://host::8080/file.pdf"
100
+
101
+ // WRONG: bare path string (Telegram will treat as a URL and reject)
102
+ document: "/Users/shumabit/work/invoice.docx"
103
+ ```
104
+
105
+ If you generated a file locally, ALWAYS use the `{ source: "/abs/path" }` form — never wrap it in a URL.
106
+
107
+ ## Picking the right `chat_id` and `message_thread_id`
108
+
109
+ The chat the bot lives in is in `~/polygram/config.json` (shumabit-side) or `~/.polygram/config.json` (ivanshumkov-side). Each chat has:
110
+
111
+ - `chat_id` — the Telegram numeric ID (negative for groups, positive for DMs)
112
+ - `topics` — optional dict of `thread_id` → `{ name, ... }` (rc.48 form)
113
+
114
+ For threaded chats (where `chatConfig.isolateTopics === true` or topics are listed), pass `message_thread_id: <number>` to land in the right topic. Without it, the message goes to the chat's General topic.
115
+
116
+ **Don't hardcode thread IDs.** Read the current conversation's threadId from your invocation context, OR query the most recent inbound message in the relevant topic before sending. Hardcoding leaks state across deploys (the agent's view of "what topic we're in" can stale-bind to an old thread).
117
+
118
+ ## Source label
119
+
120
+ Polygram tags every IPC send with a `source` string for forensics. Default is `cron:<scriptname>` if you don't pass one. **Always pass an explicit `source` from agent Bash** so it's visible in `messages.source` and forensic queries can attribute reliably:
121
+
122
+ ```js
123
+ { source: "agent:xero-invoice" } // Xero workflow
124
+ { source: "agent:rekordbox-import-summary" } // music-curation outputs
125
+ { source: "agent:status-ping" } // generic out-of-turn update
126
+ ```
127
+
128
+ Without an explicit source, the call shows up as `cron:unknown` and forensics can't tell whether it was a real cron job or an agent — which led to actual misdiagnosis on 2026-05-05 (rc.58 incident).
129
+
130
+ ## Captions
131
+
132
+ `caption` accepts up to 1024 chars (vs 4096 for `sendMessage`). Polygram's lib/telegram-format.js handles markdown escape; agents passing markdown captions don't need to pre-escape.
133
+
134
+ For long descriptions, send the file with a short caption + follow up with a separate `sendMessage` for the full text. Don't try to cram a long description into the caption.
135
+
136
+ ## Auth
137
+
138
+ The IPC socket validates a per-bot secret (`/tmp/polygram-<bot>.secret`, mode 0600, same UID readable). `tell()` reads it automatically. Cross-user / cross-UID calls fail at socket-permission level. Cross-bot calls fail at chat ownership check (`chat_id must belong to this bot`).
139
+
140
+ ## Don't
141
+
142
+ - Don't call this for normal text replies. Just emit text from the turn — polygram delivers it.
143
+ - Don't use Telegram MCP.
144
+ - Don't pass localhost URLs to `document`/`photo`.
145
+ - Don't hardcode `message_thread_id` from previous sessions.
146
+ - Don't omit `source` from agent-triggered sends.
147
+ - Don't dedupe yourself — polygram's outbound dedupe + retry is already wired.
148
+
149
+ ## Related
150
+
151
+ - IPC server: `lib/ipc-server.js` — handler registration, secret validation
152
+ - IPC client: `lib/ipc-client.js` — `tell()`, `call()`
153
+ - IPC method allowlist: `polygram.js` `IPC_SEND_ALLOWED_METHODS`
154
+ - Smoke test: `scripts/ipc-smoke.js`