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 | ็ฎไฝไธญๆ
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 isolation | Each 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-agent | Ships 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 lifecycle | Sync 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 construction | Per-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 observability | Per-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 rebuilt | CardKit 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 rails | Group @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. |
- 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.
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
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.
~/.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)
- Entry point, no Hermes core patches.
hermes_multitenancy.register(ctx)registers apre_gateway_dispatchhook. For Feishu messages the hook returns{"action": "skip"}and the plugin'shandle_async()owns routing and replies. - Identity uses the canonical sender.
_resolve_sender_for_routing()prefers the real Feishuopen_id(ou_*) from the Feishu contextvar,event.sender_open_id,source.open_id/user_id, andraw/raw_event/event.user_id_alt/union_idis only a legacy route lookup helper, not the new session key. - Routes live in SQLite.
multitenancy_routing.open_id -> profile_namedecides which~/.hermes/profiles/<profile>/handles the turn. A realou_*will not be absorbed by a staleunion_id; legacy alt routes are used only when no realou_*is available. - Normal messages run inside the routed profile. The router builds a profile-scoped event, writes the resolved
sender_open_idback to the event, then dispatches to the streaming AIAgent subprocess. The child runs with that profile'sHERMES_HOME;agent_real._build_subprocess_envstrips the parent gateway's environment down to an explicit allowlist and pivotsHOME/WORKSPACE/XDG_*/TMPDIRinto<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 setsHERMES_PROFILEplus Keep-compatibleKEP_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). Seedocs/profile-isolation.md. - Default skills and group credentials materialize from runtime state.
profile-skill-defaults.yaml,skill-distribution.yaml, andskill-bundles.yamlexpress managed skills; sync installs them into profiles while skipping secret-looking files. Any shared top-levellark-*skill is also installed for every profile as a managed symlink.credential-materialization.yamlmaps encrypted vault payloads to profile-local compatibility files;profiles: ["*"]expands to active routing rows; anenv:entry passes the secret to the routed AIAgent without the model reading the token file. - lark-cli is an external runtime dependency. This repo registers the
lark_clitool and starts a per-run localhost auth broker, but the deployment must provide an authsidecar-capablelark-clibinary (default<shared HERMES_HOME>/bin/lark-cli-authsidecar;HERMES_LARK_CLI_BINoverrides). Personal profiles useuseridentity only when the currentopen_idhas valid UAT; group/WebUI agent profiles default tobot. - 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, createsRunRequest(channel="cron"), executes through Run Broker, delivers to Feishu when requested, and mirrors context intomultitenancy_sessions. Profile-side manual runs from the nativecronjobtool are also intercepted and queued through the router Run Broker, including Hermes core runtimes whoserun/run_now/triggerbranch calls an immediate_execute_job_now(...)helper. - 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 intoproviderandmodel_only, then syncs that live tuple into Hermes coreagent.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 infinally. - Dangerous-command approvals cross the subprocess boundary. The profile AIAgent registers
tools.approvalwith a router-compatible gateway session key (multitenancy:<platform>:<profile>:<chat>:<sender>). The child emitsapproval_required/approval_resolved; the parent_stream_aiagent_subprocess()forwards them to the router; the router prompts Feishu;/approve//denywrites a decision file that releases the child and resumes Hermes' native approval flow. - Delegation is a policy-controlled tool, not a data-execution guarantee. Routed AIAgent construction forwards
agent.disabled_toolsetsfrom profile/global config into Hermes core; settingagent.disabled_toolsets: ["delegation"]removesdelegate_taskeven when a defaulthermes-*platform toolset would otherwise include it. WebUI also sets Hermes session contextasync_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 directterminalorexecute_code. - 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. Hermesvision_analyzeis 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 taggedmetadata.source=ingestdo not trigger this chat attachment preflight. - 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.
- Memory is keyed by
(profile, canonical sender)._history_key()does not usesender_alt or sender, so stale/shared alternate IDs cannot merge two users' memory. - Slash commands never leak into the LLM.
/model,/reasoning,/reload-mcpand other registry commands use Hermes gateway handlers; skill slash rewrites into native skill invocation; plugin slash delegates tohermes_cli.plugins.get_plugin_command_handler; unknown slash returns Hermes-style unknown-command. - 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 anassetsregistry; 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/expertscatalog; traversal, non-registered files, and hidden-profile assets are refused. - Group
@everyonenever triggers the bot. Admission (_admit) ignores@_allin any reply mode โ detected via structured mention metadata or raw@_allโ so an@ๆๆไบบbroadcast can never wake every routed agent in a group. - 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.
- Local exec is off by default.
quick_commandsalias remains available;type: execis denied unlessmultitenancy.allow_quick_exec: trueorHERMES_MULTITENANCY_ALLOW_QUICK_EXEC=1. Keep it off in production until profile sandboxing is enforced. - 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). OutboundMEDIA:<path>replies are filtered so only paths inside the routedprofile_homeare delivered;.env,auth.json,feishu_uat/,credentials/,tokens/are blocked. - Feishu UAT refreshes mirror into the credential vault. Org sync copies refreshed shared
feishu_uat/<open_id>.jsoninto each routed profile and, when a credential key is configured, writes the same payload intomultitenancy_credentials. JSON remains a migration fallback; the DB is the runtime credential source. - Production posture. Prefer
HERMES_MULTITENANCY_AUTO_PROVISION=0andmultitenancy.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 0700profile tree, per-profilefeishu_uat/+tokens/) is enabled by default โ verify withscripts/verify-isolation.sh. Kernel-level containment (sandbox-exec/ Linuxbwrap) is additive defense-in-depth until enabled for every profile. Full details:docs/profile-isolation.md.
| 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. |
| 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. |
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"hermes plugins install eggyrooch-blip/hermes-multitenancy --enable
hermes plugins listFor 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:
- multitenancyThis 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.shOr 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.
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.
.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.
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.
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 restartProduction 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.
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.
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>&1Department-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>/.
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>"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=12. 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 samplesIt 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.
/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.
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).
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.
- Update and verify the canonical repository locally.
- Run
uv run --extra test pytest -qormake test. - Push the reviewed commit to GitHub.
- 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. - Fast-forward the production checkout only:
git pull --ff-only. - Reinstall the package if production uses editable imports:
python -m pip install --no-deps -e /path/to/hermes-multitenancy. - Ensure
$HERMES_HOME/plugins/multitenancypoints at the production checkout (or was refreshed byhermes plugins install). - Ensure
$HERMES_HOME/bin/lark-cli-authsidecarexists and is executable, or setHERMES_LARK_CLI_BIN. - Restart the router gateway and any Run Broker / WebUI services.
- Verify
health,preflight, route rows, service logs, and one read-onlylark_cliuser-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.
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 | 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 |
agent.close() |
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) |
|
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(...) |
|
gateway.stream_consumer.GatewayStreamConsumer |
|
gateway._deliver_media_from_response(...) |
MEDIA:<path> path after filtering to profile home; no-op if unavailable |
run_agent.AIAgent |
aiagent_subprocess.py, falls back to OpenAI-compatible path |
tools.feishu_oapi_client.sender_open_id_scope |
_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.
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.
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 |
| 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 |
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"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.
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):
- Per-profile
SessionStoreโ split the sharedmultitenancy.dbsession rows into per-profile DBs to mirror hermes' own layout. - Prompt caching โ Anthropic
cache_controlfor the SOUL prefix (~50% token cut on long chats). - CI matrix โ GitHub Actions running the suite against multiple
hermes-agentversions to catch contract drift early. - More live UAT fixtures โ broaden write-path coverage without shared production-like resources.
MIT โ see LICENSE.
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.