Multi-user Telegram API gateway (MTProto) — REST, WebSocket, and an MCP server for AI agents.
A single process keeps a pool of TelegramClient instances, one per account. Any client
application talks to it over plain HTTP and receives a realtime stream over WebSocket — and
an AI agent can drive the very same account through MCP tools, with no custom
integration layer in between.
Русская версия: README.ru.md
- Install
- The
run.shhelper - Environment variables
- Quick start
- Endpoints
- WebSocket
- MCP — connect AI agents
- Errors
- Browser clients
- Proxy
- Security
- Limitations
- Tests
- Changelog
npm install apigramOr clone and run from source:
git clone https://github.com/emaxe/apiGram.git
cd apiGram
npm install
cp .env.example .env # set TELEGRAM_API_ID / TELEGRAM_API_HASH
npm startAPI credentials come from https://my.telegram.org → API development tools.
Installed as a package, the gateway is also available as a binary:
npx apigramRequires Node.js >= 18.
The repository ships an interactive helper that covers every mode:
./run.sh # menu
./run.sh <command> # direct call, e.g. ./run.sh dev| Command | Action |
|---|---|
install |
npm ci (from the lock file) or npm install |
start |
start the server, checking .env, dependencies and port availability |
start:proxy |
start the server via proxy (cached in data/.proxy) |
dev |
start with auto-reload (node --watch src/index.js) |
dev:proxy |
start with auto-reload via proxy |
test |
unit tests |
smoke |
end-to-end check against a live account (scripts/smoke.mjs) |
proxy |
configure, show or reset the cached proxy |
env |
print the configuration with secrets masked, create .env from the example |
health |
GET /v1/health against the address from .env |
doctor |
diagnostics: Node version, dependencies, credentials, data directory, port |
clean |
remove node_modules, updates log, or proxy cache |
The address used by start / health / smoke is read from .env rather than hard-coded.
clean never touches data/accounts.json — that file holds the Telegram sessions.
| Variable | Default | Purpose |
|---|---|---|
TELEGRAM_API_ID / TELEGRAM_API_HASH |
— | required |
HOST / PORT |
127.0.0.1 / 3111 |
listen address |
ADMIN_TOKEN |
empty | required for POST /v1/accounts; empty = the endpoint is open (localhost only) |
DATA_DIR |
./data |
account registry and updates log (mode 0600) |
LOG_UPDATES |
false |
write the update stream to data/updates.jsonl |
UPDATES_MAX_MB |
50 |
log rotation threshold |
LOG_MEDIA_TIMING |
false |
log one timing line per file response: description, first byte, rate |
CORS_ORIGINS |
empty | origins allowed to make browser requests (comma-separated); empty = CORS disabled |
PROXY_URL |
empty | proxy for the MTProto connection: socks5://, socks4://, http://, https://, mtproxy://; empty = direct connection |
PROXY_TIMEOUT |
5 |
proxy connection timeout, seconds |
PROXY_FROM_ENV |
false |
when PROXY_URL is empty, take the proxy from https_proxy → all_proxy → http_proxy (either case) |
BASE=http://127.0.0.1:3111/v1
# 1. Create an account — apiToken is shown exactly once
curl -X POST $BASE/accounts -H 'content-type: application/json' -d '{"name":"my"}'
# -> { "accountId": "acc_…", "apiToken": "tok_…", "status": "no_session" }
ACC=acc_…; TOKEN=tok_…
AUTH="Authorization: Bearer $TOKEN"
# 2. Log in: phone → code → (2FA, if enabled)
curl -X POST $BASE/accounts/$ACC/auth/send-code -H "$AUTH" -H 'content-type: application/json' -d '{"phone":"+79991234567"}'
curl -X POST $BASE/accounts/$ACC/auth/verify-code -H "$AUTH" -H 'content-type: application/json' -d '{"code":"12345"}'
# -> { "next": "done", "me": {…} } or { "next": "password" }
curl -X POST $BASE/accounts/$ACC/auth/password -H "$AUTH" -H 'content-type: application/json' -d '{"password":"…"}'
# 3. Send a message
curl -X POST $BASE/accounts/$ACC/chat/@username/messages -H "$AUTH" -H 'content-type: application/json' -d '{"text":"hello"}'
# 4. Send files (up to 10 at a time, form field is `files`)
curl -X POST $BASE/accounts/$ACC/chat/@username/files -H "$AUTH" -F files=@photo.jpg -F caption=HiEverything except POST /v1/accounts and GET /v1/health requires
Authorization: Bearer <apiToken>.
GET /v1/health liveness probe
# Accounts and authorization
POST /v1/accounts create an account (ADMIN_TOKEN, if set)
GET /v1/accounts your own accounts
DELETE /v1/accounts/:id delete an account
POST /v1/accounts/:id/auth/send-code { phone } → code
POST /v1/accounts/:id/auth/verify-code { code } → { next: "done"|"password" }
POST /v1/accounts/:id/auth/password { password } — 2FA
POST /v1/accounts/:id/auth/logout log out (the session is revoked in Telegram)
GET /v1/accounts/:id/auth/status { status, next?, me? }
# Profile
GET /v1/accounts/:id/me getMe
POST /v1/accounts/:id/me JSON { firstName, lastName, about }
or multipart with an `avatar` field
GET /v1/accounts/:id/status { online, status }
POST /v1/accounts/:id/status { online } — presence
# Dialogs and chats
GET /v1/accounts/:id/dialogs?limit&archived&query
GET /v1/accounts/:id/chat/:peer chat info
GET /v1/accounts/:id/chat/:peer/history?limit&offsetId&reverse
# Messages
POST /v1/accounts/:id/chat/:peer/messages { text, replyTo?, topMsgId?, quoteText?, quoteOffset?, parseMode?, silent?, linkPreview?, schedule? }
POST /v1/accounts/:id/chat/:peer/files multipart: files[], caption, replyTo, topMsgId, forceDocument, parseMode, silent
PATCH /v1/accounts/:id/chat/:peer/messages/:msgId { text, parseMode?, linkPreview? }
DELETE /v1/accounts/:id/chat/:peer/messages?ids=1,2&revoke=true
POST /v1/accounts/:id/chat/:peer/messages/:msgId/react { emoji }
POST /v1/accounts/:id/chat/:peer/messages/:msgId/pin { silent?, oneSide? } — pin message
DELETE /v1/accounts/:id/chat/:peer/messages/:msgId/pin unpin message
DELETE /v1/accounts/:id/chat/:peer/pin?topMsgId= unpin all messages (or in topic)
POST /v1/accounts/:id/chat/:peer/read { maxId }
POST /v1/accounts/:id/chat/:peer/forward { ids, fromPeer }
GET /v1/accounts/:id/chat/:peer/avatar?size=small|big download avatar (ETag)
GET /v1/accounts/:id/chat/:peer/messages/:msgId/file download media (Range)
GET /v1/accounts/:id/chat/:peer/messages/:msgId/thumb?size=s|m thumbnail (ETag)
:peer is @username, username, a numeric ID (-1001234567890) or me.
Always pass it through encodeURIComponent.
GET …/messages/:msgId/file streams and honours Range: a range yields 206
with Content-Range, a request past the end of file yields 416. Parts are
pulled from Telegram several at a time — behind a proxy that is several times
faster than one round trip per part. At most six concurrent downloads per
account; the rest queue. A dropped connection stops the download from Telegram
too.
GET …/messages/:msgId/thumb?size=s|m returns a JPEG crop: s — the smallest,
m — the smallest preview sharp enough for a bubble (long side ≥ 1280 px). The
response carries a strong ETag; a matching If-None-Match returns 304
without downloading from Telegram at all. Media without crops answers
404 no_thumb; an instant blurry preview then lives in the message itself, in
media.stripped.
Every message describes its attachment: media.{kind, mimeType, fileName, size, width, height, duration, waveform, thumbs, stripped, downloadable} — sizes are
known before the download, so a placeholder can be drawn right away. Alongside it
came chatId (always marked), groupedId (albums), fwdFrom, viaBotId and
senderName.
ws://127.0.0.1:3111/v1/ws?accountId=<id>&token=<apiToken>
Connecting spins up the Telegram client for the account if it is not running yet. One account may hold several sockets — all of them receive the stream.
Events (JSON, every one carries accountEvent: true):
type |
Payload |
|---|---|
connected |
accountId — subscription confirmed |
new_message / edited_message |
message — normalized message |
deleted_messages |
peerId, deletedIds |
typing |
chatId, userId, action |
reactions |
chatId, msgId, topMsgId, reactions[] — message reactions |
pinned_messages |
chatId, pinned, messages[] — pinned/unpinned messages |
user_status |
userId, status, online, wasOnline, expires — online status |
read_inbox |
peerId, maxId — we read the peer's messages |
read_outbox |
peerId, maxId — the peer read our messages |
session_closed |
reason — logged out, the socket closes with code 4003 |
error |
error — session unavailable, the socket closes with code 4002 |
Both read boundaries also come with every dialog in GET /dialogs
(readInboxMaxId, readOutboxMaxId). An update fires once: a client that was
not listening at that moment — or was not installed yet — has no other way to
learn what the peer has already read.
Close codes: 4001 — bad token or the account is not authorized,
4002 — session unavailable, 4003 — logged out.
apiGram doubles as an MCP server, so an AI agent (Claude and others) can drive a Telegram account directly — read chats, send messages and files, react, forward — without a bespoke integration layer between the agent and the gateway.
POST/GET/DELETE http://127.0.0.1:3111/v1/accounts/<id>/mcp
Authorization: Bearer <apiToken>
Streamable HTTP transport (spec).
Same bearer token and account scoping as the REST API — one MCP session
always acts as one account, so a client needs exactly the accountId and
apiToken from Quick start above. Nothing else to
provision: no separate MCP credentials, no extra allow-list.
Want an AI coding agent to already know all of the above — including an
interactive setup wizard that locates the instance, creates/authorizes an
account, and wires up the connection for you? Install the
apigram-mcp skill via
skills.sh:
npx skills add emaxe/apiGramThis installs it for ~20 agents at once (Claude Code, Cursor, Codex,
Cline, Goose, and more) into .agents/skills/apigram-mcp in the current
project — add -g to install it globally instead.
Any client that speaks Streamable HTTP and can send a custom header works. For Claude Desktop or Claude Code, add this to the client's MCP config:
{
"mcpServers": {
"apigram": {
"type": "http",
"url": "http://127.0.0.1:3111/v1/accounts/acc_.../mcp",
"headers": { "Authorization": "Bearer tok_..." }
}
}
}Point url at a public host if the gateway isn't local to the agent — the
bearer token is the only thing standing between the agent and that one
account, so keep it as secret as any other apiToken.
The handshake is plain JSON-RPC 2.0 over HTTP — handy for checking a deployment without an MCP client:
MCP=http://127.0.0.1:3111/v1/accounts/$ACC/mcp
AUTH="Authorization: Bearer $TOKEN"
ACCEPT='Accept: application/json, text/event-stream'
JSON='Content-Type: application/json'
# 1. Initialize — the response carries the session id in `mcp-session-id`
SID=$(curl -sD - -o /dev/null -X POST $MCP -H "$AUTH" -H "$ACCEPT" -H "$JSON" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \
| tr -d '\r' | grep -i '^mcp-session-id:' | cut -d' ' -f2)
# 2. Acknowledge the handshake — required by the protocol, no response body
curl -s -o /dev/null -X POST $MCP -H "$AUTH" -H "$ACCEPT" -H "$JSON" -H "mcp-session-id: $SID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# 3. List the available tools
curl -s -X POST $MCP -H "$AUTH" -H "$ACCEPT" -H "$JSON" -H "mcp-session-id: $SID" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# 4. Call one
curl -s -X POST $MCP -H "$AUTH" -H "$ACCEPT" -H "$JSON" -H "mcp-session-id: $SID" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"send_message","arguments":{"peer":"me","text":"hello from MCP"}}}'
# 5. Close the session when done
curl -s -X DELETE $MCP -H "$AUTH" -H "mcp-session-id: $SID"| Tool | Description |
|---|---|
list_dialogs |
List chats: limit, archived, query |
get_chat |
Chat/user card by peer |
get_history |
Message history: peer, limit, offsetId, reverse |
send_message |
Send text: peer, text, replyTo, parseMode, silent, linkPreview |
edit_message |
Edit text: peer, messageId, text |
delete_messages |
Delete: peer, ids[], revoke |
mark_as_read |
Mark read up to maxId (0 = everything) |
react |
Emoji reaction: peer, messageId, emoji |
forward_messages |
Forward: toPeer, ids[], fromPeer |
send_files |
Send up to 10 files as base64: peer, files[], caption |
download_file |
Metadata + a REST download link for a message's attachment — not the bytes |
peer accepts the same values everywhere: @username, a bare username, a
numeric ID, or me.
download_file never returns raw bytes inside the MCP response — it points
back at the existing GET .../chat/:peer/messages/:msgId/file endpoint with
the same bearer token, since Range-based streaming is already implemented
there and re-inlining bytes into a tool result would just bloat the
agent's context.
Tool failures come back as isError: true in the tool result, with the
same { error, message } shape the REST API uses (see Errors)
— one error vocabulary for both surfaces, nothing MCP-specific to learn.
{ error, message, step?, hint?, seconds? }.
| Status | When |
|---|---|
| 400 | invalid payload, or the login steps are out of order (step names the expected one) |
| 401 | missing or wrong apiToken |
| 403 | no access to the chat |
| 404 | chat, message or media not found |
| 409 | the account is not authorized, or the session was revoked |
| 429 | flood_wait, seconds says how long to wait |
| 502 | the proxy refused or dropped the tunnel (proxy_unreachable, proxy_auth_required, proxy_forbidden, proxy_connect_failed, proxy_protocol_error) |
| 504 | proxy_timeout — the proxy did not answer within PROXY_TIMEOUT |
CORS is disabled by default, so no web page can reach the gateway. That default is deliberate: the gateway holds live Telegram sessions.
To allow a web client, list the origins:
CORS_ORIGINS=http://127.0.0.1:8080A list, not *. With an empty ADMIN_TOKEN the POST /v1/accounts endpoint is
open, so a wildcard would let any page the user visits create accounts on their
local gateway. * is supported but warns on startup.
The Origin check also covers WebSocket: CORS rules do not apply to the handshake,
so the origin is verified manually. Clients that send no Origin (curl, mobile
and desktop builds, scripts/smoke.mjs) are unaffected.
MTProto can be routed through a proxy. One variable covers every scheme; authentication is
optional everywhere, and special characters in the password are percent-encoded
(@ → %40, : → %3A):
PROXY_URL=socks5://user:pass@127.0.0.1:1080 # also socks4://
PROXY_URL=http://user:pass@127.0.0.1:3128 # HTTP CONNECT
PROXY_URL=https://127.0.0.1:8443 # the same, TLS to the proxy itself
PROXY_URL=mtproxy://<secret>@1.2.3.4:443 # Telegram's own proxy (or ?secret=…)
PROXY_TIMEOUT=5 # connection timeout, secondssocks4/socks5 and mtproxy use teleproto's own transport. http/https are implemented
here — the library supports neither — as a CONNECT tunnel over node:net / node:tls, with
no extra dependency. A self-signed proxy certificate is accepted only with an explicit
https://host:8443?insecure=1.
The proxy is global: every account shares it, and media downloads from other data centres go through it too. A malformed value aborts startup rather than silently falling back to a direct connection, which would leak the real IP. Passwords and MTProxy secrets never reach the log.
The system https_proxy / all_proxy / http_proxy variables are read only with
PROXY_FROM_ENV=true, in that order and in either case; PROXY_URL always wins. They are
opt-in because such variables are routinely set in a shell for unrelated tasks, and a gateway
holding live Telegram sessions must not follow them silently. The startup line names the
variable the settings came from:
apiGram proxy: socks5://10.0.0.9:1080 (without auth) (from all_proxy, timeout 5 s)
- Sessions are credentials.
data/accounts.jsonholdssessionStringvalues that grant full access to the Telegram accounts. The file is written with mode0600and the wholedata/directory is git-ignored. Never commit it, never move it into a synced folder. ADMIN_TOKENgates account creation. While it is empty,POST /v1/accountsis open to anyone who can reach the port. That is acceptable only on127.0.0.1; the server prints a warning at startup if it listens elsewhere without the token.apiTokenis shown exactly once, in thePOST /v1/accountsresponse. It is not recoverable — a lost token means deleting and recreating the account.LOG_UPDATES=truewrites message text to disk (data/updates.jsonl). It is off by default; keep it that way unless you actually need the audit trail.- No TLS. Put the gateway behind a reverse proxy before exposing it. CORS exists but is disabled by default — see "Browser clients".
- Sessions are stored as a
StringSessionwith no entity cache: after a restart, addressing a chat by numeric ID may returnpeer_not_found— callGET /dialogsfirst to warm the cache. - Registering new phone numbers, calls, secret chats and email login are not supported.
- The proxy is global: all accounts share the same connection, there is no per-account setting.
- The system
HTTPS_PROXY/ALL_PROXY/HTTP_PROXYvariables are ignored unlessPROXY_FROM_ENV=true. They are often set in a shell for unrelated tasks, and live Telegram sessions should not follow them silently;PROXY_URLalways wins. The startup line names the variable the settings came from. - An HTTP proxy must support
CONNECT; proxies that only allow it on port 443 answer withproxy_forbidden. Proxy errors surface as502(504on timeout). mtproxy://has no connection timeout of its own: teleproto ignoresPROXY_TIMEOUTon that path, so a dead MTProxy stalls until the OS timeout (~75 s).- CORS is off by default: a browser client cannot reach the gateway until its origin is
listed in
CORS_ORIGINS— see "Browser clients".
npm testNote: the registry tests currently write to the live data/accounts.json (see
test/unit.test.js).
See CHANGELOG.md.
ISC