42 KiB
Chapter 21 — Building Discord bots on Bare OS
Guest scripts have no import or require. Discord bots on Bare OS therefore do not import('discord.js'). The booter vendors bare-discord-js (official discord.js 14 on Bare) and attaches it as ctx.bare.discordJS. You construct a Client from that object, the same way you would on Node, then keep the process alive until logout, Ctrl+C, or client.destroy().
This chapter is the complete how-to: Developer Portal setup, secrets, intents, a drop-in guest script, the stock /bin/discord-bot slash surface, the bare-os-discord initd unit, access control, packing notes, tests, and failure modes.
Canonical env inventory: Environment and POSIX appendix (DISCORD_*, BARE_OS_DISCORD*). Command reference: man discord-bot. Minimal example: examples/discord-ping-pong/.
Important
Use the Bot token from Developer Portal → Bot → Reset Token. The OAuth2 client secret is a different string and will fail gateway auth (4004/TokenInvalid). Never put a token in a commit, a chat log, orconsole.log.
On this page
- Who this is for
- How Discord fits the two runtimes
- What you can run today
- Developer Portal checklist
- Secrets,
.env, and token load order - Access control (
DISCORD_ID_WHITELIST) - Gateway intents
- Minimal guest bot
- Slash commands
- Stock
/bin/discord-bot - Settings editor (
/settings) - Run as an initd unit
- Plugin system (
~/.discord/plugins) - Extending the stock catalog
- Shipping your own
/binbot - Host, Pear, and packed standalone
- Testing
- Troubleshooting
- Security
- File map
- See also
Who this is for
- You want a Discord bot that talks to this guest session (
ctx.vfs,ctx.execLine,systemctl). - You want a custom bot (your own slash commands) as a file on the personal drive.
- You are changing the stock catalog, the initd unit, or the packed WebSocket path.
Read Chapter 1 — Two runtimes and Chapter 5 — Modules first if import in a guest script is still surprising.
How Discord fits the two runtimes
flowchart TB
subgraph portal [Discord]
API[REST and gateway]
end
subgraph host [Booter host]
Load["loadBareDiscordJs"]
WS["WHATWG bare-ws wrapper"]
Inject["applyDiscordHostEnvToShellEnv"]
end
subgraph guest [In-image]
Ctx["ctx.bare.discordJS"]
Bin["/bin/discord-bot"]
Script["~/my-bot.js"]
Unit["bare-os-discord"]
end
Load --> Ctx
WS --> Load
Inject --> Bin
Inject --> Unit
Ctx --> Bin
Ctx --> Script
Ctx --> Unit
Bin --> API
Script --> API
Unit --> API
In prose: the host (Pear, Node, or a packed bare-os-booter) loads vendored discord.js and copies token-related env into the session. In-image code only sees ctx.bare.discordJS. Drive-resident JS is an AsyncFunction body, so import / require are syntax errors. Shared helpers are either inlined or concatenated at coreutils build time (Chapter 5).
ctx.bare.discordJS is not a ctx.bare manifest row. It is attached by bare-discord-js-loader.js after the host ctx.bare merge. Disable the load with BARE_OS_DISCORD=0. Disable all of ctx.bare with BARE_OS_BARE_MODULES=0.
Useful exports on that object (same names as discord.js 14):
| Key | Use |
|---|---|
Client |
Gateway client |
GatewayIntentBits |
Intent bitmask |
Events |
ClientReady, InteractionCreate, MessageCreate, ShardError, … |
REST / Routes |
Slash-command registration and REST probes |
SlashCommandBuilder |
Command JSON for Routes.applicationCommands |
If ctx.bare.discordJS is missing, check ctx.env.BARE_OS_DISCORD_LOAD_ERROR (set when the vendor load failed) and that you are not on a host that skipped Discord.
What you can run today
| Path | When to use | How it starts |
|---|---|---|
init-discord |
First-time setup: .env + hello plugin |
Interactive prompts or --yes --token … |
Stock /bin/discord-bot |
Full Bare OS slash surface (/bare, /sys, /fs, …) |
Foreground in the guest shell |
bare-os-discord unit |
Same catalog, background after identity unlock | systemctl start when ~/.discord/.env exists |
Guest script (~/my-bot.js) |
Your own commands; copy the ping-pong example | ./my-bot.js or my-bot.js in $PWD |
New /bin name |
Ship a first-party utility | Coreutils src/ + optional preamble (Chapter 16) |
The stock binary and the initd unit share one guest-safe catalog: bare-os-discord-commands-guest.cjs. The coreutils build prepends that file to src/discord-bot.js. The unit statically imports the .cjs so bare-pack rewrites the binding into app.bundle (createRequire cannot resolve siblings under bare:/app.bundle/).
Developer Portal checklist
- Discord Developer Portal → New Application.
- Bot → Reset Token → copy once into
~/.discord/.env(guest) or a host.envyou will not commit. - Privileged Gateway Intents — leave Message Content Intent off unless you need channel text (
ping→pong). The stock client requests Guilds only by default. Requesting Message Content while the portal toggle is off closes the gateway with 4014. - Installation — enable User Install and Guild Install. User Install scope is
applications.commandsonly (profile app). Guild Install scopes arebot+applications.commands. The stock bot PATCHes this on login; you can also flip it in the portal. Pick guild permissions you actually need (Send Messages, Use Slash Commands, Embed Links, Attach Files). - Copy the guild (server) id and your user id (Discord Settings → Advanced → Developer Mode, then right-click → Copy ID). Guild id still makes slash registration instant on the home server. User id must go on
DISCORD_ID_WHITELIST— user-install / DM commands deny everyone when the list is empty. - After the bot is online, open the logged add to your Discord profile URL (
https://discord.com/oauth2/authorize?client_id=…) and choose Add to My Apps. Slash commands then work in DMs and any server from your user profile. Only whitelist members can actually run them.
Tip
discord-bot --check --env ~/.discord/.envverifies thatctx.bare.discordJSexists and a token can be resolved. It does not connect to the gateway.
Secrets, .env, and token load order
Preferred setup:
init-discord
That asks for token, guild id, and whitelist, creates ~/.discord/ and ~/.discord/plugins/, writes ~/.discord/.env, and installs /hello from examples/discord-plugins/hello.json. Flags (--token, --guild, --whitelist, --yes) work for scripts. The full token is never printed.
Or write the file yourself:
# ~/.discord/.env (VFS path on the personal drive)
DISCORD_TOKEN=your-bot-token
DISCORD_GUILD_ID=123456789012345678
DISCORD_ID_WHITELIST=123456789012345678
export KEY=value, quotes, # comments, and a UTF-8 BOM are accepted. The token is normalized (trim, strip BOM / zero-width spaces). A trailing newline in the file is fine; a token pasted with invisible characters is not — reset the token if login returns 4004.
Foreground /bin/discord-bot (first match wins)
--token TOKENctx.env.DISCORD_TOKEN(already in the session, including a host copy)--env/--env-file PATH(guest VFS)DISCORD_ENV_FILE/BARE_OS_DISCORD_ENV_FILE(guest VFS)~/.discord/.env,~/.discord.env,~/discord.env,./.env,~/.env
DISCORD_GUILD_ID and DISCORD_ID_WHITELIST are also read from those files (session env wins if already set). --guild / --guild-id override the guild for slash registration.
Host injection
When the booter process has DISCORD_TOKEN, DISCORD_ID_WHITELIST, DISCORD_GUILD_ID, or DISCORD_ENV_FILE pointing at a host filesystem path, applyDiscordHostEnvToShellEnv copies them into the guest session before /boot/init.js. The guest then does not need host paths. Existing session values are not overwritten.
Initd unit
The bare-os-discord unit requires DISCORD_TOKEN= (or BOT_TOKEN=) in ~/.discord/.env — that file is the gate for whether systemctl lists the unit at all. After the token is found it also copies DISCORD_ID_WHITELIST, DISCORD_GUILD_ID, and DISCORD_USER_INSTALL from that file, then from ~/.discord.env, ~/discord.env, ~/.env, and ./.env (first non-empty value wins). Values are written onto both ctx.env and ctx.vfs.env so user-install checks see them. No --env flag.
Do not pass --token on a recorded shell line. Prefer the .env file (mode 600 if your VFS exposes modes).
Access control (DISCORD_ID_WHITELIST)
Comma-separated Discord user snowflakes. Spaces around commas are ignored. A pasted mention (<@123> / <@!123>) is accepted.
| Value | Effect |
|---|---|
| Unset or empty | Guild-installed commands in a server keep the old “anyone may start” rule. User-install, Bot DM, and private-channel interactions are always denied. |
| One or more ids | Only those users may use slash commands, autocomplete, buttons, selects, modals, and channel ping — in every install context (guild, profile app, DM) |
| User not on a non-empty list | Denied. Replies are ephemeral and do not update the original message. Channel ping gets a short deny message |
Enforced in discordDispatchInteraction for every interaction type (slash, autocomplete, button, select, modal) and in the message//ping fallbacks. User-install / DM / private-channel traffic is identified via authorizingIntegrationOwners and interaction.context and cannot skip the list. A whitelist member cannot drive another operator’s HUD — the clicker must match the user who posted the components. Startup logs DISCORD_ID_WHITELIST active (N user id(s)) without printing the ids. User-install with an empty list logs that those surfaces are denied.
Set DISCORD_USER_INSTALL=0 (or --no-user-install) for a guild-only bot. Default is on: commands are registered globally with integration_types [guild, user] and contexts [guild, bot_dm, private_channel].
The stock /sys env handler redacts keys matching TOKEN|SECRET|PASSWORD|…. It still prints DISCORD_ID_WHITELIST (ids are not a bot token). Combine the allowlist with the /r and /fs allowlists below — a Discord user on the list can still reach a lot of the guest.
Gateway intents
const intents = [dj.GatewayIntentBits.Guilds]
if (wantsMessageContent) intents.push(dj.GatewayIntentBits.MessageContent)
| Need | Intent | How to enable |
|---|---|---|
Slash commands (/ping, /bare, …) |
Guilds only | Default |
Channel message ping → pong |
Message Content (privileged) | Portal toggle and --message-content or DISCORD_MESSAGE_CONTENT=1 |
Requesting Message Content without the portal toggle used to hang client.login() forever: discord.js swallowed 4014 on ShardError / ShardDisconnect and never rejected the login promise. Stock /bin/discord-bot now fails closed (default login timeout 45000 ms, override --login-timeout / DISCORD_LOGIN_TIMEOUT_MS). Do not copy an older “wait forever on login()” snippet.
Recommend identify properties so the gateway sees a stable client:
ws: {
identifyProperties: {
os: (typeof process !== 'undefined' && process.platform) || 'darwin',
browser: 'bare-os',
device: 'bare-os'
}
}
Fatal close codes the stock bot treats as login failure: 4014 (disallowed intents), 4013 (invalid intents), 4004 (authentication failed).
Minimal guest bot
Save as ~/discord-ping.js on the personal drive (Chapter 4). No import. Run: discord-ping.js --env ~/.discord/.env.
async function run(ctx, argv) {
const dj = ctx.bare && ctx.bare.discordJS
if (!dj || typeof dj.Client !== 'function') {
ctx.console.error('ctx.bare.discordJS is unavailable')
ctx.exitCode = 1
return
}
const token = String((ctx.env && ctx.env.DISCORD_TOKEN) || '').trim()
if (!token) {
ctx.console.error('set DISCORD_TOKEN or --env ~/.discord/.env')
ctx.exitCode = 1
return
}
const client = new dj.Client({
intents: [dj.GatewayIntentBits.Guilds]
})
client.once(dj.Events.ClientReady, function (ready) {
ctx.console.log('Logged in as ' + ready.user.tag)
})
client.on(dj.Events.InteractionCreate, async function (interaction) {
if (!interaction.isChatInputCommand || !interaction.isChatInputCommand()) {
return
}
if (interaction.commandName !== 'ping') return
await interaction.reply({ content: 'pong' })
})
await client.login(token)
await new Promise(function (resolve) {
function shutdown() {
Promise.resolve(client.destroy()).catch(function () {}).then(resolve)
}
if (typeof ctx.registerKernelShutdownHook === 'function') {
ctx.registerKernelShutdownHook(shutdown)
}
if (dj.Events.Invalidated) client.once(dj.Events.Invalidated, shutdown)
})
}
For a fuller guest script (.env parser, --check, REST /gateway/bot probe, login timeout, Ctrl+C), copy examples/discord-ping-pong/index.js. To register /ping immediately, pass --guild <id> or set DISCORD_GUILD_ID.
Foreground stock bot: Ctrl+C sets exit 130 and destroys the client (SIGINT, SIGTERM, and bare-os:host-sigint).
Slash commands
Register after Events.ClientReady with the REST client:
const rest = new dj.REST().setToken(token)
const body = [new dj.SlashCommandBuilder().setName('ping').setDescription('Replies with pong.').toJSON()]
if (guildId) {
await rest.put(dj.Routes.applicationGuildCommands(appId, guildId), { body: body })
} else {
await rest.put(dj.Routes.applicationCommands(appId), { body: body })
}
- Guild registration is visible in seconds. Use it while iterating.
- Global registration can take up to about an hour.
- Discord rejects replies longer than 2000 characters of message content. Embeds have a separate budget: description 4096, field value 1024, 25 fields, and 6000 characters across the whole embed. The stock catalog packs every embed to those caps (line-aware clip, never mid-fence), and paginates long file / journal / man / run /
systemctloutput with ← Prev / Next → when it does not fit on one card. Short output is shown in full. Token-shaped strings are redacted. - Prefer
ephemeral: truefor errors, denies, and anything that should not stay in the channel. interaction.replycan be used once. After that,followUporeditReply(if you deferred).
Probe REST before login() if you want a fast token check:
const gw = await new dj.REST({ timeout: 15000 }).setToken(token).get(dj.Routes.gatewayBot())
HTTP 401 here means a bad token, not a gateway hang.
Stock /bin/discord-bot
discord-bot --check --env ~/.discord/.env
discord-bot --env ~/.discord/.env --guild 123456789012345678
discord-bot --help
| Flag | Meaning |
|---|---|
--env PATH |
Guest VFS .env (--env-file is the same) |
--token TOKEN |
Bot token (prefer a file) |
--guild ID |
Instant guild slash registration |
--check |
Resolve token + discordJS; do not login |
--debug |
Print discord.js debug (DISCORD_DEBUG=1) |
--message-content |
Request Message Content Intent |
--login-timeout MS |
Gateway ready deadline (default 45000) |
Slash map
| Command | Subcommands / args | Notes |
|---|---|---|
/panel |
— | Control panel. Global destinations live behind a single Menu button on other replies (expand / Hide menu) |
/bare |
ping about help status whoami hostname date uptime motd uname |
Logged-in session (not guest). Embed + nav buttons |
/sys |
df mem ps env doctor features rlimits |
Parsed /proc JSON → embed fields (RAM, units, limits) |
/svc |
list status start stop restart logs |
Unit autocomplete; select menu + start/stop buttons |
/fs |
ls cat stat head |
Read-only VFS; see path rules |
/net |
peers / swarm / summary |
Swarm + net_summary.json |
/man |
page |
Runs man <page> |
/edit |
path |
Modal editor for ~/ and /tmp (≤20 000 chars, 5 fields). Save writes via VFS |
/create |
path |
Create a new file: path autocomplete, select menu, or custom path, then a contents modal. Refuses to overwrite (offers /edit) |
/upload |
file (required), optional path |
Attach a file (Discord CDN). The bot then runs guest wget -O dest URL into the /r cwd (or path if given: a directory gets path/filename, a file path is -O). Writable trees only (~/, /tmp). 60s timeout. If BARE_OS_HTTP_ALLOWLIST is set, include cdn.discordapp.com and media.discordapp.net |
/hdms |
list help health hints show create add remove invite pair |
Full Hyperdrive manager (ctx.runHdms / /bin/hdms). Default list is an interactive HUD: pick a drive, show, browse /mnt/<label>, invite (pair / RW), remove (confirm). create / add / pair open modals when args are omitted. invite prints the Autopass z32. pair defers (can wait up to the CLI timeout). health / hints read /proc/bare_os/hdms_*.json (no secrets). Requires unlocked identity. Writer secrets in show are redacted. /mnt/… is now allowed for /fs and /files (VFS still enforces RO mounts) |
/holesail |
list help status path logs show url add edit remove start stop restart enable disable service |
Full Holesail manager (ctx.bareOsRunHolesailCli / /bin/holesail). Default list is an interactive HUD: pick a tunnel, show, start/stop, enable/disable, edit (modal), remove (confirm). Add server / Add client open modals. No selection exposes start/stop/restart for the bare-holesail unit (systemctl). show / url print the shareable hs:// key; seed material is never shown (64-hex redacted). service start stop restart status the initd unit. Live tunnel ops defer. |
/agent |
list help status config models ask skills todos plan hooks history recap undo rewind compact export remember reset stop |
Full guest /bin/agent harness from Discord when configured. Ready means: ~/.agent/config.json exists and (QVAC model + ctx.bareOsQvacAvailable, or REST + rest_api_key + rest_base_url). Default list is an HUD: Ask (modal), Stop, Reset, Models, Recap, Compact, plus an Inspect / session menu (skills, todos, plan, history, hooks, undo, rewind, export, remember). ask prompt (required) runs one turn via execLine with BARE_OS_AGENT_DISCORD=1 (Discord-flavored Markdown, shared ~/.agent/history.json, up to 13 minutes, live progress from progress.txt). Optional ask flags: new, plan, auto, compact, max_turns, model. Inspect subs call the same CLI as TTY (agent skills, agent remember …, …). Unconfigured HUD explains agent --setup. API keys never appear in Discord. Whitelist still applies. |
/files |
path |
File manager: browse, paginate, open, new file/folder, rename, copy, delete (writable trees only) |
/settings |
— | Live session settings editor. Categories: appearance (theme, color depth, LS_COLORS, TUI), shell flags (errexit, pipefail, completion, …), session (HOSTNAME, TZ, EDITOR), Discord (guild / whitelist / user-install / debug — never the token), agent (~/.agent/config.json non-secret keys), IRC nick/autojoin, aliases. Changes persist to ~/.barerc, ~/.discord/.env, or app JSON and apply live when the guest already honors the knob |
/r |
cmd (required) |
Full non-interactive guest shell as the unlocked user (short name, was /run). Discord always prompts for cmd (autocomplete: commands, cd dirs, flags, history). Persistent cwd per Discord user. Pipes, &&, redirects, $VAR work. exit does not stop the bot. 20s exec timeout. All command output (ctx.console, bareOsBinWrite, process.stdout/stderr, writeScreen) is captured into the Discord reply and is not printed on the booter TTY. The shell HUD stays available from /panel → Shell and from buttons after a command |
/plugins |
list reload info disable enable |
Personal-drive plugins under ~/.discord/plugins |
/journal |
optional unit |
journalctl or /var/log/bare-os/… |
/ping |
— | pong · Bare OS is online |
/r is the Discord shell: each user keeps a working directory, command history, and last exit. Autocomplete completes builtins, /bin, aliases, flags, and paths relative to that cwd. Interactive TUIs (edit, btop, irc, …) need a real TTY and are labeled as such. Output paginates when it exceeds embed limits. Utilities such as echo and cat write via process.stdout (bareOsEmitRaw); /r hooks that stream for the duration of the command so the booter prompt does not echo guest output. ANSI color and other terminal escapes are stripped from every Discord payload (NO_COLOR=1 / TERM=dumb while capturing; ls will not emit [01;32m…).
/fs allows ., ~, ~/…, /proc, /etc, /var/log, /run, /home, /usr/share, /share, /tmp, /mnt. It rejects .., NUL, ~/.discord/.env, and ~/.discord.env. Reads are capped (~12 KiB). /files and /edit may write under /mnt/<label>/… when the mount is writable (VFS still enforces read-only HDMS drives).
Channel text ping still replies pong only when Message Content Intent is on.
Posted embeds expire after 2 minutes of inactivity. A click or select from the operator who posted the message resets the timer. Denied clicks (off-whitelist or another user) do not. When the timer fires the bot deletes the message (or strips its components and marks the embed expired if delete is denied) and drops the matching in-memory edit / files / settings session.
Settings editor (/settings)
Bare OS has two layers of configuration: host/boot flags (read once when the booter starts) and live session knobs (read from ctx.env / vfs.env on the next command, or applied immediately via a ctx hook). /settings only exposes the second set, plus a few persist-now / apply-next-start values that live in files the guest already owns.
| Surface | What you can change | When it takes effect | Where it is stored |
|---|---|---|---|
| Appearance | theme, BARE_OS_COLOR_DEPTH, BARE_OS_LS_COLORS_LOCKED, BARE_OS_TUI_NO_ALTSCREEN, NO_COLOR, BARE_OS_DIRCOLORS |
Theme / color / dircolors apply immediately via ctx.bareOsApplyTheme. TUI altscreen is next TUI |
theme … and export … lines in ~/.barerc |
| Shell | BARE_OS_COMPACT_MENU, BARE_OS_SHELL_ERREXIT, NOUNSET, NOGLOB, PIPEFAIL, PIPESTATUS, POSIX_MODE, GROUPING, DOUBLE_BRACKET, CMDSUBST, STREAMING, BRACE_EXPANSION, PARAM_EXPANSION, UNTIL, LOOP_CONTROL, READ_BUILTIN |
Next matching command / Tab / pipeline (the shell reads these from env) | export in ~/.barerc |
| Session | HOSTNAME (needs host BARE_OS_HOSTNAME_SET=1 for hostname --set), TZ, LANG, EDITOR, PAGER |
Hostname now when mutation is allowed; others next consumer | export in ~/.barerc |
| Discord | DISCORD_GUILD_ID, DISCORD_ID_WHITELIST, DISCORD_DEBUG, DISCORD_MESSAGE_CONTENT, DISCORD_LOGIN_TIMEOUT_MS |
Whitelist is next slash command. Guild / debug / intents / timeout need a bot restart | Non-secret keys only in ~/.discord/.env (existing DISCORD_TOKEN is left untouched) |
| Agent | backend, qvac_profile, qvac_device, qvac_ctx_size, qvac_gpu_layers, qvac_main_gpu, model / qvac_model, owner_name, agent_label, rest_base_url, temperature, max_tokens, max_iterations, stream, access_policy, context_compaction, compaction_keep_recent, autonomous_mode_enabled, todo_nudge_enabled, tool_parallelism, request_timeout_ms, allow_delete, show_reasoning, reasoning_mode, host-bridge flags, emergency_stop_mutations |
Next agent run |
~/.agent/config.json — never rest_api_key / confirm tokens |
| IRC | nick, autojoin, shareChannels |
Next irc |
~/.irc/config.json — never ~/.irc/secrets.json |
| Aliases | add name=command, remove an alias |
Immediate (ctx.shellAliases) |
alias / unalias lines in ~/.barerc (barerc only understands export, alias, unalias, theme) |
Not in the editor (boot-only, host-only, or secret):
- Tokens and keys:
DISCORD_TOKEN,rest_api_key, SASL password, IPC RPC token - Boot / host gates:
BARE_OS_DISCORD,BARE_OS_TUI,BARE_OS_FISH,BARE_OS_BARE_MODULES, boot profile / skip / policy, store paths, swarm / HTTP allowlists - Identity:
USER/HOMEafterlogin(those come from unlock, not a preference)
Use Reload barerc after hand-editing ~/.barerc so aliases and exports re-apply. ~/.barerc is not a general shell script.
Run as an initd unit
The unit bare-os-discord is registered only when ~/.discord/.env exists with a token. If that file is missing, systemctl list does not show the unit. After creating or editing the file:
systemctl daemon-reload
systemctl start bare-os-discord
systemctl status bare-os-discord
daemon-reload calls syncBareOsDiscordInitd. Identity unlock starts the unit when the file is present (maybeStartBareOsDiscordAfterIdentity). Logout unregisters it so the next session does not inherit a stale client.
Disable even when the file exists: BARE_OS_DISCORD_INITD=0 (or BARE_OS_DISCORD=0).
Log: /var/log/bare-os/discord.log. Example unit comments: kernel/etc/bare-os/units/bare-os-discord.unit.example (keep the seeder kernel copy identical — verify-kernel-seeder-parity).
The unit uses Guilds only (no Message Content). Slash dispatch is the same catalog as /bin/discord-bot.
Plugin system (~/.discord/plugins)
Drop files on the personal drive. No image rebuild. Plugins run as the unlocked session user (same trust as /r and ~/.barerc). Gate the bot with DISCORD_ID_WHITELIST.
~/.discord/plugins/
hello.json # JSON plugin (easiest)
echo.js # JS plugin
weather/
plugin.json # optional manifest
index.js # handler
disabled.txt # one plugin name per line
Copy the samples from examples/discord-plugins/. Then:
/plugins reload
New slash names are published immediately when the running bot has a registrar (stock /bin/discord-bot and bare-os-discord do). Otherwise restart the bot / unit. Guild registration is instant; global can take up to an hour.
/plugins
| Sub | What it does |
|---|---|
list |
Loaded, disabled, and failed plugins |
reload |
Rescan the directory and re-register slash commands |
info name: |
Manifest, kind, error |
disable name: |
Append to disabled.txt |
enable name: |
Remove from disabled.txt |
A file named *.disabled or a folder with "enabled": false is skipped.
Command names
a-z,0-9, hyphen; 1–32 characters; must start with a letter or digit.- Reserved (cannot override):
bare,sys,svc,fs,net,man,say,run,journal,edit,create,files,browse,settings,panel,ping,plugins. - At most 40 plugins. Each source file is capped at 64 KiB.
JSON plugin SDK
A single ~/.discord/plugins/<name>.json (or …/<name>/plugin.json):
{
"name": "hello",
"description": "Greet someone from Bare OS",
"version": "1.0.0",
"options": [
{ "name": "who", "description": "Name to greet", "required": false, "autocomplete": false }
],
"run": "echo Hello, ${who:-world} — from $USER on $HOSTNAME",
"title": "hello",
"ephemeral": false
}
| Field | Meaning |
|---|---|
name |
Slash command (/hello) |
description |
Shown in Discord |
options[] |
String options (name, description, required, autocomplete) |
subcommands[] |
Optional. Each may have its own options, run, file, embed |
run / shell |
Guest shell line via ctx.execLine (20s timeout). Expands ${opt} / ${opt:-default} and $ENV |
file |
Read a VFS path and page it (same pager as /fs cat) |
embed |
{ title, desc, fields, file } static card |
title |
Embed title for run / file |
ephemeral |
Reply only visible to the caller |
enabled |
false skips the plugin |
${who} is the slash option; $USER / $HOME / $HOSTNAME come from session env. Secrets are still redacted in output.
JS plugin SDK
~/.discord/plugins/<name>.js or …/<name>/index.js. No import / require. The loader evaluates the file and looks for:
async function register(bot) { ... }(preferred), orconst plugin = { name, description, run, ... }.
function register(bot) {
bot.command({
name: 'echo',
description: 'Echo text back as an embed',
options: [{ name: 'text', description: 'What to say', required: true }],
async run(ev) {
return { title: 'echo', desc: ev.opt('text'), footer: ev.user + ' · plugin' }
}
})
}
run may return:
| Return | Result |
|---|---|
string |
Plain message content (redacted) |
{ title, desc, fields, footer, color } |
One embed |
{ embeds, components, ephemeral } |
Full catalog result |
{ more: { title, body, fence } } |
Paged long text |
{ text, ephemeral } |
Short reply |
bot (also passed as sdk)
| Method | Role |
|---|---|
bot.command(spec) |
Register this slash command (call once, or once per command) |
bot.embed(opts) / bot.field(name, value, inline) |
Same cards as stock embeds (auto-fit Discord limits) |
bot.result(embed, extra) |
Attach components / nav |
bot.fence(text) / bot.redact(text) |
Code block + token redaction |
bot.more({ title, body, fence }) |
Long-text pager |
bot.id('ok') |
Custom id plug:<name>:ok (never collides with stock buttons) |
bot.sh(line) |
execLine + captured stdout/stderr (20s) |
bot.read(path) / bot.ls(path) |
VFS read / readdir |
bot.write(path, text) |
Write only under ~/, /tmp, /home/$USER (token files blocked) |
bot.user() / bot.home() / bot.env() |
Session identity; env() omits TOKEN/SECRET/… |
ev (the run argument)
| Field | Role |
|---|---|
ev.opt('text') |
Slash string option |
ev.sub |
Subcommand name |
ev.user / ev.userId |
Session user / Discord id |
ev.interaction |
Raw discord.js interaction (advanced) |
ev.ctx |
Full guest ctx (advanced — same power as /r) |
ev.bot |
The bot SDK |
ev.sh / ev.read / ev.write / ev.ls |
Same helpers |
Optional hooks on the same spec:
bot.command({
name: 'pick',
description: 'Pick a color',
options: [{ name: 'color', description: 'Color', autocomplete: true }],
async run(ev) {
return { title: ev.opt('color') }
},
async onAutocomplete(ev) {
return ['red', 'green', 'blue']
},
async onComponent(ev) {
// ev.id is the suffix after plug:<name>:
return { text: 'clicked ' + ev.id }
},
async onModal(ev) {
return { text: ev.value }
}
})
Buttons you add must use bot.id('save') so the catalog routes them back to onComponent.
Folder plugin
~/.discord/plugins/weather/
plugin.json # name, description, options
index.js # register(bot) — can ignore name if plugin.json set it
JS wins for run when both exist; JSON still supplies the slash metadata.
Reloading
/plugins reload re-reads the directory and, on stock /bin/discord-bot / bare-os-discord, PUTs slash commands again. Dispatch uses the new handlers immediately.
Extending the stock catalog
Edit packages/bare-os-booter/lib/bare-os-discord-commands-guest.cjs. That file must stay guest-safe: no import / export. Use var / function. module.exports is the host/pack surface (static import from initd). The /bin preamble ignores module.exports when module is absent.
-
Add a
SlashCommandBuilder(and subcommands) indiscordBuildSlashCommands. -
Handle the name in
discordDispatchInteraction. -
Keep replies under ~1900 characters. Use
discordCmdRedact/discordCmdFencefor command output. -
Do not read
~/.discord/.envin/fs. -
Rebuild the image binary:
npm run build -w bare-os-coreutilsThat prepends the catalog onto
/bin/discord-botand mirrorspackages/bare-os-seeder/kernel/. -
Add a Brittle case in
test.bare-discord-initd.js(buildSlashCommands+dispatchInteractionwith a fake interaction).
Do not add require('discord.js') to guest sources. The catalog receives a dj object (SlashCommandBuilder only) at register time and ctx at dispatch time.
Shipping your own /bin bot
Follow Chapter 16:
packages/bare-os-coreutils/src/<name>.jswithasync function run(ctx, argv)and noimport.- Optional
preambleinbuild.mjsif you need shared helpers (the stock bot prependsbare-os-discord-commands-guest.cjsfrom the booter tree). man/pages/<name>.json.- Register the name in
lib/commands.mjs. npm run build -w bare-os-coreutils.
For one-off bots, a personal-drive *.js is enough. A /bin name is for something every guest should have.
Host, Pear, and packed standalone
Maintainers changing login, packing, or CI should treat these as one path:
| Piece | Role |
|---|---|
vendor/bare-discord-js |
Official discord.js 14 + Bare remaps |
bare-os-discord-ws-bootstrap.mjs |
Installs the WHATWG bare-ws wrapper before packed import discord.js |
whatwg-ws.cjs |
send / onmessage (npm ws / raw bare-ws.Socket is a Duplex and will not IDENTIFY correctly) |
bare-os-standalone-pack-imports.mjs |
Pack graph: bootstrap → commands-guest → host modules → packed discord.js |
build/stubs/zlib-sync.cjs |
Must export null. A truthy {} stub makes @discordjs/ws take the zlib-stream path and hang before READY |
process.versions.bun = 'bare-os' |
Set by the wrapper so @discordjs/ws selects globalThis.WebSocket |
On macOS, do not overwrite a running bare-os-booter in place. That invalidates the ad-hoc code signature and the next exec is SIGKILL. Write a new inode, then codesign.
Bare-only smoke (needs a real token in the environment the script already documents — do not print it):
npm run test:discord-login -w bare-os-booter
Testing
| Test | What it covers |
|---|---|
packages/bare-os-booter/test.bare-discord-env.js |
Token normalize, .env parse, host inject, --check, guest-safe sources, default intents, 4014 fail-closed |
packages/bare-os-booter/test.bare-discord-initd.js |
Unit hidden without ~/.discord/.env, catalog, whitelist, /files, /settings, /r shell, plugins (JSON + JS), embed limits |
Guest --check |
ctx.bare.discordJS + token present, no gateway |
Fake an interaction without Discord:
const replies = []
await cmds.dispatchInteraction(
{ env: { DISCORD_ID_WHITELIST: '111' }, vfs: {}, console: {} },
{
isChatInputCommand: () => true,
commandName: 'ping',
user: { id: '999' },
reply: async (p) => {
replies.push(p)
}
}
)
Do not assert on live tokens in CI. Redact DISCORD_TOKEN in any fixture .env.
Troubleshooting
| Symptom | Likely cause | What to do |
|---|---|---|
ctx.bare.discordJS is unavailable |
Load skipped or failed | Unset BARE_OS_DISCORD=0. Read BARE_OS_DISCORD_LOAD_ERROR. Restart the booter, not only /bin |
missing DISCORD_TOKEN |
No session token and no readable .env |
Write ~/.discord/.env or pass --env. Confirm VFS path (~ is the personal home) |
REST /gateway/bot 401 |
Wrong secret | Bot token, not OAuth2 client secret. Reset Token |
Login timeout, last line Identifying |
Packed ws / zlib stub / old binary |
Confirm WS bootstrap runs before discord.js; zlib-sync stub is null; you are running the rebuilt booter |
| Gateway 4014 | Message Content requested, portal off | Drop --message-content or enable the intent |
| Gateway 4004 | Bad token (BOM, extra quotes, old token) | Normalize / reset |
| Slash commands missing | Global register delay, or bot not in guild | Set DISCORD_GUILD_ID / --guild. Re-invite with applications.commands |
Access denied |
Allowlist | Add your user id to DISCORD_ID_WHITELIST, restart the bot |
Unit missing from systemctl |
No ~/.discord/.env or BARE_OS_DISCORD_INITD=0 |
Create the file, systemctl daemon-reload |
| Hang after “logging in” with no REST line | Old binary still requesting privileged intents | Rebuild /bin/discord-bot; default must be Guilds-only |
macOS SIGKILL after replacing booter |
Code signature invalidated | Atomic replace + codesign a new inode |
Channel ping ignored |
No Message Content | Expected on Guilds-only. Use /ping |
reply failed: process.emitWarning is not a function |
Bare process lacks Node's emitWarning; older bots passed ephemeral: |
Rebuild /bin/discord-bot / booter. Replies use flags (64) instead of ephemeral. Restart the bot. |
Supplying "fetchReply" … is deprecated |
Older catalog passed fetchReply: true on reply / followUp |
Rebuild. Replies no longer send that option; the message is fetched afterwards for the idle timer. |
--debug / DISCORD_DEBUG=1 prints discord.js debug during login. Token JSON is redacted in the stock ws send hook ("token":"***").
Security
- The stock bot is a remote shell surface for whoever can invoke slash commands. Put
DISCORD_ID_WHITELISTin.envbefore inviting the bot to a public guild. /svccan start and stop units./ris a full guest shell. Treat whitelist members as operators.- Tokens in
ctx.envare redacted by/sys envanddiscordCmdRedact. Do notconsole.logctx.env. - Guest scripts are not a sandbox (Chapter 9). Stock
/ris a fullctx.execLineshell as the unlocked user. Gate it withDISCORD_ID_WHITELIST. Tokens are redacted in output;exit/login/logoutare intercepted so they cannot tear down the bot. - Plugins under
~/.discord/pluginsare your code with the same power as/r(execLine, VFS). Do not install plugin JS from untrusted people. The SDKwritepath still blocks token files. - Rotate a token if it appeared in a screenshot, issue, or chat.
File map
| Path | Role |
|---|---|
packages/bare-os-coreutils/src/init-discord.js |
Setup wizard (~/.discord/.env + hello plugin) |
packages/bare-os-coreutils/src/discord-bot.js |
Foreground guest bot (run) |
packages/bare-os-coreutils/man/pages/discord-bot.json |
man discord-bot |
packages/bare-os-booter/lib/bare-os-discord-commands-guest.cjs |
Slash catalog (guest-safe CJS) |
packages/bare-os-booter/lib/bare-os-discord-initd.js |
bare-os-discord unit |
packages/bare-os-booter/lib/bare-discord-js-loader.js |
Load + host .env inject |
packages/bare-os-booter/lib/bare-os-discord-ws-bootstrap.mjs |
Packed gateway WebSocket |
examples/discord-ping-pong/index.js |
Copy-paste guest example |
examples/discord-plugins/ |
Sample JSON + JS plugins for ~/.discord/plugins |
~/.discord/plugins/ |
User plugins (personal drive) |
kernel/etc/bare-os/units/bare-os-discord.unit.example |
Unit comments |
See also
- Chapter 1 — Two runtimes
- Chapter 4 — User scripts and PATH
- Chapter 5 — Modules and
import - Chapter 7 — Apps beyond the shell
- Chapter 12 —
ctx.bare - Chapter 16 — How to add a
/binutility - Environment appendix —
DISCORD_* - Reference —
/bin/discord-bot man discord-bot·man devguide-21-discord-bots(after a coreutils build)