- Go 97.3%
- Shell 2.7%
| cmd | ||
| internal | ||
| dispatcher-smoke.sh | ||
| go.mod | ||
| go.sum | ||
| identity-placement-smoke.sh | ||
| identity-routing-smoke.sh | ||
| README.md | ||
| topology.toml | ||
key.store agent-dispatcher
Unified intake service for inbound operations events, role routing, and resident identity steers.
This directory is the formal dispatcher codebase for key.store. Webhook topology
is source-controlled in topology.toml; Nix passes that file to the API service
and local callers can inspect the active, secret-free view at GET /topology.
Inbound webhook paths
POST /webhook/redminePOST /webhook/forgejoPOST /webhook/zulipPOST /webhook/forwardemailPOST /webhook/smsPOST /webhook/stablePOST /webhook/minecraftPOST /webhook/grafanaGET /healthzGET /topology(loopback only)
All webhook traffic is expected to come through https://dispatch.key.store.
Communications routing
- Forward Email aliases can post directly to
/webhook/forwardemail; the dispatcher dedupes onidentity + message-id. - With
--forwardemail-wake-commandconfigured (the forge),/webhook/forwardemaildoes not enqueue the posted payload: Stalwart stays canonical, so the webhook wakes the alias's imapsync mirror and then a targetedagent-dispatcher mail --once --message-id <id>intake against Stalwart. Without a wake command the webhook enqueues directly. - Email intake (
agent-dispatcher mail) can still poll IMAP, sharing the same dedupe path so webhook-first and mailbox-fetch flows do not double-enqueue. The targeted (--message-id) intake asks the server forSEARCH HEADER Message-IDfirst but does not trust an empty answer: Stalwart returns nothing for HEADER searches on unindexed fields, so the intake then matches the header client-side over the unseen messages plus the newest 50. - Mail intake first enqueues a resident-thread steer. The receiving identity must absorb the steer before any side effect; mail intake has no SMS sender.
- Redmine should be searched before creating duplicate todos from mail, Forgejo, SMS, or wake notifications.
- Mail sender policy is fail-closed for execution. Trusted senders can steer the owning role; untrusted mail routes only to the configured review role identity, keeps the requested role in payload context, and must not trigger repository, secret, DNS, or production changes without later human approval.
- Zulip, native: every resident identity is a real Zulip user, and the supervisor long-polls a Zulip event queue as each identity it serves (
zulip_listener.go,--zulip-listen, default on). A message that mentions the identity (@**Web** ..., personal or group mention —@allis ignored) or a direct message to it becomes azulipsteer for that identity with the webhook payload shape, so the completed-turn relay answers in the same channel/topic or DM as the identity; the listener also keeps the identity marked present. Identities can address each other this way (Zulip never fires outgoing webhooks for bot-sent messages, which is why the accounts stay users rather than bots) — but a message from another resident never starts a turn: every steer carrieskick(humans and systems kick; a notification whose actor is a resident identity — Zulip sender, Forgejo sender, Redmine author, mail from an identity mailbox — does not). Non-kicking steers queue until a human kicks that identity, then ride into that turn (or steer straight into a turn that is already running). Residents can talk to each other; only humans start lifetimes, so there is nothing to rate-limit. The sharedAgent Dispatcheroutgoing-webhook bot remains as the alias-routed fallback (@Agent Dispatcher @ios ...), and/webhook/zulipalso honoursbot_emailif an identity is ever run as a bot. - Zulip replies are relayed from completed resident-thread output. The identity absorbs the steer first, then the supervisor posts the final assistant answer back to the originating stream/topic or direct message as the mapped Zulip identity.
- SMS/calls (
/webhook/sms) and physical mail (stable) can be delivered via signed webhooks. - SMS-compatible sources default to the
assistantrole andassistantidentity, andstabledefaults to thecoorole andmanageridentity, unless an explicit mention overrides routing. - SMS does not use a completion relay. The assistant must decide in the resident thread whether the inbound text needs a response and call
communications_sms_sendbefore ending the turn when it should text back. - Forgejo webhooks route explicit role mentions and identity-specific issue assignments or pull request review requests. Assigned/review-requested identities wake their resident role thread even when the Forgejo body has no
@role/...or alias mention. - Grafana Alerting posts to
/webhook/grafanathrough the provisionedkey.store dispatchercontact point. Grafana signs the raw body with HMAC-SHA256 and includes the configured timestamp header; labels or annotations can route withkey_store_role,role_id,key_store_identity, or role aliases such asteam=infra. Alerts default to the infra identity. - The resident-agent shape is one long-running thread per identity, served by one of two runtimes chosen in
org/organization.toml(agent.runtimeon the role,runtimeon a tier, oragent_runtime.default_runtime; defaultcodex): the Codex app-server, or Claude Code driven headless (claude -p --input-format stream-json --output-format stream-json, one process per thread bound to a stable--session-id, resumed after crashes,--append-system-promptfor the identity instructions,--effortfrom the role's reasoning effort, the forge control-plane MCP hub via--mcp-configwith the bearer token expanded from the task environment). Incoming notifications become steers into that identity thread; the dispatcher no longer exposes a separate task executor path. Steers that arrive mid-turn are injected into the running turn on both runtimes (--claude-steering=falsemakes Claude finish the turn first).
Resident Steer Execution
- Resident identity threads use the configured role model, reasoning effort, sandbox, and approval policy. Supervisor flags for the Claude runtime:
--claude <path>(empty disables it),--claude-config-dir <root>(per-identityCLAUDE_CONFIG_DIR, sessions/settings/auto-memory),--anthropic-api-key-file <file>(exported asANTHROPIC_API_KEYto claude processes only),--claude-steering(default true). Claude runs with--permission-mode acceptEditsand an explicit tool allow list because a resident has no approval channel;bypassPermissionsis refused as root. - Queue state lives in forge-local PostgreSQL via
AGENT_DISPATCHER_DATABASE_URL; legacy sqlite/task imports are no longer part of the live path. - Mail-to-work links live beside the steer queue:
mail_redmine_linksrecords JMAP/RFC message ids, dispatcher event keys, steer ids, and Redmine issue ids without duplicating message bodies. - Resident environments receive role-scoped Redmine, Forgejo, JMAP, Zulip, communications, physical-mail, and MCP credentials so primary-session tools can read/update the same event the dispatcher routed from.
- Resident environments project
org/organization.tomlagent runtime policy into the app-server thread. The supervisor prewarms every mapped identity for an identity-mode role; thread persistence, app-server clients, identity-bound credentials, and multi-identity work roots are scoped by role and identity so identities that share a role, such asagentandassistant, do not replace each other or inherit each other's tokens. Role-specific environment variables still override model and provider defaults for recovery changes. - Resident environments also project the per-identity workspace placement from
org/organization.toml. Every identity has an explicit host/surface/profile/tooling contract: iOS on macOS Namespace, Android on the Android Namespace image, Windows on the Windows lane, assistant on HQ, and the remaining identities on appropriate Linux Namespace or forge surfaces. - Minecraft and office presence are primary-session MCP tools. The dispatcher no longer requires a dedicated Minecraft sub-agent.
MCP and search posture
Checkpoints preserve ongoing work (KSE-0197)
The OpenRouter harness treats its tool-round limit as a checkpoint, not a successful task outcome. It resumes the same session with tools available again, preserves late-arriving steers in the append-only journal, and never reruns calls returned during a tool-free checkpoint. Completed effects remain in the transcript; the continuation asks the resident to inspect state, finish remaining work, and synchronize its own signed memory before finishing.
There is no arbitrary limit on the number of checkpoints. The resident finishes
only after a sole complete_task call with outcome: completed or blocked
and an accurate summary, or when the turn is cancelled,
or a provider/budget error stops execution. A checkpoint alone never marks its
steers turn_completed. Prose updates keep the task active. A structured blocker
is recorded as blocked, never successful completion or a transient retry.
Completion closes atomically against new steers; an accepted late steer keeps
the same task active. Each provider request still uses the existing usage
recorder and budget policy; the founder's $50 monthly cap remains unchanged.
Restart recovery keeps the existing durable steer and journal behavior; this
change does not remove HQ's forge queue/MCP dependencies or guarantee a
model-authored memory commit. Transient failures use the existing supervisor
recovery path, separate from checkpoint continuation.
dispatch.key.store/mcpis the shared Authentik-backed MCP hub for contributor clients and the per-identity loopback bearer hub for forge-resident agents.- The public plugin metadata should mention the broad tool surface so Codex tool search can discover the hub for mail, Redmine, Forgejo, SMS, office/Minecraft, and observability work.
- Stalwart stores message data, blobs, lookup, and full-text search in PostgreSQL. The
stalwartandagent_dispatcherdatabases are part of the daily PostgreSQL backup set. - The mail MCP namespace exposes JMAP-native mailbox state, search, snippets, fetch, thread context, draft/submit/send, calendar event search/create, contact search, and readiness tools rather than inventing a separate message-body index first.
- Meeting-backed calendar events use the Stalwart/JMAP calendar create tool with
meet.key.storeJitsi URLs embedded in the event metadata; Stalwart remains the source of truth for calendar state and scheduling mail. - JMAP search, fetch, and thread results are enriched with dispatcher steer links and stored Redmine issue links. Use
mail_jmap_link_redmine_issueafter an identity decides that an email needs durable Redmine follow-up.
Resident commit signing (KSE-0192)
key-store-kms-sshsigis git'sgpg.ssh.programin resident workspaces: anssh-keygen -Y signdrop-in whose private key is a Google Cloud KMSASYMMETRIC_SIGNkey (EC_SIGN_P256_SHA256→ecdsa-sha2-nistp256, orEC_SIGN_ED25519→ssh-ed25519), reached through Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS= the forge's WIF external_account file;KEY_STORE_KMS_ACCESS_TOKENoverrides for tests/ops). It writes standard sshsig blocks thatssh-keygen -Y verifyand the forge's pre-receive hook accept, and delegates every non-sign invocation (-Y verify,-Y find-principals) to the realssh-keygen.key-store-kms-sshsig pubkey --key <cryptoKey|cryptoKeyVersion> --comment <principal>prints the OpenSSH public key line (newest ENABLED version) with akms:<version>comment;user.signingkeypoints at a file holding that line and the signer reads the KMS resource back from it.
Helper CLIs
key-store-redmine whoamiredmine_issue_searchis available through MCP for duplicate checks before creating durable work.key-store-redmine issue show <id>key-store-redmine issue create --subject "..." --description "..."key-store-redmine issue note <id> --message "..."or note text on stdinkey-store-redmine issue update <id> --status-id <n> [--assigned-to-id <n>] [--notes "..."]key-store-zulip send --message "..."for office-channel updates.communications_sms_send,communications_sms_messages,communications_phone_numbers, andcommunications_voice_callsare available through MCP for assistant-owned communications.stable_mail_itemsis available through MCP for COO-owned physical-mail lookup.
Provider scripts
Scripts/communications-fetch-events.shScripts/communications-send-sms.shScripts/communications-provision-webhook.shScripts/stable-fetch-mail.shScripts/stable-provision-webhook.sh
These scripts assume communications and Stable API keys are managed with agenix:
secrets/communications/api-key.agesecrets/stable/api-key.age
Current provider defaults:
- SMS/voice provider API:
https://api.openphone.com - Stable API:
https://api.usestable.com - Stable webhook endpoints are configured in the Stable Dashboard; the local helper validates API access and prints the target/events for manual dashboard setup.
Smoke checks
services/redmine/redmine-smoke.shservices/redmine/redmine-api-smoke.shdispatcher-smoke.shidentity-routing-smoke.shidentity-placement-smoke.shservices/mcp-hub/mcp-hub-smoke.sh
dispatcher-smoke.sh proves the dispatcher HTTP surface, including the Zulip
webhook path. identity-placement-smoke.sh fails if forge regains a dedicated
iOS supervisor or stops excluding ios and assistant from the forge-owned
supervisor.
identity-routing-smoke.sh <api base url> <database url> posts one synthetic,
no-action event per webhook source under a stable namespace and asserts that
the resulting steer reaches the expected source/role/identity and is
delivered (set KEYSTORE_ROUTING_SMOKE_EVENT_SUFFIX to force fresh events).
The forge runs it from redmine-dispatcher-smoke.service.
Its mail legs (mail-trusted-billing-cfo, mail-trusted-billing-assistant,
mail-trusted-security, mail-review-billing) go through the wake path
described above, so the synthetic message must already be in Stalwart when the
webhook fires. Before each webhook the smoke therefore APPENDs the same message
(Message-ID, From, To, Subject, no-action body) into the target account's INBOX
over IMAP, keyed by the Forward Email alias it is addressed to:
KEYSTORE_ROUTING_SMOKE_MAIL_ACCOUNTS—<alias>=<stalwart account>:<password file>[,...], e.g.billing=billing:/run/agenix/billingPassword,security=security-agent:/run/agenix/securityAgentPassword. This is the same alias → account mapping thekey-store-forwardemail-wake@unit uses; the forge derives both fromforwardEmailMirrorMailboxesinnixos/keystore-forge.nix. Unset it for deployments without a wake command, where the webhook enqueues directly and no seeding is needed.KEYSTORE_ROUTING_SMOKE_IMAP_ADDR— Stalwart IMAP address (default127.0.0.1:993; IMAPS on 993 or STARTTLS otherwise, certificate verification skipped on loopback like the dispatcher's own intake).
Seeding is idempotent: the smoke reuses stable Message-IDs across runs and
leaves an existing copy alone, and the intake dedupes on
identity + message-id, so repeated runs neither pile up mailbox copies nor
create new steers. Recipients are the real Forward Email aliases
(security@key.store, not the security-agent Stalwart login) because the
wake command only mirrors those.
HQ assistant resident supervisor
nixos/keystore-hq.nixruns theassistantresident supervisor on HQ. It keeps the Codex app-server and communications-capable assistant runtime on the failsafe node while using an SSH tunnel to forge PostgreSQL for the shared steer queue.- Forge excludes
assistant, so signed SMS and HQ watchdog steers have one canonical assistant consumer instead of competing forge and HQ sessions.
macOS iOS resident supervisor
Scripts/run-ios-macos-agent-supervisor.shruns thetech_lead_apple/iossupervisor locally on macOS. It starts a local Codex app-server and uses an SSH tunnel to forge PostgreSQL only for queue access whenAGENT_DISPATCHER_DATABASE_URLis not set.Scripts/install-ios-macos-agent-launchagent.shinstalls the no-sudo per-user LaunchAgent for the local Mac.Scripts/install-ios-macos-agent-launchd.shinstalls the system LaunchDaemon variant for managed macOS hosts where root installation is available.