No work step here. This is probably a skill that reads or coordinates, not one that produces something.
.md file to compare - side-by-side diff against telegram
telegram
description: "Triggers on prompt mention of 'telegram'."
What it does for you
Reads and sends your Telegram messages.
What it produces
A recent result, so you can see the kind of work it returns.
loading…
How to get it
These run inside the Snappy workspace. Want this working in your business? I set skills like this up with you, in one focused week.
For developers how this skill is built, graded, and how it runs
at a glance- the short version
what's inside - the parts that make up a skill 3/4 present
A skill is just a few plain-text files. Only the main one is required. The rest are optional, added as the work needs them. This is what the skill is made of; how it runs is just below.
state/skills/telegram/SKILL.md
present
state/lib/telegram.ts
present
state/bin/telegram/
not present
state/skills/telegram/AGENTS.md
present
how it runs - the shared frame every skill uses 2/5 present
Every skill runs the same way. One part does the work, a separate part checks it, and a short loader hands the AI exactly what it needs for the job. Anything this skill doesn't use shows a one-line note saying why, on purpose, not by accident.
This skill doesn't fix its own gaps yet.
state/log/evals.ndjson what it has learned - fixes written back in over time sample
When a run hits something this skill didn't handle, the fix gets written back into the skill so it doesn't happen again. FIXED means it was corrected on the spot. LOGGED means it's queued for a bigger rewrite. Either way, the skill gets a little better and never makes the same mistake twice.
- Loading feedback rows…
SKILL.md- the skill, written out in plain English
telegram
Telegram Bot API wrapper backed by state/lib/telegram.ts.
Telegram UI is composed through the live OpenUI path, grounded by get_inbox_telegram / state/lib/telegram.ts, to render recent Telegram messages for the connected Robert chat.
What it's for
- Telegram reads. Inspect recent messages with
getRecentTelegramMessages()
or the /inbox/telegram endpoint.
- Telegram sends. Send text, photos, documents, or chat actions when an
upstream skill has explicit permission to apply.
- Generated component surface. Compose the user-facing Telegram view through
compose_inline or exact Lang. Persist as artifact lang_body only when it becomes a reusable Live App.
When NOT to use it
- Multi-channel sweeps. Use
sweepwhen Telegram is one input among Slack,
email, LinkedIn, Skool, or other channels.
- Implicit sends. Default to scope-only. Do not send Telegram messages
unless the caller explicitly requested an applied send.
Steps
- For Telegram-only reads, call
getRecentTelegramMessages()from
state/lib/telegram.ts or inspect /inbox/telegram.
- For Telegram sends, call
sendText,sendPhoto, orsendDocumentonly
when the request is explicitly apply-mode.
- Before rendering or editing the Telegram generated component surface, ground
the current Telegram state and patch the active surface.
- Write one eval row for real runs.
Library API
import {
getRecentTelegramMessages,
sendText,
sendPhoto,
sendDocument,
} from "./state/lib/telegram.ts";
Credentials read from .env.cache: TELEGRAM_BOT_TOKEN and TELEGRAM_ROBERT_CHAT_ID.
Eval
Kind: auto-shape. Frontmatter plus paired loader/resource presence is the structural gate. Behavioral send evals require explicit applied requests.
Files
state/lib/telegram.ts- importable and CLI-runnable Telegram API.- Live OpenUI surface - composed from
get_inbox_telegram/state/lib/telegram.ts.
AGENTS.md- what the AI loads when this skill comes up
telegram - loader
Per-turn rules. Full reference: state/skills/telegram/SKILL.md. Lib: state/lib/telegram.ts.
Compound Response Pattern (MANDATORY)
When this loader fires for inbox/read requests ("check telegram", "telegram messages", "unread telegram", etc.), compose a structured inbox card - NOT flat prose.
Steps (always follow this order):
- Fetch recent messages:
npx tsx state/lib/telegram.ts
Or via toolProvider query get_inbox_telegram. Collect unread count, chat names, and last message previews.
- Compose via
compose_inline:
root = Card([
CardHeader("Telegram Inbox"),
Stack([
Callout("Unread Messages", [...unread chats as ListBlock items with sender + preview, or "No unread messages"...]),
Callout("Recent Chats", [...last 3-5 active chats as ListBlock items with timestamps...]),
]),
FollowUpBlock([
SnappyFollowUpItem("reply to <first unread sender> on telegram", "Reply to first"),
SnappyFollowUpItem("show all telegram messages from today", "View all today"),
SnappyFollowUpItem("mute notifications for <channel>", "Mute channel"),
])
])
- Always include the FollowUpBlock - even if inbox is empty.
Critical Rules
- Scope-only by default. Reading is safe; sending text, photos, or documents
requires an explicit applied-send request.
- Credentials gate.
TELEGRAM_BOT_TOKENandTELEGRAM_ROBERT_CHAT_IDmust
be present in .env.cache. Surface missing credentials directly.
- Use
sweepfor multi-channel work. Use this skill for Telegram-only
reads or sends.
- Generated UI is live-composed. Ground the turn with
get_inbox_telegram / state/lib/telegram.ts, then compose or refine the Telegram surface through the current OpenUI path.
Commands
| operation | command |
|---|---|
| read recent messages | npx tsx state/lib/telegram.ts or import { getRecentTelegramMessages } from "./state/lib/telegram.ts" |
| send text | import { sendText } from "./state/lib/telegram.ts" |
| send photo | import { sendPhoto } from "./state/lib/telegram.ts" |
| send document | import { sendDocument } from "./state/lib/telegram.ts" |
| CLI smoke | npx tsx state/lib/telegram.ts |
| credentials | .env.cache -> TELEGRAM_BOT_TOKEN, TELEGRAM_ROBERT_CHAT_ID |
| ui model | live composition via compose_inline, persisted as artifact lang_body, reopened with OpenArtifact |
Self-Test
After reading this loader:
- [ ] Route Telegram-only reads to
state/lib/telegram.ts. - [ ] Refuse implicit sends unless apply-mode is explicit.
- [ ] Ground Telegram state before rendering or editing the generated surface.
- [ ] Check Telegram credentials before any API call.
- [ ] Compose structured inbox card (not prose) for inbox/read requests.
Found a gap? Edit this file. <!-- footer-injection-point -->
api.ts- the code it can call
#!/usr/bin/env npx tsx
/**
* snappy-telegram/api.ts -- Telegram Bot API operations for all snappy-* skills.
*
* Uses Telegram Bot Token from snappy-settings/.env.cache.
* Direct Telegram Bot API calls.
*
* Usage:
* npx tsx api.ts send "Hello from the agent"
* npx tsx api.ts send "Hello" --parse-mode HTML
* npx tsx api.ts photo "https://example.com/img.jpg"
*
* Or import as module:
* import { sendText, sendPhoto, editTelegramMessage, deleteTelegramMessage, getRecentTelegramMessages } from "./telegram.ts";
*/
import { env } from "./env.ts";
import { realpathSync } from "fs";
const BASE = () => `https://api.telegram.org/bot${env("TELEGRAM_BOT_TOKEN")}`;
const ROBERT_CHAT_ID = () => env("TELEGRAM_ROBERT_CHAT_ID");
async function tg(method: string, body: Record<string, unknown>) {
const res = await fetch(`${BASE()}/${method}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!data.ok) {
throw new Error(`Telegram ${method} failed: ${data.description}`);
}
return data.result;
}
// --- Public API ---
export async function sendText(
text: string,
parseMode: "Markdown" | "HTML" = "Markdown",
chatId?: string,
) {
return tg("sendMessage", {
chat_id: chatId || ROBERT_CHAT_ID(),
text,
parse_mode: parseMode,
});
}
export async function sendPhoto(photoUrl: string, caption?: string, chatId?: string) {
return tg("sendPhoto", {
chat_id: chatId || ROBERT_CHAT_ID(),
photo: photoUrl,
...(caption ? { caption, parse_mode: "Markdown" } : {}),
});
}
export async function sendDocument(documentUrl: string, caption?: string, chatId?: string) {
return tg("sendDocument", {
chat_id: chatId || ROBERT_CHAT_ID(),
document: documentUrl,
...(caption ? { caption } : {}),
});
}
export async function editTelegramMessage(
messageId: number,
text: string,
parseMode: "Markdown" | "HTML" = "Markdown",
chatId?: string,
) {
return tg("editMessageText", {
chat_id: chatId || ROBERT_CHAT_ID(),
message_id: messageId,
text,
parse_mode: parseMode,
});
}
export async function sendChatAction(
action: "typing" | "upload_photo" | "upload_document" = "typing",
chatId?: string,
) {
return tg("sendChatAction", {
chat_id: chatId || ROBERT_CHAT_ID(),
action,
});
}
// --- Read operations ---
export async function getUpdates(offset?: number, limit = 100, timeout = 0) {
return tg("getUpdates", {
...(offset !== undefined ? { offset } : {}),
limit,
timeout,
allowed_updates: ["message", "edited_message", "channel_post"],
});
}
export async function getRecentTelegramMessages(
chatId?: string,
limit = 20,
): Promise<
Array<{
message_id: number;
from: string;
text: string;
date: string;
chat_id: number;
}>
> {
const updates = await getUpdates(undefined, 100, 0);
const targetChat = chatId || ROBERT_CHAT_ID();
const messages = (updates as any[])
.filter((u: any) => u.message && String(u.message.chat.id) === String(targetChat))
.map((u: any) => ({
message_id: u.message.message_id,
from: u.message.from?.first_name || u.message.from?.username || "unknown",
text: u.message.text || u.message.caption || "[non-text]",
date: new Date(u.message.date * 1000).toISOString(),
chat_id: u.message.chat.id,
}))
.slice(-limit);
return messages;
}
export async function deleteTelegramMessage(messageId: number, chatId?: string) {
return tg("deleteMessage", {
chat_id: chatId || ROBERT_CHAT_ID(),
message_id: messageId,
});
}
export async function forwardMessage(messageId: number, toChatId: string, fromChatId?: string) {
return tg("forwardMessage", {
chat_id: toChatId,
from_chat_id: fromChatId || ROBERT_CHAT_ID(),
message_id: messageId,
});
}
export async function getMe() {
return tg("getMe", {});
}
// --- CLI ---
if (
(() => {
try {
return import.meta.url === `file://${realpathSync(process.argv[1])}`;
} catch {
return false;
}
})()
) {
(async () => {
const [, , cmd, ...args] = process.argv;
switch (cmd) {
case "send": {
const parseMode = args.includes("--parse-mode")
? (args[args.indexOf("--parse-mode") + 1] as "Markdown" | "HTML")
: "Markdown";
const text = args
.filter((a, i) => a !== "--parse-mode" && args[i - 1] !== "--parse-mode")
.join(" ");
if (!text) {
console.error("Usage: api.ts send <text> [--parse-mode HTML]");
process.exit(1);
}
const result = await sendText(text, parseMode);
console.log(`sent message_id=${result.message_id}`);
break;
}
case "photo": {
const [url, ...captionParts] = args;
if (!url) {
console.error("Usage: api.ts photo <url> [caption]");
process.exit(1);
}
const result = await sendPhoto(url, captionParts.join(" ") || undefined);
console.log(`sent photo message_id=${result.message_id}`);
break;
}
case "edit": {
const [msgId, ...textParts] = args;
if (!msgId || !textParts.length) {
console.error("Usage: api.ts edit <message_id> <text>");
process.exit(1);
}
await editTelegramMessage(Number(msgId), textParts.join(" "));
console.log("edited");
break;
}
case "read": {
const limit = args[0] ? parseInt(args[0], 10) : 20;
const msgs = await getRecentTelegramMessages(undefined, limit);
if (msgs.length === 0) {
console.log(
"No recent messages (bot must have received messages via /start or direct chat)",
);
} else {
for (const m of msgs) {
console.log(`${m.date}\t${m.from}\t${m.text.slice(0, 300)}`);
}
}
break;
}
case "updates": {
const updates = await getUpdates();
console.log(JSON.stringify(updates, null, 2));
break;
}
case "me": {
const me = await getMe();
console.log(JSON.stringify(me, null, 2));
break;
}
case "delete": {
const [msgId] = args;
if (!msgId) {
console.error("Usage: api.ts delete <message_id>");
process.exit(1);
}
await deleteTelegramMessage(Number(msgId));
console.log("deleted");
break;
}
default:
console.log("Usage: npx tsx api.ts [send|photo|edit|read|updates|me|delete] ...");
}
})();
}
scripts- helper scripts it can run
prose-only skill - 1 inline code block live in SKILL.md above (no state/bin/ sidecar yet).
how we check it- the checks, plus the last 10 runs
no recent runs logged - the eval contract is declared but nothing has been graded yet