dsh-ringcentral
RingCentral Team Messaging IM channel plugin for deepseek-harness (dsh). It turns a RingCentral Bot Add-in into a first-class frontend for a dsh agent: inbound posts from RingCentral chats drive the agent loop, and assistant replies flow back as threaded RingCentral posts.
English | 中文说明
Architecture
RingCentral user ──▶ WebSocket (PostAdded) ──▶ im-ringcentral ──▶ ctx.agents ──▶ dsh agent loop ──▶ LLM
│ │
└── admission / session / event ◀────┘
(assistant reply ──▶ RingCentral post, threaded)The plugin is a pure Cordis plugin following the dsh "Plugins, not loop changes" principle. It speaks the RingCentral Team Messaging v1 REST API + WebSocket subscription stream directly (no external SDK) and reuses the host dsh services for agents, sessions, models, compaction, and tool presentation.
Install
1. Via dsh plugin manager
# install into a profile
npx @deepseek-ai/dsh plugin --profile ringcentral add dsh-ringcentral
# start
export RC_BOT_TOKEN="your-bot-jwt"
export DEEPSEEK_API_KEY="your-deepseek-key"
npx @deepseek-ai/dsh --profile ringcentralOr run the bundled installer: sh install.sh.
2. Local path
cd /path/to/dsh-ringcentral
pnpm install && pnpm build
npx @deepseek-ai/dsh plugin --profile ringcentral add /path/to/dsh-ringcentral
export RC_BOT_TOKEN="your-bot-jwt"
npx @deepseek-ai/dsh --profile ringcentral3. --patch development mode
The --patch overlay loads the plugin from a local absolute path without installing it into a profile. Generate the machine-local patch first, then boot:
cd /path/to/dsh-ringcentral
pnpm install && pnpm build # dist entry (npx dsh cannot resolve .js -> .ts)
node scripts/gen-dev-patch.mjs # writes cordis.local.yml with the real path
export RC_BOT_TOKEN="your-bot-jwt"
npx @deepseek-ai/dsh web --patch ./cordis.local.ymlUse pnpm dev (tsc --watch) while iterating: the loader hot-reloads the plugin whenever dist/ changes. Pointing the patch at src/index.ts only works inside a deepseek-harness source tree (pnpm dsh), not with the npx-installed package.
RingCentral bot setup
1. Sign in at <https://developers.ringcentral.com/>. 2. Create an app with the Bot platform type. 3. Grant at least: TeamMessaging, ReadAccounts, WebSocketsSubscription. 4. Install or publish the bot to your RingCentral account. 5. Copy the bot JWT and use it as RC_BOT_TOKEN.
Optional owner credentials (JWT REST API app for your own account, with TeamMessaging + WebSocketsSubscription + ReadMessages) give ringcentral_get_recent_messages owner read fallback and outbound owner fallback. The history tool always reads through the bot client (RC_BOT_TOKEN) first; without owner credentials it uses only the bot client (chats the bot is a member of).
Configuration
Config follows dsh practice: the cordis config tree is the single source (profile cordis.patch.yml / cordis.yml), with Schema defaults applied automatically. Secrets (RC_BOT_TOKEN, RC_USER_CLIENT_ID, RC_USER_CLIENT_SECRET, RC_USER_JWT_TOKEN) resolve through the host credentials domain — an explicit config value wins, then environment → managed $DSH_HOME/.credentials.yaml → project/user .env, with the plain process environment as the final fallback. RC_SERVER_URL works the other way for operational overrides (e.g. sandbox): the environment value wins over the configured/default server. Secrets are never persisted into profile YAML. To drive any other setting from an environment variable, use the cordis loader's !!js tag (double bang — a single !js is not evaluated), e.g. access.groupMode: !!js process.env.RC_GROUP_MODE ?? 'open'.
The access-control block mirrors @tencent-connect/dsh-qqbot exactly (QQ's c2c surface is dm here). RingCentral's three non-DM chat types (Team / Everyone / Group) are all governed by the group surface.
| Config | Type | Default | Description |
|---|---|---|---|
botToken | string | required | Bot static JWT (env: RC_BOT_TOKEN) |
ownerCredentials.clientId / clientSecret / jwt | string | - | Owner JWT (env: RC_USER_*) |
server | string | https://platform.ringcentral.com | API server (env: RC_SERVER_URL) |
access.dmMode | enum | open | DM handling: disabled, allowlist, open |
access.dmAllow | string[] | [] | Person ids allowed in DMs; empty or ["*"] = allow all |
access.groupMode | enum | open | Group handling: disabled, allowlist, open |
access.groupAllow | string[] | [] | Chat ids allowed in groups; empty or ["*"] = allow all |
requireMention | boolean | true | Require @-mention in group chats |
groupPrompt | string | - | Extra system prompt for group chats |
directPrompt | string | - | Extra system prompt for DMs |
processingPlaceholder.enabled | boolean | false | Post 👀 → ⏳ while the agent works (texts/delay are fixed) |
historyMessageLimit | number | 250 | Default record count for the history tool |
homeChannel | string | - | Fallback target for the history tool |
textChunkLimit | number | 4000 | Max chars per outgoing post |
provider / model | string | host default | LLM route (fallback chain: per-peer prefs → config → host agentDefaultModel) |
preset | string | - | Agent preset id |
cwd | string | process.cwd() | Agent working directory |
sessionIdleTimeout | number | 1800000 | Idle session eviction (ms) |
showToolResults | boolean | false | Show successful tool results (errors always show) |
debug | boolean | false | Debug logging (includes inbound message logs) |
Commands
| Command | Description |
|---|---|
/new (/reset, /clear) | Start a new session (clear context) |
/compact | Compress session history (summary replaces old records) |
/model | Show or switch the model |
/stop | Abort the current generation |
/rc-ping | Connectivity test |
/rc-version | Plugin version |
/rc-status | Current session status |
/rc-help | List all commands |
Session routing
sessionKey: ringcentral:<accountScopeKey>:<scope>:<peerId> where scope is direct (peer = person id), group (peer = Group DM chat id), or channel (peer = Team/Everyone chat id), and accountScopeKey is a SHA-256 fingerprint of server + bot token. The SessionId is derived deterministically (SHA-256), so the same user/chat always routes to the same session and survives restarts. Resolution order: in-process reuse → persisted resume → fresh create.
Agent questions (ask_user)
When the agent calls ask_user_question, the plugin renders the question into the chat (thread-anchored) and waits for the user to reply in the same session:
- Reply with an option number or label to select a choice (
multi_select
accepts "1, 3"), or type a free-text answer.
- Multi-question asks are answered one question at a time.
- Answers resolve the pending question and are not appended to session
history (same semantics as the web GUI).
- Waiting times out after 10 minutes (the ask is cancelled with a notice).
Note: the provider registers on the userQuestions service seam. In a web profile the GUI provider takes precedence — questions then appear in the web UI, not in RingCentral. Use a dedicated profile for IM-only operation.
Design principles
- Pure Cordis plugin — follows dsh "Plugins, not loop changes".
- Declarative deps —
inject = ['agents']; tools/compaction/presets are optional seams. - Session isolation — one agent per RingCentral peer.
- Mini-Markdown outbound — replies are converted to RingCentral Mini-Markdown and chunked.
- Threading — replies always anchor on the triggering post (threadId preferred), with owner fallback and unthreaded retry.
- Idle eviction — inactive agents are disposed automatically.
- Defensive degradation — missing tools/presets/owner credentials never crash the plugin.
Not in v1 (planned follow-ups)
- Adaptive Card / note / calendar / task artifact tools
- Cron and out-of-process notification sender
- Multi-account support
- Native streaming (RingCentral has no stream API; the processing placeholder is the typing affordance)
Local development
pnpm install
pnpm build # or: pnpm dev (watch)
pnpm test
pnpm typecheck
# run against the npx-installed dsh
export RC_BOT_TOKEN="xxx"
node scripts/gen-dev-patch.mjs
npx @deepseek-ai/dsh web --patch ./cordis.local.ymlscripts/gen-dev-patch.mjs emits the gitignored cordis.local.yml directly, with the entry resolved to an absolute path on the machine (default dist/index.js; pass src/index.ts to point at the TypeScript entry).
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Plugin not starting | RC_BOT_TOKEN missing | Set RC_BOT_TOKEN (env or $DSH_HOME/.credentials.yaml) or botToken in config |
| Bot never replies in a group chat | access.groupMode: disabled, not allowlisted, or no mention | Check access.groupMode / access.groupAllow and @-mention the bot |
| DM ignored | access.dmMode: disabled or sender not in access.dmAllow | Check access.dmMode / access.dmAllow |
| History tool returns nothing | Chat not visible to bot or owner | Reads try the bot first, then the owner; pass a bare chat id or channel:<chatId> and make sure one client is a member |
| Agent question not answered from RingCentral | Web GUI provider registered (web profile) | Questions go to the web UI; use a dedicated profile or answer in the web UI |
License
[MIT](./LICENSE)