intent-gate-service

August 11, 2026 · View on GitHub

English | 简体中文

License: MIT Python >=3.11 MCP

The DingTalk-interaction sister project of intent-gate (a standalone MCP service). It carries the two heavy assets the main plugin deliberately shed: the DingTalk group consensus channel and the blocking decision gates. The main plugin intent-gate stays lightweight (intent alignment + requirement analysis, zero credentials, zero dependencies); all heavy interaction that blocks waiting for a human reply is concentrated here.

intent-gate (light, required)              intent-gate-service (heavy, optional)
─────────────────────────────              ──────────────────────────────────
requirement-analysis playbook / lint       blocking decision gate ask_human
/ anchors                                  group channel group_dispatch / nudging
single-channel intent alignment            DingTalk outbound + inbound
(chat-dialog fallback)                     (http/stream)
collect / resolve / readiness self-check ⇆
        shared file contract: .harness/requests/{requirement}/_review/

The two services are joined by a file contract: intent-gate-service persists questions to pending-questions.md before dispatching (reusing intent-gate's register_question contract function), and group replies land in _review/inbox/ (reusing file_inbound_reply). Collecting answers, injecting-and-closing them, and readiness self-checks always happen on the intent-gate side. DingTalk is only the transport layer; the file ledger does not depend on it to survive.

Why the split

  • It blocks the user: ask_human blocks waiting for a human reply, and blows up if the MCP client's tool timeout is misconfigured; people who only want intent alignment shouldn't pay that cost.
  • It's heavy: fastapi / uvicorn / cryptography / httpx (+ optional dingtalk-stream) are all DingTalk-only dependencies the main plugin should never carry.
  • Fault isolation: wrong DingTalk credentials, a dead callback, or nobody answering in the group — none of these affect the main plugin's alignment loop.

When to install it

  • Intent gaps should be ruled on by business/technical roles, not guessed by the person at the chat box: you want dispatch_question items from requirement analysis posted to a DingTalk group @ the corresponding role (📋 business / 🔧 technical), with replies auto-persisted to the inbox for intent-gate to collect and close → install intent-gate-service and use group_dispatch.
  • You hit an irreversible operation or a red-light decision mid-coding and need someone in the group to rule immediately: ask_human blocks waiting for an allowlisted member's reply, and falls back to the conservative path on timeout.
  • Neither applies: don't install it — intent-gate alone is a complete closed loop (chat-dialog fallback). This service is fully optional; removing or never installing it does not affect any main-plugin capability.

MCP tool surface

ToolDescription
ask_human(question, context, options, timeout_sec)🔴 Blocking decision gate: posts to the group and waits for an allowlisted member's reply; on timeout returns a NO_REPLY fallback string (raise the client tool timeout, ≥30 min recommended)
list_pending()List pending gates
cancel_gate(token)Cancel a pending gate
group_dispatch(feature, gap, category, options, ...)Dispatch an intent-alignment question to the group (non-blocking, returns immediately; persisted before sending; 📋 business / 🔧 technical decides who gets @'d)
group_rebroadcast(feature)Re-post a summary of unresolved questions to the group (nudging after session recovery)

Companion flow: after group replies are persisted, use intent-gate's collect_answers to collect them, resolve_question to close them into the ledger, and list_pending_questions for the readiness self-check.

Quick start

⚠️ The main plugin comes first — without it this service cannot even start. intent-gate-service imports intent-gate's contract layer (register_question / file_inbound_reply) and shares its file ledger; and without the intent-gate plugin mounted in your agent, dispatched questions have nobody to collect or close them.

# 1) Install the main plugin (contract layer) + this service into the same env
pipx install intent-gate-mcp
pipx inject intent-gate-mcp intent-gate-service   # stream inbound fallback: pipx inject intent-gate-mcp "intent-gate-service[stream]"

# 2) Clone this repo (you need .env for credentials anyway)
git clone https://github.com/baixinghao/intent-gate-service.git
cd intent-gate-service

# 3) Credentials + self-check
cp .env.example .env              # fill in DingTalk credentials and the allowlist
python -m unittest discover -s tests -v   # offline tests (fake HTTP layer)
intent-gate-service               # stdio MCP + DingTalk inbound

Mounted alongside intent-gate (Claude Code .mcp.json):

{
  "mcpServers": {
    "intent-gate": { "command": "intent-gate" },
    "intent-gate-service": { "command": "intent-gate-service" }
  }
}

Note: an MCP client's default tool timeout may be shorter than a human's response time (the decision-gate scenario). Raise the tool timeout for intent-gate-service (≥ 30 minutes recommended). SSE exposure: intent-gate-service --mcp-transport sse --mcp-port 8400; event-stream subscription via GET /events (SSE, requires a Bearer token or loopback-only).

Using it: what to say

You say / the situationToolWhat happens
"这道业务题发钉钉群问"(分析中遇到 📋/🔧 断层)group_dispatchPersisted to pending-questions.md first, then posted to the group @ the right role; returns immediately (non-blocking)
群里回复:@机器人 [HG-XXXX] 选1inbound → intent-gateSignature-verified, decrypted, persisted verbatim to _review/inbox/; intent-gate's collect_answers picks it up next turn
"催一下单" / 会话恢复后group_rebroadcastRe-posts a summary of unresolved questions to the group
🔴 红灯决策 / 不可逆操作(agent 自发升级)ask_humanPosts to the group and blocks waiting for an allowlisted member's reply; on timeout returns NO_REPLY and takes the conservative path

One rule to remember: answers are always collected and settled on the intent-gate side — this service only dispatches and delivers. DingTalk is just the transport; the file ledger never depends on it.

DingTalk-side configuration

Outbound (sending messages to the group) — pick ONE

⚠️ Webhook is deprecated: DingTalk officially announced the custom-bot product is being retired — since 2023-09 no group can create new ones (existing ones still work for now). New projects should use B directly; A exists only for legacy compatibility and logs a DEPRECATED warning at runtime when configured. When the api configuration is complete it takes precedence, regardless of whether a webhook is also configured.

  • B. Enterprise internal-app bot (recommended, the official replacement) — free, and its QPS limits are irrelevant for this scenario:
    1. Have an org admin grant you "developer permission" in the developer console (for personal use you can create a free org of your own, where you are the admin yourself)
    2. In the developer console create an enterprise internal app → add the "bot" capability → publish
    3. Pull the bot into the target group
    4. Obtain AppKey / AppSecret / robotCode, and the group's openConversationId (after the bot joins the group, @ it once and read the conversationId from the callback)
    5. To @ specific people, pass DingTalk userIds via group_dispatch(..., at_user_ids=[...])
  • A. Group custom bot (legacy compatibility only, do not use for new projects): group settings → bots → custom → signing → obtain webhook + secret

Inbound (receiving @ replies) — pick ONE

  • http (recommended, zero long connections): enterprise internal app → set the bot's message-receiving URL to https://<your-public-address>/dingtalk/callback, and fill the console-provided aes_key/token into HG_CALLBACK_AES_KEY / HG_CALLBACK_TOKEN (requires a publicly reachable address; NAT traversal works); set HG_INBOUND_MODE=http
  • stream (fallback): pip install -e ".[stream]", HG_INBOUND_MODE=stream. Needs no public address, but uses DingTalk's proprietary long connection (DingTalk offers no SSE push — this is the only no-public-URL option)

Decision gates: emergency human escalation during coding

When the agent hits a confidence red light mid-execution, it posts to the group for help (and blocks waiting for a reply):

## 🔴 [HG-7F3A] Agent confidence red light
Question: Delete the old partitions of the production database directly?
Options: 1. Back up first, then delete  2. Delete directly  3. Skip
Reply format: @bot [HG-7F3A] your decision

An allowlisted member replies @bot [HG-7F3A] pick 1 → the agent immediately gets HUMAN_REPLY[Zhang San]: pick 1 and continues. When there is only one pending gate, the token may be omitted (HG_ALLOW_IMPLICIT_SINGLE_MATCH).

Security model (fail-closed by default)

  • An empty allowlist = nobody can answer a gate
  • Replies must carry the [HG-XXXX] correlation token
  • HTTP callbacks enforce DingTalk signature verification + AES decryption + timestamp freshness (300s window); per-sender rate limiting
  • /events SSE is loopback-only unless HG_EVENTS_TOKEN is configured
  • Decision gates return a NO_REPLY string on timeout, instructing the agent to take the conservative path

Project structure

src/intent_gate_service/
├── config.py                 # HG_* env vars (credentials required, fail-fast)
├── gate.py                   # GateManager: gate correlation, Futures, timeouts, event bus
├── server.py                 # MCP tool surface (3 gate tools + 2 group-channel tools)
├── bridge.py                 # group-channel bridge: group_dispatch/group_rebroadcast/build_intake
│                             #   (persistence reuses intent_gate contract functions; no second implementation)
├── __main__.py               # entrypoint (stdio/SSE MCP + resident DingTalk inbound)
└── dingtalk/
    ├── client.py             # outbound: enterprise-bot API (recommended) / webhook (legacy) + @people
    ├── crypto.py             # callback AES-256-CBC encrypt/decrypt + signing
    └── inbound/{http,stream}.py  # the two inbound transports + SSE event stream
skills/dingtalk-escalation/   # DingTalk escalation protocol (token protocol / allowlist / channels)

File-by-file documentation and the full cross-repo workflow: see intent-gate/docs/STRUCTURE.md.

License

MIT