아카이브 / 개발

openagora — 채팅 채널과 Claude Code CLI를 잇는 브리지

유형개발
기간2026.03
역할설계·구현
구분개인 프로젝트
언어TypeScript
주요 기술Node.js 20 · Claude Code CLI · Slack·Discord·Telegram 어댑터

목차프로젝트 요약 · 담당 범위 · 문제와 구현 접근 · 이 작업에서 한 일 · 결과물과 원문 · 구현 상세

프로젝트 요약

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 for claude -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 random WEBHOOK_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 /start is parsed as a run command and creates a project named start-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
ChannelParsed as a command whenOther messages
Slackalways (a leading @openagora, !openagora, !agora or bot mention is stripped)none; every message is a command
Discordit starts with !openagora, !agora or @openagorachat
Telegramit starts with a /word command, e.g. /run, /statuschat
CLI, webhookalways (a leading openagora is stripped)none; every message is a command
Emailneverchat (Subject: … plus the body)

Command verbs:

CommandEffect
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.
listList projects with queued or running tasks.
helpShow the command list.
anything elseTreated 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 start exits if claude --version fails. Setting ANTHROPIC_API_KEY also 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:

VariableUsed for
SLACK_BOT_TOKEN, SLACK_APP_TOKENSlack adapter (Socket Mode). Both are needed.
DISCORD_BOT_TOKENDiscord adapter
TELEGRAM_BOT_TOKENTelegram adapter (long polling). Also used by the notifier.
EMAIL_IMAP_HOST, EMAIL_IMAP_PORT (993), EMAIL_IMAP_USER, EMAIL_IMAP_PASSEmail adapter, inbox polling every 60 s
EMAIL_SMTP_HOST, EMAIL_SMTP_PORT (587), EMAIL_SMTP_USER, EMAIL_SMTP_PASSEmail 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_USEROwner for gh repo create on new projects (default unknown)
HEALTH_PORT (3001)Health endpoint port
SLACK_NOTIFY_WEBHOOK, TELEGRAM_NOTIFY_CHAT_IDNotifications when a run task completes or fails
ANTHROPIC_API_KEY, CLAUDE_CODE_MAX_TURNS, HTTP_PROXY, HTTPS_PROXY, NO_PROXYPassed through to the claude process
NODE_ENVproduction 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.yaml is loaded at startup, but channels are switched on by the environment variables above.
  • config/mcp.json is a sample MCP server list. The service does not read it.
  • registry/projects.json is the project registry. It is created on first use and ignored by git.
  • Logs go to logs/openagora.log and logs/error.log in the working directory.
Slack
  1. Create an app at https://api.slack.com/apps and enable Socket Mode. Copy the app-level token (xapp-…) into SLACK_APP_TOKEN.
  2. 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).
  3. Under Event Subscriptions, subscribe to the bot events message.channels, message.groups and message.im.
  4. Install the app to the workspace, copy the bot token (xoxb-…) into SLACK_BOT_TOKEN, and invite the bot to a private channel.
Discord
  1. Create an application at https://discord.com/developers/applications and add a bot. Copy its token into DISCORD_BOT_TOKEN.
  2. Enable the Message Content privileged intent. The client uses the Guilds, GuildMessages and MessageContent intents, so direct messages are not received.
  3. Invite the bot to your server with the bot scope 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
  1. Create a bot with @BotFather.
  2. 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-Secret or webhook-secret and is compared in constant time. A wrong secret returns 401, and a missing content returns 400.
  • projectId is accepted in the body but only recorded as the channel id. The project is chosen from content.
  • The response body is only the first reply, as {"reply": "…"}. help, status and list answer right away. For a run the 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. Set SLACK_NOTIFY_WEBHOOK or TELEGRAM_NOTIFY_CHAT_ID to 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.

CommandDescription
openagora startStart the server (same components as npm start, but without the claude check, the banner or the uncaught-exception handlers)
openagora setupSetup wizard: quick start (CLI only) or full setup, which writes tokens to .env
openagora statusFetch http://localhost:$HEALTH_PORT/health and print it
openagora token listList 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): runs node dist/index.js as user openagora from /opt/openagora/app and reads .env there. It uses ProtectSystem=strict and ProtectHome=tmpfs, and only /opt/openagora/{projects,data,logs} are writable. The service writes logs/ and registry/ under its working directory and creates projects under BASE_PROJECT_DIR, so make those paths writable, and make sure the service user can run an authenticated claude.
  sudo cp openagora.service /etc/systemd/system/
  sudo systemctl enable --now openagora
  journalctl -u openagora -f
  • com.openagora.plist (launchd, macOS): runs node dist/index.js from /usr/local/opt/openagora, restarts it on failure, and writes stdout and stderr to /usr/local/var/log/openagora/. Settings come from .env in 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 INBOX on 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 -p with 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.ts exists 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 lint does not run as is.
  • The first design is kept in SPEC.md for 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

GitHub에서 최신 문서 보기 ↗