A Feishu (Lark) channel plugin for Claude Code, built on Claude Code's native Channel interface. Send messages to a Feishu bot and interact with Claude — right in your chat, with WebSocket persistent connection mode requiring no public HTTPS endpoint.
Claude Code is the primary way in — you install the plugin, run claude-feishu, and every message from Feishu reaches a live Claude Code instance. But the router behind it isn't limited to Claude: each Feishu group can instead be pointed at an OpenCode session, and a group can even fall back to OpenCode automatically when its Claude Code worker isn't connected. Same bot, same chat, your choice of backend per group.
npx feishuchannel-for-claudecode # one-command installThe killer feature: route different Feishu groups to different Claude Code instances — or to OpenCode — each working in its own project directory. A single Feishu bot serves your entire team — each group gets its own isolated agent with full project context, and the backend is a per-group config choice, not a code change.
┌─ Claude Code (project-a)
Feishu Bot ──→ Router ───────┤─ Claude Code (project-b)
(single WebSocket)├─ OpenCode (project-c)
└─ Claude Code (project-d), fallback → OpenCode
▲ Claude Code workers auto-connect via Unix socket
How it works:
- The router holds the single Feishu WebSocket connection and routes messages by
chat_id → project → backend (claude | opencode) - For Claude Code, each worker (server.ts) runs inside a Claude Code instance, registered by its working directory, and connects to the router over a Unix socket
- For OpenCode, the router talks directly to an
opencode serveHTTP/SSE API — no worker process, no manual per-project startup - The first
claude-feishulaunch auto-spawns the router — no manual setup needed - When all Claude Code workers disconnect, the router auto-shuts down after a grace period — this check only counts Claude Code workers, so if you run OpenCode-only groups with no Claude Code project ever connected, run the router as a persistent service instead (see Running the Router as a Service)
Zero-config startup — just run claude-feishu in each project directory:
cd /path/to/project-a && claude-feishu # spawns router + connects as worker
cd /path/to/project-b && claude-feishu # connects to existing router
cd /path/to/project-c && claude-feishu # connects to existing routerMap Feishu groups to project directories in ~/.claude/channels/feishu/access.json:
See Multi-Group Router Setup for full configuration details, or OpenCode Backend to route specific chats to OpenCode instead of Claude Code.
- Multi-group routing — One Feishu bot serves multiple Claude Code instances, each in its own project
- Auto-managed router — Router spawns on first launch, shuts down when all workers disconnect
- OpenCode backend (opt-in per group/DM) — Route specific chats to OpenCode instead of Claude Code, with sessions persisted in SQLite across router restarts
- Claude → OpenCode fallback (opt-in) — If no Claude Code worker is connected, or Claude Code just hit its usage rate limit, hand that message to OpenCode instead of dropping it (pre-execution cases only, see Fallback to OpenCode)
- Direct messages — Chat with Claude through Feishu DMs
- Group chats — Add the bot to group chats with @mention support
- Access control — Pairing-based onboarding, allowlists, and per-group policies
- Confirm cards — Interactive confirmation cards for risky actions
- Permission cards — Interactive approve/deny cards for tool permission requests
- Unanswered reminders — Auto-nudges Claude if a message goes unanswered for 30+ minutes (up to 3 times, escalating intervals)
- Attachments — Send and receive files and images
- Reactions — Configurable emoji reactions on message receipt
- Smart connection — Only connects when launched as a channel, skipping unnecessary connections
- Graceful shutdown — Detects parent process exit via ppid polling, preventing orphaned processes
- Bun runtime
- Claude Code CLI installed
- A Feishu (or Lark) workspace with admin access to create apps
-
Go to Feishu Open Platform (or Lark Open Platform for international)
-
Create a Custom App (enterprise internal app)
-
Note the App ID (
cli_...) and App Secret -
Under Events & Callbacks, configure two separate tabs:
Event Configuration tab:
- Switch connection method to Using persistent connection (WebSocket mode) at the top of the page
- Add event:
im.message.receive_v1
Callback Configuration tab:
- Also switch to Using persistent connection (WebSocket mode)
- Add callback:
card.action.trigger(Card interaction callback) — required for confirm card buttons to work
-
Under Permissions & Scopes, add:
Permission Purpose im:messageSend messages im:message.receive_v1Receive messages im:message.p2p_msg:readonlyRead DM messages im:message.group_at_msg:readonlyRead group @mentions im:chat:readonlyRead chat metadata im:resourceDownload attachments -
Publish the app version so permissions take effect
One command:
npx feishuchannel-for-claudecodeThis clones the repo, installs dependencies, registers the Claude Code plugin, and creates the claude-feishu shortcut — all automatically.
Manual installation
git clone https://github.com/phxwang/feishuchannel-for-claudecode.git
cd feishuchannel-for-claudecode
bun install
claude plugin marketplace add .
claude plugin install feishu@feishu-localclaude-feishuOn subsequent launches, claude-feishu automatically resumes the session named after the current directory (e.g., a session named ccmyproject is matched in the myproject/ directory). If no matching session is found, an interactive session picker opens.
Or use the full command:
claude --dangerously-load-development-channels plugin:feishu@feishu-localIn your Claude Code terminal:
/feishu:auth cli_YOUR_APP_ID YOUR_APP_SECRET
Credentials are stored in ~/.claude/channels/feishu/.env (mode 600).
-
Open Feishu and search for your bot by app name
-
Send any message to the bot
-
The bot replies with a pairing code
-
In Claude Code, run:
/feishu:access pair <code> -
The bot confirms: "Paired! Say hi to Claude."
You're ready — send messages to the bot and Claude will respond.
The steps below start with the zero-config ad-hoc flow (fine for quick local testing), but if you're relying on this daily — or running any group on OpenCode, which has no worker to auto-spawn the router — install the router as a persistent service first (step 3) and skip step 2's manual spawn-ordering entirely: each
claude-feishujust connects to the already-running router.
Add workdir to each group in ~/.claude/channels/feishu/access.json:
{
"groups": {
"oc_groupA": {
"requireMention": true,
"allowFrom": [],
"workdir": "/path/to/project-a"
},
"oc_groupB": {
"requireMention": true,
"allowFrom": [],
"workdir": "/path/to/project-b"
}
},
"defaultWorkdir": "/path/to/default-project" // DMs route here
}In separate terminals, start Claude in each project directory:
cd /path/to/project-a
claude-feishu
cd /path/to/project-b
claude-feishuThe first instance auto-spawns the router (unless a router is already running as a service, in which case it just connects). Subsequent instances connect as workers. The router matches incoming messages by chat_id → project → backend (Claude Code worker, or OpenCode directly).
The auto-spawn behavior above is convenient for quick starts, but for a machine that stays up (a dev box, a server), run the router as a supervised background service instead — it survives crashes and reboots without depending on any particular claude-feishu instance staying alive to relaunch it. Ad-hoc bun router.ts or a tmux pane both work for testing, but once a service is installed, don't also start the router manually — router-lock.ts prevents two routers from serving traffic at once, so a manually-started process will just exit immediately (or fight with the service over the singleton lock).
macOS (launchd):
1. Install — pick a label (com.<you>.feishuchannel-router), then write the plist. Fill in the absolute paths for your setup: which bun for the bun binary, the repo's absolute path, and wherever you want logs written.
mkdir -p ~/Library/LaunchAgents ~/logs # or wherever you want StandardOut/ErrorPath to live
cat > ~/Library/LaunchAgents/com.you.feishuchannel-router.plist <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.you.feishuchannel-router</string>
<key>ProgramArguments</key>
<array>
<string>/path/to/bun</string>
<string>/path/to/feishuchannel/router.ts</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>10</integer>
<key>WorkingDirectory</key><string>/path/to/feishuchannel</string>
<key>StandardOutPath</key><string>/path/to/logs/router.stdout.log</string>
<key>StandardErrorPath</key><string>/path/to/logs/router.stderr.log</string>
</dict>
</plist>
PLIST
launchctl load -w ~/Library/LaunchAgents/com.you.feishuchannel-router.plistload -w starts it immediately and registers it to start on every future login/reboot too — no separate "enable" step needed.
2. Verify it's actually running:
launchctl print gui/$(id -u)/com.you.feishuchannel-router | head -5
# state = running <- what you want to see3. Restart after pulling a code update (KeepAlive only relaunches it on an unexpected exit, not on demand):
launchctl kickstart -k gui/$(id -u)/com.you.feishuchannel-router4. Uninstall, if you go back to ad-hoc/claude-feishu-auto-spawn mode:
launchctl unload -w ~/Library/LaunchAgents/com.you.feishuchannel-router.plist
rm ~/Library/LaunchAgents/com.you.feishuchannel-router.plistRouter-internal status (connected workers, regardless of how the process was started):
kill -USR1 $(pgrep -f 'bun.*router.ts')
tail -10 ~/.claude/channels/feishu/router-debug.logThe router can also dispatch a group (or DM) to OpenCode instead of Claude Code — useful as an alternative backend for specific projects. This is opt-in per group/DM and has zero effect on anything not explicitly configured.
Prerequisites: a running opencode serve instance, bound to localhost:
opencode serve --hostname 127.0.0.1 --port 4096(Run it as a persistent daemon — e.g. via launchd/systemd — independent of the router's own lifecycle.)
Create ~/.claude/channels/feishu/agents.yaml (optional — if this file is absent, everything stays Claude-only with unchanged behavior):
version: 1
defaults:
agent:
primary: claude
fallback: null
agents:
claude:
type: claude-channel
socket: /Users/you/.claude/channels/feishu/router.sock
health:
workerTtlSeconds: 15
opencode:
type: opencode
baseUrl: http://127.0.0.1:4096
requestTimeoutSeconds: 30
taskTimeoutSeconds: 600
maxConcurrency: 4
projects:
my-project:
workdir: /path/to/my-project
allowedAgents: [claude, opencode]
fallback:
enabled: true # see "Fallback to OpenCode" below for exactly what this does
triggerOn: [] # reserved for a future richer fallback state machine, currently unused
neverTriggerOn: [permission_denied, user_abort, invalid_request, tool_failure, partial_execution] # reserved, currently unused
maxAttempts: 1 # reserved, currently unused
cooldownSeconds: 300 # reserved, currently unused
notifyUser: true # reserved, currently unusedagents.yaml never decides which project a chat maps to — that's still access.json's job (see below). It only declares which backends exist and which agents each project is allowed to use. Only fallback.enabled is actually read by the router today — the other fallback.* fields are validated but not yet consulted anywhere in dispatch; they're placeholders for a future richer fallback policy.
access.json is the single source of truth for chat → project/agent mapping. Add an optional agent field to any group entry (or defaultAgent at the top level for DMs):
{
"groups": {
"oc_groupA": {
"workdir": "/path/to/my-project",
"agent": { "primary": "opencode", "fallback": null }
}
}
}Any group/DM without an explicit agent field stays on Claude, regardless of what's declared in agents.yaml. A request for an agent not in that project's allowedAgents (or a workdir not declared in agents.yaml at all) silently falls back to Claude-only — check router-debug.log if a group doesn't behave as expected.
Set agent.fallback: "opencode" on a group/DM (alongside primary: "claude") and agents.yaml's fallback.enabled: true to let that chat fail over to OpenCode. This covers two narrow, safe cases where the message provably never reached Claude, so there's no partial work to reconcile:
- No Claude Code worker connected for that project at all.
- Claude Code is rate-limited. The worker tails its own transcript for the
error:"rate_limit"event Claude Code writes when it hits a usage limit, and tells the router it's unavailable until Claude's own stated reset time (rate-limit-reset.tsparses text like"...resets 9:30pm (Asia/Singapore)"— the IANA zone Claude reports is used directly, so this is correct even across timezones/DST). If that text can't be parsed, it falls back to a fixed 30-minute cooldown (worker-link.ts'sDEFAULT_DEGRADED_MS), refreshed on each new rate_limit event. The router skips routing to that worker entirely until the window ends.
In either case the router replies with a one-line notice and hands the task to OpenCode instead of showing the usual "no active session" error.
It does not cover a Claude worker accepting a task and then failing partway through — that needs real task-state tracking (the design doc's full fallback state machine, routing/task-machine.ts), which exists as pure logic but isn't wired into the live dispatch path yet.
Notes:
- OpenCode sessions are sticky per conversation and persisted in SQLite (
router.db) — they survive a router restart. - Permission requests from OpenCode's tool calls surface as an interactive Feishu approve/deny card, same as Claude's.
- Attachments aren't supported on the OpenCode path yet.
- OpenCode replies are chunked at a fixed 4096 chars — they don't honor a group's
textChunkLimitoverride (that setting only applies to Claude Code replies today).
All access commands are run in your Claude Code terminal via /feishu:access.
/feishu:access
| Policy | Behavior |
|---|---|
pairing (default) |
Unknown users get a pairing code to approve |
allowlist |
Unknown users are silently dropped |
disabled |
All messages dropped |
/feishu:access policy allowlist
Tip: Once all your users are paired, switch to
allowlistto prevent unsolicited pairing requests.
# Approve a pairing
/feishu:access pair <code>
# Deny a pairing
/feishu:access deny <code>
# Manually allow a user by open_id
/feishu:access allow ou_xxxxxxxxxxxxxxxxxxxx
# Remove a user
/feishu:access remove ou_xxxxxxxxxxxxxxxxxxxxGroups are off by default. The bot must be added to the group by a group admin first.
# Enable a group (responds on @mention only)
/feishu:access group add oc_xxxxxxxxxxxxxxxxxxxx
# Respond to all messages (no @mention needed)
/feishu:access group add oc_xxxxxxxxxxxxxxxxxxxx --no-mention
# Restrict to specific users within the group
/feishu:access group add oc_xxxxxxxxxxxxxxxxxxxx --allow ou_id1,ou_id2
# Remove a group
/feishu:access group rm oc_xxxxxxxxxxxxxxxxxxxx# React to received messages with an emoji (default: Get)
/feishu:access set ackReaction Get
# Set max characters per message chunk
/feishu:access set textChunkLimit 4096
# Custom mention patterns for group chats
/feishu:access set mentionPatterns ["@claude","@assistant"]~/.claude/channels/feishu/
├── .env # App credentials (FEISHU_APP_ID, FEISHU_APP_SECRET)
├── access.json # Access control + chat->project/agent mapping (auto-managed)
├── agents.yaml # Optional backend/infra config for OpenCode (see OpenCode Backend)
├── approved/ # Pairing approval signals (transient)
├── inbox/ # Downloaded attachments
├── debug.log # Worker (server.ts) debug log
├── router-debug.log # Router debug log
├── router.sock # Unix socket workers connect to
├── router.db # SQLite: conversation bindings, permission callbacks, processed-event dedup
└── router.lock/ # Router singleton lock (prevents duplicate router processes)
| Variable | Required | Description |
|---|---|---|
FEISHU_APP_ID |
Yes | Feishu app ID (cli_...) |
FEISHU_APP_SECRET |
Yes | Feishu app secret |
FEISHU_ENCRYPT_KEY |
No | Event payload encryption key |
FEISHU_ACCESS_MODE |
No | Set to static to disable pairing |
FEISHU_STATE_DIR |
No | Override state directory path (default ~/.claude/channels/feishu) |
FEISHU_PROJECTS_ROOT |
No | Root directory agents.yaml project workdirs must resolve under (default ~/Projects) |
The plugin detects whether it's running under a Feishu channel Claude instance by walking up the process tree and checking for --dangerously-load-development-channels with feishu in the ancestor's command line. Non-channel Claude instances (e.g., regular claude or claude --channels plugin:discord@...) skip the Feishu WebSocket connection entirely, keeping the MCP tools available without unnecessary remote connections.
When the parent Claude process exits, the plugin detects the ppid change within 2 seconds and shuts down gracefully. This prevents orphaned bun server.ts processes from consuming 100% CPU — a workaround for Bun not reliably firing stdin end/close events on broken unix domain sockets.
bun test147 tests across 10 files. server.test.ts covers access control (gate logic), text chunking, mention detection, permission reply parsing, confirm code generation, and chat authorization. routing/*.test.ts covers config loading/validation, route resolution (access.json + agents.yaml merge, sanitization), conversation bindings, SQLite storage, and the (currently-unwired) fallback state machine. adapters/**/*.test.ts covers the OpenCode HTTP/SSE client (including the event-stream-never-closes edge case) and the Claude adapter's capability boundaries.
- Credentials are stored with
chmod 600— only the owner can read them - Pairing codes expire after 1 hour
- After 2 unapproved messages, senders are silently dropped until the code expires
- Access mutations can only be made from the Claude Code terminal — never from channel messages (prompt injection protection)
- Group chats require explicit opt-in per group
MIT
{ "groups": { "oc_groupA": { "workdir": "/path/to/project-a", ... }, "oc_groupB": { "workdir": "/path/to/project-b", ... } }, "defaultWorkdir": "/path/to/default-project" // DMs route here }