Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ย 

History

886 Commits
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

hermes-multitenancy โ˜ค

One Feishu bot. N employees. N isolated agents. A hermes-agent plugin that turns a single bot into a true multi-tenant platform โ€” every user gets their own persona, memory, sessions, and LLM credentials โ€” without changing one line of hermes-agent.

English | ็ฎ€ไฝ“ไธญๆ–‡

one bot N users 0 patches real Feishu verified tests MIT

The problem it solves: hermes-agent is a brilliant personal agent runtime โ€” but it assumes 1 bot = 1 user. You can't drop it into a 1,000-person company without either running 1,000 processes, giving everyone the same shared persona, or forking the core and re-patching on every upgrade. This plugin makes 1 bot = N users a deployable reality: a pre_gateway_dispatch hook routes each Feishu sender to their own ProfileRuntime, and the upstream core stays untouched.

True per-user isolationEach Feishu user is routed to their own profile โ€” independent SOUL.md, memory, session history, workspace, tools, and LLM credentials. Not a shared persona behind one bot. ๅƒไบบๅƒ้ข.
Zero patches to hermes-agentShips as a directory plugin via the pre_gateway_dispatch hook. Pin the upstream version, upgrade freely, never re-patch the core. The deployment contract is plugin + sidecars, not a fork.
Org-driven lifecycleSync directly from the Feishu Contact directory โ€” join / move / leave all reconcile automatically. New employees get a profile and route; departures soft-delete from routing while their memory stays on disk.
Privacy & sandbox by constructionPer-profile HOME/XDG/TMPDIR pivot + subprocess env allowlist, credentials materialized through a local broker and kept out of the model, redacted streaming output, and secret-path filtering on outbound files.
Cost & usage observabilityPer-turn token ledger with owner-based attribution (a user's groups and agents all roll up to them) feeds an enterprise leaderboard. Plus a conversation-analytics CLI for demand and completion-proxy reporting.
Real Feishu UX, reused not rebuiltCardKit streaming cards, reactions, multi-turn sessions, vision / STT / file inject, group chat, and cron delivery โ€” all delegated to hermes-agent. Full Feishu OpenAPI reach via the lark-cli bridge with per-request user-vs-bot identity isolation.
Production-grade safety railsGroup @everyone never triggers the bot, dangerous-command approvals cross the Feishu boundary, output-length truncation degrades gracefully, and credential re-auth markers never trigger proactive private-message blasts.

๐Ÿงญ Where it sits

  • vs vanilla hermes-agent: hermes assumes 1 bot = 1 user (one profile per gateway process). This plugin makes 1 bot = N users โ€” routing each user to their own ProfileRuntime โ€” without forking the core.
  • vs a single-tenant Lark/Feishu channel plugin (e.g. OpenClaw Lark): those bridge one agent identity to Feishu. This adds per-user routing, profile isolation, and a credential vault so a single deployment can safely serve a whole org โ€” each person getting their own agent, memory, and tokens.

๐Ÿ›๏ธ Architecture at a glance

A single Feishu app + one bot websocket lands on the router. The router resolves the canonical sender, looks up the profile in SQLite, and dispatches into a sandboxed per-profile subprocess. Feishu's native UX (CardKit streaming, media, approvals) is reused, not reimplemented.

flowchart TB
    admin["Feishu admin / operator"]
    app["One Feishu app + one bot\nshared app credential"]
    contact["Feishu Contact v3\norg/users/departments"]
    sync["pull-feishu org sync\nprofiles + routes + skill distribution"]
    db[("~/.hermes/multitenancy.db\nrouting + sessions + credential vault")]
    webui["Hermes WebUI\nchat/jobs/profile provisioning"]
    cron["profile cron jobs\nrouter-side worker"]
    user["Feishu user/group\nopen_id ou_* / chat oc_*"]
    gateway["Hermes gateway\nsingle Feishu websocket"]
    router["hermes-multitenancy\npre_gateway_dispatch router"]
    broker["Run Broker\nchannel=feishu/webui/cron/kanban"]
    profile["routed profile home\nSOUL + memory + config + workspace"]
    sandbox["profile runtime guard\nHOME/XDG/TMPDIR pivot + bwrap/sandbox-exec"]
    aiagent["AIAgent subprocess\nHermes runtime, no core patch"]
    larkbroker["per-run lark-cli auth broker\nlocalhost + HMAC"]
    larkcli["lark-cli-authsidecar\ntrusted Feishu OpenAPI CLI"]
    vault["credential vault\nFeishu app, UAT, provider/API keys"]
    uat["profile-local UAT mirror\nfeishu_uat/<open_id>.json"]
    card["Feishu CardKit / IM / files"]

    admin --> app
    admin --> contact --> sync --> db
    sync --> profile
    user --> app --> gateway --> router
    webui --> broker
    cron --> broker
    router --> db
    router --> broker
    broker --> db
    broker --> profile --> sandbox --> aiagent
    vault --> db
    db --> vault
    db --> uat
    aiagent --> larkbroker --> larkcli --> card
    larkbroker --> vault
    larkbroker --> uat
    aiagent -->|stream events, tools, approvals, artifacts| broker
    broker -->|CardKit stream + MEDIA only from profile scope| card
    card --> user
Loading

The contract in one line: hermes_multitenancy.register(ctx) registers a pre_gateway_dispatch hook; for Feishu messages it returns {"action": "skip"} and the plugin's handle_async() owns routing and replies. Hermes-agent: 0 lines changed.

Component map

~/.hermes/plugins/multitenancy/        (installed by `hermes plugins install`)
  โ”œโ”€ plugin.yaml            Hermes directory-plugin manifest
  โ”œโ”€ __init__.py            register(ctx) โ†’ pre_gateway_dispatch hook
  โ”œโ”€ sync.py                route-sync wrapper for directory-plugin installs
  โ””โ”€ hermes_multitenancy/
     โ”œโ”€ router.py           sync hook + async dispatch + commands + lazy singletons
     โ”œโ”€ runtime.py          ProfileRuntime + contextvars-isolated HERMES_HOME switch
     โ”œโ”€ pool.py             LRU RuntimePool (50 hot / 5min idle / cold-start sem)
     โ”œโ”€ routing.py          SQLite multitenancy_routing (open_id โ†’ profile)
     โ”œโ”€ sessions.py         SQLite multitenancy_sessions (per-user persistent history)
     โ”œโ”€ credentials.py      encrypted credential vault rows in multitenancy.db
     โ”œโ”€ agent_real.py       AIAgent subprocess bridge + sandbox env build + fallback
     โ”œโ”€ aiagent_subprocess.py  isolated child entry point for the AIAgent/tool loop
     โ”œโ”€ lark_cli_tool.py    Hermes tool registration for lark_cli / lark-cli
     โ”œโ”€ lark_cli_auth_broker.py  per-run localhost credential proxy for authsidecar
     โ”œโ”€ run_broker.py       channel-neutral execution contract (feishu/webui/cron)
     โ”œโ”€ webui_broker_server.py   localhost HTTP/SSE sidecar for WebUI and jobs
     โ”œโ”€ cron_worker.py      multi-profile cron worker + Run Broker bridge
     โ”œโ”€ skill_registry.py   managed/personal/unknown skill audit + install helpers
     โ”œโ”€ token_usage_ledger.py    per-turn token ledger (parent-written, opt-in)
     โ”œโ”€ token_usage_uploader.py  hourly owner-attributed leaderboard uploader
     โ”œโ”€ analytics/          conversation-audit summary CLI (demand + completion proxy)
     โ”œโ”€ commands.py         Hermes registry-backed slash command parser
     โ”œโ”€ upstream_health.py  secret-free upgrade/deploy health checks
     โ””โ”€ sync/
        โ”œโ”€ feishu_hr.py     apply_users (idempotent reconciler)
        โ”œโ”€ feishu_org.py    Feishu Contact v3 pull + profile/SOUL/route sync
        โ””โ”€ cli.py           shared implementation for route sync

State lives in ~/.hermes/multitenancy.db โ€” a separate SQLite file from hermes' own state.db so writes don't contend. WAL mode is enabled.

Deep dive โ€” the dispatch contract (for agents & maintainers taking over this repo)
  1. Entry point, no Hermes core patches. hermes_multitenancy.register(ctx) registers a pre_gateway_dispatch hook. For Feishu messages the hook returns {"action": "skip"} and the plugin's handle_async() owns routing and replies.
  2. Identity uses the canonical sender. _resolve_sender_for_routing() prefers the real Feishu open_id (ou_*) from the Feishu contextvar, event.sender_open_id, source.open_id/user_id, and raw/raw_event/event. user_id_alt / union_id is only a legacy route lookup helper, not the new session key.
  3. Routes live in SQLite. multitenancy_routing.open_id -> profile_name decides which ~/.hermes/profiles/<profile>/ handles the turn. A real ou_* will not be absorbed by a stale union_id; legacy alt routes are used only when no real ou_* is available.
  4. Normal messages run inside the routed profile. The router builds a profile-scoped event, writes the resolved sender_open_id back to the event, then dispatches to the streaming AIAgent subprocess. The child runs with that profile's HERMES_HOME; agent_real._build_subprocess_env strips the parent gateway's environment down to an explicit allowlist and pivots HOME/WORKSPACE/XDG_*/TMPDIR into <profile>/{home,workspace,cache,config,state,data,tmp} so token-bearing skills, MCP servers and CLIs behave like they are running as the current profile user. The runtime also sets HERMES_PROFILE plus Keep-compatible KEP_PROFILE, prepends shared <hermes_home>/bin, and translates common OpenClaw/ClawHub {baseDir} skill templates inside the child process. Feishu UAT tokens are loaded from <profile>/feishu_uat/<open_id>.json (rebound at runtime by _configure_feishu_uat_home). See docs/profile-isolation.md.
  5. Default skills and group credentials materialize from runtime state. profile-skill-defaults.yaml, skill-distribution.yaml, and skill-bundles.yaml express managed skills; sync installs them into profiles while skipping secret-looking files. Any shared top-level lark-* skill is also installed for every profile as a managed symlink. credential-materialization.yaml maps encrypted vault payloads to profile-local compatibility files; profiles: ["*"] expands to active routing rows; an env: entry passes the secret to the routed AIAgent without the model reading the token file.
  6. lark-cli is an external runtime dependency. This repo registers the lark_cli tool and starts a per-run localhost auth broker, but the deployment must provide an authsidecar-capable lark-cli binary (default <shared HERMES_HOME>/bin/lark-cli-authsidecar; HERMES_LARK_CLI_BIN overrides). Personal profiles use user identity only when the current open_id has valid UAT; group/WebUI agent profiles default to bot.
  7. Cron/reminder jobs are profile-scoped but router-executed. WebUI/upstream cron tooling writes profile-local cron/jobs.json. The router-side worker scans active profiles, creates RunRequest(channel="cron"), executes through Run Broker, delivers to Feishu when requested, and mirrors context into multitenancy_sessions. Profile-side manual runs from the native cronjob tool are also intercepted and queued through the router Run Broker, including Hermes core runtimes whose run/run_now/trigger branch calls an immediate _execute_job_now(...) helper.
  8. Custom provider model selectors are normalized for auxiliary calls. Profile configs may store Hermes selector IDs such as custom:<name>/<model>, but OpenAI-compatible endpoints expect the bare <model>. _run_with_aiagent() parses the main model into provider and model_only, then syncs that live tuple into Hermes core agent.auxiliary_client.set_runtime_main(...) for the duration of the run. This keeps title/compression/search and other auto-routed auxiliary calls from re-reading the full selector and sending it as the wire model; cleanup runs in finally.
  9. Dangerous-command approvals cross the subprocess boundary. The profile AIAgent registers tools.approval with a router-compatible gateway session key (multitenancy:<platform>:<profile>:<chat>:<sender>). The child emits approval_required / approval_resolved; the parent _stream_aiagent_subprocess() forwards them to the router; the router prompts Feishu; /approve / /deny writes a decision file that releases the child and resumes Hermes' native approval flow.
  10. Delegation is a policy-controlled tool, not a data-execution guarantee. Routed AIAgent construction forwards agent.disabled_toolsets from profile/global config into Hermes core; setting agent.disabled_toolsets: ["delegation"] removes delegate_task even when a default hermes-* platform toolset would otherwise include it. WebUI also sets Hermes session context async_delivery=False, so if delegation is enabled, delegate_task(background=true) can run synchronously and include child results in the current response instead of relying on an unavailable later WebUI callback. Exact command/stdout tasks should prefer direct terminal or execute_code.
  11. WebUI image attachments are preflighted inside the routed profile. Before the AIAgent sees a WebUI chat turn, local image markers are resolved only under the profile workspace/uploads/ tree, checked for a supported image extension, and analyzed into a text block. If the file is missing, outside uploads, or not an image file, the model is explicitly told not to infer unseen image contents. Hermes vision_analyze is attempted first; OpenAI-compatible custom providers can use the profile's main model as a fallback when Hermes' auxiliary vision provider is not configured. External ingest requests tagged metadata.source=ingest do not trigger this chat attachment preflight.
  12. CardKit heartbeat lives in the parent router. The router primes the card and sends idle heartbeat status updates before the child emits tokens; the heartbeat stops once reasoning/tool/content events arrive.
  13. Memory is keyed by (profile, canonical sender). _history_key() does not use sender_alt or sender, so stale/shared alternate IDs cannot merge two users' memory.
  14. Slash commands never leak into the LLM. /model, /reasoning, /reload-mcp and other registry commands use Hermes gateway handlers; skill slash rewrites into native skill invocation; plugin slash delegates to hermes_cli.plugins.get_plugin_command_handler; unknown slash returns Hermes-style unknown-command.
  15. Managed plugin assets are copied, registered, and served by Run Broker. Expert manifests may declare local image avatars such as experts[].avatar: ./avatars/expert.png. Ingest validates that path is repo-relative, exists, and uses a supported image suffix, then copies it into <shared-home>/.hermes-plugin-assets/<plugin_id>/ with a content-hash filename. The managed manifest stores only the broker URL plus an assets registry; WebUI loads the image through /api/run-broker/plugin-assets/<plugin_id>/<asset_name> (usually via its BFF proxy), so browser payloads never expose the original plugin checkout path. Asset reads also resolve the caller's profile/departments and must match an avatar URL in that caller's visible /experts catalog; traversal, non-registered files, and hidden-profile assets are refused.
  16. Group @everyone never triggers the bot. Admission (_admit) ignores @_all in any reply mode โ€” detected via structured mention metadata or raw @_all โ€” so an @ๆ‰€ๆœ‰ไบบ broadcast can never wake every routed agent in a group.
  17. Bot sends re-check routing at send time. A sender's freshly-created own group resolves immediately rather than being frozen to the turn's opening snapshot; bot IM sends without a broker proxy are refused regardless of declared risk, and broker deferral is gated on proxy presence.
  18. Local exec is off by default. quick_commands alias remains available; type: exec is denied unless multitenancy.allow_quick_exec: true or HERMES_MULTITENANCY_ALLOW_QUICK_EXEC=1. Keep it off in production until profile sandboxing is enforced.
  19. Attachments and file replies stay in profile scope. Inbound attachments delegate to Hermes' native _prepare_inbound_message_text, with a bounded fallback for locally cached tabular files (.csv / .xlsx). Outbound MEDIA:<path> replies are filtered so only paths inside the routed profile_home are delivered; .env, auth.json, feishu_uat/, credentials/, tokens/ are blocked.
  20. Feishu UAT refreshes mirror into the credential vault. Org sync copies refreshed shared feishu_uat/<open_id>.json into each routed profile and, when a credential key is configured, writes the same payload into multitenancy_credentials. JSON remains a migration fallback; the DB is the runtime credential source.
  21. Production posture. Prefer HERMES_MULTITENANCY_AUTO_PROVISION=0 and multitenancy.allow_quick_exec=false. Application-layer isolation (route/session/slash/media boundaries) is always on. Profile execution-environment isolationๆกฃ A (parent-env allowlist, HOME/WORKSPACE/XDG/TMPDIR pivot, chmod 0700 profile tree, per-profile feishu_uat/ + tokens/) is enabled by default โ€” verify with scripts/verify-isolation.sh. Kernel-level containment (sandbox-exec / Linux bwrap) is additive defense-in-depth until enabled for every profile. Full details: docs/profile-isolation.md.

๐Ÿข Built for the enterprise

Concern How this plugin handles it
Identity & routing Route every turn by the canonical Feishu open_id (ou_*); legacy union_id is migration-only. Memory and sessions are keyed by (profile, canonical sender) so two users can never bleed into each other's history.
App provisioning Reuse one Feishu app for the whole org โ€” no 1-app-per-user. The shared app credential lives in the vault (profile_name=__global__), never in git.
Org lifecycle pull-feishu reconciles the live Feishu Contact tree: joins create profiles + routes, moves refresh the managed SOUL.md org block, leaves soft-delete from routing while memory persists. Department-scoped sync and dry-run previews included.
Secrets & credentials Encrypted credential vault in multitenancy.db exposes only redacted status. A per-run localhost broker (HMAC) injects UAT/bot tokens into lark-cli so the model never sees raw Feishu app secrets. Outbound media is filtered to the routed profile home; known secret paths are blocked.
Execution isolation Per-profile subprocess with a stripped env allowlist + HOME/XDG/TMPDIR pivot + chmod 0700 profile tree (ๆกฃ A, on by default). Optional bwrap/sandbox-exec kernel containment. Local exec is opt-in only.
Cost & chargeback Per-turn token ledger โ†’ hourly uploader with owner-based attribution: a person's DMs, their agents, and every group they invited the bot into all roll up to them, resolved to the enterprise email/department via the routing table. "Under-count, never mis-count" โ€” a turn whose owner can't be resolved is dropped, never billed to the wrong person.
Demand analytics hermes-multitenancy-analytics summary reads the conversation-audit log and reports usage volume, top active profiles, and completion-proxy metrics over a configurable window (markdown or JSON, with optional redacted demand samples).
Group safety @everyone / @ๆ‰€ๆœ‰ไบบ is ignored at admission in every reply mode, so a broadcast can't wake every agent. Freshly-created groups route correctly at send time.
Reliability Output-length truncation returns a friendly notice instead of a hard failure (streaming path included); bare model names are runtime-normalized to heal recurring provider-prefix failures; credential re-auth markers are diagnostic/task-gating state only, so background scans never blast users with proactive auth DMs.
Upgrade safety Zero core patches + pinned hermes-agent version + an integration test suite that fails loudly on any contract drift. upstream_health.py runs secret-free health checks before declaring a deploy usable.

Roles

Role Owns
Feishu admin Creates/reuses one internal Feishu app, enables the bot/websocket/scopes, keeps the shared app credential out of git (production stores it in multitenancy_credentials as the global Feishu app row).
Platform operator Installs hermes + this plugin, keeps the gateway running, manages routing rows and profile directories.
End user Authorizes once through the Feishu auth/UAT flow, then talks to the same bot; tokens refresh offline.
Agent profile owner Maintains each profile's SOUL.md, config.yaml, .env, tool policy, session DB, and model credentials.

๐Ÿš€ Quick Start

Set HERMES_HOME first. All commands assume one shared Hermes home, one Feishu app, and per-user profiles under $HERMES_HOME/profiles/.

export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
mkdir -p "$HERMES_HOME/bin" "$HERMES_HOME/logs"

1. Install the plugin

hermes plugins install eggyrooch-blip/hermes-multitenancy --enable
hermes plugins list

For a pinned checkout or local development (the most transparent path for agents and operators, since the loaded plugin path is directly inspectable):

git clone https://github.com/eggyrooch-blip/hermes-multitenancy /opt/hermes-multitenancy
hermes plugins install "file:///opt/hermes-multitenancy" --force --enable
python -m pip install --no-deps -e "/opt/hermes-multitenancy[test]"

Manual fallback if the Hermes plugin installer is unavailable:

mkdir -p "$HERMES_HOME/plugins"
ln -sfn /opt/hermes-multitenancy "$HERMES_HOME/plugins/multitenancy"

Enable the plugin in the shared Hermes config:

# $HERMES_HOME/config.yaml
plugins:
  enabled:
    - multitenancy

2. Install lark-cli / authsidecar

This plugin registers the lark_cli tool and starts the per-run credential broker, but it does not vendor the lark-cli binary. New environments must provide an authsidecar-capable lark-cli before Feishu tools work. Lookup order: HERMES_LARK_CLI_BIN โ†’ $HERMES_HOME/bin/lark-cli-authsidecar โ†’ a plain lark-cli on PATH (limited checks).

git clone https://github.com/larksuite/cli /opt/larksuite-cli
cd /opt/hermes-multitenancy
LARK_CLI_SOURCE_DIR=/opt/larksuite-cli \
HERMES_LARK_CLI_BIN="$HERMES_HOME/bin/lark-cli-authsidecar" \
LARK_CLI_EXPECTED_VERSION="<expected-lark-cli-version>" \
LARK_CLI_EXPECTED_SOURCE_HEAD="<expected-source-short-sha>" \
  scripts/build_lark_cli_authsidecar.sh

Or drop in a vetted binary you already ship:

install -m 0755 /path/to/lark-cli-authsidecar "$HERMES_HOME/bin/lark-cli-authsidecar"
export HERMES_LARK_CLI_BIN="$HERMES_HOME/bin/lark-cli-authsidecar"

The authsidecar never receives raw Feishu app secrets from the model โ€” the routed AIAgent talks to a localhost auth broker that injects either the current user's UAT or a bot tenant token from the vault.

3. Configure one shared Feishu bot

Use one Feishu app/bot for all tenants; keep the app credential outside git.

# $HERMES_HOME/config.yaml
platforms:
  feishu:
    enabled: true
    extra:
      app_id: "${FEISHU_APP_ID}"
      app_secret: "${FEISHU_APP_SECRET}"

Import the app credential into the vault without printing the secret:

export HERMES_MULTITENANCY_CREDENTIAL_KEY="<32-byte-or-longer-secret-key>"
python /opt/hermes-multitenancy/scripts/lark_cli_canary_preflight.py \
  import-app-config --shared-home "$HERMES_HOME" --config "$HERMES_HOME/config.yaml"

User UAT is profile-scoped โ€” OAuth/device-flow writes or imports a user token, then multitenancy mirrors it to $HERMES_HOME/profiles/<profile>/feishu_uat/<open_id>.json and multitenancy_credentials. Never commit .env, auth.json, feishu_uat/*.json, tokens/, workspace/credentials/, cookies, or raw OAuth payloads.

Status and canary surfaces are secret-free and may use the profile-local UAT JSON as a fallback when the credential vault key is unavailable. Runtime decryption and vault writes still require HERMES_MULTITENANCY_CREDENTIAL_KEY / HERMES_CREDENTIAL_KEY; the fallback only tells operators and Connector Registry that the current lark-cli connector can run through the authsidecar broker without exposing token fields.

Credential re-auth markers

.needs_reauth is task-gating state, not a generic refresh-error log or a trigger for proactive private messages. Concrete local payload problems such as an expired refresh token, a missing refresh token, or missing offline_access can produce markers immediately. An expired access token with a still-valid refresh token is refreshable, so it must not create or preserve a re-auth marker. refresh_rejected only gates a task when the refresh layer parsed a Feishu invalid/revoked refresh-token response and marks the marker as authoritative. Local infrastructure errors such as missing credential encryption keys, network failures, or unparsed HTTP errors are recorded as non-user-facing .refresh_diagnostic sidecars and logs; they must not notify users to run /feishu_auth or make cron classify the profile as needs_auth. When legacy non-authoritative refresh_rejected markers are cleared, their detail is preserved as .refresh_diagnostic before deletion.

Known gotcha: a 2026-06-23 production incident produced fresh refresh_rejected markers from a local credential-encryption-key failure while affected users still had usable Feishu UAT material. The root cause was treating every proactive refresh exception as user-actionable reauth. The guardrail is that background marker scans never send Feishu DMs; only an actual blocked task may surface passive /feishu_auth guidance, and unknown or infrastructure failures must remain diagnostic-only.

Known gotcha: Feishu automatically closes CardKit streaming mode after a long-running reply; later updates return 200850 or 300309. Treating those codes as a generic card failure makes the conversation appear to stop when the user leaves the chat. The compat controller now re-enables streaming and retries that frame exactly once with a monotonic sequence; an unrecoverable provider failure preserves partial content and ends in an Error card, never a false Completed footer or a duplicate provider run.

Known gotcha: a custom chat-model selector may carry a display-only context label such as [1m]; forwarding that suffix as part of the upstream model ID can make LiteLLM reject an otherwise valid model, so chat transports strip only a trailing numeric k/m label while shared media parsing remains unchanged.

Known gotcha: Hermes core may emit [System: Empty message content sanitised to satisfy protocol] for an empty assistant envelope; the shared assistant event boundary must remove that exact placeholder, including across stream chunk boundaries, before session mirroring or user delivery.

4. Sync profiles and routes

With Feishu Contact read scopes, use org sync (dry-run first):

python "$HERMES_HOME/plugins/multitenancy/sync.py" pull-feishu --dry-run
mkdir -p "$HERMES_HOME/org-snapshots"
python "$HERMES_HOME/plugins/multitenancy/sync.py" pull-feishu --snapshot-out "$HERMES_HOME/org-snapshots"

Without Contact scopes, apply an explicit allowlist:

python "$HERMES_HOME/plugins/multitenancy/sync.py" apply users.json
[
  {"user_id": "alice", "profile_name": "alice_profile", "open_id": "ou_xxx", "union_id": "on_xxx"},
  {"user_id": "bob", "profile_name": "bob_profile", "open_id": "ou_yyy", "union_id": "on_yyy"}
]

For company deployments, prefer strict routing after the initial rollout: export HERMES_MULTITENANCY_AUTO_PROVISION=0.

5. Run the gateway and broker surfaces

At minimum, restart the Hermes gateway so it imports the plugin. WebUI and cron deployments also enable the Run Broker sidecar on localhost.

export HERMES_MULTITENANCY_RUN_BROKER_SERVER=1
export HERMES_MULTITENANCY_CRON_RUN_BROKER=1
export HERMES_MULTITENANCY_RUN_BROKER_KEY="<shared-secret-for-server-to-server-calls>"
hermes gateway restart

Production services should set this through the service manager, not an interactive shell. Keep the Feishu websocket on the router gateway only; profile gateways must not open their own websocket for the same bot.

6. Verify

hermes plugins list
sqlite3 "$HERMES_HOME/multitenancy.db" \
  'select open_id, profile_name, active from multitenancy_routing limit 20;'

python /opt/hermes-multitenancy/scripts/lark_cli_canary_preflight.py health \
  --shared-home "$HERMES_HOME" --router-profile-home "$HERMES_HOME/profiles/multitenancy_router"

python /opt/hermes-multitenancy/scripts/lark_cli_canary_preflight.py preflight \
  --shared-home "$HERMES_HOME" --profile "<profile>" --open-id "<ou_open_id>" \
  --binary "$HERMES_HOME/bin/lark-cli-authsidecar"

Then send two Feishu users the same prompt through the same bot. Logs should show different canonical ou_* senders, different routed profile homes, and lark_cli_default_identity=user only for profiles with a valid user UAT.

7. Automate, scope, and recover

Run full org sync on a timer (handles join/move/leave):

*/30 * * * * HERMES_HOME=/opt/hermes python /opt/hermes/.hermes/plugins/multitenancy/sync.py pull-feishu --snapshot-out /opt/hermes/.hermes/org-snapshots >> /opt/hermes/.hermes/logs/multitenancy-sync.log 2>&1

Department-scoped sync:

python "$HERMES_HOME/plugins/multitenancy/sync.py" pull-feishu --dept <open_department_id> --dry-run
python "$HERMES_HOME/plugins/multitenancy/sync.py" pull-feishu --dept <open_department_id>

If sync goes wrong: stop the timer, inspect pull-feishu --dry-run and the latest snapshot, then use /status from Feishu or inspect multitenancy_routing. Unknown-user fallback profiles live at $HERMES_HOME/profiles/feishu_<open_id>/.


๐Ÿ“Š Cost & usage observability

Optional LiteLLM employee billing layer

Billing-bound employees use a dedicated Hermes Key owned by their existing LiteLLM user_id + team_id membership. Multitenancy resolves the canonical payer before Run Broker admission, stores the one-time key response in the existing encrypted CredentialStore, and forces that key across main, title/compression, vision/media, warm-worker and delegated-agent model calls. It never holds a LiteLLM management key, calls LiteLLM admin APIs, injects billing headers, or installs a LiteLLM callback.

AI Gateway is the sole account/key control plane. Configure its private TLS broker endpoint and a dedicated service Bearer in the gateway systemd EnvironmentFile (mode 0600), not in profile files:

export HERMES_LITELLM_BILLING_ENABLED=true
export HERMES_LITELLM_BILLING_PAYER_IDS="<employee-id-1>,<employee-id-2>"
export HERMES_LITELLM_BILLING_BASE_URL="https://<litellm-host>/v1"
export HERMES_LITELLM_BILLING_ALLOWED_PATHS="/v1,/anthropic"
export HERMES_AI_GATEWAY_BROKER_URL="https://<ai-gateway-private-host>"
export HERMES_AI_GATEWAY_BROKER_TOKEN="<dedicated-hermes-service-bearer>"
export HERMES_AI_GATEWAY_BROKER_TIMEOUT="5"
export HERMES_LITELLM_EMPLOYEE_EMAIL_DOMAIN="keep.com"
export HERMES_ORG_SNAPSHOT_DIR="$HERMES_HOME/org-snapshots"

# Auto-provisioning path (hermes-multitenancy-billing-refresh). Separate bearer
# on purpose: this one mints a key for ANY employee, so it must not be the
# broker token. There is deliberately NO fallback โ€” an unset value means the
# refresh command cannot run, which is louder than quietly reusing a token with
# a different audience.
export HERMES_EMPLOYEE_KEY_SILENT_TOKEN="<dedicated-silent-minting-bearer>"
# Optional: defaults to HERMES_AI_GATEWAY_BROKER_URL when unset.
export HERMES_EMPLOYEE_KEY_BASE_URL="https://<gateway-ingress-host>"

Keeping the cohort provisioned

hermes-multitenancy-billing-refresh runs on a timer and keeps every cohort member holding a usable key, so the employee never triggers minting โ€” a credential minted while somebody waits turns a gateway hiccup into that person's failure. --dry-run reports who would be issued and mints nothing. Every issued key is stamped source=employee_key in the vault, and its credential row is maintained one day before expiry โ€” never by the legacy 23โ€“30 day window below.

Timer-only minting is enforced, not just documented (billing-runtime-never-mints, 2026-08-07 โ€” the previous wording promised it while the code broke it, and three employees had keys minted on their own request path in production before this landed). On the employee request path the credential manager is called with allow_mint=False, which makes it read-only against the AI Gateway โ€” no ensure, and no ack of a pending generation either:

Stored credential on a request What happens
usable (incl. pending-but-real) served as-is; the sweep completes/rotates it later
missing / expired / marked invalid degrades โ€” shared key, unbilled, one billing_degraded_unattributed audit line, employee sees a normal answer
present but inconsistent (profile / email / account drift) still refused โ€” degrading on drift would hide a real defect

A 401 is no longer repaired by re-issuing. The credential is marked invalid (so the next sweep re-provisions it) and the run is retried with attribution stripped. Cost: that run, and anything until the next sweep, is unattributed โ€” a bounded accounting gap instead of a refusal. Operators: this makes sweep health the thing to watch; a dead timer no longer shows up as failed requests, it shows up as a rising billing_degraded_unattributed count.

The legacy ensure/ACK lifecycle โ€” call versioned AI Gateway ensure, atomically save the returned key, validate it with zero-consumption GET /v1/models, ACK the generation, then renew 23โ€“30 days before expiry with stable payer jitter โ€” is now driven only by the maintenance sweep, never by a selected run. Since billing-runtime-never-mints a request path with allow_mint=False performs none of it: not ensure, not the ACK of a pending generation, not renewal. A new/missing/expired payer therefore no longer "fails closed when the broker is unavailable" โ€” it degrades (see the table above), because the broker is not on the request path at all. This lifecycle and its 401 handling only ever apply to legacy (non employee_key) credential rows.

Feishu DM bills the trusted sender; group/topic bills the group Agent owner; WebUI, cron, Kanban and shared Agents bill the routed profile/Agent owner. State moves only legacy โ†’ enforced: disabling the rollout flag stops new migrations but never sends an enforced payer back to the shared key. A 401 marks the credential invalid and retries once unattributed (never re-issuing), and only before answer/tool side effects. Monthly-budget 429 and ordinary rate-limit 429 are reported separately and never rotate keys. Credential overrides in delegated/auxiliary calls and the direct-OpenRouter moa toolset are blocked for enforced runs.

Give every employee's Hermes consumption a place on the company AI leaderboard โ€” one person's many agents (including group chats where the bot was @-mentioned) all roll up to them.

1. Per-turn token ledger (token_usage_ledger.py, opt-in). Each turn appends one line โ€” who (open_id) / profile / platform / group-or-DM / model / inยทoutยทtotal tokens โ€” to /var/log/hermes/token-usage.jsonl. The token counter lives in the sandboxed child, but the sandbox can't write the log, so the child passes usage up and the non-sandboxed gateway parent writes the ledger. Flip it on in the gateway process env only (one switch covers all users โ€” do not edit per-profile .env files):

HERMES_TOKEN_USAGE_LEDGER_ENABLED=1

2. Hourly uploader (token_usage_uploader.py + systemd units in deploy/). Reads the ledger โ†’ attributes by owner โ†’ aggregates the day โ†’ resolves enterprise email/department via the routing table โ†’ POSTs to the collector with source=hermes.

  • Group chat โ†’ the user who invited the bot (owner_open_id). A group the routing table can't resolve is dropped โ€” never billed to the whole group.
  • DM โ†’ the sender; an empty sender (e.g. a WebUI ingest service identity) falls back to the profile owner.
  • Email key โ†’ open_id โ†’ user_id (LDAP) โ†’ <user_id>@<HERMES_TOKEN_USAGE_EMAIL_DOMAIN>, the unified identity key across the company so Hermes usage merges onto the same leaderboard row as the person's other tools โ€” no Feishu email scope required.

Under-count, never mis-count. The only rows skipped are those whose owner can't be resolved (rare) and turns that errored mid-flight. No one ever receives someone else's numbers. Full runbook: deploy/README-token-usage.md.

Conversation demand analytics โ€” separate from billing, for understanding what people ask:

hermes-multitenancy-analytics summary --days 7                     # markdown to stdout
hermes-multitenancy-analytics summary --days 30 --format json      # machine-readable
hermes-multitenancy-analytics summary --include-profiles --include-samples  # + top profiles + redacted samples

It reads the conversation-audit log and reports usage volume, completion-proxy metrics, and (optionally) the top active profiles and short redacted demand samples over the selected window.


๐Ÿ”‘ Ingest API key management

/api/run-broker/ingest binds an external caller's Bearer token to either owner mode or a fixed profile/agent. Do not hand-edit production key files; use the CLI to create, inspect, rotate, and revoke bindings. The default output only prints masked tokens:

hermes-multitenancy-ingest grant \
  --keys-file "$HERMES_INGEST_KEYS_FILE" \
  --owner <owner_open_id> \
  --profile <bound_profile> \
  --agent <external_agent_id> \
  --name "<display_name>" \
  --show-token

hermes-multitenancy-ingest list --keys-file "$HERMES_INGEST_KEYS_FILE"
hermes-multitenancy-ingest rotate --keys-file "$HERMES_INGEST_KEYS_FILE" --profile <bound_profile> --agent <external_agent_id> --show-token
hermes-multitenancy-ingest revoke --keys-file "$HERMES_INGEST_KEYS_FILE" --profile <bound_profile> --agent <external_agent_id>
hermes-multitenancy-ingest smoke --base-url <run_broker_base_url> --token <bearer_token>

grant / rotate generate a new token by default but only print the masked value unless --show-token is explicit. The key file is saved as 0600 JSON shaped like {"keys":[...]} and can be loaded by the gateway runtime through HERMES_INGEST_KEYS_FILE.

Async polling ingest

Slow external-ingest jobs should use the polling API instead of holding one HTTP request open until the synchronous HERMES_INGEST_TIMEOUT is hit:

curl -X POST "$RUN_BROKER_BASE_URL/api/run-broker/ingest/async" \
  -H "Authorization: Bearer <bearer_token>" \
  -H "Content-Type: application/json" \
  -d '{"agent":"<agent-name-or-id>","content":"...","idempotency_key":"optional-stable-key"}'
# -> {"ok":true,"status":"accepted","run_id":"ing_...","profile":"...","poll_url":"/api/run-broker/ingest/runs/ing_...","duplicate":false}

curl "$RUN_BROKER_BASE_URL/api/run-broker/ingest/runs/ing_..." \
  -H "Authorization: Bearer <bearer_token>"
# -> {"ok":true,"status":"succeeded","run_id":"ing_...","profile":"...","result":"...","duplicate":false}

The async route reuses the same authentication, owner/profile binding, agent, skill, model, metadata, interactive, and idempotency semantics as synchronous ingest. Ingest runs are always treated as host-tool-capable by the server, so callers cannot downgrade sandbox admission. A duplicate submission with the same Bearer scope and effective idempotency key returns the same run_id and does not dispatch a second run. Polling requires the same Bearer scope; another valid key cannot read the result. Runtime bounds are HERMES_INGEST_ASYNC_TIMEOUT (default 1800 seconds), HERMES_INGEST_ASYNC_TTL (default 3600 seconds), and HERMES_INGEST_ASYNC_CAP (default 256 in-process records).

Per-run ingest secrets

Synchronous and async ingest requests also accept an optional secrets object for one-run credentials such as JWTs, Bearer tokens, API keys, cookies, and Basic auth values. Keep raw credentials out of content, metadata, and idempotency keys; content should only contain model-visible business instructions.

{
  "agent": "<agent-name-or-id>",
  "content": "Fetch reconciliation data for 2026-06-01 through 2026-06-22.",
  "secrets": {
    "cms_bearer": {
      "type": "bearer_token",
      "value": "<full token>"
    }
  },
  "idempotency_key": "reconcile-20260623-001"
}

Secret names must match [A-Za-z0-9_.-]{1,64}. Supported type values are bearer_token, api_key, cookie, basic, and opaque. Each value is limited to 16 KiB and the request's total secret payload is limited to 64 KiB.

The model sees only a manifest with each secret's name, type, and usage hint. The real values are written as 0600 files under a profile-scoped per-run directory, exposed to tool execution through HERMES_INGEST_SECRET_DIR and HERMES_INGEST_SECRET_MANIFEST. For example, an agent tool can read $HERMES_INGEST_SECRET_DIR/cms_bearer to build Authorization: Bearer .... Raw values are not copied into RunRequest.content, caller metadata, raw event metadata, poll results, or exact-result text; terminal results are redacted by exact secret value.

Ingest prompts also tell the agent to consume secrets only inside execution tools (terminal / execute_code) and to report at most the secret name, type, file existence, or value length. It must not echo raw secret values, token prefixes/previews, Authorization/Cookie/Basic headers, or raw secret file contents. Ingest runs are intentionally direct one-shot jobs: the broker disables the delegation toolset for these runs so useful work does not get stranded in a child session while the async poller watches only the parent run.

Secrets are tied to the run lifetime: synchronous runs clean up immediately after completion, and async runs clean up after the run reaches a terminal state subject to HERMES_INGEST_ASYNC_TTL. Idempotency includes a server-side secret fingerprint. Reusing the same Bearer scope, agent/profile, and idempotency_key with the same secret fingerprint returns the existing run; using a different secret fingerprint returns 409 secret_mismatch instead of silently reusing a run with the wrong credential.

Async ingest failures are returned as classified diagnostics such as agent_max_iterations, agent_tool_loop_error, or agent_runtime_error instead of a generic internal error. The server logs only run context, exception type, and the redacted classified message; it does not log traceback bodies for ingest run failures, and it applies a best-effort Bearer/JWT redaction fallback even when the caller did not use the secrets field.


๐Ÿšข Production deployment runbook

  1. Update and verify the canonical repository locally.
  2. Run uv run --extra test pytest -q or make test.
  3. Push the reviewed commit to GitHub.
  4. On the production host, back up the current checkout, config.yaml, .env, multitenancy.db, service units, and active profile directories. Never print secret file contents.
  5. Fast-forward the production checkout only: git pull --ff-only.
  6. Reinstall the package if production uses editable imports: python -m pip install --no-deps -e /path/to/hermes-multitenancy.
  7. Ensure $HERMES_HOME/plugins/multitenancy points at the production checkout (or was refreshed by hermes plugins install).
  8. Ensure $HERMES_HOME/bin/lark-cli-authsidecar exists and is executable, or set HERMES_LARK_CLI_BIN.
  9. Restart the router gateway and any Run Broker / WebUI services.
  10. Verify health, preflight, route rows, service logs, and one read-only lark_cli user-info canary before declaring the deploy usable.

Rollback is a normal forward fix or a restored checkout plus service restart. Do not copy tokens between profiles by hand; use the credential vault and credential-materialization.yaml.


โœ… Proof of end-to-end

This isn't a paper plugin. The UAT chain has been run against a real Feishu bot with two independent Feishu users on the same bot:

Step Action Verified result
1 User A โ†’ same bot โ†’ router Routed by real ou_* open_id to the existing coder profile.
2 User B โ†’ same bot โ†’ router Auto-provisioned and routed to a new feishu_ou_xxx profile, not the coder profile.
3 Both users send the same tool-heavy UAT case set AIAgent subprocess runs with the correct profile home and sender open_id scope.
4 Replies stream back through Feishu CardKit / IM Text cards and file-message paths delivered through the Feishu adapter.
5 Full dual-account stress suite Run with --users <userA>,<userB> --parallel-users; each case records independent case_id::user checkpoints.
6 Dynamic slash control plane Dual-account slash suite passed 16/16 โ€” gateway handlers, skill rewrite, plugin delegation, quick alias, opt-in exec, unknown-command handling all verified.

These checks ran live through Feishu's WebSocket gateway and an OpenAI-compatible model provider. Real open_ids, tokens, chat IDs and app secrets are intentionally omitted from this repository.


โœจ Feature matrix

Feature Status
Multi-tenant routing per Feishu user (open_id / union_id) โœ…
LRU runtime pool (max 50 hot profiles, idle evict 5min) โœ…
Streaming LLM via CardKit / edit_message typewriter โœ…
Reasoning-content split for thinking models โœ…
Reactions (๐Ÿ‘€ โ†’ โœ… / โŒ) via adapter.on_processing_* โœ…
Multi-turn session memory (SQLite-backed, survives restart) โœ…
Reply-context injection (quoted messages) โœ…
Rate-limit retry (429 backoff, mirrors hermes cadence) โœ…
Hermes slash command control plane โœ… โ€” dynamic registry recognition; commands never leak into the LLM
Idempotent feishu-sync reconciler (CLI + library) โœ…
Python Feishu Contact org sync (pull-feishu) โœ… โ€” creates/updates profiles, SOUL managed blocks, route rows
Vision (image attachments) โœ… โ€” delegates to hermes' inbound text prep
Audio STT (voice messages) โœ… โ€” same delegate, hermes' transcribe_audio
Text-file inject (.txt / .md / .csv / .log / .json โ€ฆ) โœ… โ€” same delegate
Tool use (real AIAgent loop with browser/search/shell) โœ… โ€” isolated AIAgent subprocess bridge
lark-cli Feishu OpenAPI bridge โœ… โ€” tool registration + per-run auth broker (deployment provides the binary)
Credential vault + materialization โœ… โ€” stores Feishu app/UAT/provider secrets; redacted status only
Managed skill distribution โœ… โ€” defaults / distribution / bundles YAML, secret guard, child inheritance
Cron / reminder proactive delivery โœ… โ€” broker-created jobs default deliver=feishu, router multi-profile worker
Dangerous-command approval delivery โœ… โ€” child events โ†’ parent stream โ†’ Feishu prompt โ†’ /approve /deny decision file
CardKit idle heartbeat โœ… โ€” parent router prime + heartbeat
Per-turn token ledger + owner-attributed leaderboard โœ… โ€” opt-in parent-written ledger, hourly uploader, routing-table email resolution
Conversation demand analytics CLI โœ… โ€” hermes-multitenancy-analytics summary over the audit log
Group @everyone admission guard โœ… โ€” @_all / @ๆ‰€ๆœ‰ไบบ ignored in any reply mode
Send-time routing re-check โœ… โ€” freshly-created own groups deliver immediately; no-proxy bot IM send refused
Graceful output-length truncation โœ… โ€” friendly notice instead of hard failure, streaming path included
Runtime model-spec normalization โœ… โ€” heals recurring bare-model provider-prefix failures at load
Credential re-auth freshness gate โœ… โ€” mode-aware dedupe; enabling live sends never blasts stale backlog
Feishu CardKit / IM file-message replies โœ… โ€” streaming cards + native MEDIA:<path>, filtered to routed profile home
Background terminal notify_on_complete โš ๏ธ not claimed โ€” child registry invisible to parent; child exit calls agent.close()

๐Ÿ›ก๏ธ How it stays compatible

We keep zero patches to hermes-agent: no edits to feishu.py, gateway/run.py, or upstream modules. The plugin loader contract (hermes_cli/plugins.py register_hook) is the gateway entry point; the AIAgent/tool bridge consumes a few Hermes integration surfaces, each with tests.

Public API we depend on Stability
pre_gateway_dispatch hook (plugins.py VALID_HOOKS) โš ๏ธ added 2026-04-21 โ€” pin hermes-agent version
BasePlatformAdapter.send / send_typing / edit_message โœ… abstract methods, very stable
BasePlatformAdapter.on_processing_start / on_processing_complete โœ…
MessageEvent.source.{user_id, user_id_alt, chat_id} โœ… stable
Platform.FEISHU enum + ProcessingOutcome enum โœ…
gateway.adapters[Platform.FEISHU] dict โœ…
hermes_constants.get_hermes_home() (read via env) โœ…
hermes_cli.commands.resolve_command / is_gateway_known_command โœ… โ€” central slash registry, with a tiny test fallback
SendResult.{success, message_id} โœ…
gateway._prepare_inbound_message_text(...) โš ๏ธ private โ€” vision + STT + file inject + reply context in one call; local vision-only fallback on signature change
gateway.stream_consumer.GatewayStreamConsumer โš ๏ธ integration surface โ€” CardKit streaming with text-edit fallback
gateway._deliver_media_from_response(...) โš ๏ธ private โ€” native MEDIA:<path> path after filtering to profile home; no-op if unavailable
run_agent.AIAgent โš ๏ธ core runtime class โ€” isolated in aiagent_subprocess.py, falls back to OpenAI-compatible path
tools.feishu_oapi_client.sender_open_id_scope โš ๏ธ Feishu UAT bridge โ€” _configure_feishu_uat_home rebinds FEISHU_UAT_DIR per subprocess

Pin your hermes-agent version (hermes-agent==X.Y.Z) and run pytest tests/test_router_integration.py tests/test_vision.py after each upgrade โ€” the integration tests fail loudly on contract drift. We currently require hermes-agent>=0.14,<1.0 in pyproject.toml.

Upstream strategy

This repository stays a third-party Hermes plugin, not a fork โ€” keeping rollout fast and Feishu-multitenancy policy out of Hermes core. Good upstream PRs to NousResearch/hermes-agent would be small and generic: expose the real Feishu sender open_id on MessageEvent.source, document pre_gateway_dispatch and the deferred-processing lifecycle, and stabilize CardKit streaming/media extension points. The full router should only be proposed as a bundled plugin after those surfaces settle.


โš™๏ธ Configuration knobs

config.yaml key Default Notes
plugins.enabled (none) Must include multitenancy
model.default (your hermes default) Per-profile model; bare names are runtime-normalized to a valid provider prefix
model.fallback (your hermes default) Used by agent_real if primary fails
multitenancy.toolsets_mode merge_default Merge a profile's platform_toolsets.feishu with Hermes defaults so web/browser/search stay available; explicit for strict replacement
multitenancy.allow_quick_exec false Allow quick_commands type: exec over Feishu; keep off until the sandbox is enforced
Sync command / env var Default Notes
pull-feishu --dry-run off Preview planned changes without writing
pull-feishu --dept <id> off Sync one department subtree; out-of-scope routes not soft-deleted
pull-feishu --soft-delete-missing on (full) / off (--dept) Soft-delete active routes missing from the pull
HERMES_MULTITENANCY_AUTO_PROVISION 1 Auto-create feishu_<open_id> fallback profiles; 0 for strict allowlist
HERMES_TOKEN_USAGE_LEDGER_ENABLED unset / off Gateway-env switch for the per-turn token ledger (set on the parent process only)
HERMES_TOKEN_USAGE_EMAIL_DOMAIN (required for uploader) Domain for <user_id>@<domain> leaderboard identity resolution
Plugin tunable (router.py constants) Default Notes
RuntimePool.max_loaded_runtimes 50 Hot pool cap
RuntimePool.idle_evict_seconds 300 Drop idle entries after 5min
_SESSION_HISTORY_MAX 20 Messages kept per (profile, user)
Streaming throttle (content) 1.0s / 60 chars Mirrors hermes cadence
CardKit idle heartbeat 2.5s Keeps the card active before the first agent event
Approval bridge timeout 300s Override with HERMES_MULTITENANCY_APPROVAL_TIMEOUT
Rate-limit backoffs 0.5s โ†’ 1s โ†’ 2s 429-only; non-429 retried once

๐ŸŽฎ Slash commands

Command Effect
/help List available commands
/status Show current profile + history length + run state
/new / /reset Reset this user's session history (cache + SQLite)
/stop Cancel the in-flight LLM call for this user
Other Hermes gateway commands Recognized dynamically from Hermes' registry and delegated to the gateway handler; otherwise a control-plane warning, never the agent prompt

๐Ÿงช Testing

uv run --extra test pytest -q          # Default suite (no network)
make test                              # Same, via Makefile

uv run --extra test pytest \           # Focused Feishu regression suite
  tests/test_hook_dispatch.py \
  tests/test_aiagent_subprocess.py \
  tests/test_streaming_card_transport.py -q

uv run --extra test pytest tests/ -m integration -v   # Live LLM integration
make skills-uat                        # skills / lark-cli / UAT audit
make skills-uat-strict

๐Ÿ› Troubleshooting

"plugin loaded but no replies" โ€” pkill -f gateway && hermes gateway run. Plugins load at gateway startup; any change requires a restart.

"gateway logs Failed to load plugin 'multitenancy': No module named 'hermes_multitenancy'" โ€” the repo is being loaded as a Hermes directory plugin under the hermes_plugins.<name> namespace, so package-internal imports must be relative. Check tests/test_plugin_install_layout.py; this failure means register(ctx) may abort before pre_gateway_dispatch is registered, even if router sidecars such as Run Broker start.

"all bots stopped responding" โ€” your routing rule probably has the wrong open_id/union_id. Add a temporary print(event.source) in router.on_pre_gateway_dispatch and watch the gateway log.

"org sync routed someone incorrectly" โ€” stop cron/systemd first, run pull-feishu --dry-run. The user can /status in Feishu; locally inspect sqlite3 ~/.hermes/multitenancy.db 'select user_id, open_id, profile_name, active from multitenancy_routing;'.

"I need to bypass a bad route immediately" โ€” soft-delete the active route and let the next message auto-provision a fallback:

sqlite3 ~/.hermes/multitenancy.db \
  "update multitenancy_routing set active=0, deleted_at=strftime('%s','now'), updated_at=strftime('%s','now'), version=version+1 where open_id='ou_xxx' and active=1;"

"user_id is g41a5b5g-ish, not the ou_ I expected" โ€” some paths expose a short SDK user ID. The plugin resolves the real sender open_id from raw sender metadata/context first, falling back to user_id_alt/union_id only for legacy rows.

"Feishu tools work, but news/web search does not" โ€” the profile likely has platform_toolsets.feishu set to Feishu-only tools. Default merge_default mode preserves web_search/web_extract; set explicit only if you need a small schema.

"the bot replied to @ๆ‰€ๆœ‰ไบบ" โ€” it shouldn't. @_all is ignored at admission in every reply mode; if you see otherwise, capture the raw mention payload and file an issue.

"sessions lost after restart" โ€” verify ~/.hermes/multitenancy.db exists and multitenancy_sessions has rows; check the gateway log for SessionStore.append failed.


๐Ÿค Contributing

Issues and PRs welcome.

Bug reports โ€” include: make test output, hermes-agent version (pip show hermes-agent | grep Version), plugin version, and relevant multitenancy:-prefixed gateway log lines.

Pull requests โ€” fork โ†’ branch โ†’ green make test โ†’ PR. Tests required for behaviour changes. Don't mass-rename. No feishu.py patches โ€” the whole point is that hermes-agent stays unmodified; file an upstream issue instead.

Wanted contributions (priority order):

  1. Per-profile SessionStore โ€” split the shared multitenancy.db session rows into per-profile DBs to mirror hermes' own layout.
  2. Prompt caching โ€” Anthropic cache_control for the SOUL prefix (~50% token cut on long chats).
  3. CI matrix โ€” GitHub Actions running the suite against multiple hermes-agent versions to catch contract drift early.
  4. More live UAT fixtures โ€” broaden write-path coverage without shared production-like resources.

๐Ÿ“œ License

MIT โ€” see LICENSE.

๐Ÿ™ Acknowledgements

Built on top of Nous Research's hermes-agent โ€” without the pre_gateway_dispatch hook (added by @KeiraVoss on 2026-04-21), this plugin would have required forking the entire upstream. Thank you for the hook.

About

One Feishu bot, N users, N profiles. A hermes-agent plugin for multi-tenant routing.

Resources

Stars

15 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages