Roundtable is a local-first desktop application for multi-agent ideation. It lets a human moderator run structured discussions between AI agents (Claude, Codex, Gemini) — agents never talk to each other directly; the user controls every round.
┌─────────────────────────────────────────────────────────────────┐
│ Electrobun Desktop Shell │
│ src/bun/index.ts — native window, dynamic port, HMR in dev │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ React Frontend (Vite + TypeScript) │ │
│ │ src/web/ — conversation UI, agent responses, settings │ │
│ │ ▲ │ │
│ │ │ WebSocket (bidirectional) │ │
│ │ ▼ │ │
│ │ Bun Backend (Hono framework) │ │
│ │ src/server/ — orchestrator, persistence, adapters │ │
│ │ │ │
│ │ ┌─────────────┬─────────────────┬─────────────────────┐ │ │
│ │ │ Claude │ Codex │ Gemini │ │ │
│ │ │ Adapter │ Adapter │ Adapter │ │ │
│ │ │ │ │ │ │ │
│ │ │ Agent SDK │ JSON-RPC over │ JSON-RPC over │ │ │
│ │ │ (npm) │ stdio │ stdio │ │ │
│ │ └──────┬──────┴────────┬────────┴──────────┬──────────┘ │ │
│ └─────────┼───────────────┼───────────────────┼─────────────┘ │
│ ▼ ▼ ▼ │
│ Anthropic API codex app-server gemini CLI │
│ (child process) (child process) │
└─────────────────────────────────────────────────────────────────┘
All three agents implement a common AgentAdapter interface (src/server/adapters/types.ts):
interface AgentAdapter {
checkAvailability(): Promise<{ available: boolean; version?: string }>;
startSession(opts: { cwd: string; systemPrompt: string; sharedDir: string }): Promise<void>;
sendTurn(input: TurnInput): Promise<TurnResult>;
interrupt(): Promise<void>;
destroy(): Promise<void>;
getSessionId(): string | null;
restoreSessionId(id: string): void;
}- Protocol:
@anthropic-ai/claude-agent-sdknpm package — direct in-process SDK calls - Connection: No subprocess. The SDK manages HTTP connections to the Anthropic API.
- Session continuity: SDK session ID is persisted in
conversation.jsonand restored on load. - Streaming: SDK provides a stream; the adapter buffers the full response while emitting deltas via
onStream. - Tool approval: Delegated back to the Orchestrator → WebSocket → user.
- Effort mapping:
low|medium|high→ Claude'slow|medium|high
- Protocol: JSON-RPC 2.0 over stdio
- Connection: Spawns
codex app-serveras a child process. A custom JSON-RPC client (src/server/adapters/codex-jsonrpc.ts) handles the bidirectional protocol. - Session continuity: Thread ID from
thread/startis persisted; resumed viathread/resume. - Key RPC methods:
initialize,thread/start,thread/resume,thread/send_message - Streaming:
message/chunknotifications push text deltas in real time. - Tool approval: Server receives
session/request_permissionRPC requests and forwards to the user.
- Protocol: JSON-RPC 2.0 over stdio (ACP — Agent Communication Protocol)
- Connection: Spawns
geminiCLI with ACP flag as a child process, using the same JSON-RPC transport as Codex. - Session continuity: Session ID from
session/new; reloaded viasession/load. - Key RPC methods:
initialize,session/new,session/load,session/prompt - Streaming:
session/updatenotifications deliveragent_message_chunkandagent_thought_chunkevents. - Tool approval: Via
session/request_permissionRPC. Certain tool kinds (read, search, fetch, think) are auto-approved. - Model config: Writes custom Gemini settings with thinking budget calibrated to the effort level.
Roundtable can ingest past conversations from each agent's CLI home directory so that agents have prior context when starting a new roundtable discussion.
| Agent | Source directory | Format |
|---|---|---|
| Claude | ~/.claude/projects/ |
JSONL session files, or sessions-index.json for rich metadata |
| Codex | ~/.codex/ |
Codex session files |
| Gemini | ~/.gemini/ |
Gemini session transcript files |
Discovery returns lightweight DiscoveredSession[] records (id, path, title, date, cwd, messageCount) for the UI to present before actually parsing.
parse-claude.ts— reads JSONL, extractsuser/assistantevents and tool callsparse-codex.ts— reads Codex session formatparse-gemini.ts— reads Gemini session transcripts
All produce a common ImportedSession type with a TranscriptMessage[] array.
- Parse all selected sessions via
importSessions() - Create conversation normally (clean system prompts)
- Store import metadata in
conversation.importedSessions - Round 0 (Context Load): Each agent receives its own prior session as a formatted context message. Agents without imported sessions get a standby message.
- Round 1+: The
TranscriptManagerprepends a cross-agent preamble (all imported sessions) on the first real user message so every agent can reference every other agent's prior work.
All Roundtable data lives in ~/.roundtable/ (configurable via ROUNDTABLE_HOME env var).
~/.roundtable/
├── {conversation-id}/
│ ├── conversation.json # Full conversation state (rounds, metadata, session IDs)
│ ├── plan.md # Shared plan file (agents can write)
│ ├── memory.md # Shared memory file (agents can write)
│ └── workspace → /path/to/project # Symlink to project cwd
└── logs/
└── *.log
ConversationStore (src/server/conversation-store.ts) handles atomic JSON writes (write + rename), listing, loading, and deletion.
The core design principle: agents never communicate directly. The human moderator controls every round, and agents see each other's prior responses only when the user sends the next message.
User Frontend (React) Backend (Orchestrator) Adapters (Claude/Codex/Gemini)
│ │ │ │
│ Type message & submit │ │ │
│─────────────────────────>│ │ │
│ │ WS: message.send │ │
│ │─────────────────────────────>│ │
│ │ │ Create Round N │
│ │ │ Parse @mentions for targeting │
│ │ │ │
│ │ WS: round.created │ │
│ │<─────────────────────────────│ │
│ │ │ │
│ │ │ For each recipient agent: │
│ │ │ ┌────────────────────────────────┐│
│ │ │ │ Build turn text: ││
│ │ │ │ [user]: <message> ││
│ │ │ │ [other_agent]: <round N-1 ││
│ │ │ │ response> ││
│ │ │ │ (imported session preamble ││
│ │ │ │ on first message) ││
│ │ │ └────────────────────────────────┘│
│ │ │ │
│ │ │──── adapter.sendTurn() ──────────>│
│ │ │ (concurrent fan-out) │
│ │ │ │
│ │ │<─── onStream(delta) ──────────────│
│ │ WS: agent.streaming │ (real-time text deltas) │
│ See live streaming │<─────────────────────────────│ │
│<─────────────────────────│ │ │
│ │ │ │
│ │ │ (if agent requests tool use) │
│ │ WS: tool.approval_request │<─── onToolApproval(req) ─────────│
│ Approve/deny tool │<─────────────────────────────│ │
│─────────────────────────>│ │ │
│ │ WS: tool.approval_response │ │
│ │─────────────────────────────>│──── resolve(approved) ───────────>│
│ │ │ │
│ │ │<─── TurnResult ──────────────────│
│ │ WS: agent.complete │ (full text, duration, usage) │
│ │<─────────────────────────────│ │
│ │ │ │
│ │ │ All agents done? │
│ │ │ → Save conversation.json │
│ │ WS: round.complete │ │
│ Input unlocked │<─────────────────────────────│ │
│<─────────────────────────│ │ │
│ │ │ │
│ Read all responses, │ │ │
│ compose next message │ │ │
│─────────────────────────>│ (repeat for Round N+1) │ │
When the user sends a message for Round N, each agent receives:
- The user's message (possibly @mention-targeted)
- Other agents' complete responses from Round N-1 — formatted as
[agent_name]: <response>. An agent never sees its own prior response in the turn text (its SDK session already has it in context). - Imported session preamble (first message only) — all imported sessions formatted as context so every agent knows what prior work was done.
This ensures agents are informed by each other's thinking without direct agent-to-agent communication. The user remains the moderator who decides when to advance the discussion.
@agentsyntax targets specific agents — unmentioned agents skip that round- Multi-line fenced block syntax for directed messages to specific agents
- Enables the user to steer the conversation (e.g., ask only Claude to elaborate, or give Gemini specific instructions)
The backend runs on Bun with Hono as the HTTP framework.
| File | Purpose |
|---|---|
index.ts |
Hono app, WebSocket server, REST endpoints, static files |
orchestrator.ts |
Round management, fan-out, context building |
transcript.ts |
Per-agent turn text construction, imported session preamble |
conversation-store.ts |
JSON persistence (atomic write + rename) |
config.ts |
Settings and agent config management |
mentions.ts |
@mention parsing and message targeting |
storage.ts |
~/.roundtable/ filesystem paths, workspace symlinks |
summarize.ts |
Token-aware message summarization |
logger.ts |
Structured logging |
adapters/ |
Claude, Codex, Gemini adapter implementations |
session-import/ |
Discovery, parsing, and formatting of prior CLI sessions |
Frontend → Backend (ClientEvent):
message.send— user sends a message in a conversationconversation.create/conversation.create_with_import— start new conversationconversation.load/conversation.list/conversation.deletesettings.update— change agent models, effort levels, timeoutsagents.check— probe CLI availabilityround.interrupt— cancel in-flight roundtool.approval_response— user approves/denies a tool call
Backend → Frontend (ServerEvent):
round.created/round.complete— round lifecycleagent.thinking/agent.streaming/agent.complete/agent.error/agent.timeout— per-agent statusconversation.loaded/conversation.created/conversation.listtool.approval_request— agent wants to use a toolsettings.updated/agents.availabilityerror— general errors
React + Vite + TypeScript SPA.
| File/Dir | Purpose |
|---|---|
App.tsx |
Root component, layout |
components/ConversationView.tsx |
Main chat display |
components/RoundView.tsx |
Single round with all agent responses |
components/AgentResponse.tsx |
Formatted agent message (markdown + code highlight) |
components/MessageInput.tsx |
User input with submit |
components/Sidebar.tsx |
Conversation list |
components/SettingsBar.tsx |
Agent toggles, model/effort selection |
components/ImportSessionsDialog.tsx |
Session import UI |
components/ToolApproval.tsx |
Inline tool approval prompts |
hooks/useWebSocket.ts |
WebSocket connection, reconnect, event queue |
hooks/useConversation.ts |
Conversation state (rounds, streaming responses) |
hooks/useSettings.ts |
Settings state + persistence |
Roundtable is packaged as a native desktop app using Electrobun, which embeds Bun as the runtime and uses the OS native webview (no bundled Chromium).
- Sets
PORT=0so the OS assigns a free port (no conflicts) - Imports and starts the backend server in-process
- In dev mode, checks if Vite HMR is running on port 3000/5173 — if so, points the window there with
?desktop=1&port=<actualPort>for API connectivity - In production, serves the pre-built frontend assets from the app bundle
- Creates a
BrowserWindowwithtitleBarStyle: "hiddenInset"on macOS
App name: Roundtable
Identifier: dev.roundtable.app
Build: Copies Vite dist → Resources/static/
CEF: Disabled (uses native webview)
| Command | Description |
|---|---|
bun run dev |
Browser: backend on 3001 + Vite on 3000 |
bun run dev:desktop |
Desktop app (one-shot frontend build) |
bun run dev:desktop:hmr |
Desktop with live frontend hot reload |
bun run build:desktop |
Full desktop app bundle |
bun run build:binary |
Standalone single-file binary in dist/ |
context.ts— token estimation, context window calculation (used by both backend transcript building and frontend display)