아카이브 / 개발
openagora — 채팅 채널과 Claude Code CLI를 잇는 브리지
목차 — 프로젝트 요약 · 담당 범위 · 문제와 구현 접근 · 이 작업에서 한 일 · 결과물과 원문 · 구현 상세
프로젝트 요약
Slack·Discord·Telegram·이메일·웹훅·터미널로 받은 메시지를 프로젝트별 큐에 넣고, 해당 프로젝트 디렉터리에서 Claude Code CLI(claude -p)로 실행한 뒤 결과를 원래 채널로 돌려주는 Node.js 서비스.
담당 범위
설계·구현을 맡은 개인 프로젝트다. 공개 저장소에 코드와 사용 방법을 남겼다.
문제와 구현 접근
채널 어댑터가 받은 메시지를 명령 파서(run·status·list·help)가 해석하고, 프로젝트 레지스트리가 대상 디렉터리를 정한다. 작업은 프로젝트마다 동시 실행 1개인 큐로 넘어가 30분 제한이 있는 claude -p 프로세스로 실행되고, 진행 메시지와 결과가 원래 채널로 돌아간다. Slack에서는 스레드 답글과 리액션으로 상태를 표시한다.
처음 설계는 모델 라우터와 빌더 에이전트, worktree 격리를 갖춘 더 큰 멀티 모델 오케스트레이터였다. 이후 설계 문서(SPEC-BRIDGE-001)에서 이를 얇은 브리지로 줄이고, 에이전트 선택·리뷰·검증은 Claude Code에 맡겼다.
/health 엔드포인트와 프로세스 감시, 셋업·토큰 관리 CLI, systemd·launchd 배포 템플릿을 함께 두었다. 받은 메시지가 권한 확인을 건너뛰는 모드로 실행되고 발신자 허용 목록이 아직 없다는 보안 경고를 README 첫머리에 적고, 비공개 워크스페이스와 채널에서만 쓰도록 안내했다.
이 작업에서 한 일
아이디어를 실제로 작동하는 코드로 만들고, 다른 사람이 설치하고 사용할 수 있도록 설명서를 함께 작성했다.
결과물과 원문
관련 등록공보·논문·저장소는 아래 링크에서 볼 수 있다.
구현 상세
README공개 저장소의 구조·기능·실행 문서
OpenAgora
A small Node.js service that connects chat channels to the Claude Code CLI.
A message from Slack, Discord, Telegram, email, an HTTP webhook or the local terminal is parsed, matched to a project directory and queued per project. It then runs as claude -p in that directory. Status messages and the final output go back to the channel the message came from.
The first design (SPEC.md) was a larger multi-model orchestrator with a model router, a stagnation loop, builder agents and worktree isolation. SPEC-BRIDGE-001 reduced it to this thin bridge. Agent selection, review and verification are left to Claude Code itself.
Security warning. Every accepted message becomes a prompt forclaude -p --dangerously-skip-permissions(src/bridge/claude-cli-bridge.ts), and the adapters have no sender allowlist. Anyone who can message the bot, or who knows the webhook secret, can make Claude Code read, write and run commands as the service user. Use it only in private workspaces and channels, set a long randomWEBHOOK_SECRET, and run it as a dedicated low-privilege user.
Private channels do not cover every adapter. Anyone who finds a Telegram bot's username can message it, and every text message it gets is run. Even Telegram's automatic/startis parsed as aruncommand and creates a project namedstart-xxxx. Anyone who can send mail to the polled inbox gets a run. In Discord, every message the bot can read is executed, with or without a prefix. Restricting Telegram and email senders needs an allowlist in code, which does not exist yet. The webhook (port 3000) and/health(port 3001) servers listen on all network interfaces, so block those ports with a firewall if other machines can reach the host.
How it works
Slack · Discord · Telegram · Email · Webhook · CLI (src/adapters)
│ ChannelMessage
▼
CommandParser run | status | list | help | chat (src/router/command-parser.ts)
▼
ProjectRouter match a project in registry/projects.json,
or create one under BASE_PROJECT_DIR (src/router)
▼
ProjectQueue one p-queue per project, concurrency 1 (src/queue)
▼
ClaudeCliBridge spawn `claude -p --dangerously-skip-permissions "<task>"`
with cwd = project directory, 30-minute timeout
(process group killed with SIGKILL) (src/bridge)
▼
Replies a start message, snippets of claude's stdout (at most
one every 5 s), then the result. On every channel,
output is converted to Slack mrkdwn and cut to about
3,000 characters; errors are cut to 500 in a code block.
In Slack: threaded replies and
hourglass / check mark / x reactions
HealthDaemon GET /health, process watcher, notifier (src/health)
Message handling
| Channel | Parsed as a command when | Other messages |
|---|---|---|
| Slack | always (a leading @openagora, !openagora, !agora or bot mention is stripped) | none; every message is a command |
| Discord | it starts with !openagora, !agora or @openagora | chat |
| Telegram | it starts with a /word command, e.g. /run, /status | chat |
| CLI, webhook | always (a leading openagora is stripped) | none; every message is a command |
| never | chat (Subject: … plus the body) |
Command verbs:
| Command | Effect |
|---|---|
run <project> "<task>" | Queue the task for the project. If no registered project matches, a new one is created (see below). |
status [project] | Show queue size and pending count for one or all projects. |
list | List projects with queued or running tasks. |
help | Show the command list. |
| anything else | Treated as run with the project detected from the text. |
Chat messages run right away in the project whose name appears in the text. If no project name matches, they run in <BASE_PROJECT_DIR>/.agora-workspace. No project is created.
New projects. When a run request matches no project, the router creates <BASE_PROJECT_DIR>/<name>. It runs git init, writes a CLAUDE.md, makes an initial commit and registers the project in registry/projects.json. It then tries gh repo create <GITHUB_USER>/<name> --private --source <dir> --push, and if that fails it logs the error and continues. In CLI and Slack mode every message is a command, so free text that does not name a registered project creates a new project directory.
Most status messages from the router are written in Korean. The help text and command parse errors are in English.
Requirements
- Node.js 20 or newer
- Claude Code CLI on
PATH, signed in.npm startexits ifclaude --versionfails. SettingANTHROPIC_API_KEYalso works, because the bridge passes it through to the child process. - Optional: GitHub CLI (
gh), signed in, if new projects should also get a private GitHub repository
Quick start
git clone https://github.com/madebysmg/openagora && cd openagora
npm install
npm run build
npm start
With no channel tokens set, only the CLI adapter runs. Type a request into the terminal, for example run myapp "add a README", and the reply is printed to stdout. If myapp is not in the registry yet, this creates ~/project/myapp first. Unless NODE_ENV=production is set, the terminal also shows debug logs.
Configuration
Copy .env.example to .env, or run the setup wizard (see CLI). A channel adapter is created only when its variables are set:
| Variable | Used for |
|---|---|
SLACK_BOT_TOKEN, SLACK_APP_TOKEN | Slack adapter (Socket Mode). Both are needed. |
DISCORD_BOT_TOKEN | Discord adapter |
TELEGRAM_BOT_TOKEN | Telegram adapter (long polling). Also used by the notifier. |
EMAIL_IMAP_HOST, EMAIL_IMAP_PORT (993), EMAIL_IMAP_USER, EMAIL_IMAP_PASS | Email adapter, inbox polling every 60 s |
EMAIL_SMTP_HOST, EMAIL_SMTP_PORT (587), EMAIL_SMTP_USER, EMAIL_SMTP_PASS | Email replies |
WEBHOOK_SECRET, WEBHOOK_PORT (3000) | Webhook adapter. It starts only when the secret is set. |
BASE_PROJECT_DIR (~/project) | Where projects and .agora-workspace live |
GITHUB_USER | Owner for gh repo create on new projects (default unknown) |
HEALTH_PORT (3001) | Health endpoint port |
SLACK_NOTIFY_WEBHOOK, TELEGRAM_NOTIFY_CHAT_ID | Notifications when a run task completes or fails |
ANTHROPIC_API_KEY, CLAUDE_CODE_MAX_TURNS, HTTP_PROXY, HTTPS_PROXY, NO_PROXY | Passed through to the claude process |
NODE_ENV | production logs at info level to files only. Any other value also logs debug output to the console. |
The claude child process gets a reduced environment: PATH, HOME, USER, SHELL, LANG, TERM, TMPDIR, NODE_ENV and XDG_CONFIG_HOME, plus the pass-through variables above.
Files:
config/channels.yamlis loaded at startup, but channels are switched on by the environment variables above.config/mcp.jsonis a sample MCP server list. The service does not read it.registry/projects.jsonis the project registry. It is created on first use and ignored by git.- Logs go to
logs/openagora.logandlogs/error.login the working directory.
Slack
- Create an app at https://api.slack.com/apps and enable Socket Mode. Copy the app-level token (
xapp-…) intoSLACK_APP_TOKEN. - Add these bot token scopes:
chat:write,reactions:write(for the status reactions),app_mentions:read, and the history scopes for the channel types you use (channels:history,groups:history,im:history). - Under Event Subscriptions, subscribe to the bot events
message.channels,message.groupsandmessage.im. - Install the app to the workspace, copy the bot token (
xoxb-…) intoSLACK_BOT_TOKEN, and invite the bot to a private channel.
Discord
- Create an application at https://discord.com/developers/applications and add a bot. Copy its token into
DISCORD_BOT_TOKEN. - Enable the Message Content privileged intent. The client uses the
Guilds,GuildMessagesandMessageContentintents, so direct messages are not received. - Invite the bot to your server with the
botscope and the View Channels, Send Messages and Read Message History permissions. The adapter answers with message replies, which fail with "Missing Permissions" without Read Message History.
Telegram
- Create a bot with @BotFather.
- Copy the token into
TELEGRAM_BOT_TOKEN. The adapter uses long polling, so no public URL is needed.
Webhook
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: $WEBHOOK_SECRET" \
-d '{"content": "run myapp \"add a health check endpoint\"", "userId": "ci"}'
- The secret can be sent as
X-Webhook-Secretorwebhook-secretand is compared in constant time. A wrong secret returns401, and a missingcontentreturns400. projectIdis accepted in the body but only recorded as the channel id. The project is chosen fromcontent.- The response body is only the first reply, as
{"reply": "…"}.help,statusandlistanswer right away. For arunthe HTTP request stays open until the task has finished (up to 30 minutes, plus any time spent waiting in the project queue), and the reply is still only the first status line, for example the start or new-project message. The task result is not returned to the caller. SetSLACK_NOTIFY_WEBHOOKorTELEGRAM_NOTIFY_CHAT_IDto be told when a task completes or fails. - If no reply has been sent within 30 s after the task handler returns, the response is
{"status": "accepted"}.
CLI
bin/openagora.js runs the compiled CLI in dist/. Run npm run build first, then use node bin/openagora.js <command> or npm link to get openagora on your PATH. npm run cli -- <command> runs the TypeScript source through tsx instead.
| Command | Description |
|---|---|
openagora start | Start the server (same components as npm start, but without the claude check, the banner or the uncaught-exception handlers) |
openagora setup | Setup wizard: quick start (CLI only) or full setup, which writes tokens to .env |
openagora status | Fetch http://localhost:$HEALTH_PORT/health and print it |
openagora token list | List known tokens with masked values |
openagora token add [adapter] | Add or update tokens for anthropic, discord, slack, telegram, github or webhook |
openagora token remove <adapter> | Remove that adapter's tokens from .env |
Health
curl http://localhost:3001/health
The endpoint returns { healthy, uptime, activeProjects, queueDepth, circuitBreakers, lastCheck }, with status 200 when healthy and 503 otherwise. The process watcher checks registered claude processes every 30 s and kills any that exceed the 30-minute limit.
Deployment
The repository ships two templates. Both need path changes for your machine:
openagora.service(systemd): runsnode dist/index.jsas useropenagorafrom/opt/openagora/appand reads.envthere. It usesProtectSystem=strictandProtectHome=tmpfs, and only/opt/openagora/{projects,data,logs}are writable. The service writeslogs/andregistry/under its working directory and creates projects underBASE_PROJECT_DIR, so make those paths writable, and make sure the service user can run an authenticatedclaude.
sudo cp openagora.service /etc/systemd/system/
sudo systemctl enable --now openagora
journalctl -u openagora -f
com.openagora.plist(launchd, macOS): runsnode dist/index.jsfrom/usr/local/opt/openagora, restarts it on failure, and writes stdout and stderr to/usr/local/var/log/openagora/. Settings come from.envin the working directory.
Development
npm run dev # tsx watch src/index.ts
npm run typecheck # tsc --noEmit
npm test # vitest run (18 test files under src/**/__tests__)
npm run coverage # vitest with v8 coverage
src/
adapters/ Slack, Discord, Telegram, email, webhook and CLI adapters; AdapterManager
bridge/ ClaudeCliBridge, which spawns `claude -p`
cli/ openagora CLI: setup wizard, token management, .env editing
config/ config loader
health/ health daemon, health monitor, process watcher, circuit breaker, notifier
queue/ per-project FIFO queue
router/ command parser, project router, registry, project creator
types/ shared types
utils/ logger, platform paths, Slack formatting
index.ts server entry point (npm start)
bin/ openagora CLI launcher
config/ channels.yaml, sample mcp.json
.moai/specs/ design specs written during development (some describe modules removed by SPEC-BRIDGE-001)
.claude/agents/ agent definitions from the earlier multi-agent design (the service does not load them)
Status and limitations
- Personal project, version 0.1.0. It was written between 2026-03-21 and 2026-03-24, and that is also the date of the last change.
- The email adapter fetches every message in
INBOXon each 60-second poll and does not mark or remember processed mail. Each poll therefore sends every message to Claude again. Treat it as experimental and do not point it at a mailbox that already has mail. - There is no live step-by-step streaming. The bridge runs
claude -pwith its default text output, which writes the answer to stdout only when the run ends. The 5-second progress snippets therefore usually come down to one line taken from the final output, sent just before the result. src/health/circuit-breaker.tsexists and its states are reported by/health, but the current bridge path does not register any breaker.- There is no ESLint configuration in the repository, so
npm run lintdoes not run as is. - The first design is kept in
SPEC.mdfor reference. It does not describe the current code.
The repository was first scaffolded with moai-adk v2.7.20 (Apache-2.0). The generated template files (skills, rules, hooks, commands, output styles, CLAUDE.md) are not included here.
License
No license has been chosen yet.
Version 0.1.0 · Maintainer: madebysmg