Developer guide
Krypton bots
for developers
Krypton bots are send-only bots that post messages and photos into Krypton group chats. You run the bot runner (a Docker image) on your own server, and it exposes a local HTTP API shaped like the Telegram Bot API: getMe, getUpdates, sendMessage, sendPhoto. Code that already posts to Telegram moves over by changing the base URL and the chat_id.
Machine-readable version:/bots.md
01
What a Krypton bot is
- Send-only. A bot posts text and photos. It never receives the chat's messages.
getUpdatesonly reports the chats the bot was added to or removed from. - Only in its owner's group chats. The owner adds the bot to a group chat they are in. Bots can't join one-to-one chats, and when the owner leaves a chat, the bot leaves too.
- Labeled. A "Bot" label appears next to the bot's name everywhere in the app. People can't call a bot or send it a fam request.
- Who can make one. Krypton+ members, up to 3 bots each. Each bot can post up to 1,000 messages a day (photos included).
- Sealing happens in the runner. The runner on your server holds the bot's keys and seals every message with the same Rust security core as the app. Krypton's server can't read the messages and doesn't know who owns the bot.
Two honest limits:
- A bot's posts carry the bot's own session so the daily limit can be counted. Krypton's server can therefore see which chat tag (a random number) a bot posts to — not who owns the bot or what it says.
- Whoever runs the bot holds the secret of every chat it is in, so the owner is in a position to read those chats. The runner doesn't fetch messages, but a modified runner could. That is why a bot joins only chats its owner is in, and wears a label.
02
Setup in 4 steps
Step 1 — Make the bot and export its key (app)
- In the Krypton app (Krypton+): Settings → Bots → New bot, give it a name, tap Create.
- Tap Export. You get one file,
krypton-bot-<name>.key(send it with AirDrop, Files, Mail — wherever you choose). The phone does not keep it.
The file's token field is the secret for the runner's HTTP API (the Telegram bot token's place). Anyone with this file can post as the bot. If it is lost or leaked, tap Make a new key in the app and switch to the new file — the old one stops working.
Step 2 — Add the bot to a group chat (app)
Open a group chat → Chat menu → Add people → under My bots, pick the bot → Add.
Step 3 — Run the runner (your server)
Rename the exported file to krypton-bot.key and, in the same folder, run:
docker run -d --name krypton-bot --restart unless-stopped \
-p 127.0.0.1:8081:8081 \
-v "$PWD/krypton-bot.key:/key:ro" \
-v krypton-bot-data:/data \
registry.krypton.today/bot-runner:latestNo environment variables are needed: the server address comes from the key file, and the rest is built into the image (linux/amd64 and linux/arm64). The key file is mounted read-only at /key; the bot's state lives in the krypton-bot-data volume at /data.
Or with Docker Compose (docker-compose.yml next to krypton-bot.key, then docker compose up -d):
services:
bot:
image: registry.krypton.today/bot-runner:latest
restart: unless-stopped
volumes:
- ./krypton-bot.key:/key:ro # the exported key file, read-only
- bot-data:/data # sealed state: chat numbers and chat secrets
ports:
- "127.0.0.1:8081:8081" # this machine only
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
volumes:
bot-data:Optional environment variables:
| Variable | Default | Meaning |
|---|---|---|
KRYPTON_POLL_SECONDS | 15 | How often the runner checks for new invitations (chats the bot was added to) |
KRYPTON_SYNC_SECONDS | 60 | How often it refreshes the chats it is in (new chat secrets, chats it was removed from) |
KRYPTON_PHOTO_MAX_MB | 25 | Largest photo accepted, in MB (the same as Krypton+) |
KRYPTON_BOT_HOST · KRYPTON_BOT_PORT | 0.0.0.0 · 8081 inside the image | Where the API listens. Keep the published port on 127.0.0.1 |
KRYPTON_ANON_KEY, KRYPTON_OHTTP_RELAY and KRYPTON_OHTTP_KEY are built into the image — normally leave them alone.
If the bot was deleted in the app, or the key file is out of date (a new key was made), the runner can't sign in and stops with exit code 2.
Step 4 — Find the chat_id
About 15 seconds after you add the bot to a chat, it shows up in getUpdates:
TOKEN=$(jq -r .token krypton-bot.key)
curl -s "http://127.0.0.1:8081/bot$TOKEN/getUpdates"{"ok":true,"result":[{"update_id":1,"my_chat_member":{
"chat":{"id":1,"type":"group","title":"Krypton 3f2a9c1d"},
"from":{"id":123456789012,"is_bot":false,"first_name":"owner"},
"date":1792700000,
"old_chat_member":{"user":{"id":98765432109,"is_bot":true,"first_name":"Signals","username":"Signals_4f1e_bot"},"status":"left"},
"new_chat_member":{"user":{"id":98765432109,"is_bot":true,"first_name":"Signals","username":"Signals_4f1e_bot"},"status":"member"}}}]}chat.id (here 1) is the chat_id you send to. Without jq: python3 -c 'import json;print(json.load(open("krypton-bot.key"))["token"])'.
03
HTTP API reference
Base URL:
http://127.0.0.1:8081/bot<token>/<method><token>is thetokenfield of the key file (jq -r .token krypton-bot.key). It looks like<8 hex>:<random>and is URL-safe as is.- Parameters can go in the query string or in the body as
application/json,application/x-www-form-urlencodedormultipart/form-data— like Telegram. JSON and form bodies are limited to 1 MB. - Success:
{"ok":true,"result":...}. Failure:{"ok":false,"error_code":<status>,"description":"...","parameters":{"retry_after":<seconds>}}—parametersonly on 429, which also sends aRetry-Afterheader. - A wrong token gives
401. Any method not listed here gives404.
getMe
GET|POST /bot<token>/getMe — no parameters.
{"ok":true,"result":{"id":98765432109,"is_bot":true,"first_name":"Signals","username":"Signals_4f1e_bot",
"can_join_groups":false,"can_read_all_group_messages":false,"supports_inline_queries":false}}getUpdates
GET|POST /bot<token>/getUpdates — returns only my_chat_member updates: the bot was added to a chat, or removed from one. Chat messages never appear.
| Parameter | Type | Notes |
|---|---|---|
offset | integer | Updates with update_id lower than offset are confirmed and dropped. A negative value returns the last -offset updates |
limit | integer | 1–100, default 100 |
timeout | integer | Long polling, in seconds — up to 50, default 0 |
Status changes in new_chat_member.status:
left→member: the bot was added (or added back) to the chat.member→kicked: the bot was removed, the owner left, or the chat was deleted.member→left: the runner no longer has the chat.
chat is always {"id":<small positive integer>,"type":"group","title":"Krypton <first 8 characters of the chat's tag>"} — the runner never sees the chat's messages, so it doesn't know a better title.
sendMessage
GET|POST /bot<token>/sendMessage
| Parameter | Required | Notes |
|---|---|---|
chat_id | yes | The number from getUpdates. A negative number means the same chat |
text | yes | Up to 4,000 characters, counted after formatting is removed |
parse_mode | no | HTML, Markdown or MarkdownV2 — accepted, but formatting is removed (see below) |
disable_notification | no | true sends no push notification. The message appears when the chat is opened |
{"ok":true,"result":{"message_id":7,"date":1792700123,
"chat":{"id":1,"type":"group","title":"Krypton 3f2a9c1d"},
"from":{"id":98765432109,"is_bot":true,"first_name":"Signals","username":"Signals_4f1e_bot"},
"text":"AAPL buy signal — 231.40"}}message_id counts from 1 in each chat, in the order this runner sent them.
parse_mode. Bold, italics, code and other marks are stripped to plain text. A link becomes label (url), and the Krypton app draws the URL as an underlined link. HTML entities (&, < …) are decoded, and <br> becomes a line break. An unknown parse_mode leaves the text as is.
sendPhoto
POST /bot<token>/sendPhoto
| Parameter | Required | Notes |
|---|---|---|
chat_id | yes | As in sendMessage |
photo | yes | A file (multipart upload) or an http(s):// URL that the runner downloads (20-second limit; redirects are allowed). file_id is not supported |
caption | no | Up to 4,000 characters. parse_mode applies as in sendMessage |
parse_mode | no | As in sendMessage |
disable_notification | no | As in sendMessage |
JPEG, PNG, GIF or WebP, up to 25 MB (KRYPTON_PHOTO_MAX_MB). The runner doesn't resize photos.
{"ok":true,"result":{"message_id":8,"date":1792700180,
"chat":{"id":1,"type":"group","title":"Krypton 3f2a9c1d"},
"from":{"id":98765432109,"is_bot":true,"first_name":"Signals","username":"Signals_4f1e_bot"},
"photo":[{"file_id":"","file_unique_id":"","width":1200,"height":800,"file_size":84211}],
"caption":"Daily chart"}}/healthz
GET /healthz — no token. Returns {"ok":true} while the API is up. It carries no secrets and no chat count.
04
Errors and limits
Daily limits
The server sets the limits; the runner turns its answer into Telegram-style errors. Days are UTC days, and retry_after is the number of seconds until the next UTC midnight. Once a limit is hit, the runner answers the same 429 for the rest of that day without contacting the server. sendMessage and sendPhoto behave the same.
| Limit | description (HTTP 429) | What is blocked |
|---|---|---|
| Bot — 1,000 messages a day per bot, photos included | Too Many Requests: this bot reached 1000 messages today | Every chat of this bot |
| Same connection — 500 messages a day into one chat from one connection (the runner's IP address, or the address the built-in relay reports) | Too Many Requests: more than 500 messages to this chat today from this connection | That chat only — other chat_ids still work |
| Whole chat — 2,000 messages a day in one chat, people's messages included | Too Many Requests: this chat reached 2000 messages today | That chat only |
The numbers 500 and 2,000 are the server's settings and are carried into the text as is; if the server changes them, the text changes too. All three come as:
{"ok":false,"error_code":429,"description":"Too Many Requests: more than 500 messages to this chat today from this connection","parameters":{"retry_after":41230}}with the same seconds in the Retry-After header. If one chat gets a lot of signals, split them across several chats — one bot can be in many chats.
All errors
| Status | description | When |
|---|---|---|
| 400 | Bad Request: chat_id is empty | chat_id missing |
| 400 | Bad Request: chat not found | chat_id is not a number, or no chat has that number |
| 400 | Bad Request: message text is empty | text missing or only whitespace |
| 400 | Bad Request: message is too long | text over 4,000 characters |
| 400 | Bad Request: message caption is too long | caption over 4,000 characters |
| 400 | Bad Request: there is no photo in the request | photo missing |
| 400 | Bad Request: wrong remote file identifier specified (file_id is not supported — send a file or an http(s) URL) | photo is a string but not an http(s):// URL |
| 400 | Bad Request: failed to get HTTP URL content | The photo URL couldn't be downloaded |
| 400 | Bad Request: this photo URL is not allowed | The photo URL points to a link-local or cloud metadata address (169.254.0.0/16, fe80::/10, fd00:ec2::254) |
| 400 | Bad Request: photo is too big | Over the photo size limit |
| 400 | Bad Request: wrong type of the photo (JPEG, PNG, GIF, WebP) | Not one of the four formats |
| 400 | Bad Request: can not parse JSON body | Invalid JSON |
| 400 | Bad Request: can not parse multipart body | Invalid multipart body |
| 400 | Bad Request: unsupported content type | Body in another content type |
| 401 | Unauthorized | Wrong token |
| 403 | Forbidden: bot was kicked from the group chat | The bot is no longer in that chat |
| 403 | Forbidden: the chat is closed for new messages | The chat's creator closed it; it may open again |
| 404 | Not Found | Unknown path or method (setWebhook, editMessageText, sendDocument …) |
| 405 | Method Not Allowed | HTTP method other than GET or POST |
| 413 | Request Entity Too Large | Body over the limit (1 MB, or the photo limit for multipart) |
| 429 | Too Many Requests: this bot reached 1000 messages today | Bot's daily limit (above) |
| 429 | Too Many Requests: more than 500 messages to this chat today from this connection | Same-connection limit for that chat (above) |
| 429 | Too Many Requests: this chat reached 2000 messages today | Whole-chat limit (above) |
| 429 | Too Many Requests: this chat reached its daily limit | A chat limit reported by an older server, without the number |
| 429 | Too Many Requests: retry after 60 | The server is busy — retry_after is 60 |
| 500 | Internal Server Error | Unexpected error in the runner |
| 502 | Bad Gateway: room moved, try again | The chat's secret changed while sending — send again |
| 502 | Bad Gateway: <reason> | Krypton's server couldn't be reached (Bad Gateway: server unreachable) |
Every 429 carries parameters.retry_after and a Retry-After header.
05
Moving from Telegram
Change two things: the base URL and the chat_id.
https://api.telegram.org/bot<token>/ → http://127.0.0.1:8081/bot<token>/
chat_id -1001234567890 → chat_id 1 (from getUpdates)What is different:
| Krypton bot runner | |
|---|---|
| Methods | getMe, getUpdates, sendMessage, sendPhoto only. Everything else (setWebhook, editMessageText, sendDocument …) is 404 |
| Receiving messages | None. getUpdates has only my_chat_member (member, kicked, left) — never message. No webhooks. offset, limit and timeout (up to 50 s) work as in Telegram |
| Buttons | No inline keyboards or reply keyboards. reply_markup and other Telegram-only parameters are ignored, and the message is sent without them |
chat_id | A small positive integer chosen by the runner (a negative number means the same chat). The chat's secret and server name never leave the runner |
chat.title | Krypton <first 8 characters of the chat's tag> — the runner doesn't receive the chat's text |
text · caption | Up to 4,000 characters. parse_mode (HTML, Markdown, MarkdownV2) is accepted but formatting is removed — links become label (url) |
sendPhoto | A multipart file or an http(s) URL (the runner downloads it within 20 seconds). No file_id. JPEG, PNG, GIF, WebP, up to 25 MB |
disable_notification | true sends no push notification; the message appears when the chat is opened |
| Limits | Per bot and per chat, reported separately (section 4) |
message_id | Counts from 1 in each chat |
If you use a bot library, change only its API address:
python-telegram-bot:base_url="http://127.0.0.1:8081/bot"node-telegram-bot-api:baseApiUrl: "http://127.0.0.1:8081"telegraf:telegram: { apiRoot: "http://127.0.0.1:8081" }
Their receiving loops (run_polling, launch) call other methods such as deleteWebhook at start and get 404. For send-only code, call the library's send functions (bot.send_message, telegram.sendMessage) or the HTTP API directly.
Python (requests) — stock signals
import json, os, requests
TOKEN = json.load(open(os.environ.get("KRYPTON_BOT_KEY", "krypton-bot.key")))["token"]
API = f"http://127.0.0.1:8081/bot{TOKEN}" # was: https://api.telegram.org/bot{TOKEN}
CHAT = 1 # was: -1001234567890
def call(method, **kw):
r = requests.post(f"{API}/{method}", timeout=60, **kw)
j = r.json()
if not j["ok"]:
wait = j.get("parameters", {}).get("retry_after")
raise RuntimeError(f'{j["error_code"]} {j["description"]}' + (f" (retry in {wait}s)" if wait else ""))
return j["result"]
def signal(sym, side, price):
call("sendMessage", json={
"chat_id": CHAT,
"text": f"<b>{sym}</b> {side} signal — {price:,.2f}",
"parse_mode": "HTML", # accepted; the bold is removed
})
def chart(png_path, caption):
with open(png_path, "rb") as f:
call("sendPhoto", data={"chat_id": CHAT, "caption": caption}, files={"photo": f})
if __name__ == "__main__":
signal("AAPL", "BUY", 231.4)Node (fetch, Node 18+) — crypto trade updates
const API = `http://127.0.0.1:8081/bot${process.env.KRYPTON_BOT_TOKEN}`; // was: https://api.telegram.org/bot…
const CHAT = 1;
async function call(method, body) {
const r = await fetch(`${API}/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const j = await r.json();
if (!j.ok) {
const err = new Error(`${j.error_code} ${j.description}`);
err.retryAfter = j.parameters?.retry_after; // seconds, on 429
throw err;
}
return j.result;
}
async function report(fills) {
const text = fills.length
? fills.map((f) => `${f.side.toUpperCase()} ${f.symbol} ${f.qty} @ ${f.price}`).join('\n')
: 'No fills';
return call('sendMessage', { chat_id: CHAT, text, disable_notification: fills.length === 0 });
}
report([{ side: 'buy', symbol: 'BTC', qty: 0.05, price: 62000 }]).then(
(m) => console.log('sent', m.message_id),
(e) => console.error(e.message, e.retryAfter ?? ''),
);Run it with KRYPTON_BOT_TOKEN=$(jq -r .token krypton-bot.key) node report.mjs.
curl
TOKEN=$(jq -r .token krypton-bot.key)
curl -s "http://127.0.0.1:8081/bot$TOKEN/getMe"
curl -s -X POST "http://127.0.0.1:8081/bot$TOKEN/sendMessage" -d chat_id=1 -d text='BTC buy signal — 62,000'
curl -s -X POST "http://127.0.0.1:8081/bot$TOKEN/sendMessage" -H 'content-type: application/json' \
-d '{"chat_id":1,"text":"Quiet update","disable_notification":true}'
curl -s -X POST "http://127.0.0.1:8081/bot$TOKEN/sendPhoto" -F chat_id=1 -F caption='Daily chart' -F [email protected]
curl -s -X POST "http://127.0.0.1:8081/bot$TOKEN/sendPhoto" -d chat_id=1 -d photo=https://example.com/chart.png06
Security of the runner
- Open the API on 127.0.0.1 only. The API's only secret is the token. Publish the port as
-p 127.0.0.1:8081:8081so only programs on the same machine can call it. If another machine must call it, put an authenticating HTTPS proxy in front, for example Caddy:
bot.example.com {
basic_auth { signal $2a$14$...bcrypt... }
reverse_proxy 127.0.0.1:8081
}- Keep the token out of logs. It is part of every URL, so a library's request logging prints it.
- The key file is a password. Anyone with it can post as the bot. Mount it read-only (
:ro). If it leaks, tap Make a new key in the app and replace the file — the old file can no longer do anything. After a new key the runner can't open its old state: it sets the old state file aside asstate.bin.old-<time>, starts fresh, and the app adds the bot with its new key back to each chat it was in. ReadgetUpdatesagain, since thechat_ids may change. - The volume plus the key file open every chat.
/data/state.binholds the chat numbers, the secret of every chat the bot is in, and its photo records, sealed with XChaCha20-Poly1305 under a key derived from the key file. Whoever has both can read those chats. Back them up and delete them together. Deleting the volume makes the bot forget its chats — the owner can add it back. - sendPhoto URLs are fetched from the runner's network. The runner downloads http(s) URLs it can reach, including addresses on your internal network (so it can fetch a chart from your own service). Link-local and cloud metadata addresses are refused. Only pass URLs you trust.
07
For AI assistants
When a user says "send X to my Krypton chat", do this:
- Make sure the runner is up:
curl -s http://127.0.0.1:8081/healthz→{"ok":true}. If not, give thedocker runcommand from section 2 (the key file must be at./krypton-bot.key). - Read the token from the key file:
jq -r .token krypton-bot.key. Never print it, log it or put it in shared code — read it from the file or an environment variable. - Find the
chat_idwithgetUpdates: for eachchat.id, the latestmy_chat_memberdecides —new_chat_member.statusmembermeans the bot is in that chat. If there is none, the user must add the bot to a group chat in the app (Chat menu → Add people → My bots → Add) and wait about 15 seconds. Updates confirmed earlier withoffsetare gone; then ask the user for thechat_id, or have them remove and add the bot again. - Send with
sendMessage(chat_id,textup to 4,000 characters, optionaldisable_notification) orsendPhoto(chat_id,photoas a multipart file or an http(s) URL, optionalcaption). - Check
ok. On 429, readdescriptionto tell the bot limit (all chats blocked until UTC midnight) from a chat limit (only that chat blocked), and don't retry beforeparameters.retry_afterseconds. On 403bot was kicked, the bot must be added to the chat again.
Do not invent features. Krypton bots have no webhooks, no receiving messages, no buttons (no inline keyboards, no callback queries), no editing or deleting messages, no documents, video or other media, no file_id, no one-to-one chats, and no hosted bots — the runner always runs on the user's own machine. The only methods are getMe, getUpdates, sendMessage and sendPhoto.