π€ Discord.py Γ OMP Plugin Cheat Sheet #
Bridge a discord.py bot (Python) with the OMP coding agent extension system (Bun/TS) β from zero to full bidirectional integration.
π§ Core Concept #
OMP extensions = TypeScript/Bun. discord.py = Python. They’re separate runtimes. You bridge them via one of three tiers:
| πͺ Tier 1: Webhook | π§ Tier 2: MCP Server | π Tier 3: Extension + Bot | |
|---|---|---|---|
| Direction | OMP β Discord only | Both (LLM invokes tools) | Both (event-driven) |
| Needs Python? | β No | β Yes | β Yes |
| Needs Bot Token? | β No (webhook URL) | β Yes | β Yes |
| Discord β OMP? | β | β | β |
| Auto-notify events? | β | β | β |
| LLM calls Discord? | β | β | β |
| Setup time | ~5 min | ~15 min | ~30β60 min |
| Deps | None | discord.py, mcp |
discord.py, aiohttp |
β‘ OMP Extension Contract (All Tiers) #
// ~/.omp/agent/extensions/my-ext/index.ts
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function myExt(pi: ExtensionAPI) {
// ββ Registration Phase (sync) ββ
pi.setLabel("My Extension");
pi.on("agent_end", async (event, ctx) => { /* ... */ });
pi.on("session_start", async (event, ctx) => { /* ... */ });
pi.registerTool({ name: "my_tool", /* ... */ });
pi.registerCommand("my-cmd", { /* ... */ });
// β οΈ CANNOT call pi.sendMessage() during load β only in handlers
}
π‘ Key Lifecycle Events #
session_start β input β agent_start β turn_start
β tool_call β tool_execution_start β tool_execution_end β tool_result
β turn_end β agent_end β session_shutdown
| Event | Use Case | Return |
|---|---|---|
agent_end |
“Done” notification to Discord | β |
tool_call |
Audit / block dangerous ops | { block, reason } |
tool_result |
Redact secrets before logging | { content } |
session_start |
Init bot connection | β |
session_shutdown |
Cleanup / final flush | β |
πͺ Tier 1 β Webhook (Zero-Dep, 5 min) #
No bot. No Python. Just fetch(). OMP β Discord one-way.
π ~/.omp/agent/extensions/discord-notify/index.ts
#
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function discordNotify(pi: ExtensionAPI) {
const WEBHOOK = process.env.DISCORD_WEBHOOK_URL;
if (!WEBHOOK) return;
const send = (content: string) =>
fetch(WEBHOOK, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
}).catch(() => {});
// π Auto-notify when agent finishes
pi.on("agent_end", async () => {
await send(`β
**Agent finished** β ${new Date().toLocaleTimeString()}`);
});
// π¬ Slash command: /discord-ping <message>
pi.registerCommand("discord-ping", {
description: "Send a message to the Discord webhook",
handler: async (args, ctx) => {
await send(args.trim() || "Ping from OMP!");
ctx.ui.notify("Sent to Discord", "info");
},
});
}
π Setup #
# 1. Create webhook: Discord β Server Settings β Integrations β Webhooks β New
# 2. Export URL
export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/123/abc..."
# 3. Drop file, restart OMP. Done.
β οΈ Rate limit: Discord enforces 5 requests / 5 seconds per webhook.
π§ Tier 2 β MCP Server (Bot as Tool Provider) #
discord.py bot runs as a stdio MCP server. OMP launches it, the LLM calls its tools.
OMP Agent βββstdio JSON-RPCβββΆ Python MCP Server βββgatewayβββΆ Discord
π ~/.omp/tools/discord-mcp/server.py
#
#!/usr/bin/env python3
"""Discord bot exposed as MCP tool server."""
import asyncio, os
import discord
from discord.ext import commands
from mcp.server import Server
from mcp.server.stdio import stdio_server
import mcp.types as types
# ββ Bot ββ
intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix="!", intents=intents)
ready = asyncio.Event()
@bot.event
async def on_ready():
ready.set()
# ββ MCP Server ββ
app = Server("discord-mcp")
@app.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="discord_send",
description="Send a message to a Discord channel",
inputSchema={
"type": "object",
"properties": {
"channel_id": {"type": "string"},
"content": {"type": "string"},
},
"required": ["channel_id", "content"],
},
),
types.Tool(
name="discord_read",
description="Read recent messages from a Discord channel",
inputSchema={
"type": "object",
"properties": {
"channel_id": {"type": "string"},
"limit": {"type": "integer", "default": 10},
},
"required": ["channel_id"],
},
),
types.Tool(
name="discord_guilds",
description="List all guilds the bot is in",
inputSchema={"type": "object", "properties": {}},
),
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
await ready.wait()
if name == "discord_send":
ch = bot.get_channel(int(arguments["channel_id"]))
if not ch:
return [types.TextContent(type="text", text="Channel not found")]
await ch.send(arguments["content"])
return [types.TextContent(type="text", text=f"Sent to #{ch.name}")]
if name == "discord_read":
ch = bot.get_channel(int(arguments["channel_id"]))
if not ch:
return [types.TextContent(type="text", text="Channel not found")]
msgs = []
async for m in ch.history(limit=arguments.get("limit", 10)):
msgs.append(f"[{m.created_at:%H:%M}] {m.author.display_name}: {m.content}")
return [types.TextContent(type="text", text="\n".join(reversed(msgs)) or "(empty)")]
if name == "discord_guilds":
lines = [f"{g.name} (id={g.id})" for g in bot.guilds]
return [types.TextContent(type="text", text="\n".join(lines) or "(none)")]
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
async def main():
token = os.environ["DISCORD_TOKEN"]
async with bot:
bot_task = asyncio.create_task(bot.start(token))
async with stdio_server() as (r, w):
await app.run(r, w, app.create_initialization_options())
bot_task.cancel()
if __name__ == "__main__":
asyncio.run(main())
π ~/.omp/agent/mcp.json
#
{
"mcpServers": {
"discord": {
"command": "python3",
"args": ["/home/dev/.omp/tools/discord-mcp/server.py"],
"env": { "DISCORD_TOKEN": "DISCORD_TOKEN" }
}
}
}
π¦ Install deps #
pip install discord.py mcp
# env value "DISCORD_TOKEN": "DISCORD_TOKEN" = copy from shell env
export DISCORD_TOKEN="your-bot-token"
# Restart OMP β it auto-discovers mcp.json and launches the server
π Tier 3 β Full Extension + Sidecar Bot #
Most powerful. OMP extension (TS) β discord.py bot (Python) over localhost HTTP. Full bidirectional event-driven bridge.
ββββββββββββββββββββββββββββββββββββ
β OMP Extension (Bun/TS) β
β β on(agent_end) β POST /notify β
β β registerTool(discord_notify) β
β β registerCommand(/discord) β
ββββββββββββ¬ββββββββββββββββββββββββ
β HTTP :8901
ββββββββββββΌββββββββββββββββββββββββ
β discord.py Bot + aiohttp (Py) β
β β POST /notify β channel.send() β
β β GET /status β bot info β
β β GET /channels β list all β
β β !ask-omp <prompt> β omp CLI β
ββββββββββββ¬ββββββββββββββββββββββββ
β Discord Gateway
ββββββββββββΌββββββββββββββββββββββββ
β Discord β
ββββββββββββββββββββββββββββββββββββ
π Python Sidecar: discord_omp_bot.py
#
#!/usr/bin/env python3
"""discord.py sidecar bot with HTTP API for OMP."""
import asyncio, logging, os, subprocess
import discord
from discord import app_commands
from discord.ext import commands
from aiohttp import web
log = logging.getLogger("discord-omp")
intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix="!", intents=intents)
NOTIFY_CH = int(os.environ.get("DISCORD_NOTIFY_CHANNEL", "0"))
HTTP_PORT = int(os.environ.get("DISCORD_HTTP_PORT", "8901"))
# ββ HTTP endpoints (called by OMP extension) ββ
async def handle_notify(req: web.Request) -> web.Response:
data = await req.json()
ch = bot.get_channel(NOTIFY_CH)
if not ch:
return web.json_response({"error": "channel not found"}, status=404)
embed_data = data.get("embed")
if embed_data:
embed = discord.Embed(
title=embed_data.get("title", ""),
description=embed_data.get("description", ""),
color=embed_data.get("color", 0x5865F2),
)
for f in embed_data.get("fields", []):
embed.add_field(name=f["name"], value=f["value"], inline=f.get("inline", False))
await ch.send(content=data.get("content"), embed=embed)
else:
await ch.send(data.get("content", "π¨ (empty)"))
return web.json_response({"ok": True})
async def handle_status(_: web.Request) -> web.Response:
return web.json_response({
"bot": str(bot.user), "guilds": len(bot.guilds),
"latency_ms": round(bot.latency * 1000, 1), "ready": bot.is_ready(),
})
async def handle_channels(_: web.Request) -> web.Response:
return web.json_response([
{"id": str(c.id), "name": c.name, "guild": g.name}
for g in bot.guilds for c in g.text_channels
])
async def start_http():
app = web.Application()
app.router.add_post("/notify", handle_notify)
app.router.add_get("/status", handle_status)
app.router.add_get("/channels", handle_channels)
runner = web.AppRunner(app)
await runner.setup()
await web.TCPSite(runner, "127.0.0.1", HTTP_PORT).start()
log.info(f"HTTP API β 127.0.0.1:{HTTP_PORT}")
# ββ Discord commands ββ
@bot.event
async def on_ready():
await start_http()
await bot.tree.sync()
log.info(f"Ready as {bot.user}")
@bot.hybrid_command(name="ask-omp", description="Ask the OMP coding agent")
@app_commands.describe(prompt="Your question")
async def ask_omp(ctx: commands.Context, *, prompt: str):
await ctx.defer()
try:
r = subprocess.run(
["omp", "-p", prompt, "--no-input"],
capture_output=True, text=True, timeout=120, cwd="/home/dev",
)
out = r.stdout.strip()[:1900] or "(no output)"
await ctx.send(f"```\n{out}\n```")
except subprocess.TimeoutExpired:
await ctx.send("β±οΈ Timed out (120s)")
except Exception as e:
await ctx.send(f"β {e}")
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(bot.start(os.environ["DISCORD_TOKEN"]))
π OMP Extension: ~/.omp/agent/extensions/discord-bridge/index.ts
#
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
const BOT = process.env.DISCORD_BOT_URL ?? "http://127.0.0.1:8901";
export default function discordBridge(pi: ExtensionAPI) {
const { z } = pi.zod;
pi.setLabel("Discord Bridge");
// π Auto-notify on agent completion
pi.on("agent_end", async (_ev, ctx) => {
const tokens = ctx.getContextUsage?.()?.tokens ?? "?";
await fetch(`${BOT}/notify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
embed: {
title: "β
Agent Run Complete",
description: `Session: ${pi.getSessionName?.() ?? "β"}`,
color: 0x00ff00,
fields: [{ name: "Tokens", value: `${tokens}`, inline: true }],
},
}),
}).catch(() => {});
});
// π οΈ LLM-callable tool
pi.registerTool({
name: "discord_notify",
label: "Discord Notify",
description: "Send a notification to the configured Discord channel",
parameters: z.object({
message: z.string().describe("Message to send"),
}),
async execute(_id, params) {
const res = await fetch(`${BOT}/notify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: params.message }),
});
const data = (await res.json()) as { ok?: boolean; error?: string };
return data.ok
? { content: [{ type: "text", text: "β
Sent to Discord" }] }
: { content: [{ type: "text", text: `β ${data.error}` }], isError: true };
},
});
// β¨οΈ OMP slash command: /discord
pi.registerCommand("discord", {
description: "Check Discord bot status",
handler: async (_args, ctx) => {
try {
const s = (await (await fetch(`${BOT}/status`)).json()) as Record<string, unknown>;
ctx.ui.notify(`π€ ${s.bot} | ${s.guilds} guilds | ${s.latency_ms}ms`, "info");
} catch {
ctx.ui.notify("Bot unreachable", "warning");
}
},
});
}
π Deploy the Sidecar as systemd #
# ~/.config/systemd/user/discord-omp-bot.service
[Unit]
Description=Discord OMP Bridge Bot
After=network.target
[Service]
ExecStart=/usr/bin/python3 /home/dev/discord_omp_bot.py
Environment=DISCORD_TOKEN=your-token
Environment=DISCORD_NOTIFY_CHANNEL=123456789
Environment=DISCORD_HTTP_PORT=8901
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now discord-omp-bot
journalctl --user -u discord-omp-bot -f
π¦ Packaging as Distributable OMP Plugin #
π File Structure #
omp-discord-bridge/
βββ index.ts β source
βββ package.json
βββ dist/
βββ index.js β bundled (generated)
π package.json
#
{
"name": "omp-discord-bridge",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"omp": { "extensions": ["./dist/index.js"] },
"peerDependencies": {
"@oh-my-pi/pi-coding-agent": "*"
},
"scripts": {
"build": "bun build ./index.ts --outdir ./dist --target node --external @oh-my-pi/pi-coding-agent"
}
}
π¨ Build & Install #
# Bundle (CRITICAL β OMP legacy loader copies to /tmp, breaking relative imports)
bun build ./index.ts --outdir ./dist --target node \
--external @oh-my-pi/pi-coding-agent
# Install as plugin
omp plugin link /path/to/omp-discord-bridge
# Verify
omp plugin list
π¨ NEVER skip bundling. OMP mirrors extensions to
/tmp/omp-legacy-pi-file/β unbundled relativerequire()calls break silently. Always--external @oh-my-pi/pi-coding-agent.
π discord.py Essentials #
π Bot Token Setup #
1. discord.com/developers/applications β New Application
2. Bot tab β Reset Token β π copy
3. Bot tab β β
MESSAGE CONTENT Intent
4. OAuth2 β URL Generator β bot + applications.commands
β Send Messages, Read History, Embed Links, Use Slash Commands
β Copy invite URL β open in browser β select server
π§© Intents Quick-Ref #
intents = discord.Intents.default()
intents.message_content = True # β οΈ Required for prefix commands
intents.guilds = True # Guild join/leave events
intents.members = True # β οΈ Privileged β needs portal toggle
π Hybrid Commands (Slash + Prefix) #
@bot.hybrid_command(name="ping", description="Pong!")
async def ping(ctx: commands.Context):
await ctx.send(f"π {bot.latency*1000:.0f}ms")
# Don't forget:
@bot.event
async def on_ready():
await bot.tree.sync() # β registers slash commands with Discord
π§± Cog Pattern #
class MyCog(commands.Cog):
def __init__(self, bot): self.bot = bot
async def cog_load(self): ... # setup
async def cog_unload(self): ... # teardown
@commands.hybrid_command()
async def hello(self, ctx): await ctx.send("Hi!")
async def setup(bot): # β entry point
await bot.add_cog(MyCog(bot))
β° Background Tasks #
from discord.ext import tasks
@tasks.loop(minutes=5)
async def heartbeat():
... # periodic work
@heartbeat.before_loop
async def wait_ready():
await bot.wait_until_ready()
heartbeat.start() # call in on_ready or cog_load
π±οΈ Views (Buttons) #
class Confirm(discord.ui.View):
value: bool | None = None
@discord.ui.button(label="β
Yes", style=discord.ButtonStyle.green)
async def yes(self, interaction, button):
self.value = True
await interaction.response.edit_message(content="Confirmed!", view=None)
self.stop()
@discord.ui.button(label="β No", style=discord.ButtonStyle.red)
async def no(self, interaction, button):
self.value = False
await interaction.response.edit_message(content="Cancelled.", view=None)
self.stop()
# Usage:
view = Confirm(timeout=60)
await ctx.send("Proceed?", view=view)
await view.wait()
π Security Checklist #
| Rule | Why |
|---|---|
β Never commit DISCORD_TOKEN or webhook URLs |
They’re bearer credentials |
π Bind HTTP API to 127.0.0.1 only |
No external exposure |
| β³ Rate-limit webhook calls (5/5s) | Discord enforces this |
| π‘οΈ Validate all Discord input | Before passing to OMP tools |
| π― Use least-privilege bot permissions | Only what you actually need |
π Store tokens in env vars or systemd Environment= |
Not in code or config files |
πΊοΈ Quick Decision Flowchart #
Need Discord integration?
β
ββ Just notifications? ββββββββββββ πͺ Tier 1 (Webhook)
β ββ Drop index.ts, set env var, done.
β
ββ LLM needs to call Discord? ββββ π§ Tier 2 (MCP)
β ββ Write Python MCP server, add mcp.json, done.
β
ββ Full two-way bridge? βββββββββββ π Tier 3 (Extension + Bot)
ββ OMP ext (.ts) + sidecar bot (.py) + systemd service.
π Reference Links #
| Resource | URL |
|---|---|
| discord.py Docs | https://discordpy.readthedocs.io/ |
| MCP Spec | https://spec.modelcontextprotocol.io/ |
| MCP Python SDK | pip install mcp |
| OMP Extension Docs | omp://extensions.md |
| OMP MCP Config | omp://mcp-config.md |
| OMP Plugin System | omp://plugin-manager-installer-plumbing.md |
| Pi-Langfuse Reference | ~/.omp/plugins/node_modules/pi-langfuse/ |
Last updated: 2026-06-08 Β· Grounded in OMP extension docs, pi-langfuse reference impl, and discord.py v2.x API.