bots.md

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. getUpdates only 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)

  1. In the Krypton app (Krypton+): Settings → Bots → New bot, give it a name, tap Create.
  2. 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:latest

No 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:

VariableDefaultMeaning
KRYPTON_POLL_SECONDS15How often the runner checks for new invitations (chats the bot was added to)
KRYPTON_SYNC_SECONDS60How often it refreshes the chats it is in (new chat secrets, chats it was removed from)
KRYPTON_PHOTO_MAX_MB25Largest photo accepted, in MB (the same as Krypton+)
KRYPTON_BOT_HOST · KRYPTON_BOT_PORT0.0.0.0 · 8081 inside the imageWhere 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 the token field 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-urlencoded or multipart/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>}} — parameters only on 429, which also sends a Retry-After header.
  • A wrong token gives 401. Any method not listed here gives 404.

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.

ParameterTypeNotes
offsetintegerUpdates with update_id lower than offset are confirmed and dropped. A negative value returns the last -offset updates
limitinteger1–100, default 100
timeoutintegerLong 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

ParameterRequiredNotes
chat_idyesThe number from getUpdates. A negative number means the same chat
textyesUp to 4,000 characters, counted after formatting is removed
parse_modenoHTML, Markdown or MarkdownV2 — accepted, but formatting is removed (see below)
disable_notificationnotrue 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 (&amp;, &lt; …) are decoded, and <br> becomes a line break. An unknown parse_mode leaves the text as is.

sendPhoto

POST /bot<token>/sendPhoto

ParameterRequiredNotes
chat_idyesAs in sendMessage
photoyesA file (multipart upload) or an http(s):// URL that the runner downloads (20-second limit; redirects are allowed). file_id is not supported
captionnoUp to 4,000 characters. parse_mode applies as in sendMessage
parse_modenoAs in sendMessage
disable_notificationnoAs 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.

Limitdescription (HTTP 429)What is blocked
Bot — 1,000 messages a day per bot, photos includedToo Many Requests: this bot reached 1000 messages todayEvery 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 connectionThat chat only — other chat_ids still work
Whole chat — 2,000 messages a day in one chat, people's messages includedToo Many Requests: this chat reached 2000 messages todayThat 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

StatusdescriptionWhen
400Bad Request: chat_id is emptychat_id missing
400Bad Request: chat not foundchat_id is not a number, or no chat has that number
400Bad Request: message text is emptytext missing or only whitespace
400Bad Request: message is too longtext over 4,000 characters
400Bad Request: message caption is too longcaption over 4,000 characters
400Bad Request: there is no photo in the requestphoto missing
400Bad 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
400Bad Request: failed to get HTTP URL contentThe photo URL couldn't be downloaded
400Bad Request: this photo URL is not allowedThe photo URL points to a link-local or cloud metadata address (169.254.0.0/16, fe80::/10, fd00:ec2::254)
400Bad Request: photo is too bigOver the photo size limit
400Bad Request: wrong type of the photo (JPEG, PNG, GIF, WebP)Not one of the four formats
400Bad Request: can not parse JSON bodyInvalid JSON
400Bad Request: can not parse multipart bodyInvalid multipart body
400Bad Request: unsupported content typeBody in another content type
401UnauthorizedWrong token
403Forbidden: bot was kicked from the group chatThe bot is no longer in that chat
403Forbidden: the chat is closed for new messagesThe chat's creator closed it; it may open again
404Not FoundUnknown path or method (setWebhook, editMessageText, sendDocument …)
405Method Not AllowedHTTP method other than GET or POST
413Request Entity Too LargeBody over the limit (1 MB, or the photo limit for multipart)
429Too Many Requests: this bot reached 1000 messages todayBot's daily limit (above)
429Too Many Requests: more than 500 messages to this chat today from this connectionSame-connection limit for that chat (above)
429Too Many Requests: this chat reached 2000 messages todayWhole-chat limit (above)
429Too Many Requests: this chat reached its daily limitA chat limit reported by an older server, without the number
429Too Many Requests: retry after 60The server is busy — retry_after is 60
500Internal Server ErrorUnexpected error in the runner
502Bad Gateway: room moved, try againThe chat's secret changed while sending — send again
502Bad 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
MethodsgetMe, getUpdates, sendMessage, sendPhoto only. Everything else (setWebhook, editMessageText, sendDocument …) is 404
Receiving messagesNone. 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
ButtonsNo inline keyboards or reply keyboards. reply_markup and other Telegram-only parameters are ignored, and the message is sent without them
chat_idA 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.titleKrypton <first 8 characters of the chat's tag> — the runner doesn't receive the chat's text
text · captionUp to 4,000 characters. parse_mode (HTML, Markdown, MarkdownV2) is accepted but formatting is removed — links become label (url)
sendPhotoA 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_notificationtrue sends no push notification; the message appears when the chat is opened
LimitsPer bot and per chat, reported separately (section 4)
message_idCounts 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.png

06

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:8081 so 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 as state.bin.old-<time>, starts fresh, and the app adds the bot with its new key back to each chat it was in. Read getUpdates again, since the chat_ids may change.
  • The volume plus the key file open every chat. /data/state.bin holds 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:

  1. Make sure the runner is up: curl -s http://127.0.0.1:8081/healthz → {"ok":true}. If not, give the docker run command from section 2 (the key file must be at ./krypton-bot.key).
  2. 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.
  3. Find the chat_id with getUpdates: for each chat.id, the latest my_chat_member decides — new_chat_member.status member means 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 with offset are gone; then ask the user for the chat_id, or have them remove and add the bot again.
  4. Send with sendMessage (chat_id, text up to 4,000 characters, optional disable_notification) or sendPhoto (chat_id, photo as a multipart file or an http(s) URL, optional caption).
  5. Check ok. On 429, read description to tell the bot limit (all chats blocked until UTC midnight) from a chat limit (only that chat blocked), and don't retry before parameters.retry_after seconds. On 403 bot 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.