There is no separate bot account type. Any wallet can declare itself a bot by publishing a small descriptor — a handle and a list of slash commands — on its own profile. No registration, no approval, no fee. Clients render a neutral “Bot” badge next to the name and show the wallet's truncated address alongside it, because the declaration is self-made: it tells you what the wallet claims to be, not that anyone verified it.
/name, with the bot's wallet mentioned in it — there is no separate command message type. That is deliberate: it keeps commands indistinguishable from chat on the wire, so no relay can selectively drop them.The fastest path to having a working bot is to run ogmara-bot, the reference implementation: it composes AI-written posts from RSS feeds, topic prompts, or a local image folder, and answers slash commands in the channels you point it at. Configuration lives in one file, and a built-in settings panel lets you change almost everything — sources, AI provider, rate limits, the commands it answers — without SSHing into the server to restart it.
Get it from the stable, official mirror — the account we actually develop against moves fast and can break; this one only updates when a release has been run and checked:
git clone https://github.com/Ogmara/ogmara-bot.git
cd ogmara-bot
npm install
cp .env.example .env # set your bot wallet's private key
cp config.example.yaml config.yaml
npm run dev -- --init # generates a wallet if you didn't set one
npm run dev
Full setup, every config option, and the wallet-safety rules that matter (never share the key in .env, back it up before you need it) are in the repository's own README — that stays the single source of truth, so this page and the code can't quietly drift apart.
ogmara-bot is a small core plus optional modules — news posting and command-answering are both modules, not special cases. A module you write gets config validation, a settings-panel section, and (if you implement it) live reconfiguration, all for free, just by declaring a Zod schema and implementing one contract.
Two shapes to copy from, depending on what your module does: a scheduled module runs on its own timer (like news posting); a reactive module holds a subscription and responds to other people's traffic (like answering commands) — and needs its own rate limiting, since input you didn't schedule is input someone else controls the volume of.
dryRun, never validate anything network-dependent in the schema) are in docs/WRITING-A-MODULE.md.If you don't want ogmara-bot's architecture at all — a different language, a different hosting model, a bot that's one small piece of a larger app — the whole mechanism is just a few SDK calls away.
Publish your handle and command list once at startup, and again every time you restart — there's no need to track what you last published, since the node ignores a republish that changed nothing:
import { OgmaraClient, WalletSigner } from '@ogmara/sdk';
const client = new OgmaraClient({ nodeUrl: 'https://your-node.example.com' });
client.withSigner(new WalletSigner(privateKeyBytes));
await client.setBotCommands({
handle: 'mybot',
commands: [
{ name: 'about', description: 'What this bot does' },
{ name: 'price', description: 'Latest price', argsHint: '<symbol>' },
],
});
parseCommand is the other half: hand it every incoming channel message, and it tells you whether it was addressed to you.
import { parseCommand } from '@ogmara/sdk';
const parsed = parseCommand(envelope, myAddress, 'mybot');
if (parsed?.name === 'price') {
await client.sendMessage({ channelId, text: lookupPrice(parsed.args[0]) });
}
Everything above is enough for public channels. A private channel is force-encrypted, and a bot can't read or reply in one without three more pieces: a device encryption identity, a way to receive the channel's current key, and somewhere to keep that key between restarts.
buildDeviceEncBinding + client.publishEncKeyEnvelope() — this is what lets another member's client wrap a channel key to your bot later. Do this before joining anything.client.getKeyEnvelope() + unwrapConvKey. There is no other delivery path: if nobody serves it, the bot stays unable to read that channel.sealKeyVault / openKeyVault, keyed by a signature-derived backup key via client.syncKeyVault() / client.getKeyVault()) and restore from it on every start — otherwise a redeploy makes every private channel wait for a member to re-serve its key from scratch.key_epoch_floor before every encrypted reply, not just once: a member removal raises it, and encrypting a reply under a stale epoch a just-removed member still holds defeats the point of the rotation.Attach a row of interactive buttons to any reply by passing a buttons array on send — each button carries a label (what the user sees) and a literal command string, sent verbatim as the message's content the moment it's tapped:
await client.sendMessage(channelId, 'BTC - $65,000 (1h: +2.1%)', {
buttons: [{ buttons: [
{ label: '15m', command: '/c BTC 15m' },
{ label: '1h', command: '/c BTC 1h' },
{ label: '1d', command: '/c BTC 1d' },
] }],
});
A tap is just another invocation — the client sends it as an ordinary signed ChatMessage, with via_button: true and reply_to pointing at the message the row was shown on. There is no separate callback to wire up: the same parseCommand() handler from step 2 above sees it and dispatches it exactly like a typed command, because it is one.
label and its command are independent strings, and pressing signs command under the tapper's own wallet with no confirmation dialog — a button reading “Show chart” could just as easily carry command: "/ban someone". Surface the literal command somewhere the user can see before or after a tap (a tooltip or long-press is enough); don't build a bot UI that hides it.In-place menu editing is what makes a button row feel like a real UI instead of a wall of replies: when a press arrives, edit the original message — swap its text and its buttons — instead of posting a new one. editMessage's buttons option follows the same override rule everywhere on Ogmara: omit it to leave the row unchanged, pass [] to clear it, or a new array to replace it wholesale.
// `via_button`/`reply_to` live on the envelope's own decoded payload, next
// to `content`/`mentions` — `parseCommand()` only ever needs the
// latter two, so read the other two yourself, the same way you already
// decode `content` before handing it to `parseCommand()`.
const { content, mentions, via_button, reply_to } = decodePayload(envelope.payload);
const parsed = parseCommand({ content, mentions }, myAddress, 'mybot');
if (parsed?.name === 'c') {
const [symbol, timeframe = '1h'] = parsed.args;
const card = await priceCard(symbol, timeframe); // your own lookup
const buttons = [{ buttons: ['15m', '30m', '1h', '4h', '1d'].map((tf) => (
{ label: tf, command: `/c ${symbol} ${tf}` }
)) }];
// A press carries `reply_to` pointing at the card it came from —
// edit THAT message instead of posting a new one.
if (via_button && reply_to) {
await client.editMessage(channelId, reply_to, card, { buttons });
} else {
await client.sendMessage(channelId, card, { buttons });
}
}
editMessage() is plaintext-only, matching sendMessage's own limit — use buildEncryptedChannelEdit({ channelId, msgId, convKey, epoch, text, buttons }) sent via sendMessageEnvelope instead, and pass forward any encrypted media the original message carried, or its decryption keys are stranded permanently. Whichever path you use, treat a failed edit (an expired 30-minute window, or a reply_to that turns out not to name your own message) as recoverable: fall back to posting a fresh reply rather than leaving the tap unanswered — the node's own authorship check is what actually decides whether an edit is allowed, not anything the client can verify in advance./Price BTC is what a real user will type for /price. Lowercase the command token only — never the arguments, or a ticker symbol turns into nonsense.