Signet

Bot API · draft v1

Bots the relay cannot read a word of

A Signet bot is an ordinary account without a screen. It talks to people over the same sealed, end-to-end encrypted channel two humans use, so the relay holds ciphertext, not your users’ words. It does NOT hold nothing else: it can still tell who is talking to your bot, and when. What the relay can see is spelled out further down.

Audience: developers running their own host. English only — the field names are English.

Status

What of this actually exists

This page describes a surface that is only partly built. The split is below, and every section repeats it where it matters.

Registration, keys, sessionslive on the relay
Sealed send / receivelive · shared with every client
Inline keyboards in the applive · shipped client
Client SDKnot published
Wire formatv1 draft · may change

The SDK below is a design, not a download. There is no Bot.connect library you can depend on today. What exists in the repository is a headless reference host driven entirely by environment variables: it bootstraps an identity, exchanges the sbk_ key for a session, polls GET /v1/messages/pending every two seconds, decrypts, and replies. The Kotlin on this page is the shape that host is meant to grow into.

The model

A bot is a phone without a screen

Sealed like any other chat

A bot bootstraps its own cryptographic identity (X25519 + Kyber-1024, the same as a person — the post-quantum half applies when both sides support it) and gets a normal account_id. Every message to it is sealed with the Double Ratchet, exactly like a human chat. The relay is blind to content: there is no server-side plaintext.

Your keys never leave your host

The bot's private keys are generated and stored on your machine, never on Signet's infrastructure. Registration hands you a single-use ticket; your host mints the identity and proves it against the commitment you registered. The relay signs nothing it could later decrypt with.

No cloud Bot API

There is no server-side bot runtime and no webhook the relay could read, because the relay has nothing in the clear to forward. Your logic runs where your keys are. That is the whole trade: you get real encryption, and you get to run a process.

The one thing to tell your users. End-to-end encryption to a bot is not privacy from the bot. A bot must read its own messages to work, so its operator reads everything sent to it and learns the sender's account identity. Encryption protects the channel from the relay and third parties — not from you, the operator. Signet shows a disclosure the first time a user opens a chat the client has flagged as a bot — which depends on that flag being set on their device, so treat it as a helpful default rather than a guarantee every user saw it. Say it yourself as well.

Quickstart

From zero to a live bot

  1. Register.

    DM @Botsmith in the app → /newbot → pick a name and @username. You get a one-time provisioning ticket and, after your host runs, an API key: sbk_<botId>_<secret>.

  2. Keep the key safe.

    It is the bot's password. Anyone with it can operate the bot and read its messages. Store it in your own secret manager — never paste it back into any app field.

  3. Run your host.

    Point the SDK at the relay with your key. It bootstraps the bot's keys once, then attaches and serves forever.

// the SDK below is not published — see the note under this block val bot = Bot.connect("sbk_4417029_9f3c…") // key → E2EE session bot.onCommand("start") { msg -> bot.sendMessage(msg.chat, "Hi! I'm a Signet bot. Try /weather <city>.") } bot.onCommand("weather") { msg, args -> bot.sendMessage(msg.chat, forecast(args)) } bot.start() // long-poll; runs until stopped

Signet never generates or holds your bot's keys — the account is created by your host, not the relay. That is why registration hands you a ticket, and your SDK finishes the mint.

How registration actually works today. The shipped app has no @Botsmith account and no /newbot command: bot creation is a screen inside the app where you enter the display name, the @username, and the identity_key_commit your host computed over its own identity key. That call mints the ticket. The sbk_ key is issued separately, by you as the owner, once the bot account exists.

SDK reference

What you write against

The intended call surface. None of it is published yet; treat this as the contract being built toward, not an import you can add.

CallWhat it does
Bot.connect(apiKey): BotTrade the sbk_ key for a session and bring up the crypto engine. On first run it bootstraps the bot's identity keys into your local store; on restart it re-attaches without re-bootstrapping.
bot.onMessage { msg -> … }Every inbound text message. msg.from carries the sender's cert-verified identity and msg.text the plaintext.
bot.onCommand("weather") { msg, args -> … }A /command. args is everything after the command token.
bot.onCallbackQuery { cbq -> … }A tap on an inline-keyboard button. cbq.data is the opaque payload you set on the button; cbq.from is the verified tapper.
bot.sendMessage(chatId, text, replyMarkup? = null)Send a message, optionally with an inline keyboard. Sealed 1:1 to the recipient.
bot.answerCallback(callbackId, text?, alert? = false)Acknowledge a button tap — a quiet toast, or a modal alert.
bot.setMyCommands(commands)Publish the bot's /command menu — clients show it as autocomplete in the composer.
bot.setMyProfile(name?, about?, avatar?)Set the bot's display name, description, and avatar.

Delivery is long-poll by default. Opt into a webhook and the SDK decrypts on your host, then POSTs the already-plaintext Update to your own HTTPS endpoint — the relay never pushes, because it has nothing in the clear to push.

Three of these do not exist yet. There is no acknowledgement path for a button tap, so answerCallback has nothing to send — the shipped client debounces taps locally instead. There is no long-poll and no webhook: the reference host does a plain two-second poll of GET /v1/messages/pending. The two profile calls are real, but they land on one relay endpoint, and what they publish is stored in the clear — see Limits.

The Update object

Decrypted, delivered to your handler

{ "update_id": 10427, "message": { "message_id": "8f2c…", "from": { "id": "4417029", "verified": true }, // cert-verified — unspoofable "chat": { "id": "4417029", "type": "private" }, // or "group" "date": 1720613400, "text": "/weather Berlin", "entities": [ { "type": "bot_command", "offset": 0, "length": 8 } ] }, "callback_query": { // present INSTEAD of message on a button tap "id": "cbq_77f0", "from": { "id": "4417029", "verified": true }, "message_id": "…", "data": "vote:yes" // your opaque payload, echoed verbatim } }

from.verified is the sealed-sender identity, derived on-device from the sender's key — no field in the JSON can forge it. Attribute every side-effecting action to that identity, not to data.

Read that claim precisely. The sender certificate is signed by the relay's own key, so verified stops other users from impersonating each other — it does not defend against the relay operator, who is the signer. And this Update is an SDK-level object, not the wire: what actually travels inside the sealed ciphertext today are the envelopes bot_message, bot_callback and bot_meta, whose fields do not match the JSON above.

Keyboards & callbacks

Buttons under a bubble

Inline keyboards ride inside the encrypted message — the relay sees only ciphertext. A button has exactly one action:

KeyButton does
cbEmits a callback_query with your opaque payload (≤64 bytes). Never parsed or run on the client — only echoed back to you.
uAn https:// url. Opened only through a confirm dialog that shows the full URL and warns it is operator-controlled.
cmdSends a /command on tap.

bot.sendMessage(chatId, "Confirm your subscription?", keyboard( row(button("Yes", cb="sub:yes"), button("No", cb="sub:no")), row(button("Open docs", url="https://signets.social/docs")), ))

Limits enforced on the client: ≤8 rows, ≤8 buttons per row, labels ≤64 chars. Unknown button types render as an inert chip, so older clients degrade cleanly.

Those limits are real and are enforced by the shipped app. The keyboard(), row() and button() helpers belong to the unpublished SDK; when a button carries more than one action key the client resolves it in the fixed order cbucmd. What goes out on the wire, inside the ciphertext, is:

{"v":1,"ik":[[{"l":"Yes","cb":"sub:yes"},{"l":"No","cb":"sub:no"}]]}

HTTP endpoints

The relay surface

The SDK wraps these — you rarely call them directly. Registration and sessions are live; message send/receive reuses the standard sealed client protocol.

EndpointWhoPurposeStatus
POST /v1/botsownerReserve @username + mint a provisioning ticketlive
POST /v1/accounts/bootstrapSDKCreate the bot account with the ticket (keys stay on your host)live
POST /v1/bots/sessionSDKTrade the sbk_ key for an access + refresh sessionlive
POST /v1/bots/{id}/tokenownerRegenerate the API key (old key + sessions revoked)live
POST /v1/bots/{id}/revokeownerKill the key and all live sessions, atomicallypath wrong
POST /v1/messages · GET /pendingSDKSealed E2EE send / receive (shared with every client)path short

Two rows above do not match the deployed router, and are left as written rather than quietly corrected. The kill switch is DELETE /v1/bots/{botAccountID}/token — there is no /revoke route at all, so that call 404s. And the receive path is GET /v1/messages/pending; GET /pending is shorthand, not a route.

Live on the relay, missing from the table

EndpointWhoPurposeStatus
POST /v1/bots/profilebotPublish display name, description and the /command menulive
PUT /v1/bots/profile/avatarbotUpload the bot's one immutable avatar (raw bytes; type sniffed server-side)live
GET /v1/discovery/bot-avatar/{ref}anyoneRead that avatar by its random ref — never by account idlive
GET /b/{username}anyoneHuman-facing web preview of a bot, from its handle alonelive

The bot's verification tierunverified · verified · official — is a signed claim inside its sealed-sender certificate, checked offline by every client, and a human account cannot fake bot-ness.

Who signs that claim matters. The certificate is signed by the relay, so the relay is precisely the party that could mint a false tier; the offline check defends against everyone else. And every bot bootstraps as unverified: there is no application, review or process today that moves a bot to verified or official. The profile card served over the web is always rendered unverified for exactly this reason.

Without varnish

What we promise, and what we don't

The same accounting the rest of this site uses, applied to bot traffic. Amber is unfinished work; green is something the server sees and always will.

DataThe relayHow exactly
Message text to your botdoes not seesealed on device; the relay stores ciphertext
Files, images, voice notesdoes not seeencrypted with the same session key as the text
Inline keyboards, callback payloadsdoes not seethey ride inside the encrypted message body
Your bot's private keysnever holdsgenerated on your host; the relay only ever saw a hash commitment
Phone number, emaildoes not existnever collected at registration — not for people, not for bots
Google servicesnot involveddelivery is the app's own persistent WebSocket, no FCM
Who talks to your botcan determinethe stored sender field is NULL on a sealed send, but the sender certificate travels readable by the relay in every build shipped today, and routing hints exist. The encrypted-certificate work is written and proven; it is not enabled on the deployed relay
The sender, at restnot erased everywherethe routing marker is purged only when a read receipt arrives — a delivery receipt does not clear it, and with read receipts switched off it survives the message’s whole retention. Copies outside the message row have their own lifetimes
Your bot's public profileseesname, description, command menu and avatar are stored in the clear, by design — they are discovery data shown before any chat exists
Connection IPseesunavoidable for any server. Host behind a VPN or Tor if that matters
When an account is onlinepartlypresence can be switched off; last-seen is coarsened to a minute

The five things to design around

  • Content is always end-to-end encrypted. There is no non-encrypted path to a bot. The server-visible cloud-bot model was rejected on purpose.
  • The operator reads what's sent. Stated above, and shown to every user in-app. Design for it: don't ask for secrets you don't need.
  • The relay sees interaction metadata. It can observe that an account talks to your bot, and today it can work out which account. We minimize what is kept, but we do not hide it, and we will not claim the relay is blind to it.
  • You hold the keys. Signet never generates or stores your bot's identity keys. Losing them means burning the bot and re-minting — not "recovering" it.
  • Nobody outside has audited this. No external security review has ever been done on this code. The internal audits live in the repository, findings included.

Do not tell your users the relay cannot see who talks to your bot. That sentence is false on the relay that is running right now. There is a first-contact gate in the code intended to stop unsolicited messages, but it ships in shadow mode — it counts what it would have dropped and drops nothing — and it only ever binds accounts that opted into a contacts-only inbox, which is not the default. Assume a bot can reach anyone whose account id it knows, and say so if you are asked.