@mporenta/pi-discord-remote
v0.2.5
Published
Pi extension: bidirectional Discord remote control for a local Pi coding-agent session.
Maintainers
Readme
discord-remote
Bidirectional Discord remote control for a local Pi coding-agent session. Your Pi instance keeps running on the laptop; you talk to it from Discord.
- Session threads: every Pi session gets a Discord thread named with the Pi
session ID (for example
pi-<session-id>), and all session traffic is routed to that thread. - Outbound: every assistant message streams to the active Discord thread as the tokens arrive. Tool calls and tool results render as concise Discord embeds.
- Inbound: messages you type in the allowlisted channel or active session
thread inject into Pi via
pi.sendUserMessage(...). Mid-turn messages steer the running turn; idle-state messages queue as a normal user turn. - Discord commands: authorized Discord users can run Discord-side Pi commands
such as
/commands,/session,/compact,/abort, and/new. The bot can optionally register these bridge controls as guild-scoped Discord app slash commands. Because Pi only exposes session replacement from command handlers, run/discord-armin the active local Pi session to enable/newfrom Discord. Re-run it after local session changes; Discord-created sessions keep the bridge armed.
⚠️ Security
Allowlisted Discord users get effectively the same access to your machine as
sitting at the keyboard. Pi will gladly run bash, edit files, and call any
configured provider's APIs. Treat your bot token and user allowlist with the
same care as an SSH key:
- Use a fresh, private Discord application dedicated to this. Do not reuse a bot that's in any public server.
- Put the bot in a private guild with only you (and trusted users) in it.
- Set
PI_DISCORD_USER_IDSto the Discord user IDs that may drive Pi. The extension refuses to start with an empty allowlist. - Never commit
.env. The repo's.gitignorealready excludes it. - The extension also refuses to start without a bot token, channel allowlist,
and user allowlist. Prefer
PI_DISCORD_BOT_TOKEN,PI_DISCORD_CHANNEL_IDS, andPI_DISCORD_USER_IDS; legacyDISCORD_*fallback names are disabled unlessPI_DISCORD_ALLOW_LEGACY_ENV=true.
Setup
1. Create a Discord bot
- Go to https://discord.com/developers/applications, click New
Application, name it
pi-remote(or whatever). - In the sidebar, open Bot. Click Reset Token and copy the token — this is
PI_DISCORD_BOT_TOKEN. - Still on the Bot page, scroll to Privileged Gateway Intents and enable Message Content Intent. Save. (Reactions and typing use the default non-privileged intents — no extra toggle required.)
- In the sidebar, open OAuth2 → URL Generator. Check
botandapplications.commandsunder scopes; under bot permissions, checkView Channels,Send Messages,Read Message History,Add Reactions,Embed Links,Create Public Threads,Send Messages in Threads, andManage Threadsif you want the bot to unarchive old session threads. Copy the generated URL, open it in a browser, and invite the bot to your private guild.
2. Find IDs
Enable Discord's Developer Mode (Settings → Advanced → Developer Mode). Then:
- Right-click the target channel → Copy Channel ID →
PI_DISCORD_CHANNEL_IDS. - Right-click your own avatar → Copy User ID →
PI_DISCORD_USER_IDS.
3. Configure .env
Copy .env.sample to .env (project root) and fill in:
PI_DISCORD_ENABLED=true
PI_DISCORD_BOT_TOKEN=<your bot token>
PI_DISCORD_CHANNEL_IDS=<channel id>
PI_DISCORD_USER_IDS=<your discord user id>
# Optional: enabled by default; creates one Discord thread per Pi session.
PI_DISCORD_CREATE_THREAD_PER_SESSION=true
PI_DISCORD_THREAD_NAME_PREFIX=pi
# Optional: disabled by default. Set one or more guild IDs to register
# Discord bridge controls as guild-scoped app slash commands.
PI_DISCORD_REGISTER_SLASH_COMMANDS=false
PI_DISCORD_SLASH_COMMAND_GUILD_IDS=<guild id>bin/pi-safe.sh (and friends) load .env at launch, so these vars become
visible to the extension. The extension also loads .env from Pi's current
working directory by default; override with PI_DISCORD_ENV_FILE=/path/to/.env
if needed.
In this repo, the extension also accepts newer Pi-prefixed app keys as fallbacks:
PI_DISCORD_BOT_CHANNEL_ID→ channel allowlist and default primary channelPI_DISCORD_USER_ID→ user allowlist
Legacy OpenClaw DISCORD_* names (DISCORD_BOT_TOKEN,
DISCORD_BOT_CHANNEL_ID, DISCORD_USER_ID) are disabled by default to avoid
silently selecting the wrong bot or channel. Set
PI_DISCORD_ALLOW_LEGACY_ENV=true only when you intentionally want those
fallbacks. Any fallback still requires PI_DISCORD_ENABLED=true.
4. Install from npm
pi install npm:@mporenta/pi-discord-remoteFor local development from this repo instead:
cd extensions/discord-remote
npm install
cd ../..This populates extensions/discord-remote/node_modules/discord.js and uses the
committed package-lock.json. The Pi packages stay shared at the repo root
(declared as peerDependencies here).
5. Run Pi with the extension
pi -e extensions/discord-remote/index.tsOr combine with the team launcher:
npm run pi:safe -- -e extensions/discord-remote/index.tsThe extension is opt-in — it's not in bin/pi-safe.sh by default because
it requires the env vars above.
Publishing
The npm package is @mporenta/pi-discord-remote. CI publishes from
extensions/discord-remote when changes land on main and the package version
is not already on npm. Configure the GitHub repository secret
NPM_ACCESS_TOKEN with an npm automation token that can publish to the
@mporenta scope.
To release a new version:
cd extensions/discord-remote
npm version patch --no-git-tag-versionCommit the package files and push to main; GitHub Actions handles
npm publish --access public.
Slash commands (inside Pi)
/discord-arm— arms the Discord command bridge for session-changing commands such as/newfrom Discord for the active Pi session. This is required because Pi only exposesctx.newSession(...)to command handlers, not background message events. Re-run it after local/new,/resume,/fork, or/reload; sessions created by Discord/newre-arm themselves./discord-status— token set?, gateway ready?, mute state, channel allowlist, user allowlist, active session thread, and render flags./discord-test [message]— post a one-off message to the active session thread (or primary channel before a thread exists). Use to confirm the bot is connected before you start a session./discord-reconnect— destroy and recreate the client. Useful if the gateway disconnected and didn't auto-recover./discord-mute— suppress further Pi output to Discord without disconnecting the gateway. Inbound Discord messages still drive Pi. Pair with/discord-unmuteto resume./discord-unmute— resume Pi output to Discord after/discord-mute.
Inbound commands (from Discord)
- Plain text — forwarded as
<steerLabel> <text>viapi.sendUserMessage. When Pi is idle the message queues as a normal user turn (✅ react). When Pi is mid-turn the message steers the running turn (📥 react). Only messages posted in an allowlisted channel or in the current session's thread are forwarded; posts in stale session threads are ignored so they can't cross-steer the live session. - React 🛑 — react with 🛑 on any bot message in an allowlisted channel
or the current session's thread to abort the running Pi turn. Faster
than typing
/abortwhen a long-running tool needs cancelling. Reactions in stale session threads are ignored for the same scoping reason as inbound text. /commandsor/help— lists Discord bridge controls plus Pi's currently registered session commands (extension, prompt, and skill metadata frompi.getCommands()). Pi command metadata is visibility-only because the public extension API does not expose a safe background dispatcher for arbitrary Pi command handlers. WhenPI_DISCORD_REGISTER_SLASH_COMMANDS=trueandPI_DISCORD_SLASH_COMMAND_GUILD_IDSis set, the explicit Discord bridge controls are registered as native Discord slash commands via the@discordjs/restmodule (re-exported by discord.js) and the explicitRoutes.applicationGuildCommands(applicationId, guildId)route, so registration is scoped to the private guild(s) and never escapes to global application commands. Each command is defined withSlashCommandBuilderand the bot listens forinteractionCreateevents over the gateway./session— shows the active Pi session ID and Discord thread binding./compact— callsctx.compact()for the active session./abortor!abort— callsctx.abort()to cancel an in-flight turn. 🛑 react confirms. (Reacting 🛑 on a bot message does the same thing.)/mute— suppress all further Pi output (streaming assistant edits, tool embeds, raw posts, and typing indicator) without disconnecting the gateway. Any pending stream-buffer flush is cancelled so an in-flight turn goes quiet immediately. Inbound messages still drive Pi. 🔇 react confirms./unmute— resume Pi output. 🔈 react confirms./new [message]— creates a new Pi session and a new Discord session thread, then optionally sendsmessageas the first prompt. Run/discord-armin the active local Pi TUI session to enable this command; re-run it after local session changes.
Reaction shortcuts
| React | Where | Effect |
| ----- | ------------------------------------------------------------------------- | --------------------------------------------------- |
| 🛑 | Any bot message in an allowlisted channel or the current session's thread | Abort the running Pi turn (equivalent to /abort). |
The bot also reacts on your inbound messages to confirm delivery: ✅ (queued while idle), 📥 (steered into a running turn), 🔇 / 🔈 (mute / unmute applied), 🛑 (abort issued), 🧹 (compact), 🆕 / 🚫 (new session created / cancelled).
Live feedback
While Pi is generating an assistant message, the bot shows a Discord typing indicator in the active session thread. The indicator refreshes every 8 seconds and stops when the turn ends, so the channel always reflects whether Pi is actively working.
Tool-result embeds also include a Duration field showing how long the tool
took to run (e.g. 850ms, 12.3s, 1m 42s), so you can spot slow tools
without scrolling the local TUI.
Pi's public extension API currently executes extension slash commands only from
Pi prompt handling and deliberately skips command expansion for
pi.sendUserMessage(...). The Discord bridge therefore implements the safe
Discord-side built-ins above and lists all available Pi slash commands for
visibility; additional custom command execution should be added as explicit
Discord handlers when the command requires command-context-only methods. The
bridge validates that the armed command context still belongs to the active
session before honoring Discord /new, and asks you to re-arm if a local session
change or reload made the context stale.
Configuration reference
| Var | Default | Effect |
| ---------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| PI_DISCORD_ENABLED | false | Master toggle. Default off so a stray .env doesn't auto-start the bot. |
| PI_DISCORD_ALLOW_LEGACY_ENV | false | Enable legacy DISCORD_* fallbacks. |
| PI_DISCORD_BOT_TOKEN | DISCORD_BOT_TOKEN only when legacy fallback is enabled | Bot token. Required. |
| PI_DISCORD_CHANNEL_IDS | PI_DISCORD_BOT_CHANNEL_ID, then legacy DISCORD_BOT_CHANNEL_ID only when enabled | Comma-separated channel ID allowlist. Required. |
| PI_DISCORD_USER_IDS | PI_DISCORD_USER_ID, then legacy DISCORD_USER_ID only when enabled | Comma-separated Discord user ID allowlist. Required. |
| PI_DISCORD_PRIMARY_CHANNEL_ID | first allowlisted channel | Parent channel where session threads are created. |
| PI_DISCORD_CREATE_THREAD_PER_SESSION | true | Create a Discord thread for each Pi session and route traffic there. |
| PI_DISCORD_THREAD_NAME_PREFIX | pi | Prefix for session thread names; the Pi session ID is appended. |
| PI_DISCORD_THREAD_AUTO_ARCHIVE_MINUTES | 10080 | Thread auto-archive duration (60, 1440, 4320, or 10080). |
| PI_DISCORD_EDIT_INTERVAL_MS | 1100 | Min ms between edits per streamed message. |
| PI_DISCORD_CREATE_INTERVAL_MS | 600 | Min ms between new-message creates per channel. |
| PI_DISCORD_MAX_CHARS | 1900 | Soft cap per Discord message (Discord hard-limits at 2000). |
| PI_DISCORD_RENDER_USER_INPUT | true | Mirror locally-typed user input to Discord. |
| PI_DISCORD_RENDER_TOOL_CALLS | true | Post readable tool-call embeds with action + key inputs. |
| PI_DISCORD_RENDER_TOOL_RESULTS | true | Update tool-call embeds with concise result summaries. |
| PI_DISCORD_RENDER_REASONING | false | Include thinking content blocks (italicized). |
| PI_DISCORD_TOOL_ARGS_MAX_CHARS | 800 | Truncate tool-call argument JSON to this length. |
| PI_DISCORD_TOOL_RESULT_MAX_CHARS | 1000 | Truncate tool-result text to this length. |
| PI_DISCORD_PREFIX | "" | Optional prefix on relayed messages (e.g. 🤖). |
| PI_DISCORD_STEER_LABEL | [discord] | Tag prepended to inbound text so local view shows source. |
| PI_DISCORD_REGISTER_SLASH_COMMANDS | false | Register explicit Discord bridge controls as app slash commands. |
| PI_DISCORD_SLASH_COMMAND_GUILD_IDS | "" | Required comma-separated guild IDs for slash-command registration. |
Rendering behavior
Tool calls and tool results are rendered as Discord embeds instead of raw JSON.
The embed shows the tool action, selected non-secret inputs, and a concise result
summary. Secret-like argument keys (token, secret, password, api_key,
webhook, etc.), common token formats, and sensitive environment variable
values are redacted before rendering. Discord mentions are disabled on relayed
messages to avoid accidental pings.
Known limitations
- Discord supports explicit bridge commands (
/commands,/session,/compact,/abort, and/new) and lists Pi slash command metadata. Pi's public API does not expose a general command dispatcher to background Discord message events, so arbitrary custom slash commands need explicit Discord-side handlers if they require command-context-only methods. Discord/newis intentionally session-scoped: re-run/discord-armafter local session replacements or/reload. - Discord rate limits: edits to a single message ≈ 5/5s; messages per channel ≈ 1/0.5s. The defaults stay under both. Pathologically fast streams will briefly buffer.
- 2000-char hard cap → long assistant messages span multiple Discord posts. Once page 2 exists, page 1 is locked; late retroactive shrinkage of early content does not re-flow pages.
- No image / file attachment relay (inbound or outbound) in v1. Inbound
attachments are ignored; only
msg.contentis forwarded. - One active Discord output target at a time. By default this is the current session thread; if thread creation is disabled or fails, the primary channel is used. Multi-channel inbound is allowed via the allowlist.
- The
tool_call/tool_resultpairing relies on Pi running tool calls in order; if parallel tool execution interleaves rendering, results still attach to the right call (lookup is bytoolCallId).
Troubleshooting
/discord-statusshowsready=false— checkPI_DISCORD_BOT_TOKEN, confirm Message Content Intent is enabled in the developer portal, and that your network allows outbound WSS togateway.discord.gg.refusing to starton stderr — one of the required env vars is empty.- Messages in channel ignored — you're not in
PI_DISCORD_USER_IDS, or the channel isn't inPI_DISCORD_CHANNEL_IDS. Missing Accessor send fails — the bot is in the guild but cannot see the target text channel. Grant the bot role (or the bot user)View Channel,Send Messages,Send Messages in Threads,Create Public Threads,Read Message History,Add Reactions, andEmbed Linkson the channel/category, then rerun/discord-test.- Bot stays online after Pi quits —
session_shutdownshould destroy the client; if it didn't (e.g. you killed Pi withkill -9), Discord still shows the bot online for ~30s of heartbeat timeout.
