Status: Research complete Topic: agents Type: Feature & Design Date: 2026-04-20 Last-Validated: 2026-05-21 Original-Query: How do we fix ZOE's context loss in Telegram? What other Telegram APIs can ZOE use? Design tip-to-PR shipping flow. (reconstructed) Tier: v2-ready Goal: Fix ZOE's "I didn't send that" reply bug, map every Telegram Bot API capability ZOE can adopt, and design a tip-to-PR shipping loop (Telegram → VPS Claude Code → branch + PR). Builds on: docs 234, 236, 245, 256, 289, 460, 463 Repro case (2026-04-20): Zaal received
[ZOE TIP] 157 — Cross-Project Asset Auditthen asked ZOE "zoe tip 157 can u see that". ZOE replied "No file matching 'tip 157' or '157' exists in the repo." The file exists atresearch/dev-workflows/157-cross-project-asset-audit/README.md.
| Decision | Recommendation |
|---|---|
| Root cause of "didn't send that" bug | USE two fixes together: (a) make bot.mjs read ~/.cache/zoe-learning-pings/sent.json on every turn and inject the last 7 days of tip titles + paths into the system prompt; (b) add a per-chat conversation buffer (~/.cache/zoe-telegram/conv-<chatId>.json, last 20 turns) so ZOE has short-term memory between messages. Right now each Claude Code invocation in callClaude is stateless. |
| Tip lookup shortcut | ADD /tip <n> slash command in bot.mjs that reads sent.json + falls back to find research -name "<n>-*". Short-circuits the Claude call, saves ~$0.02 + 15 seconds per lookup. |
| Ship-PR loop entry | USE the already-built /act portal page + handleSpawnAgent in spawn-server.js:205 as the canonical path. Add a Telegram inline button "SHIP FIX" alongside the existing Read: + Act: URLs so Zaal can trigger a PR directly from the tip. |
| Telegram parse mode | SWITCH sendMessage from plain text to parse_mode: "MarkdownV2" (or HTML) so [ZOE TIP] headers, tip paths, and PR links render as tap-targets. Current raw URL shipping is lowest-friction but highest-UX-ceiling cost. |
| Inline keyboards | ADD reply_markup with inline_keyboard to every tip. Buttons: [Read] [Ship Fix] [Not Now] [Mute 24h]. Wire callback_query handler in bot.mjs poll loop. |
| Authorization scope | HARDCODED ALLOWED_USER = "1447437687" stays. ADD per-action confirmation for ship (ZOE asks "confirm ship PR for doc X?" before spawning AO). |
| Conversation memory store | USE JSON per chat (not sqlite) — 20 turns, prune ≥7 days. Keeps the bot dependency-free. If it outgrows, upgrade to Mem0 per doc 245. |
| Act button + Ship-Fix unification | KEEP /act page (URL in Telegram) AND add inline button. The page gives form-based intent (review/expand/update/custom). The button defaults to intent=review. Both land at the same handleSpawnAgent. |
| VPS Claude Code session for alignment | SKIP a second live claude session on the VPS today. Instead: align via this doc + patch bot.mjs via PR, let the VPS auto-pull cron pick it up. Spawning a second interactive session doubles token burn without payoff. |
| Expand ZOE scope beyond tips | ADD 5 capability tracks (see Capability Matrix below): (1) PR shipping, (2) cast / content ops, (3) on-chain ops via WALLET, (4) contact + CRM via ROLO, (5) scheduling via Claude Routines. Don't ship all at once — order by effort/impact. |
infra/portal/bin/bot.mjs:238-277 (callClaude):
const cmd = "echo '" + safeMsg + "' | claude -p --append-system-prompt-file " + tmpSys + " ...";System prompt = SOUL.md + USER.md + AGENTS.md + MEMORY.md only. Zero conversation state. Zero awareness of what random_tip.py sent earlier.
scripts/zoe-learning-pings/random_tip.py:316-322 writes to ~/.cache/zoe-learning-pings/sent.json:
{"path": "research/dev-workflows/157-cross-project-asset-audit/README.md",
"title": "157 — Cross-Project Asset Audit: Reusable Code from Sibling ZAO Projects",
"mode": "summary",
"at": "2026-04-20T..."}bot.mjs never reads this file.
Add to bot.mjs:
const SENT_TIPS_FILE = process.env.HOME + "/.cache/zoe-learning-pings/sent.json";
const CONV_DIR = process.env.HOME + "/.cache/zoe-telegram";
function loadRecentTips(days = 7) {
try {
const s = JSON.parse(readFileSync(SENT_TIPS_FILE, "utf-8"));
const cutoff = Date.now() - days * 86400000;
return (s.sent || []).filter(t => new Date(t.at).getTime() > cutoff)
.slice(-30)
.map(t => `- ${t.title} (${t.path})`);
} catch { return []; }
}
function loadConversation(chatId) {
try {
const f = `${CONV_DIR}/conv-${chatId}.json`;
return JSON.parse(readFileSync(f, "utf-8")).turns || [];
} catch { return []; }
}
function appendConversation(chatId, userMsg, botReply) {
try { mkdirSync(CONV_DIR, { recursive: true }); } catch {}
const f = `${CONV_DIR}/conv-${chatId}.json`;
const prior = loadConversation(chatId);
const turns = [...prior, { t: new Date().toISOString(), user: userMsg, bot: botReply.slice(0, 800) }]
.slice(-20);
writeFileSync(f, JSON.stringify({ turns }, null, 2));
}Modify loadSystemPrompt() signature to (chatId) and append:
const tips = loadRecentTips();
if (tips.length) {
prompt += "\n\n---\n\nRECENT TIPS YOU SENT (last 7d):\n" + tips.join("\n") + "\n";
}
const conv = loadConversation(chatId);
if (conv.length) {
prompt += "\n\n---\n\nRECENT CONVERSATION (last 20 turns):\n" +
conv.map(t => `Zaal: ${t.user}\nZOE: ${t.bot}`).join("\n\n");
}And in the poll loop (bot.mjs:289-321) call appendConversation(chatId, msg.text, reply) after sendMessage.
Full list of what a bot can do today. Checked against the 2026-04 Bot API 8.3 spec.
| Capability | What it enables for ZOE | Current status | Difficulty to add |
|---|---|---|---|
sendMessage |
Text replies | SHIPPED | — |
sendChatAction |
"typing..." indicator | SHIPPED | — |
getUpdates long polling |
Inbound messages | SHIPPED | — |
parse_mode (MarkdownV2 / HTML) |
Rich formatting (bold, links, code) | NOT USED | 2/10 |
reply_markup inline keyboards |
Tap-to-reply buttons under messages | NOT USED | 3/10 |
callback_query handler |
Receive button taps | NOT USED | 3/10 |
editMessageText / editMessageReplyMarkup |
Mutate a sent tip after the tap (e.g. "SHIPPING...") | NOT USED | 3/10 |
Inline mode (@zaoclaw_bot query) |
Search research from any chat | NOT USED | 4/10 |
| Web Apps (Mini Apps) | Full web UI inside Telegram (e.g. todos dashboard) | NOT USED | 7/10 |
| Webhook mode (replace polling) | Lower latency, no poll loop | NOT USED | 4/10 |
sendPhoto / sendDocument / sendVoice |
Ship images (PR screenshots), docs (digest PDF), voice notes | NOT USED | 2/10 |
sendPoll |
Quick polls to Zaal ("which PR first?") | NOT USED | 2/10 |
| Voice messages + speech-to-text | Zaal speaks to ZOE on walks | NOT USED | 6/10 (needs Whisper) |
Commands menu (setMyCommands) |
Blue "menu" button lists all slash commands | NOT USED | 1/10 |
| Forum / topics | Group threading — one chat with multiple topics | NOT USED | 5/10 |
Reactions (setMessageReaction) |
Thumbs-up on Zaal's message as ack | NOT USED | 1/10 |
| Payments (Stars / Telegram Pay) | Accept micropayments for premium features | NOT USED | 8/10 |
| Bot-to-Bot via Bot API + user's session | Coordinate with other bots (raidsharks, etc.) | NOT USED | 7/10 |
| Groups / channels posting | Broadcast to ZAO community | NOT USED | 4/10 |
| Scheduled messages (Telegram-side) | Not available via Bot API — use cron locally | N/A | — |
| File uploads from Zaal → ZOE | Zaal sends a screenshot, ZOE OCRs + responds | NOT USED | 5/10 |
forwardMessage / copyMessage |
Relay content between chats | NOT USED | 2/10 |
| Live location | N/A for ZOE | N/A | — |
| Passport / ID verification | N/A | N/A | — |
- Inline keyboards on tips —
[Read] [Ship Fix] [Not Now] [Mute 24h]. Biggest UX jump. parse_mode: "HTML"— URLs become actual hyperlinks, bold titles stand out. One-line change.setMyCommands— populates Telegram's blue "menu" button so Zaal sees every slash command without typing/help.
1. ZOE sends [ZOE TIP] with inline buttons
2. Zaal taps [Ship Fix]
3. bot.mjs receives callback_query with data="ship:<doc-path>"
4. bot.mjs POSTs to http://127.0.0.1:<portal-port>/api/spawn-agent
with { doc, title, intent: "review" }
5. spawn-server.js:handleSpawnAgent builds agent prompt + delegates to handleSpawn
6. ao spawn --prompt "<agentPrompt>" runs in detached Claude Code session
7. Claude Code reads doc, makes targeted edits, creates ws/act-<slug>-<ts> branch
8. git push + gh pr create
9. spawn-server returns { sessionId } (or 202 if >8s)
10. bot.mjs edits the original Telegram message:
"[SHIPPING] session <id> started — will reply with PR link when ready"
11. Session watcher (poll ~/.agent-orchestrator/ZAOOS/sessions/<id>/result.json)
picks up PR URL, bot.mjs sends follow-up: "PR #<n> ready — <url>"
| # | Change | File | Effort |
|---|---|---|---|
| 1 | Inject sent.json tips + conversation into system prompt |
bot.mjs |
2/10 |
| 2 | /tip <n> slash command |
bot.mjs |
1/10 |
| 3 | Inline keyboard on every tip sent | scripts/zoe-learning-pings/random_tip.py |
3/10 |
| 4 | callback_query handler in poll loop |
bot.mjs |
3/10 |
| 5 | Internal POST to localhost:3000/api/spawn-agent on ship: callback |
bot.mjs |
2/10 |
| 6 | Session watcher → PR-link follow-up | new file infra/portal/bin/session-watcher.mjs + cron |
5/10 |
| 7 | editMessageText to reflect SHIPPING → SHIPPED |
bot.mjs |
2/10 |
| 8 | setMyCommands to expose all slash commands in menu |
one-shot script | 1/10 |
Total: ~1 day of work, mostly in bot.mjs which is already the right layer.
handleSpawnAgentALREADY validates doc path regex + wraps user context as data. Keep that contract.- Callback data from Telegram is untrusted — re-validate
docagainst/^[a-z0-9][a-z0-9/_.-]*\.md$/ibefore POST. - Re-check
ALLOWED_USERinside thecallback_querybranch (users can't inject callbacks, but belt + braces). - Rate-limit: max 3 ship-fix spawns per hour (store in
~/.cache/zoe-telegram/ship-rate.json).
Current ZOE = tip-sender + chat responder. Gaps mapped to the 4 capability tracks below.
- Ship-Fix button (Part 3)
/pr <number>summary command/cistatus digest
/cast <text>→ Neynar API cast as app FID 19640/digest→ latest newsletter draft viascripts/ZAO OS V1/...(TODO: find script)- Farcaster mention webhook → Telegram relay (per doc 245 Upgrade 3)
/balance→ wallet balance across ZABAL/ETH/USDC on Base/stake <amount>→ routes to WALLET dispatch per doc 460 Flow 2/swap <from> <to> <amount>→ same
/contact <name>→ ROLO lookup- Incoming email via Composio triggers ZOE digest to Telegram
/introduce <a> <b>→ ROLO drafts intro message
- Move morning brief to a Claude Routine (doc 422)
- Move evening reflect to a Routine
- Keep ZOE learning pings on VPS cron (file-local state needs local FS)
User asked for a parallel VPS session. Here's why that's not needed THIS turn, and what it WOULD do if started:
- The root cause is code in
bot.mjsandrandom_tip.py. Both are in this repo. Editing here + PR + VPS auto-pull (cron every 15 min per doc 460) deploys the fix. - A second Claude Code session on VPS via
/vpsskill doubles token burn for zero additional information. The code is here, the decision is made. - The VPS would duplicate this research doc, write the same patches, then collide with the Mac session's PR.
- Validate the current deployed
bot.mjson VPS matches git (drift check) - Check
~/.cache/zoe-learning-pings/sent.jsonexists + has real entries - Tail
journalctl -u zoe-bot(or wherever the bot runs) to see live behaviour - Confirm
~/.agent-orchestrator/ZAOOS/sessions/is populating
Run a one-shot /vps skill invocation from THIS session to do those checks remotely, instead of a persistent parallel session. See "This Week's Action Plan" below.
| # | Task | Owner | Difficulty |
|---|---|---|---|
| 1 | Open PR with bot.mjs patch + random_tip.py inline-keyboard change |
Claude Code on Mac | 3/10 |
| 2 | Run /vps skill to verify sent.json exists + bot process is healthy |
Claude Code | 2/10 |
| 3 | Activate ZOE learning pings (paste Anthropic key, enable cron — per doc 460 Week #1) | Zaal | 1/10 |
| # | Task | Difficulty |
|---|---|---|
| 4 | Add callback_query handler + Ship-Fix round-trip | 4/10 |
| 5 | Add setMyCommands + parse_mode: "HTML" |
2/10 |
| 6 | Session watcher cron for PR-link follow-up | 5/10 |
| 7 | Rate limiter on ship-fix spawns | 2/10 |
| # | Task | Difficulty |
|---|---|---|
| 8 | /cast, /pr, /ci slash commands (Track A + B) |
5/10 |
| 9 | Neynar webhook → Telegram relay (doc 245 Upgrade 3) | 5/10 |
| 10 | Migrate morning-brief and evening-reflect to Claude Routines | 6/10 |
| Option | Memory depth | Cost/mo | Plumbing effort | Verdict |
|---|---|---|---|---|
| No memory (today) | 0 turns | $0 | — | BROKEN — caused 2026-04-20 bug |
| Flat JSON (proposed) | 20 turns + 7d tips | $0 | 2/10 | SHIP FIRST — fixes bug in 30 lines |
| SQLite turn log | Unbounded + queryable | $0 | 5/10 | Overkill until Mem0 proves insufficient |
| Mem0 (per doc 245) | Semantic, self-organizing | ~$5 | 6/10 | SHIP AFTER flat JSON works 2+ weeks |
| Zep / Graphiti | Graph with relationships | ~$20 | 8/10 | SKIP — higher token footprint (600K+ vs Mem0's 1,764) |
| Metric | Value |
|---|---|
| Lines of JS needed for Part 1 patch | ~30 |
| Claude Code call cost per inbound Telegram message today | $0.01-0.05 (Opus, cap $2/call) |
| Expected conv-buffer file size after 1 week | <500 KB per chat (20 turns, 800 chars each) |
| Tips cooldown window | 7 days (per random_tip.py:56) |
| Telegram Bot API version as of 2026-04 | 8.3 |
| Callback data max size | 64 bytes — must shorten doc path in callback_data (use hash or index) |
handleSpawnAgent body cap |
600 chars extra context (spawn-server.js:210) |
| AO spawn 8-second timeout before 202 accepted | spawn-server.js:320 |
ALLOWED_USER Telegram ID |
1447437687 (Zaal) |
| Ship-fix spawn rate limit (proposed) | 3/hour |
| bot.mjs max reply budget | $2 USD per call (--max-budget-usd 2) |
| Telegram message char cap | 4096 (bot.mjs chunks at 4000) |
Files to modify:
/Users/zaalpanthaki/Documents/ZAO OS V1/infra/portal/bin/bot.mjs— all Part 1 + Part 3 patches/Users/zaalpanthaki/Documents/ZAO OS V1/scripts/zoe-learning-pings/random_tip.py— inline keyboard + shorter callback payload/Users/zaalpanthaki/Documents/ZAO OS V1/infra/portal/bin/spawn-server.js— accept callback-shaped payload (optional, Part 3 Patch 5)- New:
/Users/zaalpanthaki/Documents/ZAO OS V1/infra/portal/bin/session-watcher.mjs— Part 3 Patch 6
Paths on VPS (referenced, not edited from here):
/home/zaal/openclaw-workspace/— SOUL/USER/AGENTS/MEMORY/home/zaal/.cache/zoe-learning-pings/sent.json— tips ledger (already written)/home/zaal/.cache/zoe-telegram/conv-<chatId>.json— NEW, conversation buffer/home/zaal/.agent-orchestrator/ZAOOS/sessions/— AO session outputs
Related docs in library:
- Doc 234 — OpenClaw comprehensive guide
- Doc 236 — autonomous OpenClaw operator pattern
- Doc 245 — ZOE upgrade (Mem0, Telegram inline, Neynar webhook)
- Doc 256 — ZOE agent factory vision
- Doc 289 — chat-based command center UX patterns
- Doc 460 — end-to-end agentic stack design
- Doc 463 — portal + AO security audit (safe spawn contract)
- Telegram Bot API 2026 changelog
- Telegram Bot API — InlineKeyboardMarkup
- Telegram Bot API — callback_query
- Telegram Bot API — setMyCommands
- Mem0 GitHub (per doc 245)
- ZAO internal:
infra/portal/bin/bot.mjs,scripts/zoe-learning-pings/random_tip.py,infra/portal/bin/spawn-server.js
Pick one — both in scope today:
- A (ship the fix, 30 min): Branch off main, patch
bot.mjsper Part 1, open PR. VPS auto-pull + bot restart clears the "I didn't send that" bug. - B (verify VPS state, 5 min via
/vps): Run remote checks onsent.json, bot process, session dir — confirms diagnosis before the patch.
Recommended: B → A in one session. Don't open a second live Claude Code session on VPS; no new information to be had there.