For developers
Bots for Aivium Work
A bot writes to people in Aivium Work Personal: events from your CRM, server or service, action buttons and «/» commands. The bot runs on your side, and the conversation with it is end-to-end encrypted — the Aivium server cannot read it.
How it works
A bot is a device
A bot has its own account and one device with an X25519 key. It encrypts and decrypts messages itself, just like a person's computer or phone.
The server only carries envelopes
The aivium.app relay stores and delivers encrypted copies: one per device of every participant. The server never sees message text, buttons or commands.
People write first
A person finds the bot by @nickname and opens the conversation. A bot cannot create rooms or message anyone who has not contacted it. A group admin adds a bot to a group — there the bot gets only «/» commands, mentions of its @nickname and replies to its messages; it is never a group admin.
Your code, your server
Aivium does not run bot code. The bot fetches new messages with getUpdates long polling — no open port required. If a webhook suits you better, set an https:// address in «My bots»: the server POSTs the same encrypted envelopes there, signed. Answer 2xx at once, within 2 seconds, and do the work after answering: a slow answer counts as a failure, and the next delivery is put off.
Getting started
-
1. Create the bot in your account
Account → «My bots»: a name and a nickname ending in «_bot». The bot token is shown there once (reissuing revokes the old one at once), along with the picture, description, commands and agent skill.
-
2. Generate the device key
The bot creates and keeps its own X25519 private key (32 random bytes). Send the public key with setKey. The private key is the bot's main secret: a leak exposes the conversations, and losing or changing it shows «key changed» to everyone the bot talks to.
-
3. Receive messages
getUpdates with a timeout of up to 25 seconds returns new envelopes. Store them and confirm their ids in the ack field of the next call — otherwise the relay delivers them again.
-
4. Reply
Get the room participants and their device keys with getRooms, encrypt a copy for every device and send them in one sendEnvelopes call with a shared client_message_id.
Bot API
Every method is a POST with a JSON body to https://aivium.app/api/v1/work/bot/<method>, header Authorization: Bearer <bot token>. Success is {"ok": true, …}, a refusal is {"ok": false, "error": {"code": "…"}} with an HTTP status; a malformed request is 422. The token works only on these addresses.
| Method | Request body | Response |
|---|---|---|
| getMe | — | bot {user_id, name, nickname, device_id, public_key}, quota {daily_limit, used_today} |
| setKey | public_key (base64url, 32 bytes), rotate? | ok. A different key over an existing one only with rotate: true, otherwise 409 key_already_set |
| getRooms | room_ids? (up to 100) or after? + limit? (1–100) | rooms[] with participants (is_bot, devices with keys), next — cursor of the next page |
| getUpdates | ack? (up to 500 ids), timeout? 0–25 s, limit? 1–100 | updates[] — envelopes {id, room_id, from_user_id, from_device_id, client_message_id, nonce, ciphertext}, has_more, sometimes retry_after |
| sendEnvelopes | room_id, client_message_id (up to 80), items[] {to_user_id, to_device_id, nonce, ciphertext} (up to 64) | accepted, inserted, skipped[] (reason: queue_full; bot_not_addressed — a copy to another bot in a group: bots do not write to bots). A retry with the same client_message_id does not duplicate |
| setProfile | about? (up to 200), commands?[] {command, description}, skill? {name, content} | null | ok, skill_version. Name and picture are changed in the account only |
| leaveRoom | room_id | ok — the bot leaves the conversation |
curl -s -X POST https://aivium.app/api/v1/work/bot/getUpdates \
-H "Authorization: Bearer $AIVIUM_BOT_TOKEN" -H "Content-Type: application/json" \
-d '{"timeout": 25, "ack": [""]}'
Encryption
The scheme is the same in the desktop app, the phone and the bot. Binary values are base64url without «=», strings are UTF-8. A device address is «<user_id>/<device_id>».
shared = X25519(own_secret, peer_public)
key = HKDF-SHA256(ikm = shared, salt = room_id,
info = "aivium-work-msg/1||", length = 32)
aad = "aivium-work/1||||"
ciphertext = XChaCha20-Poly1305(key, nonce /* 24 bytes */, plaintext, aad) // 16-byte tag appended
from, to = "/" // e.g. "77/c7e3d9b4-1a2f-4e8c-b5d6-0f9a3e2c1b48"
- Sender and recipient are bound into both the key and the AAD: a copy cannot be redirected to another device, another room or another message id.
- The nonce is 24 random bytes for every copy.
- Pin a participant's key the first time you see it. If the relay later shows a different key, do not encrypt to it or accept messages under it until the bot owner confirms.
- Check your implementation against the reference vectors (sent on request): the same keys, nonce and text must produce the same ciphertext byte for byte.
Messages, buttons and commands
Inside the envelope is JSON. A bot message is an ordinary text message (markdown) with a button card: an older app shows the text, a newer one shows the text with buttons.
{
"v": 1,
"kind": "text",
"body": "**Задача 360553** — на проверке",
"authorName": "CRM",
"sentAtMs": 1791300000000,
"replyToKey": null,
"mentions": [],
"card": {
"type": "bot_buttons", "v": 1,
"rows": [
[{ "id": "status:12", "text": "Готово" }],
[{ "text": "Открыть в CRM", "url": "https://crm.example/task/360553" }]
]
}
}
// press of a button (from the person to the bot)
{
"v": 1,
"kind": "button",
"buttonId": "status:12",
"targetKey": "77/c7e3d9b4-…|crm-n-48213",
"sentAtMs": 1791300005000
}
// command
{ "v": 1, "kind": "text", "body": "/link 482913", … }
- Text up to 16,000 characters. Buttons: up to 8 rows, up to 4 per row and 12 in total; a label up to 40 characters.
- A button has exactly one of: id (up to 64 characters [A-Za-z0-9_.:-]) — the press is sent to the bot, or url — an https:// link opened on the person's side.
- A press arrives as a button envelope with targetKey «<bot address>|<message client_message_id>». Accept it only if that button was in your message and it was pressed in the room the message was sent to. When answering a press, set replyToKey = targetKey: the app uses it to stop the button's waiting state.
- A command is text starting with «/»: /name arguments; /name@bot_nickname is a command to one bot of a group — treat a command to another bot as plain text. The app shows the commands from setProfile as a menu when the person types «/». An empty conversation with a bot offers a «Start» button that sends /start.
- Service kinds (read, typing, react and any unknown kind) are not messages: confirm the envelope and forget it. Accept edits and deletions only from the message's author.
- Do not act on commands or replies from a person's device you have not confirmed yet (a new device), except a binding such as /link CODE: the server adds devices to an account, and a forged server could add its own.
- The relay may deliver an envelope again. Perform an action once per key «<from>|<client_message_id>».
Limits and errors
- Messages per day (UTC) per bot: 1,000 on the owner's free plan and during the trial, 50,000 on Plus, Ultra and a team seat. Every new client_message_id counts once, not every copy; typing and edits count too.
- sendEnvelopes — up to 600 a minute per bot and 60 a minute per room, other methods — up to 60 a minute, getUpdates — one at a time (a second one gets 409 poll_in_progress — temporary: the previous poll, e.g. from before the bot restarted, is held up to 25 seconds; retry in a few seconds).
- A rate refusal is 429 with a Retry-After header and error.retry_after in seconds; when the quota runs out it is 429 quota_exceeded until midnight UTC.
- When all waiting slots on the server are busy, getUpdates answers at once with retry_after — poll again after that many seconds.
Error codes
TypeScript SDK
The @aivium/work-bot package for Node.js 20+ (running .ts directly — 22.6+) with no third-party dependencies: encryption, every Bot API method, buttons, parsing of incoming messages, key pinning and press checks. Verified against the contract's reference vectors. The package is being prepared for publication; if you need it now, write to us.
import { readFileSync } from 'node:fs'
import { AiviumWorkBot, AiviumWorkClient, MemoryStore } from '@aivium/work-bot'
const client = new AiviumWorkClient('https://aivium.app', process.env.AIVIUM_BOT_TOKEN!)
const me = await client.getMe()
const bot = new AiviumWorkBot(client, new MemoryStore(), {
userId: me.bot.user_id,
deviceId: me.bot.device_id,
// crypto.newSecret() once, into a file readable only by the bot; never print or log it
secret: readFileSync(process.env.AIVIUM_BOT_SECRET_FILE!, 'utf8').trim(),
name: me.bot.name,
nickname: me.bot.nickname,
})
await bot.ensureKey()
await client.setProfile({ commands: [{ command: 'start', description: 'Say hello' }] })
let ack: string[] = []
for (;;) {
const page = await bot.receive(ack, 25) // retry e.isTemporary() errors after e.retryAfter
ack = []
for (const u of page.updates) {
// device_not_allowed: an unconfirmed device — do not act on it (but /link-style binding)
if (u.status === 'ok' && u.event?.type === 'command' && u.event.command === 'start') {
await bot.reply(u, `reply-${u.id}`, `Hello, ${u.fromName}!`, [
[{ id: 'like', text: '👍' }, { text: 'Docs', url: 'https://aivium.app/developers/bots' }],
])
} else if (u.status === 'ok' && u.event?.type === 'button') {
// reply() quotes the message with the button: the app stops its waiting state
await bot.reply(u, `reply-${u.id}`, `Pressed: ${u.event.buttonId}`)
}
ack.push(u.id) // after it is handled (and saved, in a real bot)
}
}
MemoryStore in the example forgets pinned keys on restart. In a real bot implement the BotStore interface on top of your database.
Questions about bots and SDK access — support@aivium.app.