Files
bare-operating-system/developer-guide/21-discord-bots.md
T
2026-08-18 15:58:42 -04:00

42 KiB
Raw Blame History

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, or console.log.


On this page


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

  1. Discord Developer Portal → New Application.
  2. Bot → Reset Token → copy once into ~/.discord/.env (guest) or a host .env you will not commit.
  3. Privileged Gateway Intents — leave Message Content Intent off unless you need channel text (pingpong). The stock client requests Guilds only by default. Requesting Message Content while the portal toggle is off closes the gateway with 4014.
  4. Installation — enable User Install and Guild Install. User Install scope is applications.commands only (profile app). Guild Install scopes are bot + 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).
  5. 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.
  6. 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/.env verifies that ctx.bare.discordJS exists 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)

  1. --token TOKEN
  2. ctx.env.DISCORD_TOKEN (already in the session, including a host copy)
  3. --env / --env-file PATH (guest VFS)
  4. DISCORD_ENV_FILE / BARE_OS_DISCORD_ENV_FILE (guest VFS)
  5. ~/.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 operators 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 pingpong 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 / systemctl output with ← Prev / Next → when it does not fit on one card. Short output is shown in full. Token-shaped strings are redacted.
  • Prefer ephemeral: true for errors, denies, and anything that should not stay in the channel.
  • interaction.reply can be used once. After that, followUp or editReply (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 (≤20000 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.jsonnever rest_api_key / confirm tokens
IRC nick, autojoin, shareChannels Next irc ~/.irc/config.jsonnever ~/.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 / HOME after login (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; 132 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:

  1. async function register(bot) { ... } (preferred), or
  2. const 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.

  1. Add a SlashCommandBuilder (and subcommands) in discordBuildSlashCommands.

  2. Handle the name in discordDispatchInteraction.

  3. Keep replies under ~1900 characters. Use discordCmdRedact / discordCmdFence for command output.

  4. Do not read ~/.discord/.env in /fs.

  5. Rebuild the image binary:

    npm run build -w bare-os-coreutils
    

    That prepends the catalog onto /bin/discord-bot and mirrors packages/bare-os-seeder/kernel/.

  6. Add a Brittle case in test.bare-discord-initd.js (buildSlashCommands + dispatchInteraction with 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:

  1. packages/bare-os-coreutils/src/<name>.js with async function run(ctx, argv) and no import.
  2. Optional preamble in build.mjs if you need shared helpers (the stock bot prepends bare-os-discord-commands-guest.cjs from the booter tree).
  3. man/pages/<name>.json.
  4. Register the name in lib/commands.mjs.
  5. 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_WHITELIST in .env before inviting the bot to a public guild.
  • /svc can start and stop units. /r is a full guest shell. Treat whitelist members as operators.
  • Tokens in ctx.env are redacted by /sys env and discordCmdRedact. Do not console.log ctx.env.
  • Guest scripts are not a sandbox (Chapter 9). Stock /r is a full ctx.execLine shell as the unlocked user. Gate it with DISCORD_ID_WHITELIST. Tokens are redacted in output; exit / login / logout are intercepted so they cannot tear down the bot.
  • Plugins under ~/.discord/plugins are your code with the same power as /r (execLine, VFS). Do not install plugin JS from untrusted people. The SDK write path 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