A self-hostable, modular bot for Ogmara.
Today it publishes AI-composed posts to the News Feed. It is being made modular so an operator can pick which features to run — news posting, answering slash commands in channels — from one config file, and so contributors can add more without forking.
Renamed from
ogmara-newsbotin 0.16.0, repository included. Old clone URLs keep working — GitHub redirects a renamed repository — but if you have an existing checkout, point it at the new one:git remote set-url origin git@github.com:Test0rMaik/ogmara-bot.gitThe Docker image tag changed from
ogmara-newsbottoogmara-bot; existingnewsbot-*tags stay valid and are not being deleted.
Point it at news feeds, your own topics, or a folder of images. It composes posts with the AI provider of your choice, adds hashtags, and publishes them to a decentralized feed under a wallet you control.
Status: v0.9.0. Three sources — news feeds, your own topics, and local image folders — composed by the AI provider of your choice and published on a schedule without duplicates. Ships with a web control panel, Docker packaging, and a first-run
--initflow. See Roadmap.
Ogmara news posts propagate across the whole node mesh and cannot be truly unpublished. A misconfigured bot doesn't just embarrass you, it permanently adds noise to a feed other people read.
So dryRun: true is the default. The bot composes and prints posts, and you
turn publishing on deliberately once you've read real output. --dry-run on the
command line can only ever make the bot safer — there is no flag that forces
live posting.
- Node.js 22 or newer
- An Ogmara L2 node to publish through (yours or a public one)
- A dedicated Klever wallet for the bot —
--initcan generate one for you (see Quickstart), or bring your own - An API key for an AI provider (or a local server, e.g. Ollama — see Choosing an AI provider)
git clone https://github.com/Test0rMaik/ogmara-bot
cd ogmara-bot
npm install
npm run dev -- --init--init creates config.yaml from the example, and — since it's running at a
real terminal — offers to generate a wallet key for you (or set OGMARA_WALLET_KEY
in .env yourself if you'd rather supply your own). It never overwrites either
file if you already have them, so it's always safe to run again as a status
check. Then edit config.yaml (node URL, network, enable at least one
source), add an AI provider key to .env, and:
npm run dev -- --dry-runYou should see your bot's wallet address, the node's health, and a rendered post. Nothing is published.
git clone https://github.com/Test0rMaik/ogmara-bot
cd ogmara-bot
npm install
npm run dev -- --init # generates config.yaml + .env on the host
# edit config.yaml, add an AI key to .env, then:
docker compose up --build--init needs to run on the host first (once) — Docker's bind mount expects
config.yaml and .env to already exist before the container starts.
Afterward, same files, same data/ ledger — just containerized.
config.yaml is mounted read-only and data/ is bind-mounted from the host
(not a Docker-managed named volume — deliberately, so the ledger, queue and
wallet-backup-reminder state --init just created on the host are the exact
same files the container sees, not a separate store), so neither disappears
on a rebuild. If you enable the control panel
(panel.enabled: true), see the networking note in docker-compose.yml
first: its bind: 127.0.0.1 default is unreachable through Docker's port
publishing, for a reason worth understanding before you widen it.
Use a wallet dedicated to this bot. The key in .env is the identity
every post is attributed to — anyone holding it can post as your bot. Never use
a wallet that holds funds you care about.
.env is gitignored. Keep it that way, and chmod 600 it (--init does this
for you automatically).
If you let --init generate a wallet, back it up before doing anything
else — copy .env somewhere safe. This key is the only copy of that
identity; if it's lost, it's gone, and if it's ever funded, so is the KLV.
Generation is never silent or automatic on an unattended run (cron, systemd,
a restarting container) specifically to avoid minting a key nobody notices —
it only happens when you explicitly ask (--init) or confirm at a real
terminal prompt. Once generated, the control panel (panel.enabled: true)
shows a reminder banner on every visit until you confirm you've backed it up,
since a one-time terminal message is easy to miss entirely.
You do not need to register the wallet on-chain to post news. Unregistered wallets can publish; the node will make your bot solve a one-time proof-of-work puzzle (a few seconds) before its first post is accepted.
Registering does two things: it raises the posting ceiling 6x (see below) and unlocks editing and deleting your own posts. It costs ~4.4 KLV, once.
Two files, split so secrets never end up in the shareable one:
| File | Contains | Committed |
|---|---|---|
config.yaml |
node URL, cadence, tags, ratings | your choice — safe to share |
.env |
wallet key, AI API keys | never |
Every key is documented inline in config.example.yaml.
Each scheduled run posts at most one item. A source's schedule: cron
controls when it's willing to publish — a burst of twenty articles doesn't
become twenty posts.
That's one of two independent controls, though, not the only one:
posting.maxPostsPerHour is a separate, hard ceiling on top of every
source's schedule. Setting rss.schedule to fire twice an hour does nothing
on its own if maxPostsPerHour is still 1 (the default) — the second
attempt each hour is queued and published once budget frees up, not dropped,
but not published right away either (see
Rate limits and the retry queue). Raise
both if you want a faster cadence. The bot warns at startup if your
schedules can fire more often than maxPostsPerHour allows, and separately
if maxPostsPerHour itself exceeds 80% of your wallet's actual current
daily ceiling (50/day unverified, 300/day registered) — checked against
whichever tier the node confirms at startup, never assumed, so raising your
cadence after registering doesn't need a config workaround.
sources:
rss:
enabled: true
schedule: "0 * * * *" # hourly, on the hour
maxAgeDays: 2 # skip anything older
fetchImages: true # attach an item's own thumbnail, when the feed has one
feeds:
- url: https://feeds.bbci.co.uk/news/world/rss.xml
publisher: BBC NewsWhen a feed item carries its own illustrative image (an <enclosure>,
media:thumbnail/media:content, or an Atom rel="enclosure" link),
fetchImages: true (the default) downloads and attaches it alongside the
AI-written text — decorative only, never shown to the model. It's
best-effort: a dead link, an oversized image, or the node's IPFS backend
being down costs the image, never the post.
No feeds, no fetching — the bot writes about subjects you define:
sources:
topics:
enabled: true
schedule: "0 */6 * * *"
topics:
- The Klever blockchain ecosystem
- Decentralized social networks and censorship resistance
minIntervalHours: 168 # don't repeat a topic within a weekThis is the only source with no external input, which makes it the safest one: nothing an attacker controls reaches the prompt.
Topics rotate rather than cycling in order, so a short list doesn't read as repetitive. The re-post gap is enforced through the dedup ledger, so it survives restarts.
The prompt tells the model to write from durable knowledge rather than inventing specifics — there's no source article behind a topic post, so anything specific it states is unverifiable.
sources:
imagedir:
enabled: true
schedule: "0 12 * * *"
directories:
- /home/you/pictures/bot
maxBytes: 8388608
contentRating: generalA random unposted image is picked, captioned by a vision model, uploaded to IPFS through your node, and attached to the post.
Things worth knowing:
- Images are identified by content, not filename. Renaming a file or copying it into another watched folder won't republish it.
- Directories are not scanned recursively. Deliberate — pointing this at a folder shouldn't be able to sweep up everything beneath it.
- Both prerequisites are checked at startup: the model must accept images, and your node must have media uploads available. The bot refuses to start otherwise, rather than captioning a picture the model never saw.
- In dry run the image is validated but not uploaded. Pinning bytes to
IPFS for a post that's never published would be a real side effect, so the
render says
(validated, not uploaded — dry run). - Set
contentRatingdeliberately. Mislabelling is a reportable offence under the moderation spec.
Running a local model? Set ai.compatibleSupportsVision: true only if it
genuinely accepts images.
ai:
provider: anthropic # or openai | gemini | openai-compatible
model: claude-opus-5Four providers, one interface — switching is a config change. openai-compatible
points at any server speaking the OpenAI API (Ollama, LM Studio, vLLM,
OpenRouter), so the bot can run with no cloud AI dependency at all.
Only your chosen provider's API key is needed. Full setup, cost notes and prompt customisation: docs/AI-PROVIDERS.md.
The prompt lives in prompts/news.md as editable Markdown —
that's where your bot gets its voice.
Safety classifiers on current models cover cybersecurity and life-sciences topics, and real world news brushes against both. When a model declines an item, the bot logs it, marks it seen, and moves on:
Model declined to write about "..." (cyber) — skipping it.
Occasional on a general feed; not a bug.
Two separate protections, because they catch different problems:
- Already posted — items are keyed by feed GUID or canonicalized URL, with
tracking parameters (
utm_*,fbclid, …) stripped, so the same article shared by newsletter and by social counts once. - Same story, different outlet — a wire story syndicated to five publishers is five URLs. Headline similarity catches those so the bot doesn't post the same news five times.
The record lives in data/ledger.json. Keep it alongside your config — deleting
it makes the bot treat everything as new again.
profile:
displayName: My News Bot
bio: Automated world-news posts. Not a human.npm run dev -- --set-profilePublishes to your node as a signed profile update. Works on an unregistered wallet, and is last-write-wins, so re-running is harmless.
The control panel's Settings tab (panel.enabled: true) does the same thing
from the browser: it shows the display name currently set (the field starts
pre-filled, not blank), and lets you upload and set a profile picture
directly — pick a JPEG, PNG, GIF or WebP up to 5 MB, and it's uploaded to
IPFS through your node and set as the avatar in one step. No config.yaml
edit or restart needed for either.
The node caps posts per wallet, and the cap depends on whether that wallet is registered on-chain:
| Unregistered | Registered | |
|---|---|---|
| Per 10 minutes | 5 | 20 |
| Per 24 hours | 50 | 300 |
npm run dev -- --registerCosts ~4.4 KLV, once, non-refundable, so the bot wallet must hold KLV
first. The command shows your balance and what you'd unlock, then asks for
confirmation — it never spends on its own, and it refuses outright when there's
no terminal to confirm at (use --yes for scripted runs).
The bot reports its registration status at every startup, so you always know which tier you're on.
Two more things worth knowing about limits:
- They're enforced at the ingress node only — whichever node you post
through sets your real ceiling. Run your own node and you set it yourself,
via
[api.rate_limits]inogmara.toml. - They aren't discoverable over the API, so the bot mirrors them in config
(
posting.node*). It refuses to start if your cadence would exceed 80% of the unregistered daily ceiling, since every wallet starts in that tier.
A small browser UI, served alongside the bot's normal schedule (not --once):
panel:
enabled: trueTwo tabs. Dashboard (the default view) opens with a reactions / reposts
/ comments history chart — switch metric via its own tabs, and time range
via Monthly / Yearly / Overall — built from periodic snapshots the bot takes
of your account's totals (stats: in the config; off only if you also turn
off the panel). Monthly and Yearly show new activity per period (reactions
gained each day, or each month) rather than a running total, so a quiet day
reads as flat and an active one as a spike; Overall shows the cumulative
growth curve instead, since there's no larger period to bucket it against.
The top-right number is labelled "so far" on Monthly/Yearly because it's
always the current, still-in-progress day or month. Below that: your last 25
posts, each linking out to its live page on ogmara.org,
with their reaction/repost/comment counts; total published vs. currently
queued; hashtag usage; and how long since the last successful post — the
fastest way to notice an unattended bot has gone quiet. Settings holds the
display name, avatar, and wallet-registration controls.
The history chart needs at least two snapshots before it shows anything —
expect an empty chart for the first stats.schedule interval (6 hours by
default) after first enabling the panel, or a chart that falls back to
showing raw totals for its first day or month until there's enough history to
bucket into full periods. A Refresh button at the top of the Dashboard
tab reloads everything in place (posts, stats, and the chart) without a
browser refresh — clicking it also forces a brand-new chart
snapshot right then, rather than waiting for the next scheduled one, so it's
the fastest way to see fresh engagement numbers on demand.
The bot always signs with its own wallet (OGMARA_WALLET_KEY) — the panel
never holds or asks for anyone else's key. panel.adminWallets instead names
the separate wallets allowed to log in and drive the bot's wallet, exactly
like the l2-node dashboard's admin_wallets: you prove ownership by signing a
one-time challenge with your Klever Extension or K5, never by handing over a
key.
panel:
enabled: true
adminWallets:
- klv1youroperatorwallet...From http://127.0.0.1:8787 (the default), no login is required at all —
matching the node dashboard's localhost bypass, and enough for
ssh -L 8787:localhost:8787 tunnelling with zero extra config. Only widen
panel.bind beyond loopback once adminWallets is set, and only put a
reverse proxy in front of it once you've read panel.trustedProxies and
panel.allowedHosts in config.example.yaml — both exist to stop a
misconfigured proxy from silently handing out that same bypass to everyone.
When the node throttles the bot, the already-composed post is parked in
data/queue.json and published on a later run. It is never recomposed, so a
throttled post never costs a second AI call — and an article that scrolls out of
the feed while the bot waits still gets published.
Queued posts are published before anything new is composed. They expire after 24 hours by default, because stale news is worse than no news.
Set bot.enabled and the bot declares itself a bot on the network and
answers slash commands in the channels you list. Clients read the descriptor it
publishes and render those commands in their / autocomplete, the way Telegram
does.
bot:
enabled: true
handle: ogmarabot # for "/cmd@handle" when several bots share a channel
channels: [1, 7] # REQUIRED — answering spends your posting quota
commands:
- name: about
description: What this bot is and who runs it
- name: topic
description: Do I cover this topic?
argsHint: "<name>"A slash command is an ordinary chat message whose content starts with
/name, with the bot's wallet in the message's mentions. There is no separate
command message type — deliberately, so the traffic is indistinguishable from
chat and a hostile relay cannot selectively drop it.
The cost of that design is that the node cannot rate-limit commands, because
it cannot tell them apart from chat. The bot does that job itself, under
bot.rateLimit. The setting worth understanding is maxShareOfNodeBudget
(default 0.5): a reply is a chat message, so it spends the same per-wallet
quota the node meters for your news posts. A registered wallet gets 300 messages
a day — an unbounded command module could spend them all by breakfast and your
bot would post no news for the rest of the day. Half the quota goes to commands,
half stays reserved for posting.
Three things startup refuses rather than limping on:
- a command name with no handler in this build — advertising a command the bot silently ignores reads to a user as a broken bot;
- a private channel — those are force-encrypted and this build replies in plaintext only, so it would answer nothing there;
- an empty
channelslist — there is no "everywhere I've joined" default.
posting.dryRun covers replies too, so you can point a configured bot at a live
channel and watch what it would say. The Settings tab shows the handle,
channels and command list it is advertising.
Requires a node on l2-node 0.127.0 or newer.
npm run dev -- --init # scaffold config.yaml / generate a wallet key, then exit
npm run dev -- --once --dry-run # one run, compose and print, never publish
npm run dev -- --once # one run (for cron / systemd timers)
npm run dev # run on the configured schedule
npm run dev -- --config other.yaml
npm run dev -- --help
npm run dev -- --set-profile # publish display name / bio from config
npm run dev -- --register # register on-chain (SPENDS ~4.4 KLV, asks first)
npm test # unit tests
npm run lint # typecheck
npm run build # compile to dist/| Phase | Scope | Status |
|---|---|---|
| P0 | Config, wallet, node connection, publish pipeline, dry-run | done |
| P1 | RSS source, dedup ledger, scheduler | done |
| P2 | AI providers — Claude, OpenAI, Gemini, OpenAI-compatible | done |
| P3 | Topic source, image-directory source, media upload | done |
| P4 | Rate-limit backoff, retries | done (in the 0.4.0 audit pass) |
| P5 | Web control panel (display name, wallet registration, wallet-signature login) | done |
| P6 | Docker (done), full docs, v1.0.0 | in progress |
| P7 | Module contract; news extracted behind it |
done (0.17.0) |
| P8 | commands module — slash commands in channels |
done (0.18.0) |
This bot posts to a censorship-resistant feed under your name. Defaults are set accordingly, and you should keep them:
- Disclose that it's a bot. A
#bottag is added by default. - Attribute your sources. Summarize and link — don't republish other people's reporting wholesale.
- Read your dry-run output before going live.
MIT — see LICENSE.