# Krypton bots — developer guide

> 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`.

Human-readable version: https://krypton.today/bots · Security design: https://krypton.today/security#bots

## 1. 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.

## 2. 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:

```sh
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`):

```yaml
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`:

```sh
TOKEN=$(jq -r .token krypton-bot.key)
curl -s "http://127.0.0.1:8081/bot$TOKEN/getUpdates"
```

```json
{"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"])'`.

## 3. 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.

```json
{"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 |

```json
{"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`

| 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.

```json
{"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.

## 4. 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_id`s 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:

```json
{"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.

## 5. 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

```python
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

```js
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

```sh
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 photo=@chart.png
curl -s -X POST "http://127.0.0.1:8081/bot$TOKEN/sendPhoto" -d chat_id=1 -d photo=https://example.com/chart.png
```

## 6. 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_id`s 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.

## 7. 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`.
