Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/backtest-kit/ai-trading-mcp/llms.txt

Use this file to discover all available pages before exploring further.

Every time Claude calls get_status, the response includes not just the live portfolio state and command history but also the last 15 posts from a configured Telegram channel — text and chart screenshots together, rendered as MCP text and image blocks. This is the rig’s sensory organ: a GramJS-powered scraper that turns a Telegram feed into structured model input.

Why a User Session, Not a Bot

The scraper authenticates as a Telegram user account, not a bot. The distinction matters:

User account

Can read any channel the account is subscribed to. No admin approval needed. Session is a string stored in session.txt — treat it like a password.

Bot account

Can only read channels it has been explicitly added to as a member, and often requires admin approval. Not usable for arbitrary channel monitoring.
The QR-auth flow (npm start -- --session) produces a session.txt that getTelegram reads at runtime via singleshot — the client is created once and reused across calls until a timeout forces a teardown.

The ScraperMessage Data Model

Every Telegram post becomes a ScraperMessage:
// packages/agent/src/model/ScraperMessage.model.ts
export interface ScraperMessage {
  id: number;
  channel: string;
  content: string;
  date: Date;
  photo: string | null;
}
photo is null for text posts. For posts that contain an image, it holds a base64 string produced by GramJS’s downloadMedia — which thumbnail size is downloaded is controlled by GET_PHOTO_THUMB_FN.

The scrapeLast Method

ScraperService.scrapeLast is the method called on every get_status. It fetches limit most recent messages before a when timestamp, with offset support for pagination:
// packages/agent/src/lib/services/base/ScraperService.ts
public scrapeLast = async (dto: {
  channel: string;
  limit: number;     // how many posts to return
  offset: number;    // skip this many from the newest end
  when: Date;        // fetch messages posted before this timestamp
}): Promise<ScraperMessage[]> => { ... }
StatusControllerService always calls it with limit: 15 and offset: 0, anchored to the current when timestamp:
const FEED_MESSAGES_LIMIT = 15;

const feed = await self.scraperService.scrapeLast({
  channel: CC_TELEGRAM_CHANNEL,
  limit: FEED_MESSAGES_LIMIT,
  when,
  offset: 0,
});

The scrapeDay Method

ScraperService.scrapeDay collects every message from a given calendar day, making it useful for historical back-fills rather than the live feed loop. It iterates all messages that fall between midnight and 23:59:59 UTC for the specified when date and only keeps messages that carry text — photo-only posts without a caption are skipped:
// packages/agent/src/lib/services/base/ScraperService.ts
public scrapeDay = async (dto: {
  channel: string;
  when: Date;       // target calendar day (UTC)
}): Promise<ScraperMessage[]> => { ... }
The key difference from scrapeLast is the filtering rule: scrapeDay skips any message where message.message is empty or absent, so caption-free photo posts are excluded from the result. scrapeLast retains those posts as long as a photo is present (!message.message && !message.photo is the skip condition), making it more permissive about image-only content.

Photo Thumbnail Selection

Telegram stores each photo at multiple sizes: 320 / 800 / 1280 / 2560 px wide. GET_PHOTO_THUMB_FN picks the smallest width that is still ≥ 800 px:
// packages/agent/src/lib/services/base/ScraperService.ts
const PHOTO_THUMB_WIDTH = 800;

// Picks the smallest size with width >= 800px,
// falls back to the largest available if none qualify.
const GET_PHOTO_THUMB_FN = (message: Api.Message): Api.TypePhotoSize | null => {
  // ...collect PhotoSize / PhotoSizeProgressive candidates...
  candidates.sort((a, b) => a.width - b.width);
  const fit = candidates.find(({ width }) => width >= PHOTO_THUMB_WIDTH);
  return (fit ?? candidates[candidates.length - 1]).size;
};
Why 800 px? At 320 px the card text in exchange screenshots becomes illegible. The 1280 and 2560 px tiers are retina sizes — the model doesn’t benefit from extra pixels and the base64 payload is proportionally heavier. 800 px is the sweet spot: text in chart screenshots is readable, and the MCP response stays lean. If the chosen thumbnail download fails, DOWNLOAD_MEDIA_FN falls back to downloading the full-size image with a console warning.

How Messages Become MCP Blocks

FETCH_TELEGRAM_HISTORY_FN in StatusControllerService converts each ScraperMessage into one or two MCP blocks:
1

Header block

A single text block announces the channel ID and message count:
Telegram feed -1002833393903 (last 15 messages, newest first):
2

Text post

One text block with the ISO timestamp and post content:
[2026-08-01T06:57:32.000Z]
Good morning everyone ☀️ Saturday, time to rest…
3

Photo post

A text block marks the presence of an image ((photo post, image attached below) for caption-free images), followed immediately by an MCP image block carrying the base64 JPEG:
[2026-08-02T09:34:19.000Z]
Working BTC — short, sized for a possible add
<MCP image block: exchange position screenshot — BTCUSDT Short, Cross 100x>
The explicit text marker ensures the model never has to guess whether a block contains an image.

What the Model Actually Receives

A real excerpt from a get_status dump shows exactly what lands in the conversation:
Telegram feed -1002833393903 (last 15 messages, newest first):

[2026-08-02T09:34:19.000Z]
Working BTC — short, sized for a possible add
<MCP image block: exchange position screenshot — BTCUSDT Short, Cross 100x>

[2026-08-01T06:57:32.000Z]
Good morning everyone ☀️ Saturday, time to rest…
Posts arrive newest-first so the most actionable signal is always at the top of the feed section.

Concurrency and Timeout Handling

Two wrappers protect the Telegram client from being overwhelmed: queued() — serialized execution. GET_STATUS_FN is wrapped with queued() from functools-kit. If multiple get_status calls arrive simultaneously (e.g., from a looping agent and an interactive query), they line up and execute one at a time rather than stampeding the client. timeout() — 90-second ceiling. FETCH_TELEGRAM_HISTORY_FN is wrapped with timeout(…, 90_000). If the Telegram fetch does not complete within 90 seconds, the function returns TIMEOUT_SYMBOL instead of messages.
// packages/agent/src/lib/services/controller/StatusControllerService.ts
const FEED_FETCH_TIMEOUT = 90_000;

const FETCH_TELEGRAM_HISTORY_FN = timeout(
  async (self, when) => {
    // ...scrape and convert to MCP blocks...
  },
  FEED_FETCH_TIMEOUT,
);

// on timeout: disconnect and clear the singleshot cache
// so the next call reconnects fresh
const RESTART_TELEGRAM_FN = async () => {
  const telegram = await getTelegram();
  await telegram.disconnect();
  getTelegram.clear(); // singleshot.clear() — forces reconnect on next getTelegram()
};
When TIMEOUT_SYMBOL is detected in the message list, RESTART_TELEGRAM_FN runs: it disconnects the existing client and clears the singleshot cache so the next get_status call reconnects from scratch. The current call throws a clean error that the agent can read and handle, rather than hanging the MCP bridge.

Configuration

VariableDefaultPurpose
CC_TELEGRAM_CHANNEL-1002833393903The channel ID (or username) to scrape. Prefix numeric IDs with -100.
FEED_MESSAGES_LIMIT15 (hardcoded)Number of posts fetched per get_status call.
CC_TELEGRAM_API_ID / CC_TELEGRAM_API_HASHdev fallback in params.tsTelegram API credentials from my.telegram.org. Replace with your own before deploying.
session.txt grants full read access to the Telegram account that scanned the QR code. Keep it out of version control and off shared machines — it is listed in .gitignore by default.

Build docs developers (and LLMs) love