@friendlyrobot/discord-pi-agent
v0.29.3
Published
Reusable Discord gateway for persistent pi agent sessions
Downloads
8,651
Readme
@friendlyrobot/discord-pi-agent
Reusable Discord gateway for persistent pi agent sessions — DM and forum channels.
What it does
- runs persistent pi agent sessions (one per DM, one per forum thread)
- resumes sessions on restart via scoped session directories
- loads project context from the target repo via pi resource loading
- accepts DM messages and forum thread messages from allowed users
- serializes prompts per-scope through FIFO queues
- exposes built-in session commands (per-scope, including
!archive) - can expose slash commands, autocomplete, buttons, and reminder modals through Discord interactions
- can run scheduled prompt jobs from a JS/TS jobs file
- can optionally self-review a reply and send one extra proactive follow-up
- can run in Discord-only, scheduler-only, or combined mode
Built-in commands
!help!status!thinking!model!compact!session reset <scope|here>!reload!remind <when>, <task>!jobs!job <id>!job info <id>!job run <id>!job run-here <id>!job update <freeform request>!jobs reload!abort!archive(forum threads only — archives the thread and shuts down the session)
Any other text is sent to the active session (DM or thread).
Command UX
The gateway supports two command entry points:
- prefix commands in regular Discord messages
- slash commands through Discord interactions
Prefix commands default to !. You can add others, such as ;, with discordCommandPrefixes.
Loaded pi prompt templates also work through those same Discord prefixes. If pi has a /review prompt template from .pi/prompts/, ~/.pi/agent/prompts/, or another loaded prompt source, you can invoke it in Discord as !review ... or ;review .... The package expands the template with pi's normal argument rules and sends the expanded prompt into the current DM or thread session.
Slash command handling uses the existing Discord gateway connection (InteractionCreate). It does not require a separate inbound webhook server or an extra public port on your VPS.
!abort cancels the active run for the current DM or thread scope and clears any queued prompts behind it.
!session reset <scope|here> clears persisted session data for a scope. The least-impact scope names are the existing internal ones: dm, thread:<id>, and job:<id>. Slash command /session-reset supports autocomplete for known scopes.
/prompt and /p are slash-only prompt entry points. They send text into the same DM/thread session as normal chat, keep Discord's interaction loading UI while the run is starting, and show an Abort run button on the ephemeral control reply. Slash job execution commands that start a run (/job run and /job run-here) reuse the same abort-button flow.
Post-reply review
When postReplyReview is enabled, the gateway can send one extra proactive follow-up after a normal prompt reply.
Current behavior:
- runs one hidden second-pass review after the main reply
- sends at most one extra follow-up message
- uses a temporary in-memory review session, so the review does not pollute the main DM/thread session history
- tries to use the same model as the main reply
- is available for normal message replies and slash prompt replies
- is intentionally conservative; if the reviewer does not return a valid follow-up block, nothing extra is sent
This feature is still a tuning area. Expect the review prompt and heuristics to evolve.
Slash command sync
Interaction handling is always wired in, but automatic slash-command registration is opt-in.
discordCommandRegistrationScope: "none"(default) — do not auto-sync commandsdiscordCommandRegistrationScope: "global"— sync global application commandsdiscordCommandRegistrationScope: "guild"— sync guild-scoped commands usingdiscordCommandRegistrationGuildIds
Guild-scoped sync is usually the best choice during development because Discord applies it faster than global command updates.
When the scheduler is enabled, !jobs shows the loaded runtime state with a prompt preview for each job, !job <id> runs a loaded job immediately in the current DM or thread, !job info <id> shows one job with its full prompt, !job run <id> runs a loaded job immediately with its configured result target, !job run-here <id> runs a loaded job immediately but overrides delivery to the current DM or thread, !jobs reload reloads the jobs file without restarting the process, and !job update <freeform request> turns your request into a scheduler-aware agent prompt that edits the jobs file in the normal agentic way.
Job IDs should avoid reserved subcommand words like run, run-here, info, and update.
!remind <when>, <task> creates a one-off runtime reminder from natural language. It is parsed through a temporary in-memory agent session, shows up in !jobs, runs once, and is then forgotten. It is not written back to the scheduled jobs file. Runtime reminders always target the current Discord conversation by saving message.channel.id as a discord-channel result target. In a DM, that is the DM channel ID. In a forum thread, that is the thread ID.
Prompt metadata
Every Discord prompt is wrapped with lightweight Discord context before promptTransform runs:
<discord_message_context>
{
"scope": "thread",
"sent_at": "2026-05-07T04:31:00.000Z",
"sent_at_local": "Thu, 7 May 26, 14:31 AEST",
"message_id": "...",
"author_name": "Alice",
"author_id": "...",
"thread_title": "Bug report",
"thread_id": "...",
"forum_channel_id": "..."
}
</discord_message_context>
<user_message>
...
</user_message>DM prompts omit thread-only fields. sent_at_local uses promptTimeZone and promptLocale.
When a forum thread's starter post body is edited, the next prompt also includes:
event_type: "thread_starter_edit"edited_atedited_at_local
Install
npm install @friendlyrobot/discord-pi-agentUsage
import {
loadDiscordGatewayConfigFromEnv,
startDiscordGateway,
} from "@friendlyrobot/discord-pi-agent";
const config = loadDiscordGatewayConfigFromEnv({
cwd: process.cwd(),
promptTimeZone: "Australia/Sydney",
promptLocale: "en-AU",
// Enable forum channel support (omit for DM-only)
discordAllowedForumChannelIds: ["1498563501780897832"],
// Optional extra prefix support
discordCommandPrefixes: ["!", ";"],
// Optional one-message second-pass follow-up after each reply
postReplyReview: true,
// Optional slash-command sync during startup
discordCommandRegistrationScope: "guild",
discordCommandRegistrationGuildIds: ["123456789012345678"],
});
await startDiscordGateway(config);Each forum post creates a scoped pi session in sessions/thread-<id>/.
The initial post body becomes the first prompt. Sessions survive restarts.
Discord + scheduler
import {
loadDiscordGatewayConfigFromEnv,
startDiscordGateway,
} from "@friendlyrobot/discord-pi-agent";
const config = loadDiscordGatewayConfigFromEnv({
cwd: process.cwd(),
});
await startDiscordGateway(config, {
scheduler: {
jobsFile: "./scheduled-jobs.ts",
},
});Scheduler-only mode
import {
loadDiscordGatewayConfigFromEnv,
startTaskScheduler,
} from "@friendlyrobot/discord-pi-agent";
const config = loadDiscordGatewayConfigFromEnv({
cwd: process.cwd(),
});
await startTaskScheduler(config, {
jobsFile: "./scheduled-jobs.ts",
});Scheduler-only mode does not handle inbound Discord user messages. It only runs scheduled jobs and sends results to the configured targets.
Scheduled jobs
Scheduled jobs are defined in a trusted JS/TS module.
The file must export:
loadScheduleJobs(context)
The function receives:
context.config— resolved Discord gateway configcontext.schedulerConfig— resolved scheduler config
Example:
import type { ScheduledJobsContext } from "@friendlyrobot/discord-pi-agent";
export function loadScheduleJobs(context: ScheduledJobsContext) {
return [
{
id: "repo-heartbeat",
schedule: {
type: "every-minutes",
interval: 60,
timeZone: "Australia/Sydney",
daysOfWeek: ["mon", "tue", "wed", "thu", "fri"],
startTime: "09:00",
endTime: "22:00",
},
prompt: `Check ${context.config.cwd} and summarize anything important.`,
result: {
target: "logs",
},
},
{
id: "daily-standup",
schedule: {
type: "daily-at",
hour: 9,
minute: 0,
timeZone: "Australia/Sydney",
daysOfWeek: ["mon", "tue", "wed", "thu", "fri"],
},
prompt: "Review recent work and draft a standup update.",
session: {
strategy: "reuse",
scope: "dm",
},
model: {
provider: "openrouter",
id: "anthropic/claude-sonnet-4",
},
result: {
target: "discord-dm",
userId: context.config.discordAllowedUserId,
},
},
{
id: "one-shot-report",
schedule: {
type: "every-minutes",
interval: 60,
},
prompt: "Build a quick status report and post it.",
session: {
strategy: "ephemeral",
},
result: {
target: "logs",
},
},
];
}Supported schedules
every-minutesdaily-at
daily-at supports:
hourminutetimeZone?daysOfWeek?— optional subset of["sun", "mon", "tue", "wed", "thu", "fri", "sat"]
every-minutes supports:
intervaltimeZone?daysOfWeek?— optional subset of["sun", "mon", "tue", "wed", "thu", "fri", "sat"]startTime?endTime?
When startTime or endTime is set, both are required. The run window is inclusive, so startTime: "09:00" with interval: 60 starts at 09:00, then 10:00, 11:00, and so on until the last run at or before endTime.
Session strategy
When session is omitted, the default is a fresh dedicated persistent session stored under sessions/job-<id>/.
strategy: "fresh"— create a fresh persistent session for the runstrategy: "reuse"— reuse the existing persistent session for the job or scopestrategy: "ephemeral"— use a temporary in-memory session for the runscope: "dm" | "thread:<id>" | "job:<id>"— optional persistent scope to target withfreshorreuse
Examples:
{ strategy: "fresh" }— fresh dedicated persistent job session{ strategy: "reuse" }— reuse the dedicated job session atsessions/job-<id>/{ strategy: "reuse", scope: "dm" }— reuse the DM session{ strategy: "fresh", scope: "thread:123" }— replace the thread session with a fresh persistent one{ strategy: "ephemeral" }— one-shot in-memory session, never saved
Model override
Jobs can optionally set model: { provider, id }.
- omit
modelto use the gateway default model fromconfig.modelProviderandconfig.modelId - use
modelwhen one job should run on a different LLM provider/model - avoid model overrides on shared
dmorthread:<id>scopes; use a dedicatedjob:<id>scope instead
Result targets
logsdiscord-dmdiscord-channel— can also target a thread by using the thread ID
Discord scheduled job deliveries intentionally send each message chunk with embeds suppressed. This keeps digest-style jobs clean when the model includes links. If you still want clickable links without previews, format them as <https://example.com> in the prompt or model output.
Config
Required
discordBotTokendiscordAllowedUserIdcwd
Optional
agentDirdefault:<cwd>/.pi-agentmodelProviderdefault:openroutermodelIddefault:anthropic/claude-3.5-haikuthinkingLeveldefault:medium(values:off,minimal,low,medium,high,xhigh)promptTimeZonedefault:PI_PROMPT_TIME_ZONEorUTC— used forsent_at_localin Discord prompt metadatapromptLocaledefault:PI_PROMPT_LOCALEoren-AU— used forsent_at_localin Discord prompt metadatapromptTransformdefault: identitystartupMessagedefault: `Bot is online and ready.\n```\nHost: \nStarted: \n````shutdownOnSignalsdefault:truediscordCommandPrefixesdefault:["!"]postReplyReviewdefault:false— when enabled, the bot runs one hidden second-pass review after each prompt reply and may send one extra proactive follow-up if it adds clear valuetrueenables it with defaults{ enabled: true, maxFollowUpLength: 280 }enables it with an explicit follow-up length cap
discordCommandRegistrationScopedefault:"none"discordCommandRegistrationGuildIdsdefault:[]- scheduler via
startDiscordGateway(config, { scheduler: { jobsFile } })
Scheduler config
jobsFile— required JS/TS jobs module path, resolved fromcwdwhen relative
Logging
The package uses pino for structured logs.
Behavior:
- when stdout is a TTY, logs use
pino-prettyfor readable local console output - when stdout is not a TTY, logs stay as JSON
Log level env vars:
DISCORD_PI_AGENT_LOG_LEVELLOG_LEVELfallback
Optional observability env vars:
DISCORD_PI_AGENT_LOG_RAW_EVENTS=true— log short summaries of raw Discord gateway packets (t,op,s, ids, short preview)
Default level is info.
For detailed prompt and tool monitoring during local runs, use:
DISCORD_PI_AGENT_LOG_LEVEL=debugPretty console logs use:
- colors
- local timestamp (
SYS:standard) - level first
- hidden
pidandhostname - module-aware labels like
[discord-gateway] - direction markers like
INandOUT - multi-line payload blocks for easier input/output inspection
Forum channel options
discordAllowedForumChannelIds— string array of forum channel IDs to respond indiscordAllowedUserIds— string array of allowed user IDs (defaults to[discordAllowedUserId])
Env helpers
loadDiscordGatewayConfigFromEnv() — the config loader:
DISCORD_BOT_TOKENDISCORD_ALLOWED_USER_IDPI_AGENT_CWDPI_AGENT_DIRPI_MODEL_PROVIDERPI_MODEL_IDPI_PROMPT_TIME_ZONEPI_PROMPT_LOCALEDISCORD_STARTUP_MESSAGEDISCORD_FORUM_CHANNEL_IDS— comma-separated forum channel IDsDISCORD_ALLOWED_USER_IDS— comma-separated allowed user IDsDISCORD_COMMAND_PREFIXES— comma-separated command prefixes (example:!, ;)DISCORD_POST_REPLY_REVIEW—trueorfalseto enable one proactive second-pass follow-upDISCORD_POST_REPLY_REVIEW_MAX_FOLLOW_UP_LENGTH— optional positive integer character limit for proactive follow-upsDISCORD_COMMAND_REGISTRATION_SCOPE—none,global, orguildDISCORD_COMMAND_REGISTRATION_GUILD_IDS— comma-separated guild IDs for guild-scoped slash-command sync
If PI_AGENT_CWD is missing it falls back to process.cwd().
Set DISCORD_STARTUP_MESSAGE=false to disable the startup DM.
Thinking Levels
Use !thinking to view the current thinking/reasoning level and available options. Use !thinking <level> to set it (e.g., !thinking high).
Not all models support thinking/reasoning. The configured thinkingLevel is applied automatically when the model supports it.
Build
npm run build
npm run typecheckDependency updates
To check for newer package versions and update package.json, run:
npx npm-check-updates -u
npm installThis is the npm-side replacement for the old bun update workflow.
Notes
- DM and forum threads supported via
startDiscordGateway - scheduled jobs are opt-in through
startDiscordGateway(config, { scheduler }) startTaskScheduler()runs the scheduler without inbound Discord message handling- Forum thread sessions are stored in
sessions/thread-<id>/(one directory per thread) - Scheduled job sessions are stored in
sessions/job-<id>/when using dedicated persistent sessions - Ephemeral scheduled jobs use in-memory sessions and do not write session files
- Sessions survive restarts —
SessionManager.continueRecent()resumes the latest.jsonl - Single Discord client with all intents (DM + Guild + MessageContent)
- No mode flags — forum support activates when
discordAllowedForumChannelIdsis set - The package does not register Discord slash commands
- pi resources are loaded from the configured
cwdandagentDir
