The bot of Horizon's Discord server (Minecraft Java Edition rewritten in Rust, https://playhorizon.dev).
Since 2026-10-14 the server is about Horizon only: everything else (music, AI coding, Ludovic's other
projects) is talked about in #general (#off-topic was merged into it on 2026-10-30). Free: it runs on ludothinkpad, no paid bot.
Open source under the MIT licence: improvements are welcome, see CONTRIBUTING.md and Run your own copy.
Fixes and features are welcome: open an issue first for anything big, run your own bot on a test server, and send a
small pull request with a test. New features sit behind a flag that is off by default, and merging to main deploys
Kira live. See CONTRIBUTING.md; security problems go to SECURITY.md.
npm run setup makes sure the server has everything src/config.js lists (it is additive, see Setup is additive). LAYOUT follows the live server, whose names and types are the truth (2026-11-01):
- Categories: PLAYHORIZON.DEV (#ping-roles (key
welcome), #new-members, #horizon-info with the download, #🗝️│join-server (keyofficialserver: Horizon Official, the SMP hosted by Sculk), FAQ, rules, #🚫│no-text (the honeypot, named so scam bots that skip channels called "do-not-post" still post there), #📢│announcements, #ludovic-videos, #sponsors) · HORIZON-GENERAL (#horizon-bugs: reproducible bugs only, #horizon-support: help, #horizon-ideas, #💬│horizon-chat, #showcase for videos/clips/screenshots/builds made in Horizon, #📰│releases, #📰│updates) · HORIZONSMP (#🌐│server-chat, the SMP's chat, posted by the server; pinned inLAYOUTby its ID) · 🛠️ CONTRIBUTORS (#contributors: everyone reads, only 🛠️ Contributor, the Team and mods write; #horizon-team) · GENERAL (#general-chat, #introductions, #hall-of-fame, #memes, #bot-commands) · 🌍 languages (eleven channels everyone sees: default channels; re-measured 2026-11-11, fewer than ~5 people writing → proposed for removal) · 🔊 voice · 🛡️ staff. The old channels (#projects, #resources, #help, #beats, …) were deleted on 2026-10-14 after their messages were exported (scratch/deleted-channels-2026-10-14/, not committed). #work-board and #dev-log are replaced by #updates, and #off-topic by #general (REPLACEDinconfig.js): setup exports every message of each toscratch/replaced-channels-<date>/<name>.jsonand.md, and every attachment to<name>-files/(Discord's links expire; not committed), then deletes the channel; a failed export (an attachment that won't download included) stops setup with the channel still there. - Forums each have post guidelines, a template, required tags (some only mods set), a minimum time
between posts. Kira checks every new post against the template. Each forum with a template also has
ONE locked, pinned 📌 How to post here: read me first post (guide, template, rules). It is Kira's own
post: never checked against the template, no reactions, never a GitHub issue,
/donerefuses it. - Roles (top to bottom): kira · 👑 ludovic · 🌌 Horizon Team · 🛡️ Mod · 🤝 Support · 💚 Sponsor · 🧪 Horizon Tester · 💎 Booster · lvl 50 → lvl 5 ·
🛠️ Contributor (earned with a merged PR and
/verify-github, not picked: see Contributors) · 🧩 Modder · 🎬 Videos · 📣 Big news · 📦 Releases. - Onboarding (2 questions when you join: what brings you to Horizon, which pings; the default
channels,
DEFAULT_CHANNELSinconfig.js, include #new-members) and the Server Guide (Discord's newcomer home; bots get "Bots cannot use this endpoint", so Ludovic applies it by hand in Server Settings → Onboarding: setup writes the wanted body tostate.serverGuide), the server icon (assets/horizon-icon.png, Horizon's mark: the horizon repo'sdocs/media/brand/avatar.svgat 512 px; uploaded again only when it changes; the running bot applies it and Kira's own avatarassets/kira-avatar.pngtoo, on start, when either file changes:src/brand.js), the same roles as buttons in#welcome, Community mode, rules screening (new members read and agree to the rules before they can talk: one TERMS field with each rule as "Title. first sentence", built fromRULESbysrc/screening.jsand sent by setup, which only ever turns it on), Discord AutoMod rules, a permanent invite.
Only the AutoMod rules (or a channel's slowmode) changed? node scripts/apply-moderation.js [--dry-run] (with DISCORD_TOKEN)
applies just those, finding channels and roles by name.
Run npm run setup after adding a channel, category or role to config.js. It creates what is missing and
touches nothing that exists: it never renames, retypes, re-topics, reorders or deletes a channel or category, never
changes bitrates, permission overwrites, roles, the Onboarding questions, the AutoMod rules, rules screening or the
Community settings. (On 2026-11-01 a plain run applied a stale LAYOUT to the live server: ~13 renames, two
announcement channels turned into text channels, bitrates, the Onboarding prompts, an extra overwrite and a second
#server-chat. The live server is the truth; LAYOUT follows it.)
What it does do: finds each channel by the ID in data/state.json, else by the id pinned in LAYOUT, else by its
name (the name, the name without its decoration, or an old name in aka), and makes it, in its category, only if none
is there; it stops if it finds two copies. It posts the messages that are missing and keeps Kira's own messages
(welcome, roles, rules, FAQ, pins, the how-to posts) up to date. The bot and setup each keep to their own keys in
state.json, so running setup while Kira is up is safe; restart Kira afterwards so it knows the new channels.
To make the server follow config.js anyway (a topic, a guide, a permission, an order you changed on purpose):
npm run setup -- --apply-layout # prints the diff and stops: nothing changes
npm run setup -- --apply-layout --yes # applies it
The diff lists every channel and category rename, retype, move, topic, bitrate, slowmode, permission-overwrite and
order change, and every role change; --yes also re-applies the Onboarding, AutoMod rules, rules screening,
Community settings, role order and the retired and replaced channels (RETIRED, REPLACED). Read the diff first.
--yes alone is refused. In config.js, aka lists the names a channel or category went by, id pins a channel that
already exists, and a voice channel's bitrate is its own (not "the most the boosts allow").
| Moderation | Floods / copy-paste / link floods (5 messages with links in 30 s) → messages deleted, timeout of 10 min, then 1 h, then 1 day the same day. All-caps, emoji walls, more than 5 links in a message and executables removed. Accounts under 3 days can't post links. Discord AutoMod (src/automod.js, applied by setup): spam, slurs, mention spam, invites and scams, bad names, piracy (rule 3: TLauncher, SKlauncher, cracked Minecraft, offline mode + bypass, warez… MOD.piracy in config.js), and Telegram links (t.me, telegram.me/.dog/.org, tg://, "t . me/": blocked and a 1-hour timeout; TELEGRAM_PATTERNS). Every rule alerts #mod-log (the piracy alert too, since 2026-10-30: #mod-chat is for the mods talking); mods and the Horizon Team are exempt. In every language, Kira's AI reads the 🌍 languages channels, #horizon-chat, #general and non-English messages anywhere: clear cases removed, borderline ones flagged for a mod (see AI answers). Honeypot (Kacper, 2026-11-01): #🚫│no-text (PLAYHORIZON.DEV; everyone sees and can post in it, its topic and a pinned Kira message say posting there removes you instantly) catches scam bots: whoever posts there is soft-banned, i.e. banned with the last day of their messages deleted, then unbanned at once so a real person can rejoin; one soft-ban per user however many messages, a line in #mod-log (user, account age, the message, "honeypot soft-ban"; a failed ban or unban is logged too, with /unban for the second). Bots, mods, the Horizon Team and Ludovic are exempt (their message there is just deleted). On by default: MOD.honeypot in config.js, or KIRA_HONEYPOT=off; src/honeypot-kit.js, handler in src/features/moderation.js. Raids (15 accounts younger than 7 days in 2 minutes) → raid mode for an hour: verified phone, DMs between members paused. No slowmode (Ludovic, 2026-10-28): none set (slowmode in LAYOUT) and none automatic (MOD.autoSlowmode); mods and the Horizon Team have Bypass slowmode for when they set one with /slowmode. Verification level high (10 minutes on the server before talking); @everyone can't ping everyone, open threads or use outside apps. /warn (3 in 30 days → 1 h timeout), /warnings, /clearwarnings, /timeout, /untimeout, /quarantine, /unquarantine, /kick, /ban (only CSAM, raids, scammers and bots: anything else is a quarantine), /unban (kick and ban: the Horizon Team only; mods time out and quarantine), /case; every action is a numbered case in #mod-actions with appeals: see Mod cases, quarantine and appeals, /purge, /clear-chat, /slowmode, /lock, /unlock, /raidmode |
| Chat clears | Every 6 hours the messages older than 24 h in #horizon-chat and #general are deleted (bulk deletes under 14 days, one by one beyond, paced); pins and messages that started a thread stay, nothing else is touched. One line in #mod-log per run. /clear-chat [older_than:<hours>] (Manage Messages) does it now in the current chat channel. CLEAR in config.js; src/features/chatclear.js |
| Logs | #mod-actions: the numbered cases (timeouts, quarantines, bans, unbans, kicks, appeals' outcomes; the one channel mods watch) and warnings. #join-leave: joins (id, account age, #kira-handoffs: Kira's AI answers, the questions she passes to the team, their cost. #mod-log: deleted and edited messages, nicknames, AutoMod and the filters' removals (each with its Undo), everything else. A split-off channel not there yet: its lines go to #mod-log |
| Reports | /report-member and "Report to mods" (right-click a message → Apps) → #reports (/report is the bug form: below) |
| Mod-mail | DM kira → a thread in #mod-mail; mods answer in the thread (// for notes, //close or /close). Each thread's name starts with its status: 🟢 Open (the member wrote last), 🟡 Waiting (a mod answered), ⚪ Closed; buttons set it by hand. Every day from 09:00 UTC, #mod-chat gets the threads Open for more than 12 h |
| Levels | XP for messages (once a minute) and for voice time with others; lvl 5/10/20/30/50 roles; /rank, /leaderboard, /givexp |
| Voice rooms | Join ➕ create your room → your own channel; /room lock / unlock / private / public / invite / kick / limit / rename / claim / transfer; deleted when empty. 🎙️ ludovic is locked: people wait in ⏳ waiting room and Ludovic or a mod /pulls them in |
| Forums | Each new post is checked against its template; Kira lists what's missing and closes it after an hour if it's still missing. /done closes your post with its tag (solved, taken, found, filled, done) |
| #horizon-support | Help, apart from the bugs (Ludovic, 2026-10-30): can't start, performance, the launcher, sign-in, multiplayer. Everyone opens posts; only the poster, 🤝 Support and the staff write in a post (Kira removes other replies, one short note per member every 10 minutes, logged in #mod-log); 🤝 Support has Manage Threads in this forum only (the ✅ solved tag, closing, /done on any post). A post that follows the template gets Kira's first answer when her AI is on (from her sources only, nothing posted when they don't settle it) and a note for 🤝 Support, pinging the role at most once every 15 minutes (the next ping names the posts that came in between). Never GitHub issues. SUPPORT in config.js; src/features/support.js |
| Community | Welcome in #new-members (grouped when many people arrive), role buttons, 👍/👎 on ideas, 🔥 in #showcase, ⭐ hall of fame (5 stars), /remind |
| The hub's verbs | Kira is the front desk of playhorizon.dev/hub with the same verbs, as slash commands and as @Kira <verb>: report (a bug form → a post in #horizon-bugs and a GitHub issue), roadmap / roadmap <area> (the whole roadmap, or one area in full), claim, status: see The hub |
| GitHub issues | Posts in #horizon-bugs that pass their template, and /report, become issues in horizon-rust/horizon (a post that reads as a request for help, tagged only install / launch or performance, is pointed to #horizon-support instead; the poster's reply bug files it); answers on GitHub come back to the post, pinging the poster. #horizon-ideas posts go to the suggestion queue instead. /issue (staff). Off until its token exists: see below |
| Ludovic's videos | His YouTube channel (@lu4ovic) → #ludovic-videos, a read-only channel: every new video, Short or live stream, once, as <@&🎬 Videos> 🎬 New video: **title** and its link (Discord shows the player). Pings only the opt-in 🎬 Videos role (picked in Onboarding's "Want to be pinged?" or with the buttons in #welcome; allowedMentions: { parse: [], roles: [that role] }, so nothing in a title can ping; the role's key is newvideos, not the retired videos of the old server). The channel's public Atom feed every 10 minutes (src/videos.js, channel id in VIDEOS in config.js); the ids posted are in state.videos. The first run only records the videos already out and sets the date before which nothing is ever posted; a feed error is logged and retried 10 minutes later. Giving the role to the members already on the server is a one-off: scripts/grant-videos-role.js (below) |
| Releases | Releases of horizon-rust/horizon → #releases: the game (vX.Y.Z tags, a summary of New and Fixed) and the launcher (launcher@X.Y.Z; before 0.2 launcher-v…), nothing else (REST API every 3 minutes, newest 200; src/releases.js). One channel since 2026-10-23: every release gets one post, once by release id (state.releases; ids posted as Alpha before that, state.nightlies, are never posted again). Every post is silent (no role, Discord's SUPPRESS_NOTIFICATIONS flag 4096) except one a day: the first game release published after 20:00 UTC pings the 📦 Releases role and lists the game releases since the last ping ("Out since the last ping"; state.releasePings). Launcher releases never ping. No dates in the posts. Drafts never. Hand-written posts: ONE_OFFS in config.js, each posted once |
| X | Releases, the weekly summary and announced shipped items, from the same journal, ≤ 240 characters; off without credentials, X_DRY_RUN=1 prints: see below |
#updates |
One feed generated from the hub's journal: merged work grouped by roadmap item, releases (pointing to #releases), shipped items, the weekly summary; the board's summary on top: see below |
| Work board | The board behind the hub's kanban, /claim, /unclaim, /working, /board, /move, /link-github, /mytasks, /goodfirst, /notify: see below |
| Sponsors | 💚 Sponsor for anyone GitHub reports as a sponsor of Ludovic (one-time included). /sponsor github:<login>: Kira asks GitHub's isSponsoredBy, then gives a code to put in the account's bio or a public gist (kira.txt) and a Verify button; on success the role and a #mod-log line. A daily recheck takes back only the roles Kira gave, when GitHub stops reporting the sponsorship (a DM says so). Private sponsorships read false: the mods use /sponsor-grant user: [github:] [remove:]; roles given by hand are never taken back. 6 tries an hour per member. SPONSORS in config.js; src/features/sponsors.js, rules in src/sponsor-kit.js |
| Team roles | A team's leader gives and takes back their team's role, and only it, with /team add member:<user>, /team remove member:<user> and /team list (Ludovic, 2026-11-03: Discord's Manage Roles would also let them edit every channel's overwrites). Refused: bots, 🔒 Quarantined members, a role Kira can't manage, a role that carries a moderation permission (DANGEROUS in the kit); 30 changes an hour per member. The mods, the Chief of Mods and 👑 ludovic may run it on any team (team:). Ephemeral replies, one line in #mod-log per change. Teams: TEAMS in config.js (now hexforge-horizons, led by azetech); src/features/teamroles.js, rules in src/team-roles-kit.js |
| Team votes | /team-vote propose|remove member: (Horizon Team and Ludovic, in #horizon-team): a 48-hour poll decides who joins or leaves the Team; Kira posts the result and never changes the role: see Horizon Team by vote |
| Applications | /apply role: (🤝 Support, 🛡️ Mod, 🌌 Horizon Team; Ludovic, 2026-10-30) is the Discord side of the applications on the website's staff dashboards (docs/staff-api.md): Kira asks the staff side whether the member may apply (one open application per role, 14 days after a no), then DMs a one-time link to the form (never in a channel; a refusal is said in words). Applications go to a vote on the dashboards, then Ludovic's 12-hour veto window: the staff side DMs him once through Kira's dm hook when it opens (DECISION_TEXTS.vetoWindow). When the decision is final, the dashboards call Kira's decide (src/staff-hooks.js): accept gives the role and DMs where to start, decline DMs a short no with the date they may apply again; each one is a line in #mod-log, and what failed (left the server, closed DMs) goes back to the card. APPLY in config.js; src/features/apply.js, texts in src/apply-kit.js |
| Notifications | /notify dm:on: DMs about your own pull requests and issues (reviews, comments, merged/closed/reopened, a failing required check, merge conflicts, labels like needs-proof), batched: see Notifications |
| Contributors | /verify-github proves a linked GitHub account is the member's; a merged pull request in Horizon then gives 🛠️ Contributor, and ranks (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite) count merged pull requests above it; /rank, /ranks: see Contributors |
| Triage | /gh (label, close, reopen, duplicate, pause, resume) for Ludovic's trusted reviewers, through Kira's GitHub token: see Triage through Kira |
| Agents | Kira's MCP server for contributors' AI agents, /agent connect, /agent tokens, /agent revoke, #agent-log: see below |
| AI answers | A message that mentions Kira or replies to her gets one answer from Claude, grounded in the FAQ, Horizon's docs and release notes; what she can't answer goes to the team. Off unless KIRA_AI is set; /kira-ai off|on|status: see AI answers |
| Game chat | On a Minecraft server with the kira-pumpkin plugin, a chat message starting with kira or @kira gets one answer in chat (<Kira> …): the same sources, budget, off switch and moderation as her Discord answers, at most 256 characters, plain text, links only to playhorizon.dev. POST /game/ask with the server's token: see Game chat |
| Info | /help (a short tour of the server with channel mentions, then what Kira does and its commands; helpText() in src/texts.js), /links, /horizon, /userinfo, /serverinfo, /avatar, /modmail |
Asked by the mods (pointofpressure, bunchy7s, dezer-x) and Ludovic on 2026-11-01, after a day of disputed bans; built to
pointofpressure's spec (horizon-mod-spec.md, posted in #mod-chat). The rules
are src/modcase-kit.js (no Discord), the Discord side src/features/modcases.js;
everything lives in state.modcases.
- One case per action, from the audit log. Every timeout, timeout removal, quarantine, ban, unban and kick reaches Kira
through Discord's audit log (
guildAuditLogEntryCreate, the GuildModeration intent), whether a mod did it by hand, with a Kira command, or Kira did it herself. Each is a numbered case (#123,/case 123) posted in#mod-actionsin one format: the user (mention and id), the action, the length (timeouts, and quarantines or bans with an end), who did it ("Kira (automatic)" for hers), the reason ("none given" when empty) and the evidence. When Kira acts she first says who really acted and why (expect()), so the audit entry, whose executor is Kira, makes one case, never two lines. Her own actions carry their evidence: a spam timeout's case holds the deleted messages and the Undo button; the AI's timeout links to its removal entry in #mod-log. A human's case has Add evidence (a link or text), usable only by the mod who acted. The honeypot's soft-bans keep their own line (a ban undone at once, not a case). - Quarantine instead of most bans (pointofpressure's idea, 2026-11-01). The 🔒 Quarantined role hides every channel but
#appeal(read and press the button only: no voice, no reactions)./quarantine member reason [evidence] [length](mods) keeps the member's roles aside and gives the role (one role change, one audit entry); giving the role by hand in Discord does the same./unquarantine, the end of its length, or a lifted appeal give the roles back. Leaving and rejoining keeps it. Real bans (/ban, the Team) are only for CSAM, raids, scams, bots and DM abuse. The member gets a DM with the reason, the case number and how to appeal. To hide the channels, Kira adds one overwrite for the Quarantined role (deny View, Send, Connect…) to every channel and category that lacks it, at start and on each new channel: the one change to existing channels this feature makes; nothing else of theirs is touched. Kira makes the role,#appeal,#mod-actions,#join-leaveand#kira-handoffsherself when they are missing (they are inLAYOUTtoo, so setup finds them and never makes a second). - Appeals are a form, not a chat (dezer-x's point). Fixed questions (the case number, prefilled; then
QUESTIONS: what happened, why it should be lifted or shortened, anything else) in a Discord pop-up: the Open appeal button in Kira's pinned message in#appeal(quarantined members) and in the DM Kira sends with every timeout and quarantine (a timed-out member can't use/appealeverywhere, the DM button always works), and/appeal(minecraft_name:for a ban of the official Minecraft server). Banned users can't see the server: Kira DMs them a link to playhorizon.dev/hub/appeal before/banbans (30 days, only its SHA-256 kept), and the page has a Discord sign-in (below). One open appeal per member, and one new appeal per member every 7 days; 30 days after the action at most. - Where it goes. Each appeal is a thread in
#mod-mail(a text channel: it has no forum tags, so the name carries the status:⚖️ 🟢 Appeal · case #12 · name, then⚖️ ⚪once decided), with the case (action, evidence) and the answers, pinging 🛡️ Mod (Minecraft cases: the ops inMC_OPS: luzW). Waiting marks it 🟡 (waiting on evidence, a second opinion) and back. An appeal not decided after 24 hours pings again in its thread, then every day, and appeals not decided after 12 hours are on #mod-chat's daily list with the mod-mail threads. - Deciding. Buttons Uphold, Shorten (a new total length from the action), Lift, and on a quarantine Uphold +
ban (the Team only). They are refused to the mod who acted (anyone may decide on Kira's own actions; Minecraft cases:
the ops). Each takes a one-line reason the person reads. Lift ends the timeout, gives the roles back, or unbans; Shorten
sets a new timeout, a quarantine's end or a ban's end (Kira lifts it then); Minecraft decisions say what an op does in
game (Kira can't reach the server, and can't read
banned-players.json: the op adds the ban reason with Add evidence). Kira DMs the outcome (if DMs are closed: in#appealwhile quarantined; banned users read it on the website), logs it in#mod-actionsunder the case number, updates the case, and locks and archives the thread. - The website (horizon-site's
/hub/appeal, through the staff API's proxy):POST /staff/api/appeal/config,appeal/peekandappeal/submit(public: the link is the proof; 20 failures a minute per address),GET appeal/loginandappeal/callback(Discord sign-in, scopeidentify). The sign-in is off untilDISCORD_OAUTH_CLIENT_SECRET(sealed, 1PasswordDiscord bot Kira→ a newoauth_client_secretfield) is set and the redirecthttps://playhorizon.dev/staff/api/appeal/callbackis added in the Developer Portal (Kira → OAuth2 → Redirects): the bot's token can't register it. The Moderation dashboard's Cases tab readsGET mod/cases(cases and appeals, no answers).
To work on Kira you need your own bot and a test server: never Horizon's.
-
Create an application in the Discord Developer Portal, add a bot, turn on the Server Members and Message Content intents, and invite it to an empty test server of yours with the Administrator permission (setup creates channels, roles and AutoMod rules).
-
cp .env.example .envand fill inDISCORD_TOKEN,GUILD_ID(your test server) andKIRA_OWNER_ID(you)..envis ignored by git; never commit a token. -
Install and test, then build the layout on your server and start the bot (Node 20 or later):
npm ci npm test # no network, no tokens node --env-file=.env src/setup.js # creates what src/config.js lists; additive node --env-file=.env src/bot.js
Without Discord at all, KIRA_DISCORD=off KIRA_HTTP_PORT=8787 node src/bot.js starts only the work board and the
MCP server (GET /health). Every other feature (GitHub, AI answers, X, the hub, staff dashboards) stays off until
its variables are set; they are listed in the sections below and in .env.example.
src/config.js holds Horizon's own channel and role IDs; on your server, setup creates them by name and Kira keeps
their IDs in its state file, but the few features pinned to a fixed ID (staff roles, the Minecraft server's ops)
won't find them. Change those locally if you need them; don't commit your server's IDs.
Discord app kira (application ID 1559239271271891004, private bot; Server Members and Message
Content intents), owned by Ludovic's account. Token: op://Agents/Discord bot Kira/credential.
npm ci
~/fleet/op run --env-file .env.op -- npm run setup # creates what config.js lists and the server lacks; changes nothing else
systemctl --user restart kira # the bot (deploy/kira.service)
journalctl --user -u kira -f
Install the unit with ln -s ~/code/kira/deploy/kira.service ~/.config/systemd/user/.
src/features/updates.js reads the hub's journal (HUB_JOURNAL_URL, default
https://playhorizon.dev/data/journal.json, every 10 minutes) and posts each new entry once (state.updates.seen),
silently and with no pings (src/journal.js):
- merged pull requests, grouped by roadmap item (their
roadmap:<id>label): one post per item, its name linked to<hub>#<id>, one line per pull request in plain words; merges with no item under "Other changes"; - each release: one line with its link, pointing to
#releasesfor the notes (Kira still posts those there); - each roadmap item that shipped, with its
post(orplain) text and the release; - the weekly summary (written by the maintainers into the horizon repo's
roadmap/journal/), as an embed.
The first look only records what is already there; until setup has made #updates nothing is fetched or recorded.
Kira also keeps its own pinned copy of the whole roadmap (what /roadmap shows, one or two messages): posted and
pinned once, then edited in place when roadmap.json changes (checked every 10 minutes and at every start; the message
ids and a hash of what they show are state.roadmapPin, a message a moderator deletes is posted again at the next check).
At the top of the channel, one pinned message is the board's summary (the columns' counts, three good first tasks, the hub's link), edited in place after every board refresh.
journal.json (generated in the horizon repo): { "entries": [ { "id", "kind", "date", "title", "plain"?, "dev"?, "url"?, "item"?, "item_title"?, "number"?, "tag"?, "shipped_in"?, "post"?, "announce"? } ] }. kind is
merge, release, weekly or shipped (others are skipped); id never changes (it is what "posted once" keys
on); item is a roadmap id; links outside github.com and playhorizon.dev are not shown.
X only broadcasts: src/xposter.js and src/features/x.js post, from each
journal fetch of #updates, every new release, weekly summary and shipped item flagged announce: true,
once each (state.x.seen; the first look only records), at most 3 a round and 10 a day. Each post is at most 240
characters: the entry's post (or plain) cut at a word, then its link (github.com or playhorizon.dev, else the
hub); @ names are broken so nobody is mentioned. Nothing is read or answered on X.
- Off unless credentials are set:
X_ACCESS_TOKENand/orX_REFRESH_TOKEN(OAuth 2.0 user context of the Horizon account's own X app, scopestweet.write users.read tweet.read offline.access),X_CLIENT_ID, andX_CLIENT_SECRETfor a confidential client. Access tokens last two hours: on a 401 Kira refreshes once and keeps the new pair (X rotates the refresh token) inx-tokens.jsonnext to the state (mode 600), which wins over the variables from then on. Errors never include X's answer to a refresh. X_DRY_RUN=1: every post is printed in the log instead (x dry-run: "…"), with or without credentials.- Preview without Kira:
node scripts/x-preview.mjs [journal.json]prints every text it would post, with its length.
The board is built from GitHub every 10 minutes, after each command, and 30 s after someone posts a link to a
Horizon issue or pull request; people see it as the kanban on the hub (built from the same public GitHub data), and
on Discord through /board, /working, /mytasks and the summary in #updates. Each card is the issue or pull
request (link), who is on it (🔨 = claimed on Discord), its labels, its pull requests and their status (draft, CI,
review), and when.
- Where GitHub puts a card (
autoColumninsrc/board.js): an open issue → To do; claimed, assigned, a branch named for it (40-…,issue-40) or a draft pull request that fixes it → In progress; a pull request ready for review that fixes it → Review; thebackloglabel → Backlog; closed in the last 7 days → Done. A pull request that fixes no issue on the board is a card of its own (draft → In progress, ready → Review, merged → Done). Issues labelledteststay off the board. - By hand:
/move <number> <column>(maintainers, and a member on a card they claimed). A manual move holds until the card's state on GitHub changes; moving a card back where GitHub puts it clears it. - Claims:
/claim <issue>adds thein progresslabel and a "Claimed by … on Discord" comment and records the claim;/unclaim. A claim lapses after 14 days without activity (a comment, a commit or a pull request mentioning the issue), with a DM 3 days before./link-github <username>shows Discord names on pull requests (Ludovic and Cosmic are inWORK.github). - One identity per contributor:
/link-github <username> credit:Truealso puts the pair on the hub's contributors page, which readsGET /contributors.jsonon Kira's HTTP server (the MCP host without/mcp, public, CORS open, cached 5 minutes):{ "contributors": [{ "github", "discord" }] }, the GitHub login and the Discord display name of each member who said yes and is still on the server, nothing else.credit:Falsehides it; a mod linking someone else never shows them (they choose by running it themselves). - Org chart:
GET /staff.json(same host, public, CORS open, rebuilt at most every 5 minutes) is the hub's People tab:{ "updated", "tiers": [{ "key", "role", "post", "people": [{ "name", "username", "avatar", "github", "job" }] }] }, top to bottom 👑 ludovic, 🌌 Horizon Team, 🛡️ Chief of Mods, 🛡️ Mod, 🤝 Support; each person once, in their highest tier (src/org-chart.js).githubis set only when it is confirmed: the member's owncredit:Truelink, orWORK.github/ORG.githubin config.js;jobcomes fromORG.jobs. - CI: a pull request's author hears about CI starting to fail on its commit through
/notify(see Notifications). Nothing for what was already failing at Kira's first look. - Good first tasks: open, unclaimed issues in To do labelled
good first issueorhelp wanted(board.goodFirst);/goodfirstlists them, the summary in#updatesshows three. - For other programs (the coming agent/MCP feature):
src/board.jshas no Discord and no network:build(github, work)→{ snapshot, stale },list(snapshot, { column, person, kind }),find(snapshot, ref),move(work, snapshot, ref, column, by),assign/unassign(claims). Each refresh writes the snapshot todata/board.json(version,repo,generatedAt,columns[].cards[]withid,kind,number,title,url,state,column,auto,moved,people,claim,labels,prs,branches). The claims, moves and the summary's message id live instate.work.
The token needs Issues: Read and write on horizon (labels and comments). Pull requests, checks and branches are read without a permission because the repo is public; if it ever goes private, add Pull requests: Read-only, Commit statuses: Read-only and Checks: Read-only.
Contributors connect their AI coding agent (Claude Code, Codex, Cursor…) to Kira's MCP server at the
endpoint in the Railway variable KIRA_MCP_URL (now https://kira-production-500e.up.railway.app/mcp; later
https://mcp.playhorizon.dev/mcp, once its DNS record exists). That one value feeds /agent connect, the
announcement in #announcements and the pinned guide in #horizon-info (ONE_OFFS agents-live and
agents-info, edited in place at every start): switching domains is setting the variable. Railway keeps the
generated *.up.railway.app domain working after a custom domain is added, so agents set up with the old
URL keep working.
with a personal token from /agent connect. Streamable HTTP, stateless, the official SDK
(src/mcp.js); tokens and limits in src/agents.js; the Discord side and the
actions in src/features/agents.js.
| Tool | Scope | What |
|---|---|---|
whoami |
read | the token's owner, scope, whether they are a maintainer |
board_list |
read | the board's cards, filtered by column, label (area), person, kind |
task_get |
read | one card, its issue or pull request text and latest comments |
ludovic_now |
read | what Ludovic works on: his /status, his open pull requests, the repo's branches, his cards. Public data only |
task_claim, task_unclaim |
write | /claim and /unclaim, in the token owner's name |
card_move, card_note |
write | move or note their own cards: an issue they claimed, or a Kira card they own (not to Done: maintainers only) |
card_add |
write | a card of Kira's own (card#N) for work that is not a GitHub issue or pull request |
ask |
write | a question in #horizon-chat, as "'s agent", 5 an hour |
task_assign, card_close |
maintain | claim for someone else; close Kira's cards; move any card |
ai_handoffs |
maintain | the questions Kira's AI passed to the team (member text marked untrusted); close the ones answered |
Tokens. /agent connect scope: days: label: answers with the token once, ephemerally, with the config
snippets; Kira keeps only its SHA-256 (data/agents.json, mode 600). kira_<id>_<secret>: the 8-character
id names it in /agent tokens and the logs. Scopes read < write < maintain (maintain: only for
maintainers, and checked again at every call). 90 days by default, 365 at most, 5 live tokens per member.
/agent revoke id: works at once (mods can revoke anyone's; /agent tokens member: lists them).
/agent connect is closed until KIRA_AGENT_ISSUANCE=on (a Railway variable). From the host's shell:
node scripts/agent-token.mjs issue|list|revoke (test tokens, maintainers' own agents).
Every agent's work goes on the board. Agents without MCP support use the same tools from a shell:
KIRA_TOKEN=… node scripts/board.mjs list | get #40 | add "Release 2026.10.15" progress | move card#3 done | note card#3 "tagged" | claim 40 | ludovic.
Rate limits per member (all their tokens together): 240 requests (any kind, initialize and ping included), 120 reads and 20 writes a minute, 5 questions an hour; claims and unclaims also spend a GitHub budget of 6 an hour and 20 a day. 30 failed logins a minute per
address, then 429. Audit: every call (who, token id, tool, arguments, outcome, refusals included) goes to
the staff channel #agent-log (grouped every 30 s) and the service log.
| Threat | Answer |
|---|---|
| A token leaks (pasted in a repo, a log) | Only a hash is stored; revocation is immediate; tokens expire (90 days); scope read by default; every action is logged with the token id and owner. Tokens die when their owner leaves or is banned (all revoked at once, logged in #agent-log), and every call checks that the owner is still a member, past rules screening (not pending) and not timed out (5-second cache) |
| Someone guesses or forges tokens | 256-bit secrets, constant-time hash comparison, 30 failures a minute per address then 429. The address is Railway's X-Real-IP (else the last X-Forwarded-For hop, the one Railway's edge adds), never a hop the client wrote |
| A contributor's agent does what the contributor may not | Scopes, then ownership: write tokens only move or note issues their owner claimed and Kira cards they own (a GitHub login linked with /link-github is not proof, so it never counts for writes); Done is for maintainers; maintainer actions check the scope and the Discord role at each call; actions run in the owner's name |
| Prompt injection through issues, comments, notes, names, branches | All text people wrote comes wrapped in <untrusted source=…> (titles, bodies, comments, notes and their authors, Discord names, Kira cards' labels and links, pull request branch names, statuses), with &, <, > and their lookalikes (< > ‹ › ⟨ ⟩ 〈 〉 …) escaped inside, so nothing in it can open or close a tag; every answer says to treat it as data. No tool can do anything an injected agent could abuse beyond its owner's own cards |
| An agent reads what it shouldn't | No tool reads files, runs code, sees tokens (others' or its own), DMs, mod-mail, private channels or the server's members. ludovic_now uses only public GitHub data and the /status Ludovic sets |
| Spam and abuse through agents | Limits per member, not per token; at most 3 tokens issued a day (revoked ones count) and 5 live; at most 10 open Kira cards per member. Claims (agents and /claim alike): 3 held at once per member, 2 minutes between two claims or unclaims by a member, 10 minutes between a claim and an unclaim of one issue; agents' claims also spend the GitHub budget above (maintainers are exempt from the per-member caps only through the maintain scope). An agent without maintain unclaims only its owner's claims. Agents' notes and moves stay on the cards: nothing they do is posted in a public channel except ask. Agent text in Discord is one line (no headings, lists, quotes or fake -# lines), never pings (no mentions parsed, @everyone broken), can't make masked links (brackets escaped), and says it comes from an agent. Card links: https on github.com or playhorizon.dev only, exactly as parsed, with no spaces, < > ( ); labels: a-z 0-9, space, dash. Cards stored before these rules are re-checked at start |
| Names in GitHub comments | Discord names go in a code span: no @mention, link or Markdown |
| Malformed or oversized input, and errors | zod schemas on every argument (numbers, refs, columns, lengths, card links), 64 KB bodies, POST only, 15/30 s timeouts. Agents see refusals; any other error is logged and shown as "Internal error." |
| The host is compromised | Kira runs on Railway, not on Ludovic's machines, as the unprivileged node user (the entrypoint only chowns /data as root); it holds only the Discord bot token and the fine-grained GitHub token (Issues: write on horizon). Nothing on the internet talks to the ThinkPad |
The admin routes (/admin/backup, /admin/restore: Kira's files, for moving hosts) |
Off: KIRA_ADMIN_TOKEN_SHA256 is empty on Railway, so the routes don't exist. Turned on only for a move (below), with a separate random token whose secret is only in 1Password; constant-time check; failures rate-limited like logins; a restore must have a well-formed agents.json. A backup holds token hashes, never tokens |
| Secrets in transit to the host | Sealed: op read … | node scripts/seal.mjs <host>/seal-key encrypts with a key generated on the host's volume; only ciphertext (FOO_SEALED) is in Railway's variables and anyone's terminal |
After setup made the role and Kira is deployed with the ping, give it to the members already on the server
(bots skipped; from then on it is opt-in). Sequential, honours Discord's 429 retry_after, safe to run again
(members who have it are skipped), prints counts only:
~/fleet/op run --env-file .env.op -- node scripts/grant-videos-role.js --dry-run
~/fleet/op run --env-file .env.op -- node scripts/grant-videos-role.js
--role <id> skips the lookup by name. The code is src/grant.js.
GitHub has no Triage role on a personal account's repo, and write access would let a branch push run workflows with
the repo's secrets. So the reviewers Ludovic trusts (TRIAGE.reviewers in config.js: kacpherek, rdbtCVS,
Online5277, the three with most merged PRs; he edits the list) triage horizon-rust/horizon through Kira, with Kira's token
(src/features/triage.js, rules in src/triage-kit.js):
/gh label <#> add|remove <label>(the label must exist),/gh close <#> [reason] [note],/gh reopen <#>,/gh duplicate <#> of <#>(labelsduplicate, closes as not planned), for issues and pull requests.- Never merge. Never a label with "release" in its name (
release-now,release-blocker,skip-release…) nor Kira'sin progress. Never a pull request by ludovic111 (his and his agents'), never a merged one. - Each action is a GitHub comment "🤖 Kira, on behalf of @login: …" (free text goes through the same neutraliser as
forum posts: no pings, no cross-references) and a line in
#mod-log. 60 actions per reviewer in any 24 hours; an action GitHub refuses is not counted. - The member needs a verified link (
/verify-github) to a listed login. Ludovic removes someone by editing the list;/gh pause(Ludovic or a mod) stops everyone until/gh resume. - Kira's token needs permission to write issues and pull requests for closing and labelling a pull request; if GitHub refuses, the reviewer is told nothing changed.
Asked by Isloka (2026-11-01): a contributor hears at once when their own pull requests and issues change, so they can
react fast. src/features/notify.js (Discord, the webhook, the state), rules in
src/notify-kit.js; NOTIFY in config.js.
- Who: members whose GitHub login is verified (
/verify-github; the logins inWORK.githubcount), after they opt in./notify dm:onchecks that, then sends a first DM at once (which proves DMs are open);/notifyalone (ordm:status) says where they stand;/notify dm:offstops it. The members who had turned on the old CI-only/notifystay opted in. A link that changes or loses its verification sends nothing until it is verified again. - What, for pull requests and issues they authored in any public repository of horizon-rust (
NOTIFY.repos:'public', or a list of names), one short line each with its link: a review (approved, changes requested, commented: one line per reviewer per batch, however many inline replies), a comment by someone else (people and agents, "🤖 Agent:" comments included; bots' comments are skipped), merged, closed (with "as not planned" / "as a duplicate"), reopened, a label fromNOTIFY.labels(needs-proof, parity-regression, release-blocker, duplicate, invalid, wontfix, question), a watched check failing on their head (NOTIFY.checks: verify, parity, launcher on horizon; check on horizon-mods; any check elsewhere), and merge conflicts appearing (a pull request going from mergeable to conflicting; nothing for one already in conflict at the first look). Nothing for what they did themselves. - Batching: a member's events wait 3 minutes after the first one, then go as one DM; never two DMs within 5
minutes (
windowMs,gapMs). The same event twice in a batch is one line. Pending batches are in memory: a restart drops them. DMs go withallowedMentions: { parse: [] }; every GitHub text in them goes throughplain()(no Markdown, mentions, masked links or line breaks, links only to the watched repos' pull requests and issues). - Closed DMs: noted (
state.notify.users[id].closedAt) and nothing more is tried;/notifytells them, and/notify dm:ontries again. - Source: GitHub's webhook (GitHub's webhook). A check run from a fork carries no pull request:
Kira finds it by its head (the pull requests seen in deliveries, else one GraphQL search per head). Merge conflicts
are no webhook event: one GraphQL search of the subscribers' open pull requests in the org (
mergeable, 1 point per 10 subscribers; private repositories dropped) after each board refresh and 3 minutes after main moves; GitHub's "unknown" is read again 3 minutes later. GraphQL has its own hour, apart from the REST budget. - Without the webhook (no secret), only the board's CI look (horizon, every 10 minutes) and the conflicts search
feed it, and the #contributors announcement (
ONE_OFFSnotify-dms,when: 'notify') waits. KIRA_NOTIFY_DRY_RUN=1: every DM is printed in the log instead.
One signed endpoint, POST /github/webhook on Kira's HTTP server (src/webhook.js), receives
GitHub's deliveries for horizon-rust's repositories (events push, pull_request, pull_request_review,
issue_comment, issues, check_run, check_suite, release; JSON). Each delivery is checked against
X-Hub-Signature-256 with KIRA_GITHUB_WEBHOOK_SECRET (1Password Kira GitHub webhook secret, sealed in Railway as
KIRA_GITHUB_WEBHOOK_SECRET_SEALED); a bad signature is a 401 and counts in the "auth" rate limit; a redelivery (same
X-GitHub-Delivery) is handled once; deliveries from repositories of another account are dropped. No secret, no route.
Kira answers 202 at once and hands the delivery to every listener:
| Listener | Does at once | Its polling meanwhile |
|---|---|---|
/notify |
the DMs (Notifications) | the board's CI look, only without the webhook |
| Work board | a refresh after Horizon's issues, pull requests, reviews, comments, finished checks or a push to main (at most one a minute, WORK.webhookGapMs); contributor roles for merged pull requests come with it |
every 10 minutes → hourly (WORK.slowRefreshMs) |
| Releases | #releases after a release event (30 s apart at most) |
every 3 minutes → every 30 (RELEASE_SLOW_MS) |
| Forum posts ↔ issues | the answers poll after an issue or comment on an issue a post follows | every 5 minutes → every 30 (GITHUB.pollSlowMs) |
| Kira's AI sources | the docs and README reload at the next question after a push to main, release notes after a release | daily / hourly, as before |
| The website | below |
"Meanwhile" holds while deliveries keep coming (webhook.healthy(): one in the last 30 minutes; Horizon's check runs
alone send several a minute): if they stop, every poll goes back to its own pace by itself. Every GET still stops at
the reserve (KIRA_GITHUB_KEEP), so the webhook can never push Kira under the rate floor.
The website (src/features/site.js, SITE in config.js): playhorizon.dev caches what it
reads from GitHub for 15 minutes or more. A push to Horizon's main, its issues and pull requests (opened, closed,
labelled…), its releases and a push to horizon-mods' main make Kira call the site's POST /api/revalidate with the
matching cache tags (github:horizon:main, github:horizon:issues, github:horizon:releases,
github:horizon-mods:main; horizon-site's components/github-fresh.ts), Authorization: Bearer $KIRA_SITE_REVALIDATE_SECRET (1Password Site revalidate secret, sealed in Railway; the site's REVALIDATE_SECRET).
At most one call every 30 s per tag; horizon-mods' tag once more 3 minutes later (its registry index is published by a
Pages build). Then Kira loads the pages that show the data once (SITE.warm) so the next visitor gets them fresh, and
#updates reads the journal again. KIRA_SITE_DRY_RUN=1 logs the calls instead.
The official server's market (src/features/smp.js, src/smp-kit.js):
the SMP mod writes world/horizon-smp/web/market.json and economy.json once a minute and POSTs each one that changed
to POST /smp/ingest/market.json / economy.json with Authorization: Bearer $KIRA_SMP_INGEST_TOKEN (1Password
Agents Kira SMP ingest token; the same value is the SMP's web.token). Kira checks it (schema 1, at most 8 MB, never
an older page over a newer one), keeps the last copy of each in memory and in its data folder (smp-market.json), and
serves it as GET /smp/market.json and GET /smp/economy.json: public, read-only, X-Kira-Received. When a page
brings a new daily summary (daily_summary: biggest movers and news) it is posted once in #server-chat. Kira holds no
key to the server. Off without KIRA_SMP_INGEST_TOKEN.
The site has no GitHub token of its own (60 requests an hour for its host's address), and revalidating on every change
would spend them in minutes. So it reads GitHub's REST API through Kira: GET /github/proxy/repos/horizon-rust/ {horizon,horizon-mods}/{issues,releases,releases/latest,contributors,commits/main} with the same bearer secret, a few
query keys (state, sort, direction, per_page, page) and X-GitHub-Accept: full for releases' body_html.
Kira answers from its GitHub App client (makePublicGitHub: ETag cache, a 304 costs nothing, its own reserve, never
Ludovic's token). Anything else is a 404; a wrong secret a 401 in the "auth" rate limit. The site's GITHUB_PROXY_URL
is https://mcp.playhorizon.dev/github/proxy; when Kira doesn't answer, the site reads GitHub directly, as before.
The launcher has no token either, and players behind a shared address (school, dorm, carrier NAT) spend GitHub's 60
anonymous calls an hour together. When GitHub answers it with a rate limit (or not at all), it asks Kira instead:
GET /github/public/repos/horizon-rust/horizon/{releases,releases/latest,releases/tags/<tag>,git/matching-refs/tags/<prefix>,commits/<ref>}
(src/launcher-github.js), public, no secret, read-only, only those paths of that public repository, per_page
and page only. Each answer (a 404 too) is kept 60 s, so a crowd of launchers costs one GitHub call per path a minute,
through the same makePublicGitHub client; when GitHub fails, the last answer is served. 60 requests a minute per
address (launcherGithub in LIMITS). The launcher's base is https://mcp.playhorizon.dev/github/public.
Setting the webhook up (a new host or a new secret): one organization webhook, gh api orgs/horizon-rust/hooks
(needs the admin:org_hook scope: gh auth refresh -h github.com -s admin:org_hook), or one per repository with
gh api repos/horizon-rust/<repo>/hooks; config.url <Kira's host>/github/webhook, content_type: json, the
events above, and the secret from 1Password piped in with --input -, never on the command line:
~/fleet/op read "op://Agents/Kira GitHub webhook secret/password" | jq -Rn '{name:"web", active:true,
events:["push","pull_request","pull_request_review","issue_comment","issues","check_run","check_suite","release"],
config:{url:"https://mcp.playhorizon.dev/github/webhook", content_type:"json", insecure_ssl:"0", secret: input}}' \
| gh api -X POST orgs/horizon-rust/hooks --input - --jq .id
🛠️ Contributor is earned: a member whose verified GitHub account has at least one merged pull request in
horizon-rust/horizon. Members don't pick it any more (no button, not an Onboarding answer). The code is
src/features/contributors.js (Discord) and
src/contributor-kit.js (the rules, no Discord).
- Verifying (
/link-githubalone proves nothing): after/link-github <login>,/verify-githubgives a one-time code (kira-and 8 characters, valid 30 minutes) to put in the GitHub profile bio, or in a public gist namedkira.txt;/verify-github check:Truereads the bio (/users/<login>) or the gist and, on a match, marks the link verified (state.work.verified). A mod can verify someone by hand with/verify-github member:; it is logged in#mod-log. Linking another login clears the verification (and takes the role). The logins inWORK.github(config.js, set by Ludovic) count as verified. - Granting: on a successful verification Kira counts the merged pull requests (
/search/issues, its own limit of 30 a minute,repo:… is:pr is:merged author:<login>) and gives the role at 1 or more, with one welcome line in#contributors. It also gives it when the board's refresh sees a verified author's pull request merged, and a daily sweep catches the ones it missed. Kira never takes the role away because of a count: only when the link or the verification goes. - The role clean-up (one-off, 2026-10-27,
cleanup()in the feature): holders who are staff keep it; verified with a merged pull request keep it; a linked login with a merged pull request that isn't verified gets a DM and 7 days to run/verify-github(the daily sweep removes the role after that). A holder who never linked GitHub is compared with every author of a merged pull request in Horizon (the search API, split by merge date because it shows 1000 results at most): the Discord username (display names are too loose), without spaces, dots and underscores, equal to a login or containing it (or the other way round) when both have at least 5 characters, with look-alike digits read as letters (Excurs1ons). A match gets a DM and the same 7 days to/link-githuband/verify-github. Ludovic's trusted reviewers (CONTRIBUTORS.protectedMembers, by Discord id) always count with their login and are never removed automatically. Everyone else (no merged PR) loses the role at once, with no grace (Ludovic, 2026-10-27), and gets one DM saying how to get it back; Kira posts one line without a ping in#announcements. It runs inside Kira (it needs the state), through the admin routes: withKIRA_ADMIN_TOKEN_SHA256set,POST /admin/contributors-cleanupstarts a dry run (nothing changes) and{"dry": false}the real one;GET /admin/contributors-cleanupreturns the last result: counts, and thematches(Discord name and the GitHub login it was matched to) for the staff to read.
Above 🛠️ Contributor (Ludovic, 2026-11-09), Minecraft ores earned the same way, from the merged pull requests of the
verified account in every public repo of the horizon-rust org, summed (Akirabane, 2026-11-09: a PR in
horizon-mixins was missing; first it was horizon and horizon-mods only). It is one search per login,
org:horizon-rust is:public is:pr is:merged author:<login>: is:public is needed, because a bare org: also counts
the private repos the token can see (checked on 2026-11-09: 2198 public + 149 private = 2347 for one author). New
public repos count by themselves. 🛠️ Contributor itself still needs one merged in horizon.
| Rank | Merged PRs | Authors at it or above (2026-11-09) |
|---|---|---|
| 🛠️ Contributor | 1 (in horizon) | 305 |
| ⛏️ Iron | 10 | 98 |
| 🥇 Gold | 40 | 34 |
| 💎 Diamond | 100 | 13 |
| 🟪 Netherite | 200 | 5 |
The thresholds (CONTRIBUTORS.ranks in config.js) come from the merged-PR count of every author in horizon and
horizon-mods on 2026-11-09, ludovic111 (his agents) and bots left out: Netherite for a handful, Diamond for about the
top 13. The wider count can only raise people, never lower them.
- Holding: a member holds their highest rank only, plus 🛠️ Contributor (the channels' permissions use it). Ranks
only go up (
work.rankskeeps the best one reached with the verified login); a rank goes only when the link or the verification goes, like the base role. Ludovic is left out. - When: on the same paths as the base role: verifying, the board's refresh when a verified author's pull request
merges, and the daily sweep (which now reads every verified holder's count). Climbing posts one line in
#contributors, no ping. A member's first record is not announced (the backfill when ranks start; a new
contributor's welcome line says their rank instead). Neither is the first record under a new counting rule
(
CONTRIBUTORS.countScope, kept in eachwork.ranksentry asscope): after the public-repos change Kira runs the sweep once at start, which rewrites every record quietly, so people promoted only by the wider count get their role without a line; later climbs are announced as before. - Commands:
/rank(the XP card) adds the contributor rank, the count and the next threshold, from the stored count (read again in the background when older than an hour)./ranks: the top 10; names only, nobody pinged. Both reply ephemerally everywhere, so only the caller sees them (rdbt, 2026-11-09);/link-githuband/verify-githubalready did. - The roles: hoisted, distinct colours, no permissions, right above lvl 50 (so the member list shows them above
🛠️ Contributor and the levels).
POST /admin/contributor-ranks(admin token) lists what it would create and move;{"dry": false}creates the missing ones (found by name first), puts them in order, keeps their ids inwork.rankRoles(setup ownsstate.roles) and starts the sweep that hands them out.npm run setupalso knows them (ROLES,rank: true) and finds them by name. Nothing about ranks happens until all four exist.
Since 2026-11-09 the 🌌 Horizon Team role is decided by a vote of the Team (dezer-x proposed it in #horizon-team
because the role was getting bloated; Ludovic agreed). src/features/teamvote.js does
the talking, the rules are in src/team-vote-kit.js, the numbers in TEAM_VOTE (config.js).
- Starting one:
/team-vote propose member:or/team-vote remove member:, only by a Horizon Team role holder or Ludovic, only in #horizon-team. A candidate for propose must hold at least the 🥇 Gold contributor rank (see Ranks); Ludovic may skip that. Remove needs a Team member. One open vote per member at a time; bots refused. - The poll: a Discord native poll in #horizon-team, Yes/No, one answer each, 48 hours. Its message pings the Horizon
Team role once (
allowedMentions: { parse: [], roles: [<team role>] }); nothing else is ever pinged. The vote is instate.teamVotes(survives restarts) and a line goes to #mod-log. - The result: once the poll ends and Discord finalises the counts (Kira waits up to 30 minutes for that), Kira
replies to the poll with the counts, no ping. It passes with at least 2/3 Yes among the votes cast, when at least
half of the Team voted. The Team here is every holder of the role plus Ludovic, without the member voted on; votes
from anyone else are left out. The last 50 results are kept in
state.teamVoteLog, and each one is in #mod-log. - Kira never gives or takes the role. A passed result says that a 🛡️ Chief of Mods or Ludovic applies it.
Horizon is run by a council of equals (GOVERNANCE.md).
Everything Kira does for it sits behind KIRA_GOVERNANCE=on, off by default: while it is off, Kira behaves exactly
as before. src/governance-kit.js holds the rules, GOVERNANCE in config.js the data.
- The council registry: the founding six (
GOVERNANCE.founding, Discord id → GitHub login), thenstate.councilonce a passed poll changes it.isCouncil(id)is false for everyone while the flag is off. - Former owner powers, sorted in
POWERS: council (project decisions: the council holds them once the flag is on), account (the account holder's own spending and accounts: they stay his), unchanged (operations, or rules about his own data). With the flag on,/team-voteis refused (the Team is decided by a 2/3 council poll) and 🏛️ Council may manage every team role. /vote question: [type:] [two_thirds:](council members): the short way to put a question to the council. Only the question is needed: it starts a 24-hour project-decision poll at simple majority.typepicks another kind that changes no role (rules, money, accounts…, with its threshold in the name) andtwo_thirdsraises the threshold. Same code as/poll startand the MCP toolpoll_open(poll.start()): same rules, channels and decisions log./poll start kind: question: [member:] [role:] [action:](council members; rules insrc/poll-kit.js, Discord insrc/features/poll.js): a 24-hour poll with Yes / No / Abstain buttons (a ballot changes until the close), "Needs 2/3" (any member raises the threshold) and "Recuse". It pings 🏛️ Council once and shows how many voted, not who; a thread holds the discussion.- Count: quorum of half the eligible members (Abstain counts); simple = more Yes than No, a tie fails; two thirds = Yes ≥ 2/3 of Yes + No and at least 3 Yes. The member a poll is about and recused members neither vote nor count. It closes after 24 h, or as soon as everyone eligible voted. DMs to non-voters 12 h and 2 h before the end.
- Where: #polls-and-decisions; polls about a person (removals, appeals, 18+ cases) in #council, where only the role change is posted publicly.
- After a pass: Kira gives or takes the role herself (reason
Council decision D<n>); for 🏛️ Council she also updates the registry. Team changes say that an org owner updateshorizon-teamon GitHub. - Asking again: a failed poll can't be repeated for 7 days unless two members sign
/poll reopen decision:.
- The 18+ declaration (GOVERNANCE.md 9;
src/age-kit.js,src/features/age.js): its own switch,KIRA_AGE_DECLARATION=on, and only with governance on. Kira DMs each holder of a role of trust (AGE.roles: Council, Team, Chief of Mods, Mod, 🔧 Server Manager, the leads; not Support) once, with a button;/age-declarationshows anyone the same button. Kira stores only{ yes: true, at }under the member's id (state.age.declared), plus when each holder was asked: no age, no birth date, no ID. AfterAGE.days(7), the holders still not declared are listed once in #council; Kira pauses no role herself. A passed poll can't give a role of trust to someone who hasn't declared. - Elections and body votes (GOVERNANCE.md 2.1, 5.3 and 7; rules in
src/elections-kit.js, Discord insrc/features/elections.js, measures insrc/election-evidence.js): their own switch,KIRA_ELECTIONS=on, and only with governance on. Off, every command and option is exactly as before./elect post: member: [group:] [action:]: an election (or a recall) in the body that votes for the post: the mods in #mod-chat (🛡️ Mod, 🛡️ Chief of Mods), the mods and the Server lead (server operator; the lead votes by DM), a group's active members and the Team in the group's channel (group lead), the Team in #horizon-team (2/3). Kira checks who may start it, the seat, the 30-day cooldown, the cap of 3 per body and the floor. A candidate below the floor gets a private DM with what is missing; the starter only a neutral "not now"; nothing is posted.- Ballots: Yes / No / Abstain (the candidate can decline); a second nominee for the same seat within 12 hours turns it into an approval ballot, then a run-off of the top two if nobody has half of the voters. Quorum max(3, ⅓); a tie fails, except that the Chief of Mods breaks ties in the mods' votes. Turnout only, never names. It closes at 24 h, or as soon as the result can't change.
- Result: logged (
electionin the decisions log, ballots hidden in public), posted in the body's channel and in #polls-and-decisions; Kira gives or takes the Discord role, the GitHub team withKIRA_GITHUB_ORG_TOKEN(members:write), and lists what she can't do (a GitHub team without a token, the server's ops list) as clicks for the person named inELECTIONS.clicks. - Veto: a button under a passed result for 24 hours. A council member's press opens a council poll (
veto, simple majority, in #council for elections); if it passes Kira undoes the change and the person can't be proposed for that post for 30 days. /vote body:mods|team|group [group:] [private:]: a body's own decision (P41: the mods decide moderation matters), simple majority, the same 24 hours, early close and veto.private: only the result is public.- Council polls on money last 48 hours, and rule or accounts polls when
/poll start two_days:says it is this document, the licence or the organisation's ownership. - Vacancies: every hour Kira compares the holders of 🛡️ Chief of Mods and the 🧭 lead roles with the last look. A
seat that was held and is empty opens 24 hours of nominations (
/electadds nominees), then the vote with all of them; nobody nominated: the seat stays open. Kira never picks a candidate; seats never filled are left to the monthly reminder. - Free actions:
/grant role: member: [action:]gives or takes 🤝 Support (a mod) or 🧩 Horizon Modding Team (the Modding lead), or either (a council member), with one line in #staff-log (KIRA_STAFF_LOG_CHANNEL; until it is set, #mod-actions). Hand-made changes of the posts' roles, Support and helper roles are logged there too, with who did it; bans and unbans once #staff-log is its own channel. - Monthly: a reminder in a group's channel only when it has no lead or someone in it meets the lead floor (no names), and a private list of people at a floor in #council (the 18+ declaration is left out of it). Kira notes when a group role or 🛡️ Mod was picked up, for the 14 days of an active group member and the Chief of Mods' 30 days.
- Applications are off (the council, 2026-11-09: it recruits the people it wants itself). Unless
KIRA_APPLICATIONS=on: no/apply, no form or dashboard application routes, no vote tick, no role given or DM sent for a decision. The polls Kira had opened from applications were cancelled (cancelStaleinsrc/features/poll.js: never closed, applied, reminded or logged; listed instate.pollsCancelled). With the flag on, the flow below applies. - Applications (
src/application-votes.js,src/features/staff-apply.js): with the flag on, Ludovic has no veto and no owner decision (the dashboards' owner route answers 403). The mods' vote is unchanged; then a passed Mod vote opens the council's 24-hour objection window, posted in #council with an "Object" button (it pings 🏛️ Council once). Two objections from council members send it to a council poll in #council (simple majority); otherwise it is accepted when the window ends. A passed Support vote is accepted at once; a passed Team vote goes to a 2/3 council poll. Votes without quorum or voters (stalled,owner) go to a council poll as well. The poll's result accepts or declines the application, with the decision id as who decided. A Mod or Team accept then waits for the applicant's 18+ declaration (age-wait: Kira DMs them the button) when that switch is on. - The decisions log (
data/decisions.json,KIRA_DECISIONS): append-only (poll,applied,council,reopen), served asGET /decisions.jsononce the flag is on. Person polls show only their result and role change there (no question, ballots or kind). Agents act on a decision only when they find it here. - The Discord side was created hidden on 2026-11-09: the 🏛️ Council role, nine 🧭 lead roles, eight group roles,
#council and the 🧭 groups category (ids in
GOVERNANCE.roles/GOVERNANCE.channels).
Kira answers questions herself with the Claude API (src/ai.js, src/ai-kit.js, src/features/ai.js).
- When: a message that mentions Kira, or replies to one of her messages, gets one answer. A reply in a
#horizon-bugsor#horizon-ideaspost counts only with a mention, because those posts get Kira's template notices.@Kira <verb>and a bare@Kirastay the hub's.KIRA_AI=allalso answers questions (with a?) inAI.helpChannels; that list is empty for now. - From what:
- the FAQ in
config.js(both lists,FAQandFAQ_MORE); - the server guide, built from
config.jsbyserverGuideText()insrc/texts.js(no GitHub call, always there): the welcome text, every channel by category with its topic and, for forums, the how-to-post guide and template, the roles (aboutinROLES), the rules,/helpand the official server. So "how does this server work", "where do I ask for help", "how do I become a mod" are answered from the server's own texts;#channel-namein a reply becomes a clickable mention; - from horizon-rust/horizon main, the README and
docs/*.md(refreshed daily) and the notes of the latest published releases (hourly). - All of it is read through
github.js'smakePublicGitHub: the GitHub App with a reserve of its own (KIRA_GITHUB_PUBLIC_KEEP, default 100, so the sources still load when the board has stopped for the hour atKIRA_GITHUB_KEEP), else without a token (60 an hour per IP); never Ludovic's token. A doc is fetched again only when its sha changes. - A failed refresh keeps the last good copy, saved in
/data/ai-sources.json(KIRA_AI_SOURCES): a restart during a GitHub outage or a held-back hour still answers from it. "Sources not loaded" (passed to the team) only when nothing was ever loaded. A failure is logged once until that part loads again. - When sources disagree, the FAQ wins, then the server guide, then the release notes, known issues, the README and the docs.
- the FAQ in
- Models:
claude-haiku-5-5.claude-sonnet-5-5only when Haiku says it is unsure, or for a reply chain of 4 messages or more. The model's only tool isread_doc(read-only). Its output is JSON: answer, pass, or unsure. - Safety:
- Member text goes in as data inside
<member_message>/<context>tags, and our own tags inside it are defused. - Every reply goes through
checkReply()before it is posted. It allows links only to playhorizon.dev, github.com/{ludovic111,horizon-rust}/*, the server invite and microsoft.com/link (the FAQ's sign-in page). It blocks code blocks, commands, dates and promises, strips pings, and caps the length. A reply that fails is replaced by "passed to the team". - Replies go out with
allowedMentions: { parse: [] }.
- Member text goes in as data inside
- Limits: 5 questions per member an hour, and
KIRA_AI_DAILY_USDdollars a day (default 3, UTC days), counted from each response'susage. Over the budget, Kira says she has passed the question to the team. - Logs: every answer goes in
#mod-log: the question link, the reply, the model, the tokens, the cost and the day's total. Questions passed to the team are kept instate.kiraAi.handoffs; the orchestrator reads them, and closes them once answered, with the MCP toolai_handoffs(maintainers). When one has waited 6 hours unanswered, Kira posts one reminder in#kira-handoffsthat pings 🌌 Horizon Team and 🤝 Support, lists the open questions (at most 10, none older than 7 days) and carries a Mark these answered button for the Team and the mods; at most one reminder every 12 hours (AI.handoffRemind;KIRA_HANDOFF_REMINDERS=offturns it off). Humans answer when no agent does. - Usage:
GET /ai-usagewithAuthorization: Bearer <KIRA_USAGE_TOKEN>(1PasswordKira usage token; no variable, no route) returns the spending per UTC day (cost_usd,answers,passed_to_team,bug_reviews,moderated: messages read for moderation,models: calls per model), the current month's totals,daily_budget_usd,bugs(bug reports read or not) andmoderation(on or not). It has no message text and no user ids. The LifeOS control center reads it. - Bug reports (
KIRA_AI_BUGS=1, off by default): before a#horizon-bugspost or a/reportbecomes an issue, GitHub's search finds open issues for its title and one Haiku call reads the report next to them (about $0.0003 a report). Clearly the same bug: the report goes on that issue as a comment and the post follows it. Maybe the same: Kira asks the reporter, and yes adds it there, no files a new issue; with no answer in 30 minutes it's filed as new, naming the possible duplicate. A new issue keeps the reporter's text as written; the model only fixes the title's spelling (or writes one when the title says nothing, like "bug") and copies the version and OS above the report, which code checks are the report's own words. Kira then asks once for what's missing (version, OS, steps, a log or screenshot); the replies go to the issue. Without the model (variable unset,/kira-ai off, budget spent, an API error) reports are filed as before. Each one is logged in #mod-log. - Moderation in every language (
KIRA_AI_MOD, on wheneverKIRA_AIis;KIRA_AI_MOD=0turns it off): AutoMod would need regexes per language, so Kira's AI reads what the filters let through in the 🌍 languages channels, in #horizon-chat and #general (every message, any language), and non-English messages anywhere (another script, or common words of the server's other languages). Staff, bots and messages without letters are skipped, and so is anything a filter already removed. Messages of one channel that arrive within 4 seconds go in one Haiku call (10 at most). The rules fromconfig.jsare the cached first block of the user turn, the messages come after it as data (our own tags defused). The model returns JSON per message:verdict(ok, flag, remove),category,rule,reason,english(a translation),language;checkModeration()checks every field and drops the whole batch on any doubt.- remove, only for slurs, hate, threats, sexual content, doxxing, scams and targeted harassment (any other category is downgraded to a flag): the message is deleted, the usual short note is posted for 12 seconds in the member's language (English, or one of the eleven language channels' languages), and #mod-log gets the original, the English translation, the rule and the model's reason. From the second removal in a day, the spam timeout ladder applies (10 min, 1 h, 1 day).
- flag: nothing is deleted; #mod-log gets the original, the translation and a jump link, and a mod decides. While the batch lane is on (
KIRA_BATCH=on) this one-message check no longer flags (it made the false piracy flags, "send shaders pls"); it only removes its clear cases. - Nothing else is ever done on the model's say: no bans, no warnings, no roles. Budget spent, the API down (the check pauses 5 minutes after an error) or switched off: nothing is read and the filters still run.
- Cost, measured on 50 real messages from the language channels and #off-topic, before it merged into #general (
node scripts/measure-ai-mod.mjs, read-only): $0.00009 per message (36 calls; the ~2K-token rules block read from cache, ~140 output tokens a call). 10,000 messages a day would cost about $0.90 of the daily budget.
- Off switch:
/kira-ai off(Ludovic and the mods) stops all of it (answers, bug reports, moderation) at once,/kira-ai mod offonly the moderation (mod onrestarts it),/kira-ai onrestarts it, and/kira-ai statusshows the day's use. RemovingKIRA_AI(or setting it to0) turns it off for good. - Variables:
KIRA_AI,ANTHROPIC_API_KEY(1PasswordHorizon Claude API Key, set on Railway with the CLI, never printed),KIRA_AI_DAILY_USD,KIRA_USAGE_TOKEN,KIRA_AI_BUGS,KIRA_AI_MOD. - Cost, measured on 38 real questions from the server's history: $0.0013 per answer with Haiku and a warm cache. The 21K-token sources block is cached for an hour as the first block of the user turn: a breakpoint on the system prompt was never read back on this account.
pointofpressure's proposal (the chief of mods, 2026-11-08), Ludovic's decision the same day: two steps. Rules in
src/batch-kit.js (no Discord), the Discord side src/features/batchmod.js,
the call makeBatchModerator in src/ai.js, settings BATCH in config.js.
- Windows. Every public channel and thread is read in windows: its new messages since the last window, plus ~50 earlier ones for context. A window closes after a random 10 to 30 minutes (so it can't be timed) or at 200 new messages. With it the model gets a line per author: account and membership age, staff or not, cases and notes in the last 30 days, and what they wrote in other channels in the last hour (raids, following someone around). One Haiku call per window. Never read: the staff category, #horizon-team, #appeal, the honeypot, private threads and any channel @everyone can't see (tickets, mod-mail). After a restart the first window of a channel fetches its earlier messages from Discord once.
- What the model returns is the proposal's incident JSON (
user_id,rule,category,confidence,message_ids,quotes,context_ids,reason,proposed_action). Its instructions list what is not an incident: banter between people joking with each other, swearing at no one, asking for or sharing free mods/shaders/packs, Horizon's own builds, venting, arguing about the game, other languages. Member text, names and topics are data (our tags defused); the model refers to messages and users by short refs (m12,u3), socheckIncidents()maps them back and drops any incident that doesn't hold up (an unknown ref, only earlier messages, another author's message, staff, a quote that isn't in the messages). - Step 1 (
KIRA_BATCH=on): flags only. Confidence 0.70 or more: a flag in #mod-flags (staff only, Kira makes it at start; before 2026-11-09 flags went to #mod-chat, and those cases still work), which is also a numbered case (action "Flag",/case N), with the evidence saved when it opens: the messages' text, jump links, the context messages, attachment names and SHA-256 (files up to 10 MB), the model's reason and quotes. Buttons:- Confirm: does what the model proposed, as the mod who pressed it (delete the messages; timeout on the ladder; quarantine); the timeout or quarantine is its own case in #mod-actions with the flag as its evidence. "note only" deletes nothing.
- Dismiss: nothing is done; counted as a wrong flag.
- Change action: pick note only / delete / delete + timeout / delete + quarantine, then it is done as with Confirm. Kira never acts on staff or the Horizon Team (Confirm says so). Below 0.70: a note in the member's history (no ping), which the next windows see.
- CSAM suspicion (0.50 or more): one alert in #mod-flags pinging 🛡️ Mod and the Horizon Team, with message ids, times and jump
links only: no text, no file names, no hashes, no image is ever stored or reposted. Confirm deletes and quarantines. From the
text alone Kira never acts herself (
KIRA_BATCH_CSAM_AUTO, off); from an image, see the image lane. - The image lane (Ludovic, 2026-11-08;
src/image-kit.js, the callmakeImageClassifierinsrc/ai.js). Every image a member posts where the windows read (same exclusions; staff, the Team and bots are skipped) gets one Haiku vision call as it arrives, at most 4 images a message. The picture is downscaled first by Discord's media proxy (long side 1,024 px, one JPEG frame, even for a GIF), held in memory for the call only, and never written anywhere. The model reads the instructions (a cached first block), the image, then the member's text and file name as data, and answers one of csam, sexual_gore, scam (fake giveaways, free Nitro, QR codes, crypto, fake staff or login notices), none, with a confidence; a csam answer keeps no reason.- CSAM at 0.90 or more: the one thing Kira does by herself. The message is deleted at once, then the alert above (ids, time,
jump link only, pinging 🛡️ Mod and the Team), then the author is quarantined (
case #N: an image removed under "Keep it SFW"in their DM, nothing more). The alert says what was done and has Undo (undo case #Ntoo), which lifts the quarantine; the message can't come back. The image, its name, its hash and the message's text are never kept, posted or logged (the console line names the message id only). Below 0.90: the alert with Confirm / Dismiss, nothing done; below 0.50 a note.KIRA_BATCH_IMAGE_CSAM_AUTO=offmakes every image suspicion an alert with buttons. - sexual_gore and scam at 0.70 or more: a flag, exactly as in step 1 (categories
image-sexual-goreandimage-scamin the week's numbers), never an action, however sure. Its evidence holds the message's text and the attachment's name and SHA-256, not the image (the jump link leads to it while it's up). Below 0.70: a note. - Budget: counted in the same daily budget, and paused with the windows when less than $0.75 of it is left, so Kira's answers
are never cut for it;
/kira-ai mod offstops it too.GET /ai-usagecountsimage_callsandimage_cost_usd.KIRA_BATCH_IMAGES=offturns the lane off. - Cost, measured (
node scripts/measure-image-mod.mjs <hours> <out.json> [max images], read-only: the last hours' public images through the real call, posts nothing, writes no image): on 2026-11-08, 541 member images in 24 hours, 530 read, $0.103 (~$0.0002 an image, ~$0.11 a day); 9 flags, allscam: 3 right (screenshots of a "cracked" Horizon launcher repo), 6 wrong (a "free RAT" joke meme posted 4 times, a member's ban screen from another server twice). The instructions then learned that jokes about malware and members' own ban screens are not scams: re-read, 2 of the 6 still flag (0.72, 0.80), the 3 right ones too. 11 answers were cut by a 300-token cap (thinking), now 1,500. This API account caches every prompt for an hour on its own, billed at 2x: the instructions carry an explicit 1-hour breakpoint so each call reads them from the cache (without it each image cost ~2.4x more); the image itself is still written at 2x and never read back.
- CSAM at 0.90 or more: the one thing Kira does by herself. The message is deleted at once, then the alert above (ids, time,
jump link only, pinging 🛡️ Mod and the Team), then the author is quarantined (
- Accuracy: each Monday from 09:00 UTC, one line in #mod-flags: per category, flags confirmed, changed, dismissed, open.
/kira-batch statusshows it any time. - Step 2, prepared and off (
KIRA_BATCH_AUTO=1). At confidence ≥ 0.95, for spam, scams and raids only, Kira deletes the messages and times out (ladderKIRA_BATCH_LADDER, default1h,24h,7d, by the member's timeouts in 30 days) or quarantines (scams, raids; lifted by mods only). Never a ban, never staff or the Team, never a message older than 24 hours. Any mod writesundo case #Nin a staff channel (or presses Undo) to reverse it. A category with more than 5 % of its week's auto actions overturned (undone, or lifted on appeal) falls back to flags until a mod runs/kira-batch restore. Turning it on is a later decision, after a week of step-1 numbers. - Cost, measured (
node scripts/measure-batch-mod.mjs <hours> <out.json>, read-only: replays real traffic window by window through the real call, posts nothing): see the PR; about $0.00005 a message, ~$0.6 a day. Same/kira-aiswitches (mod offstops it) and daily budget as the rest of Kira's AI, but it pauses when less than $0.75 of the day's budget is left, so her answers never stop for it.GET /ai-usagecountsbatch_windows,batch_messages,batch_cost_usdper day. - Variables:
KIRA_BATCH(off,dry: read and logged to the console only,on),KIRA_BATCH_AUTO,KIRA_BATCH_LADDER,KIRA_BATCH_CSAM_AUTO,KIRA_BATCH_IMAGES(on unlessoff),KIRA_BATCH_IMAGE_CSAM_AUTO(on unlessoff).
POST /game/ask (src/features/game.js, rules and checks in src/game-kit.js): Authorization: Bearer <server token>,
body { "player": "<Minecraft name>", "question": "kira why …" } (the kira/@kira prefix is optional), answer
{ "answer": "…" } (empty: say nothing). The token names the server; a body can't claim another.
- Tokens, one per server, in
data/game-servers.json(never usable for MCP). Kira issues one and DMs it to the server's owner herself, so it is never printed anywhere: with the admin routes on (see Hosting),node scripts/admin.mjs game-token https://mcp.playhorizon.dev '{"action":"issue","server":"<address>","owner":"<discord id>","deliver":"dm"}'answers its id only; a failed DM revokes it at once.{"action":"list"},{"action":"revoke","id":"…"};"deliver":"response"gives a test token for a day. From a shell on the host,scripts/game-token.mjsdoes the same. - Limits (
GAMEinsrc/game-kit.js): 20 questions per player an hour (then "ask me again a bit later"), 60 per server an hour and 200 a day (then nothing in chat); failed logins per address like the other routes; and the AI's daily budget. - Rules: her Discord rules (only what works in a release, no dates or promises, no piracy), then game chat's: 240 characters, plain text, playful is fine, server questions go to its staff, passes point to Horizon's Discord. A question AutoMod's piracy or scam rules would block, or the moderation model would remove, gets "I'm not answering that one."; a reply that fails the checks becomes the pass line.
- Log: one line per question in #mod-log (
🎮 Kira answered in game): server, player, question, reply, model, cost.
Kira runs on Railway (project kira, service kira, volume kira-data at /data: state.json,
board.json, agents.json, seal-key.pem, ai-sources.json, github-etags.json: the GitHub clients' ETag caches, saved every 5 minutes and on SIGTERM so a deploy starts with conditional reads; KIRA_GITHUB_ETAGS), built from this repo's Dockerfile on every push to main.
Variables: KIRA_STATE=/data/state.json, DISCORD_TOKEN_SEALED, GITHUB_TOKEN_SEALED, GITHUB_APP_ID +
GITHUB_APP_PRIVATE_KEY_SEALED (the GitHub App kira-horizon: Kira's own hour of calls; /sponsor keeps
GITHUB_TOKEN; key in 1Password GitHub App Kira, a document: op document get),
ROADMAP_SUGGESTIONS_CHANNEL_ID,
KIRA_AGENT_ISSUANCE (on opens /agent connect), and for tests KIRA_DISCORD=off (no Discord: board and
MCP server only) and KIRA_GITHUB_DRY_RUN=1. Health: GET /health.
- A secret changes:
~/fleet/op read "op://Agents/<item>/credential" | node scripts/seal.mjs https://mcp.playhorizon.dev/seal-key, then set the printed blob as<NAME>_SEALED. A new volume means a new key: seal again. - Moving Kira's files (state.json, agents.json): the admin routes are off. For a move, set
KIRA_ADMIN_TOKEN_SHA256to~/fleet/op read "op://Agents/Kira admin token/credential" | tr -d '\n' | sha256sum(the hash only), thennode scripts/admin.mjs backup|restore <host>with the admin token (op://Agents/Kira admin token/credential; Railway only has its SHA-256 inKIRA_ADMIN_TOKEN_SHA256). A restore replaces the files and restarts the service on them. Empty the variable again afterwards. - Setup (
npm run setup): back up, run setup on the ThinkPad against the backup's state (KIRA_STATE=… npm run setup), restore it. Setup only touches Discord and its own keys of the state, and only adds what is missing unless--apply-layout --yesis given. - Rollback to the ThinkPad: back up from Railway, set
KIRA_DISCORD=offon Railway (or remove the service), put the backup'sstateandagentsin~/code/kira/data/state.jsonanddata/agents.json,systemctl --user enable --now kira. The unit stays installed there, disabled.
One place where the work is written (the horizon repo: roadmap/roadmap.toml, issues for bugs only, pull requests
labelled roadmap:<id>, a generated journal), one public entry point (https://playhorizon.dev/hub), one intake:
a bug becomes a GitHub issue wherever it comes from, an idea goes to the suggestion queue (never an issue), and the
answer goes back where the request came from. Kira is that intake on Discord (src/hub.js has no
Discord, src/features/hub.js does the talking):
| Verb | Slash command | @Kira … |
What happens |
|---|---|---|---|
| report | /report (a form: title, steps, what happened and what you expected, version, OS/CPU/GPU) |
@Kira report (a button that opens the form) |
Kira makes a post in #horizon-bugs in the member's name (staff tag via /report, no ping) and sends it to GitHub like any other post: the member owns it, their replies become comments and the answers ping them there. 3 an hour per member, then the GitHub limits |
| roadmap | /roadmap [area:] |
@Kira roadmap [area] |
the whole roadmap: one embed per area in the roadmap's order, its items by Now, Next, Later and Help wanted as titles linked to <hub>#<id>, Shipped as a count; several messages (the first answers, the others follow in the same place). With an area: every item of that area in plain words, shipped ones too. No dates |
| claim | /claim issue: |
@Kira claim 123 |
the work board's claim (below) |
| status | /status [of:] [text:] [clear:] |
@Kira status [#123 | item] |
yours (status text, claims, reports and their state), a card of the board, or a roadmap item; text: sets what you are on (agents and /working show it) |
Ideas reach the queue only as #horizon-ideas posts (Ludovic, 2026-10-30: /suggest and @Kira suggest are gone;
@Kira suggest now answers with a pointer to the forum). A post in #horizon-ideas that follows its template goes to
the queue once (state.suggestions, an embed with who and a link back), and Kira says so in the post with the
roadmap's link; ideas sent to GitHub before keep their issue, votes line and answers. A mod's
/issue in an idea post still opens an issue by hand.
What Kira reads. The website publishes the roadmap as JSON at HUB_ROADMAP_URL (default
https://playhorizon.dev/data/roadmap.json): { "areas": [{ "id", "name" }], "items": [{ "id", "title", "status", "area", "plain", "dev", "links", "shipped_in"?, "focus"? }] }, the fields of roadmap.toml. Fetched at most every 5
minutes; a failed fetch keeps the last good one, and with none Kira answers with the hub's link. Item links are
<hub>#<id>.
Variables: ROADMAP_SUGGESTIONS_CHANNEL_ID (the queue: the ID of the staff-only #roadmap-suggestions setup makes; unset: #horizon-ideas posts stay in the
forum), HUB_URL, HUB_ROADMAP_URL, HUB_JOURNAL_URL.
Open idea issues (the one-off move to the roadmap): node scripts/idea-issues.mjs [--json] lists them (number,
👍 on Discord, age, from Discord or GitHub, title, link). It only reads; each one is moved to the roadmap or closed
with a link to it by hand.
When a post in #horizon-bugs passes its template (at once, or after an edit), or a member sends /report,
Kira opens an issue in horizon-rust/horizon. The mapping is in GITHUB in src/config.js;
the code is src/features/github.js and src/issuekit.js.
- The issue: the post's title; the post quoted (no HTML,
@mentions,#refs, issue links or closing keywords survive), the poster's display name, the thread link, attachments as links (Discord's links expire; the thread keeps the files); labelsbug/enhancementandfrom-discord(created if missing). Ideas carry a👍 N on Discordline, updated every 30 minutes. - Once per post: the post → issue mapping is in
data/state.json(github.issues), so restarts and edits never open a second issue. At most 3 issues per member and 20 in all per hour; beyond that Kira tells#mod-logand a mod can use/issue. - Follow-ups: the poster's later replies in the post become one issue comment at most every 10 minutes ("+1", "same", thanks and emoji-only replies are skipped). Nobody else's replies go.
- Answers (polled every 5 minutes, one request per repo): the issue closed as completed ("Fixed
in " when the closing commit or merged PR is in a release, else "Fixed"; the post gets its
done tag and is archived), closed as not planned or duplicate (with the comments that explain why),
or a comment from ludovic111 or a collaborator. Kira replies in the post and pings the poster alone
(no ping if they left the server); comments within 10 minutes come as one message. Archived posts
are unarchived to answer, then archived again.
notifyByDm: truein the config also DMs them. /issue(mods, in a forum post): sends the post by hand (repo:andkind:when the forum or tags don't say);/issue link:<number>links the post to an existing issue instead (duplicates).- Dry run:
KIRA_GITHUB_DRY_RUN=1logs every write (issues, comments, labels, Discord replies) instead of doing it, and keeps nothing in the state. It also works without a token (public repos). - Tests:
npm test(recorded GitHub answers, no network).
Kira's issues and comments appear under the token owner's name (ludovic111); its own comments carry a
hidden <!-- kira --> marker so they're never relayed back.
It's on: the token (fine-grained, the seven repos, Issues read/write and Contents read, expires
2027-10-13) is op://Agents/GitHub Kira issues/credential, in .env.op. Without GITHUB_TOKEN the feature is off: Kira logs github issues: off and runs as before. Never
give it the desktop's gh login: that token can do everything.
-
Create a fine-grained token at https://github.com/settings/personal-access-tokens/new: name
Kira issues, resource ownerludovic111, an expiry you'll remember (1 year), Only select repositories: horizon, lsuite, ryolune, kimchi, nori, folio, DISPO. Repository permissions: Issues: Read and write, Contents: Read-only (for release lookup; Metadata: Read-only is added by itself). Nothing else. Since 2026-10-27 Horizon's repos are in thehorizon-rustorganisation: a token whose resource owner isludovic111can no longer reachhorizon-rust/horizon; it needshorizon-rustas resource owner (and the org must allow it). -
In 1Password, vault Agents, a new API Credential item named
GitHub Kira issueswith the token in itscredentialfield (op://Agents/GitHub Kira issues/credential). -
Only then add it to
.env.op(before the item exists,op runfails and Kira can't start):GITHUB_TOKEN="op://Agents/GitHub Kira issues/credential"commit, push, and deploy on the ThinkPad:
cd ~/code/kira && git pull --ff-only && npm ci && systemctl --user restart kira. -
journalctl --user -u kira -n 30should saygithub issues: on, 0 posts linked.
To try it first without writing anything, add Environment=KIRA_GITHUB_DRY_RUN=1 with
systemctl --user edit kira, restart, and read the journal. Test end to end in a staff-only forum
post (/issue repo:horizon), with a throwaway issue labelled test, closed afterwards.
The Discord server's and Kira's images live here, not in the horizon repo (which holds only the game, the launcher and
the website). Moved from horizon's docs/media/brand on 2026-10-15.
| File | What | Made from |
|---|---|---|
assets/horizon-icon.png |
the server icon | rsvg-convert -w 512 -h 512 ~/code/horizon/docs/media/brand/avatar.svg |
assets/kira-avatar.svg |
Kira's avatar, master: Horizon's mark on violet, so Kira reads apart from the project | |
assets/kira-avatar.png |
Kira's avatar | rsvg-convert -w 512 -h 512 assets/kira-avatar.svg |
assets/discord-banner.html |
the server banner, as a page (uses the mark and fonts from a horizon checkout next to this one) | |
assets/discord-banner.png |
the server banner, 960×540 (needs boost level 2) | a 960×540 Playwright screenshot of the page |
Uploading (Ludovic's accounts): server icon and banner in Server Settings › Server Profile; Kira's avatar in discord.com/developers/applications › Kira › Bot (the running bot also applies both icons when they change).
Two dashboards on the website, each behind a password its member sets: playhorizon.dev/hub/team (🌌 Horizon
Team) and playhorizon.dev/hub/mod (🛡️ Mod); Ludovic sees both. Applications for Mod, Support and the Team are
a form at playhorizon.dev/apply. Who owns what between the dashboards and the Discord side:
docs/staff-api.md.
- Passwords and sessions (
src/staff-auth.js): scrypt hashes; one-time links (30 minutes to set a password, 24 hours to apply) and sessions (12 hours idle, 7 days at most) are kept as SHA-256 only. - The file (
src/staff-store.js):/data/staff.json(KIRA_STAFF), mode 600, written only by the bot. Before the first write of each day the file is copied to/data/staff-backups/staff-<date>.json(mode 600, the last 7 days kept). A file Kira can't read stops the staff side with a message; nothing else of Kira stops. - Restoring a backup:
railway ssh --project kira --service kira(it needs an SSH key on the Railway account first:railway ssh keys add, Ludovic's call), thenls -l /data/staff-backups/andcp /data/staff-backups/staff-<date>.json /data/staff.json, then restart the service (railway redeploy --service kiraor the dashboard). Everything since that copy is lost (sessions, decisions, applications), and members whose password was set later need a new link. - Getting in:
/staff password(Team and Mods;member:sends it to another of them) DMs a one-time linkplayhorizon.dev/hub/setup#…; the username is the Discord username. Losing the role, or leaving, removes the password and ends the sessions at once (and at start, for what happened while Kira was away). - Counters (
src/staff-counters.js): per day, counts only (messages, distinct authors, joins, leaves, reports, mod-mail threads, warns, timeouts, kicks, bans, AutoMod blocks), 120 days kept. - Variables:
STAFF_PROXY_KEY(the same secret as the site's,op://Agents/Horizon staff proxy key/password; unset: no/staff/apiroutes at all),STAFF_SITE_URL(defaulthttps://playhorizon.dev: the Origin writes must come from, and the links' host),KIRA_STAFF(the file). - Applications (
src/applications.js, routes insrc/features/staff-apply.js): the form at playhorizon.dev/apply opens with the link/applyDMs (24 hours, once, for one role); Kira records the answers and what GitHub says about the member (their linked login, whether/verify-githubproved it, their merged pull requests in Horizon: verified contributors are listed first). Then a vote on the dashboards (src/application-votes.js): Mods vote on Mod and Support applications (shown on /hub/mod), the Team on Team and Support ones (/hub/team); For / Against (with a comment) / Abstain, blind until you vote (then the others' ballots show; Ludovic sees only the counts and the Against comments, without names, until it closes), changeable until it closes, recusal; quorum max(2, ceil(40% of the voters active in the last 30 days: logged in or acted)); it passes with 2/3 of For and Against; it closes 72 h after the first ballot or once every active voter voted; without quorum it is extended once by 48 h, then Ludovic decides. A decline goes out at once; a pass opens Ludovic's 12-hour veto window (one DM), after which the Discord side grants the role and DMs (docs/staff-api.md). Ludovic can veto at any time before that, approve early, and take the role back later. A Team accept grants only the Discord role: GitHub organisation or team access stays his, by hand. With the governance on, the council's objection window and polls replace the veto and Ludovic's decisions (Governance). Every step is in the append-only audit log (auditin staff.json). Answers are deleted 90 days after the decision; a decline keeps only its member, role and date, for the 14-day cooldown. - The Team dashboard's data (
src/team-data.js, routes insrc/features/staff-team.js): the decisions log (who, what, when, a link, a roadmap item; an edit keeps the previous version, a withdrawn decision stays listed), the Team kanban (To do, Doing, Done: coordination that is not a GitHub issue), and the roadmap's Team layer: notes on public items (owner, priority, note) and Team-only items, which stay in staff.json. "Publish" gives the item'sroadmap.tomlentry for a person or an agent to propose in a pull request to horizon; when the website's roadmap.json lists the id, the item is marked public. Kira never writes to the public repo for this. The work board is shown read-only (moves stay/moveand the MCP tools). - Statistics (
src/team-stats.js, fetched bysrc/features/staff-stats.jsa minute after start and then every hour, through GraphQL's own budget, ~2.5 minutes a round): for the Team, the last 12 weeks of horizon's pull requests (opened and merged per week and per person, median hours to the first review by someone else and to merge, the share reviewed), the open ones' CI state, open bugs (by area label, by source, crucial), pull requests per roadmap item (theirroadmap:<id>label), game and launcher releases per week, and Discord activity from the counters. For the Mods, the moderation counters per week and over 7 and 30 days, and the applications waiting. Only public GitHub data and counts; a failed round keeps the last numbers. - Working on the pages:
node scripts/staff-dev.mjsserves the staff API with fake members, applications, decisions and made-up stats on 127.0.0.1 (no Discord, no GitHub, a fresh store in a temporary folder) and prints how to point horizon-site'sserve.mjsat it, the test logins, a password link and an application link.