Build a Bot

What a bot is on Ogmara

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.

A command is an ordinary channel message whose text starts with /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.

Run ogmara-bot as-is

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.

Extend it with your own module

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.

The full contract, both worked examples, and the rules that aren't negotiable (route posting through the shared budget, respect dryRun, never validate anything network-dependent in the schema) are in docs/WRITING-A-MODULE.md.

Build your own bot from scratch

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.

1. Declare yourself a bot

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>' },
  ],
});

2. Answer commands

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]) });
}

3. Private channels need a key vault, not just decryption

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.

  • Bind a device encryption key once, with buildDeviceEncBinding + client.publishEncKeyEnvelope() — this is what lets another member's client wrap a channel key to your bot later. Do this before joining anything.
  • Your bot only ever consumes keys — it never creates or rotates them. After joining, wait for a member to serve the current epoch key, then fetch and unwrap it with client.getKeyEnvelope() + unwrapConvKey. There is no other delivery path: if nobody serves it, the bot stays unable to read that channel.
  • Back learned keys up to the network key vault (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.
  • Check the channel's 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.
ogmara-bot's own channelKeys.ts is a complete, audited reference implementation of all four pieces — copy from it rather than re-deriving the key-delivery flow yourself.

4. Add buttons to your reply

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.

A button's 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 });
  }
}
Encrypted channels need a different builder for the edit. New Public/ReadPublic/Private channels are encrypted by default (see step 3 above), and 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.
The exact descriptor shape, field caps, and every SDK method version-by-version are in the SDK reference.

Things worth getting right

← All Tutorials API Reference →