@mporenta/pi-discord-remote

Pi extension: bidirectional Discord remote control for a local Pi coding-agent session.

Packages

Package details

extension

Install @mporenta/pi-discord-remote from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@mporenta/pi-discord-remote
Package
@mporenta/pi-discord-remote
Version
0.3.11
Published
Jun 7, 2026
Downloads
2,491/mo · 786/wk
Author
pi-porenta
License
MIT
Types
extension
Size
93.4 KB
Dependencies
1 dependency · 5 peers
Pi manifest JSON
{
  "extensions": [
    "./index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

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.
  • /new works automatically: the extension starts a local HTTP session-launcher server on 127.0.0.1:8765. When /new is invoked from Discord, it either replaces the session in-process (if /discord-arm was run in the Pi TUI) or spawns a fresh Pi process (configured via PI_DISCORD_SPAWN_COMMAND). The HTTP server can also be reached by external callers via nginx (see nginx.conf.sample).

⚠️ 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_IDS to the Discord user IDs that may drive Pi. The extension refuses to start with an empty allowlist.
  • Never commit .env. The repo's .gitignore already 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, and PI_DISCORD_USER_IDS; legacy DISCORD_* fallback names are disabled unless PI_DISCORD_ALLOW_LEGACY_ENV=true.

Setup

1. Create a Discord bot

  1. Go to https://discord.com/developers/applications, click New Application, name it pi-remote (or whatever).
  2. In the sidebar, open Bot. Click Reset Token and copy the token — this is PI_DISCORD_BOT_TOKEN.
  3. 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.)
  4. In the sidebar, open OAuth2 → URL Generator. Check bot and applications.commands under scopes; under bot permissions, check View Channels, Send Messages, Read Message History, Add Reactions, Embed Links, Create Public Threads, Send Messages in Threads, and Manage Threads if 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 IDPI_DISCORD_CHANNEL_IDS.
  • Right-click your own avatar → Copy User IDPI_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 channel
  • PI_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-remote

For 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.ts

Or combine with the team launcher:

npm run pi:safe -- -e extensions/discord-remote/index.ts

The 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-version

Commit the package files and push to main; GitHub Actions handles npm publish --access public.

Slash commands (inside Pi)

  • /discord-arm — arms in-process session replacement for Discord /new. This is now optional: when not armed, /new falls back to spawning a new Pi process via PI_DISCORD_SPAWN_COMMAND. Run it if you prefer in-process replacement (faster, no new TUI window). Re-run after local /new, /resume, /fork, or /reload; Discord-created sessions re-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-launcher-status — show the launcher HTTP server status (port, running, armed, spawn config).
  • /discord-mute — suppress further Pi output to Discord without disconnecting the gateway. Inbound Discord messages still drive Pi. Pair with /discord-unmute to resume.
  • /discord-unmute — resume Pi output to Discord after /discord-mute.

Inbound commands (from Discord)

  • Plain text — forwarded as <steerLabel> <text> via pi.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 /abort when a long-running tool needs cancelling. Reactions in stale session threads are ignored for the same scoping reason as inbound text.
  • /commands or /help — lists Discord bridge controls plus Pi's currently registered session commands (extension, prompt, and skill metadata from pi.getCommands()). Pi command metadata is visibility-only because the public extension API does not expose a safe background dispatcher for arbitrary Pi command handlers. When PI_DISCORD_REGISTER_SLASH_COMMANDS=true and PI_DISCORD_SLASH_COMMAND_GUILD_IDS is set, the explicit Discord bridge controls are registered as native Discord slash commands via the @discordjs/rest module (re-exported by discord.js) and the explicit Routes.applicationGuildCommands(applicationId, guildId) route, so registration is scoped to the private guild(s) and never escapes to global application commands. Each command is defined with SlashCommandBuilder and the bot listens for interactionCreate events over the gateway.
  • /session — shows the active Pi session ID and Discord thread binding.
  • /compact — calls ctx.compact() for the active session.
  • /abort or !abort — calls ctx.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 sends message as the first prompt. Works without /discord-arm: if the bridge is not armed the extension spawns a fresh Pi process (configured by PI_DISCORD_SPAWN_COMMAND / PI_DISCORD_SPAWN_ARGS). The spawned process inherits all PI_DISCORD_* env vars, connects to Discord, and creates its own session thread. Arm the bridge for faster in-process replacement instead.

Session launcher HTTP server

The extension automatically starts a local HTTP server on 127.0.0.1:PI_DISCORD_LAUNCHER_PORT (default 8765) when Pi starts. It provides two endpoints:

Method Path Description
GET /health JSON: { ok, ready, armed }
POST /new-session JSON body: { "secret": "…", "args": "…" } — creates a new Pi session

When /new-session is called:

  1. If the bridge is armed (via /discord-arm): replaces the current session in-process using ctx.newSession(). The args string becomes the first user message.
  2. If not armed: spawns PI_DISCORD_SPAWN_COMMAND with PI_DISCORD_SPAWN_ARGS. The child process inherits all PI_DISCORD_* vars, connects to Discord, and creates its own thread. The launcher sets PI_DISCORD_LAUNCHER_ENABLED=false in the child to prevent port conflicts.

If the port is already in use (e.g. a second Pi instance), the server silently skips binding — no error, the Discord /new command still works via the first instance.

Authentication

Set PI_DISCORD_LAUNCHER_SECRET in .env to a random string. The server returns 401 when the secret is present but wrong. Without a secret the endpoint is unauthenticated — only expose it externally behind nginx and HTTPS (see nginx.conf.sample).

External access via nginx

See nginx.conf.sample for a ready-to-adapt nginx location block that proxies https://<YOUR_DOMAIN>/pi-launcher/ to the local server with TLS termination. Example curl:

curl -X POST https://your.host/pi-launcher/new-session \
  -H "Content-Type: application/json" \
  -d '{"secret":"mysecret","args":"Summarise open PRs"}'

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 /new now works without /discord-arm by spawning a new Pi process. Set PI_DISCORD_SPAWN_COMMAND and PI_DISCORD_SPAWN_ARGS to the launcher you want (e.g. bin/pi-safe.sh -e extensions/discord-remote/index.ts). In-process session replacement via arm is faster but requires running /discord-arm after each local session change.
  • 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.content is 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_result pairing relies on Pi running tool calls in order; if parallel tool execution interleaves rendering, results still attach to the right call (lookup is by toolCallId).

Troubleshooting

  • /discord-status shows ready=false — check PI_DISCORD_BOT_TOKEN, confirm Message Content Intent is enabled in the developer portal, and that your network allows outbound WSS to gateway.discord.gg.
  • refusing to start on 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 in PI_DISCORD_CHANNEL_IDS.
  • Missing Access or 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, and Embed Links on the channel/category, then rerun /discord-test.
  • Bot stays online after Pi quitssession_shutdown should destroy the client; if it didn't (e.g. you killed Pi with kill -9), Discord still shows the bot online for ~30s of heartbeat timeout.