Skip to content

About

A plugin framework for Discord agent teams, on Pi and Bun

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

pi-roundtable

English | Traditional Chinese

Docs: https://pi-roundtable.wayneh.tw Source and issues: https://github.com/wayne930242/pi-roundtable

An agent server on Pi that you talk to through Discord, or through a web chat. In Discord, each AI agent has its own channel and conversation in your server, and they share tools and memory. On the web, people your OpenID Connect provider signs in chat with an assistant in private conversations, through pi-roundtable-webchat. You can extend the bot with TypeScript plugins.

  • Built for one owner. Discord and the web chat both work, and you can let other people talk to the assistant, but running it for several people is at the operator's risk: read the threat model first.
  • Bun only. The package ships its TypeScript source, so there is no build step.
  • MIT licensed.

What you need

  • Bun 1.4.4 or newer.
  • PostgreSQL. The project init creates has a docker-compose.yml that runs one.
  • A model login: the API key of the provider of the model you choose (ANTHROPIC_API_KEY for anthropic/...), or a login made with Pi.
  • For Discord: a Discord bot (an application with a bot user, the Message Content intent turned on, and an invitation to your server), and an address that reaches the process from the internet, such as a tunnel, because Discord fetches the agents' avatars from it.
  • For the web chat: an OpenID Connect provider that issues access tokens for the chat's API, and a reverse proxy that serves the host over HTTPS.

Five minutes

npx pi-roundtable init my-bot    # or: bunx pi-roundtable init my-bot
cd my-bot
bun install
docker compose up -d             # PostgreSQL, matching .env.example
cp .env.example .env             # then fill it in
bunx roundtable doctor
bunx roundtable start

init writes a working project without asking for secrets. It refuses to write anything when Bun is missing or too old, or when a file it would create already exists. init --adapter web writes a project without Discord around the web chat instead; see web chat. Both load the Pi package pi-self-compact, whose compact_session tool every session needs.

.env

.env.example says where each value comes from. Bun loads .env by itself, and .gitignore keeps it out of Git.

Variable What it is
DISCORD_TOKEN The bot's token, from the application's Bot page
DISCORD_GUILD_ID, DISCORD_ENTRY_CHANNEL_ID The server and the channel where the coordinating agent lives (turn on Developer Mode, then right-click to copy ids); leave both out of the configuration to keep the agents in the app's rooms
OWNER_ID, OWNER_NAME You: the one person who can change everything
DATABASE_URL PostgreSQL; the default matches docker-compose.yml
MODEL The agents' model, <provider>/<id>
PUBLIC_URL The address that reaches this process from the internet

A web chat project asks for OWNER_NAME, DATABASE_URL, and MODEL, and for the OpenID Connect issuer, audience, and signing keys address, the roles that may chat, and the origins of the pages that open it.

doctor

bunx roundtable doctor checks, in order, and prints each check as passed or failed with how to fix it:

  1. Bun's version.
  2. .env has a value for every variable .env.example lists.
  3. roundtable.config.ts against its schema, naming the failing key.
  4. Every plugin loads, no two share a name, and the registration is valid (Roundtable.check()).
  5. Whom access serves: the owners, and a warning when everyone: true admits everyone on every surface of a host with more than one.
  6. Whether a plugin fills the images slot. The check passes with or without one; agents' avatars are composed characters and no longer drawn with it.
  7. Whether canvas can rasterize SVG, which the agent server needs for the agents' pictures.
  8. PostgreSQL is reachable and migratable.
  9. The principals: a dry run of what the next start makes from the people the database already knows, printed as the start logs it; a configured owner's identity linked to someone else, which stops the start; and a disabled owner.
  10. With Discord configured: the Discord token is valid, the bot is in your server, the Message Content intent is on, and the bot has the permissions it needs in the entry channel (including Pin Messages). When the bot is not in the server, the fix is an invitation link that asks for exactly those permissions.
  11. The model login exists.
  12. When http.publicUrl is set: PUBLIC_URL is a well-formed address; with --reachable it also has to answer, which is only true while the bot runs.

It exits non-zero on any failure, not on a warning, and changes nothing it checked. A fresh project fails only on the credentials you have not entered yet, and says which.

start

bunx roundtable start runs the checks that need no network, stops with the same message doctor prints when one fails, and otherwise starts the bot. The running bot gives the agents in agents.ts their channels, and /roundtable schedule list shows their schedules. On SIGTERM or SIGINT it finishes running work before it stops.

A plugin

roundtable add plugin <name> creates plugins/<name>.ts and its test and lists it in roundtable.config.ts. roundtable add package <spec> does the same for a Pi package from npm: it installs the package and writes a plugin that loads its extensions and selects its tools. A plugin is an object with a name and a setup function that returns what it adds; this one gives every agent a tool:

import { definePlugin, defineTool } from "pi-roundtable";
import { Type } from "typebox";

export const hello = definePlugin({
	name: "hello",
	setup: () => ({
		tools: [
			defineTool({
				name: "hello_greet",
				description: "Greet someone by name. Call it when asked to say hello.",
				parameters: Type.Object({ who: Type.String() }),
				minTier: "member",
				run: ({ who }) => `Hello, ${who}!`,
			}),
		],
	}),
});

You can test it without Discord or PostgreSQL:

import { expect, test } from "bun:test";
import { testPlugin } from "pi-roundtable/testing";
import { hello } from "./hello.ts";

test("hello greets", async () => {
	const harness = await testPlugin(hello);
	expect(await harness.runTool("hello_greet", { who: "Ada" })).toBe("Hello, Ada!");
	await harness.stop();
});

The plugin guide explains every part a plugin can add (tools, prompt sections, agents, events, services, migrations, providers, slash commands, HTTP routes, and more), the order things start and stop in, and every startup error with its fix. Its examples live in examples/, and the test suite runs each of them. A plugin can also listen without owning a channel: context.observe hears guild messages whichever claim owns them, a claim hears of deleted and edited messages, and OpsAlerts can tell a sink which kind an alert is and when its fault is over. pi-roundtable/kit supplies claim, tool and presentation helpers and type-only names for the context’s existing services. pi-roundtable/discord supplies the slash-command registrar, owner-command and panel helpers, and the agent panel. It is the entry that names discord.js types (pi-roundtable/testing names a few, through testHost's composed commands). Both follow the main entry's versioning: before 1.0, breaking changes come in minor releases and are listed in the changelog.

Web chat

The package pi-roundtable-webchat is a chat network that comes as a plugin: a WebSocket and a REST API under /chat on the host's listener. People your OpenID Connect provider signs in open private conversations with the personas you list, see each turn's text and tools as it runs, and answer approval cards for held calls. A host whose configuration has no discord and lists webChat({ ... }) in plugins is a web-only assistant; roundtable init --adapter web creates one.

npx pi-roundtable init my-desk --adapter web

Its README describes the protocol, core access rules, the private notice inbox, the limits, and the provider settings that keep one person one identity, such as a stable subject claim and a pinned tenant.

MCP connectors

The separate package pi-roundtable-mcp connects the bot to the MCP ecosystem in both directions, with two plugins:

  • mcpConnectors: you add an MCP server in Discord with a private form, such as Notion, a calendar, or anything that speaks MCP over HTTP. Your code then gives its tools to the agents you choose. A ContextForge gateway that you run keeps each server and its token.
  • remoteMcp: an agent outside Discord sends your agent a message over MCP and reads the answer. It can also use the Discord channels you grant, with only the operations you choose.
bun add pi-roundtable-mcp

It releases in lockstep with pi-roundtable, and its README lists every option.

Official packages

Eight packages in this repository release in lockstep with the core: webchat and mcp above, plus browser, coding, drawing, line, sandbox and web. The plugin guide describes each and what it needs.

Settings

roundtable.config.ts holds the settings and the list of plugins. An unknown key is an error that names the closest known one.

locale sets the language of the bot's Discord text: en by default, or zh-TW.

export default {
	// ...
	locale: "zh-TW",
	timeZone: "Europe/Berlin", // the zone schedules and time stamps use; default UTC
	plugins: [hello],
} satisfies RoundtableConfig;

A turn works 10 minutes by default before it asks you whether to go on, and 30 minutes at most in all; turnLimits and autonomy change that, and a conversation you switch to long-running mode goes on by itself up to autonomy.longRunning.maxSeconds:

Setting Default What it is
turnLimits.timeoutSeconds 600 A turn's budget before the card asks to continue, 60 to 86400; each message steered into the turn renews it
turnLimits.maxSeconds three times timeoutSeconds The most one turn works in all, at least timeoutSeconds
turnLimits.cardSeconds 300 How long that card waits for an answer, 30 to 3600; with none, the turn stops
autonomy.longRunning.maxSeconds 14400 (4 h) The most a long-running conversation's turn works before it asks or stops, 600 to 604800
autonomy.yolo.allow, autonomy.yolo.card none Hold classes yolo mode may approve beyond its defaults (shell.rm, say), and rule names whose calls it approves although the rule reports no classes (tool:repo_push, say), and classes or rule names it never approves (shell.push, say). By default yolo approves a plain git push and writes outside the workspace and cards the other destructive shell classes; see the plugin guide
autonomy.yolo.questionGraceSeconds 30 In a yolo conversation, how long an ask_user card holds the turn, 5 to 600; unanswered, the turn goes on with its own best choice and the card stays open
autonomy.yolo.timeoutCardSeconds 60 In a yolo conversation, how long the timeout card waits, 10 to 3600; unanswered, the turn goes on as if you pressed Continue, up to autonomy.longRunning.maxSeconds

Without discord, or with discord but no discord.guild, the agents still run when agents lists them, in the rooms of a surface that hosts them, such as pi-roundtable-web; the host refuses to start when none does:

Setting Default What it is
discord.guild none The agent server's guild, where the agents' channels, categories and #dashboard live; needs discord.entryChannel and http.publicUrl. Left out, the bot keeps serving the plugins' claims, slash commands and the owner's direct messages in every guild it is in, and the team lives in the app's rooms as without discord: agents is required and entryChannel is an error
agents none The first team, created once. Without discord.guild, a seed has no channelId and the team's rows are kept under the id local
coordinator the first of agents Without discord.guild, the agent the owner's general requests come to; it cannot be archived. With discord.guild it is an error: the entry channel's agent coordinates
team.id, team.formerIds the discord.guild id, else local; none The id the team's rows are stored under, independent of the guild, and the ids it was stored under before: their rows move to it at every boot, and without Discord channels lose their channel ids
skills on with agents The skills addon; without discord it is on only when agents lists some
helpers 4 per knight, 12 in all, archived 24 hours after finishing Temp knights, the helpers a knight makes with helper_create for one task and clears with helper_end: { perParent, perHost, archiveFinishedAfterHours }; false offers neither tool

Work no person is waiting on (a foreign webhook's post in an agent's channel, a message_agent call that names no conversation, an agent's opening task, the error reports) lands where it always did, unless the optional routing block says otherwise. It names each agent's primary place, which surface each kind of event goes to, and the host's named places such as ops; a message, a card or a schedule's firing is never routed and stays where its person is. For example, Infra's alerts handled in the web app while Discord keeps the raw posts:

routing: {
	agents: { infra: { primary: "app", integration: "primary" } },
	places: { ops: { agent: "infra" } }, // replaces the deprecated top-level `ops`
},

A mistake stops the start naming the key and the fix. The settings page lists every key, its values and the checks.

When someone addresses the assistant in an agent's channel, the turn also reads the channel messages posted since the assistant's last post there (channel context; see the plugin guide). Tune it under discord.channelContext, or set channelContext: false to turn it off:

discord.channelContext Default What it is
fetch 50 Messages fetched before the addressed one, at most 100
keep 15 Messages kept, the newest
similarity 0.8 How alike two consecutive messages of one author must be to count as one
messageChars 500 Where a message is cut
botMessageChars 80 Where another bot's message is cut
peerMessageChars 4000 Where a peer bot's message is cut (a bot discord.peerBots lists for the channel)
foreignWebhook none (webhookId) => boolean | "skip": posts through the webhooks it returns true for are read as other people's messages, not the assistant's; "skip" drops a webhook's posts as if absent, without ending the window (code only)
attachments { images: 0, maxBytes: 10 MiB } images: how many images of the kept messages are fetched for the turn, at most 4

By default the assistant ignores every other bot. To let separate bots talk, list the ones to answer by user id under discord.peerBots:

discord.peerBots Default What it is
allow none Bot user ids, each as a string or { id, guilds?, channels? } to limit it to servers or channels. Explicit ids only: there is no wildcard and no "all bots", and a bad id stops the start. A listed bot's mention or reply is answered like a guest's, as far as access serves it
maxRounds 3 Messages from listed bots answered in a row in one channel; a person's message starts the count over

When a conversation is started over in a server channel or thread, the assistant posts a short divider there so the next turn does not read what came before; direct messages get none.

discord.freshMarker Default What it is
text ─── new conversation ─── The divider's text
false Post no divider
discord.memberEvents Default What it is
false false The bot asks Discord for no privileged intent beyond Message Content
true Plugins hear each member that joins the server (memberJoined). Needs Server Members Intent switched on under Bot in the Developer Portal; roundtable doctor checks it
discord.formatting Default What it is
text Markdown renders except tables What the model is told, in English, about how a reply renders in Discord; appended to the system prompt of every turn that replies there
false Tell the model nothing

An approval card or an ask_user question (a set of up to four questions is one card) holds its turn for discord.cardGraceSeconds (default 120). Answered within it, the turn goes on with the answer; otherwise the turn ends saying it is waiting, the card stays open with no deadline, and answering it later starts a new turn in that conversation with the answer, or with the result of the approved action. A coding worker's cards keep the worker waiting instead. Every ask_user card has a Cancel button beside Submit: pressed while the turn waits, the model is told you cancelled the question and does not ask it again. A turn that reaches its time limit (the built-in runtime's budget, 10 minutes by default, or its cap, 30 by default; see turnLimits) asks before it ends, on a card that names the limit and what the turn is doing, with Continue and Cancel; with no answer within turnLimits.cardSeconds (5 minutes by default) the turn is aborted. The copy button of an approval card keeps its text in card-copies.json under the data directory, the newest 100 for 30 days; discord.cardCopies ({ keepDays?, keep? }) changes that, and false keeps them in memory only. The files of a message in an agent's channel download within discord.attachmentDownload.deadlineMs (default 60,000) for the message and the one it replies to together, and a file that sends no byte for discord.attachmentDownload.idleMs (default 10,000) is given up; the turn goes on without a file it could not get.

Threat model

pi-roundtable runs one assistant for one owner. 0.9 adds principal-scoped memory and private conversations, but this is not a complete multi-user service: credentials and host execution remain shared, and whoever lets others in takes that on.

  • Who is trusted. The operator controls the host, its configuration, and its credentials. Principals holding owner may use every tool; owner is granted only in access.owners or through roundtable principal, never a token claim. Others are admitted at member or admin through core access rules over verified identities and surface-prefixed roles. Linking identities makes the accounts one principal; surfaces cannot assert a speaker's authority.
  • What a turn may do. A turn gets only the tools its speaker's tier holds, and a tool no plugin gives a tier is the owner's alone. Tools run in the host process, with its files and its network: web_search and fetch_content can reach internal addresses, so name the tools of a web persona in its selection. Plugins run in the same process, with the same access.
  • Approvals. A held call is approved only by the speaker whose turn held it, at a tier that still holds the call, or, in a shared conversation, by an owner, on its card or by a confirming message. In a private conversation nobody else may, and a call above the speaker's tier is not approved there.
  • Conversations. In Discord an agent's channel and a group room are shared: everyone who writes there adds to one conversation the agent reads. A web chat conversation belongs to the person who opened it: in the web chat only they may read it, write in it, or answer its prompts, but the owner can read it through the owner console (pi-roundtable-web), and the operator through the database and the data directory.
  • Memory isolation. Memory follows the principal, not owner tier: a private conversation's person, or the current speaker in a shared room. Link accounts to share one person's memory across Discord and web. In shared requests and summaries, memory-tool and memory-loaded-worker exchanges (arguments and results together) are hidden from other speakers, and earlier reasoning is removed with provider-aware handling. Other tools' arguments and public assistant replies remain public, even if the model repeats private memory there; this is not general secret-flow prevention. Third-party compactors must use privateCompaction before summarizing shared history. claude-bridge shared turns refuse when raw reader records, including compacted turns, show another reader's private memory (even prompt-only memory and its reasoning), or an exchange is hidden from the current reader. A turn with no private memory does not block other readers, and disabling future memory does not bypass prior private history; admitting other people only warns at boot and in doctor. SYSTEM counts as primary owner for this core guard alone, so it can read and publicly repeat the owner's retained exchanges/reasoning; pre-0.9 untagged built-in memory results before the first scope record retain owner attribution. Sandbox party-wide prompt blocks must explicitly use memory.visibility: "shared", and private turns require worker capabilities. Bridge replays unfiltered history outside these projections; see the exact rule and remaining risk.
  • The host shell. An agent's shell runs as the host's user. Holds keep risky commands behind a card; they are not a sandbox. The shell gets only part of the host's environment (the paths, user, locale, terminal, TMPDIR and proxies, plus what shellEnv adds), so the bot tokens, model logins, GH_TOKEN, DATABASE_URL and keys the host holds stay in the host process; printing the environment, reading a secret file such as .env or ~/.ssh/*, and gh auth token are held as shell.secret-read, which yolo mode always cards; a secret's value in a tool's output is replaced with ****. This stops casual and accidental leaks, not a determined command run as the same user, which can read /proc/<host pid>/environ and any file that user can. To keep secrets from the agents, run their shells under a dedicated account without the docker group (membership in it equals root), or use pi-roundtable-sandbox.
  • Model credentials. Every turn, whoever speaks, runs on the host's model login and is billed to it. Per-person credentials are planned for a later release.
  • Data. Conversations, memory, and schedules sit unencrypted in PostgreSQL and the data directory.

Upgrading

Migrating to 0.9 covers principal backfill, explicit speakers, package changes, the memory isolation boundary, prompt differences, and rollback. Ordinary single-owner Discord hosts can keep their old configuration; custom runtime callers and web integrations have explicit migration steps. Migrating to 0.8 covers upgrading a Discord project from 0.7, which needs no configuration change, and starting a host without Discord.

Commands

Command What it does
roundtable init [dir] [--adapter discord|web] Creates a project in dir (default: the current directory) that talks through Discord (the default) or through pi-roundtable-webchat
roundtable doctor [--reachable] Checks the setup and says how to fix what is wrong
roundtable start Runs the checks that need no network, then the bot
roundtable add plugin <name> Adds plugins/<name>.ts and its test, and lists it in the config
roundtable add package <spec> Installs a Pi package with bun add and adds a plugin that loads it and gives its tools to every agent turn
roundtable upgrade [--write [--unchecked]] Shows roundtable.config.ts rewritten in the 0.9 form (owner and speakers as access, discord as an adapter) as a diff; --write writes it after checking it serves the same people, which needs the environment the host runs with, and refuses when it cannot check; --unchecked writes it unchecked, with a warning
roundtable principal list|show|create|link|unlink|grant|revoke|disable|enable Lists and changes the principals, their identities, and their roles in the database; a running host sees a change within 30 seconds

Changes and license

CHANGELOG.md lists every change to the package's exported names. MIT.

About

A plugin framework for Discord agent teams, on Pi and Bun

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages