A self-hosted bridge between a chat platform and an AI agent. You run one daemon. It brings a bot online 24/7, exposes slash commands to spin up isolated sessions, and for each session it turns your messages into prompts, asks a model, and posts the answer back — streaming tool activity and cost as it goes. Every session can run in its own git worktree, so an agent works on real code in isolation.
This is the single binary: the agnostic domain (core), the composition root
and daemon, and the plugin-management CLI all live here in one module. The
swappable edges — the channel gateway, the model backend, memory and the
orchestrator — stay in their own repos and are compiled in. The host itself is
gateway-agnostic: it imports zero dctl (the concrete Discord client) and
drives chat platforms only through the contracts Gateway port — an invariant
guarded by purity tests (TestHostPurity, TestCorePurity).
Built with hexagonal architecture: a narrow contract package in the middle, interchangeable edges (the channel, the model), an agnostic domain, and a host that bolts them together. Swapping Discord for Slack, or Claude for another model, is a one-file wiring change — never a domain rewrite.
- The mental model
- Architecture at a glance
- The plugin model — four categories
- The members
- How a message flows
- The two run modes
- Session lifecycle
- Durable agents
- Installation
- CLI reference
- Managing plugins
- Layout & wiring
- Configuration
- Roadmap
Herrscher has exactly one job: route. Someone says something on a channel →
the platform figures out which agent session that conversation belongs to →
forwards it to a model → posts the answer back. The domain in the middle
(core) never knows who is speaking or which model answers. It only knows
the ports declared in contracts.
Two facts hold the whole design together:
contractsis the authority. It dictates the shape every plugin must implement, and contains zero platform-specific mechanics. No "Discord", no "Claude" — just neutral ports and types.- All dependency arrows point toward
contracts. The core depends on no edge; the edges depend on no core. Only the host (the binary'smain) ever sees both concrete types at once, in a single wiring file.
flowchart TB
subgraph plugins_in["INBOUND EDGE · channel plugins"]
GW["🔌 <b>Gateway</b><br/>herrscher-discord-gateway<br/><i>Discord ⇄ ports</i>"]
end
subgraph plugins_out["OUTBOUND EDGE · model plugins"]
BE["🧠 <b>Backend</b><br/>herrscher-claude-backend<br/><i>Claude ⇄ ports</i>"]
end
subgraph plugins_aux["AUX EDGES · recall + policy"]
MEM["🗄️ <b>Memory</b><br/>herrscher-obsidian-memory<br/><i>recall / persistence</i>"]
ORCH["🪢 <b>Orchestrator</b><br/>herrscher-orchestrator<br/><i>conversation policy</i>"]
end
CONTRACTS{{"<b>contracts</b><br/>ports + neutral types<br/>zero deps · zero logic"}}
CORE["<b>core</b><br/>the agnostic domain<br/>sessions · worktrees · supervision"]
HOST["<b>herrscher</b><br/>the only main()<br/>composition root + daemon"]
GW -- implements --> CONTRACTS
BE -- implements --> CONTRACTS
MEM -- implements --> CONTRACTS
ORCH -- implements --> CONTRACTS
CORE -- consumes --> CONTRACTS
HOST -- wires --> CORE
HOST -- "blank-imports + rebuilds" --> GW
HOST -- "blank-imports + rebuilds" --> BE
HOST -- "blank-imports + rebuilds" --> MEM
HOST -- "blank-imports + rebuilds" --> ORCH
classDef hub fill:#1f2937,stroke:#f59e0b,stroke-width:3px,color:#fff;
class CONTRACTS hub;
The golden rule is the arrows above: everything points in toward
contracts. That is what makes the edges swappable and the domain stable.
Neither the host nor core ever imports a concrete adapter: there is no dctl
anywhere in this module — it lives only in the Discord gateway plugin. The host
talks to every chat platform through the contracts Gateway port, and that
invariant is enforced by TestHostPurity (root) and TestCorePurity (core/),
which fail the build if a concrete client ever leaks in.
Plugins are compiled into the single binary (the xcaddy pattern): you add a
blank import and rebuild — no dynamic loading. Each plugin self-registers into the
global contracts.Default registry from its init(), before any token or runtime
config exists. The host then asks the registry for what it needs at startup and
instantiates it with live config. They run in-process by default; a category
can opt into running as a separate process over the NATS/gRPC transport (see
Roadmap) with no plugin code change.
contracts declares four plugin categories:
| Category | Edge | Port(s) | Status | Official plugin |
|---|---|---|---|---|
| 🔌 Gateway | channel (inbound) | Gateway, ChannelSource, ChannelReader, ChannelAdmin, CommandRegistrar, Prober, MenuRouter, Responder, EventSink (smart gateways), Foreground (a gateway that owns the main thread, e.g. a TUI) |
✅ live | herrscher-discord-gateway, in-tree terminal |
| 🧠 Backend | model (outbound) | Backend (+ ChoiceAware, ChoiceInjector) |
✅ live | herrscher-claude-backend |
| 🗄️ Memory | recall / persistence | Memory |
✅ live | herrscher-obsidian-memory |
| 🪢 Orchestrator | conversation policy | Orchestrator |
✅ live | herrscher-orchestrator |
All four categories have an official plugin. Orchestrator is a
conversation-policy port; the default stack ships the published
herrscher-orchestrator module (the basic kind). Every plugin plugs in the
same way — a blank import and a rebuild, no domain change.
Gateways come in two flavours. A plain gateway exposes only the read/post ports;
for it the host posts only the final reply (chunked), with no platform-
specific dressing. A smart gateway also implements EventSink, receives the
raw turn-event stream, and renders it itself (live progress, emojis, summary).
Both shipped gateways are smart: the in-tree terminal gateway
(plugins/terminal) is a Bubbletea TUI that serve brings up in the foreground
as a first-class peer of Discord, and the Discord gateway renders its own
progress message, ⏳ acknowledgement, and summary via DCTL. Rich, platform-
specific presentation lives in the gateway — never in the host: the host only
emits abstract semantic events (turn received, reply, mid-turn reset, and an
abandoned signal when a turn ends without a reply), and each gateway decides
how to display them. The host never picks an emoji or reaction itself.
flowchart LR
A["author a plugin<br/>(implements a contracts port)"]
B["add blank import<br/>in plugins.go<br/>between the markers"]
C["go build -o herrscher ."]
D["plugin self-registers<br/>via init() → contracts.Default"]
E["host discovers it<br/>at startup"]
A --> B --> C --> D --> E
Optional ports may be nil: the host wraps the gateway in a degrading decorator
(contracts.Degrade) so a plugin that can't, say, render select-menus simply
falls back to plain text instead of crashing.
This repo holds the binary and everything agnostic that ships inside it. One
external module stays separate by design: contracts, so third-party plugins can
import the ports without pulling in the host.
| Location | Repo | Role |
|---|---|---|
core/ (this repo) |
— | The agnostic domain: sessions, channels, worktrees, supervision. |
main.go, serve.go, … (this repo) |
— | The composition root and daemon — gateway-agnostic, drives platforms only through the Gateway port. |
manage/ (this repo) |
— | The plugin-composition CLI (init / plugin / update / install). |
| external module | herrscher-contracts | The ports: interfaces + neutral types. Zero deps, zero logic. |
The edges are interchangeable plugins, each its own repo, not part of the
binary's module — they are the official Gateway, Backend, Memory and Orchestrator
listed in the table above. dctl is not a family member either: it is the
pure, dependency-free Discord REST client (v10) the gateway plugin consumes —
no gateway socket, no CLI, just on-demand HTTP. The host never imports it (it
shows up only as an indirect dependency, pulled in transitively by the gateway
plugin).
The daemon (hub) owns all gateway I/O. Each session's driver polls every
bound gateway for input, serializes those inputs through a per-session FIFO, and
hands one input frame per turn to a pure-runner bridge over a persistent
control socket. The bridge talks only to the backend; it streams the turn's
events back over the same socket, and the hub fans them out to every bound
gateway. One turn is active at a time: the next queued input is sent only after
the current turn ends — either its terminal reply{done}, or an abandonment
(bridge disconnect or shutdown), which fans out an abstract abandoned signal so
smart gateways can finalize their live acknowledgement.
sequenceDiagram
actor U as Human
participant CH as Channel (Discord / terminal)
participant GW as Gateway plugin
participant HUB as Hub session driver (core/host)
participant BR as Bridge (pure runner)
participant BE as Backend plugin
participant M as Model (Claude)
U->>CH: types a message
HUB->>GW: Read(channel, after=lastID)
GW-->>HUB: [Message{content, author, attachments}]
Note over HUB: enqueue on the session FIFO<br/>(one active turn at a time)
HUB->>BR: input frame (over control socket)
BR->>BE: Respond(Prompt, onEvent)
BE->>M: stream prompt
loop streaming
M-->>BE: tool calls · text · cost
BE-->>BR: onEvent(BackendEvent)
BR-->>HUB: chunk / status events
HUB->>GW: fan out to every bound gateway<br/>(smart gateway renders itself; plain gateway gets the final reply only)
end
M-->>BE: final answer (+ total cost)
BE-->>BR: output string
BR-->>HUB: reply{Done:true, Cost}
HUB->>GW: deliver reply event<br/>(smart: rendered in-gateway; plain: posted, split to 2000)
GW->>CH: shows the reply
Note over HUB: turn ends → next FIFO input may start
A smart gateway (both Discord and the terminal) implements EventSink and
renders the live event stream itself — progress, emojis, and a cost summary. For
a plain gateway the host only posts the final reply, chunked; it adds no
platform-specific presentation, keeping the host gateway-agnostic.
If the model hits a permission prompt mid-turn, the backend exposes a
PendingChoice; when the MenuRouter capability is present, the gateway posts a
select menu keyed to the session, the hub routes the click to the session's
FIFO (Pick), and the bridge injects the choice into the live session
(InjectChoice) out-of-band. Otherwise it degrades to a plain-text prompt.
The same binary runs in two shapes. serve is the always-on daemon (the
multi-gateway hub, host.RunHub) you install as a service; it owns all
gateway I/O and supervises one pure-runner bridge child process per
session. When serve runs on an interactive TTY, it also runs the one bound
gateway that implements the Foreground capability on the main thread — the
in-tree terminal gateway by default, an in-process Bubbletea TUI that is a
first-class gateway peer of Discord (quitting it stops the daemon). serve never
imports a concrete frontend: any plugin can implement Foreground and replace
the default. A background service (no TTY) runs headless, hub-driven only.
Command surface (current): operator commands run through a neutral
contracts.Cmdregistry, reachable two ways. The operator CLI (herrscher session create|close|list|who,herrscher service restart|update,herrscher set home|source) dispatches them directly. The Discord gateway binds the same commands as slash commands: it translates each interaction into a neutral argv and dispatches it through thecontracts.SessionControlseam, so the core never learns the Discord command surface. All slash handling (including the/allowpermission lists) lives in the gateway plugin; the daemon only ever sees neutral argv.
flowchart TB
START(["herrscher serve"]) --> INIT["load state.json · seed allowlist<br/>build supervisor + command registry<br/>build the multi-gateway hub (host.RunHub)"]
INIT --> HEALTH["start /health endpoint<br/>+ ping loop (30s) + status embed (60s)"]
INIT --> BIND["bind each gateway · BindSessionControl(hub)<br/>(Discord registers its slash tree itself)"]
INIT --> LIVE["bring persisted sessions live<br/>(control socket + RunSession loop each)"]
BIND -->|neutral argv| DISPATCH["hub.Dispatch(argv) — serialized<br/>reg.Dispatch + reconcile live set"]
DISPATCH --> CMDS["session create·close·list·who<br/>set home·source · service restart·update"]
CMDS -->|session create| SUP["supervisor.Start(session)"]
SUP --> CHILD["spawn child:<br/>herrscher bridge -c CHANNEL --cmd ...<br/>(in the session worktree)"]
CHILD -->|exits| RESTART["restart w/ backoff+jitter"] --> CHILD
flowchart TB
P(["herrscher bridge -c CHANNEL --hub-socket …"]) --> DIAL["Dial the hub control socket"]
DIAL --> WAIT{"read next frame from the hub"}
WAIT -->|input| RESP["backend.Respond(prompt, onEvent)"]
WAIT -->|pick| INJ["backend.InjectChoice(value)"]
RESP --> EV["onEvent → emit chunk / status events over the socket"]
EV --> DONE["emit reply{Done:true}"]
INJ --> DONE
DONE --> WAIT
WAIT -->|socket closed| EXIT["exit → supervisor restarts + reconnects"]
The bridge does no gateway I/O: it never reads a channel, posts, or reacts.
When serve runs on an interactive TTY, the in-tree terminal gateway (a
Bubbletea TUI) takes over the foreground and brings multiple sessions online as
a tabbed interface; any configured Discord gateway keeps running headless in the
background. Each session bound to the terminal appears as a tab; use Tab and
Shift+Tab to switch between them. When a background tab receives output from
the model, it displays a bullet marker (•) to indicate unread activity; switching
to that tab clears the marker.
Session management. Create, list, and close sessions directly from the TUI
using slash commands — no Discord required. Type /session create --name foo to
spin up a new session; it appears immediately as a new tab. Use /session list
to see active sessions, and /session close --name foo to shut one down and remove
its tab. Sessions are bound to the terminal and isolated by name; typing routes your
input to the active session and streams replies into its pane.
It is a stateless backend runner driven entirely by the hub's input frames. The hub owns the FIFO and connection lifecycle, so if the bridge crashes the supervisor restarts it and it re-dials the same socket, resuming with the next queued input. Authorization and message-id tracking live in the hub, not here.
Keybindings.
| Key | Action |
|---|---|
Tab / Shift+Tab |
Switch to next/previous session tab |
Enter |
Send the typed line to the active session (lines starting with / are run as commands) |
Ctrl+W then y |
Close the active session (any other key cancels) |
? (empty input) |
Toggle keybinding help overlay |
PgUp / PgDn |
Scroll the active tab's transcript |
Ctrl+C / Esc |
Quit the TUI (shuts down the daemon) |
Tab markers: • = unread background activity, ⟳ = streaming. Footer shows active session state (working/idle/disconnected) and last turn cost.
A session is the unit of work: a channel + an agent + (optionally) an isolated git worktree, supervised by a long-lived bridge.
stateDiagram-v2
[*] --> Created: /session create name:x
Created --> Worktree: branch session/x + isolated tree<br/>(unless shared:true)
Worktree --> Channel: CreateUnder(home) or ForumPost(home)
Channel --> Running: supervisor spawns the bridge child
Running --> Running: restart w/ backoff+jitter if the bridge exits
Running --> Closed: /session close name:x
Closed --> [*]: stop bridge · remove worktree · archive channel
project:picks an existing repo from your workspace;clone:forges one first (gh/glab).shared:trueskips the worktree and runs in the main checkout./session closerefuses to delete a worktree with uncommitted work unless you passforce:true./session allowand/allowgate who may drive a session; everyone else is observed (journaled for/session who) but never executes.agent:(CLI--agent NAME) provisions the session from a durable companion agent: it materializes that agent's persona and config into the session worktree before the channel is created. This requires an isolated git worktree — it is rejected withshared:trueor a non-git project. The session records which agent it was provisioned from (see Durable agents).
A durable agent is a persistent companion that outlives any single session. Where a session is disposable (its worktree is removed on close), an agent's home is a long-lived directory holding its identity and provisioning files, so the same persona can be dropped into a fresh worktree again and again.
An agent home lives at <stateDir>/agents/<name>/, where <stateDir> is
$DCTL_STATE_DIR if set, else ~/.config/dctl (the directory holding
state.json). Each home seeds three source files:
| File in the home | Purpose |
|---|---|
SOUL.md |
the agent's persona (a default companion persona if none is given) |
mcp.json |
an optional stdio MCP server declaration |
settings.json |
zero-prompt Claude settings (auto-enable project MCP servers, acceptEdits, an allow-list) |
Create and list agents from the operator binary or through a gateway (the Discord
gateway binds the same verbs as /agent create / /agent list):
herrscher agent create --name <name> [--soul '<persona text>'] [--mcp 'neublox serve --project {{WORKTREE}}']
herrscher agent list
soul: seeds the persona; mcp: is a stdio MCP server command line whose first
token is both the server name and its command. The literal {{WORKTREE}} is
substituted with the session's worktree path at provision time.
Provisioning into a session. session create … --agent NAME (or agent:NAME
on Discord) materializes the agent into the new session's worktree as the files
Claude Code auto-reads from its working directory:
<home>/SOUL.md → <worktree>/.claude/CLAUDE.md
<home>/mcp.json → <worktree>/.mcp.json
<home>/settings.json → <worktree>/.claude/settings.json
Because the materialized files live inside the disposable worktree, an agent
companion always needs an isolated git worktree: --agent is rejected with
shared:true or a non-git project.
Durable memory is an Obsidian vault the memory plugin auto-provisions — no
manual setup and no required env var. On first use it creates the vault directory
and a minimal .obsidian/ app config, so the folder opens directly as an Obsidian
vault; existing config is never overwritten. By default it lives at
~/.herrscher/memory — outside any session worktree, so it survives worktree
teardown — and the optional OBSIDIAN_VAULT env var overrides the location.
Memory stays optional: if no memory plugin is compiled in, the bridge just runs
transcript-only.
A session carries a project and (optionally) an agent, and the supervisor
threads both to the bridge as --project / --agent. The orchestrator turns them
into a memory scope (P1): the project is the shared root — durable memory every
agent of that game recalls (decisions, conventions, the studio tree) — and the
agent is the private root for that companion's learned skills. Each turn the
orchestrator prepends the shared project memory and this agent's private skills
before the rolling session transcript.
Both scope roots (projects/<project>/, agents/<agent>/) are created in the
vault at session start, so recall is well-defined from the first turn even before
anything has been written. Both are optional and backward-compatible: with no
project the bridge omits the flags and the orchestrator falls back to
transcript-only continuity. The policy
itself lives in herrscher-contracts
(MemoryScope) and is applied by
herrscher-orchestrator.
The scope above is also the write axis. By default an agent only recalls
durable memory; turning on learning lets it grow that memory by
consolidating a session's work into new scoped nodes. Learning is opt-in and
off by default — with no extractor configured the orchestrator stays the plain
Curator and a deployment is byte-for-byte unchanged.
Enable it per session at session create (the supervisor forwards each as a
bridge flag, which the bridge threads into the orchestrator config):
session create param |
bridge flag | orchestrator config key | meaning |
|---|---|---|---|
extractor |
--extractor NAME |
memory.extractor |
names a registered curation extractor — the switch that turns learning on |
journal |
--journal PATH |
memory.journal |
the call journal Consolidate reads (worktree-relative ok) |
consolidate_every |
--consolidate-every N |
memory.consolidate-every |
run Consolidate every N turns (0 = manual only) |
With an extractor named, the orchestrator becomes a Learner: every
consolidate-every turns it runs the extractor over the journal and persists
each candidate under the right scope — facts under the shared project root
(projects/<project>, via RecordShared) and skills under the private agent
root (agents/<agent>, via RecordPrivate).
No default extractor ships. The heuristics that decide what is worth
remembering are the closed part of the moat; this repo wires only the seam, so
an empty or unknown extractor name fails open to the plain Curator (no
learning) rather than erroring. A concrete extractor is plugged in by blank
import.
Policy:
- Multi-writer. Shared project memory is written by every agent of the game — an agent fleet co-curates one project graph.
- Collision → shared wins. If the same key is both shared and private,
scoped recall surfaces the shared copy and drops the private duplicate
(
mergeSubgraphs, in herrscher-contracts). - Idempotent.
Consolidatere-runs every N turns over the same journal, but a per-sessionseenset skips already-persisted keys — so re-running adds no duplicate facts/skills, and is a no-op when nothing new is extracted.
- Go 1.25+
- A Discord bot token (the default Gateway is Discord)
# a fresh clone builds on its own — go.mod points at published, tagged
# modules from github.com/Herrscherd, so the proxy resolves the plugins.
git clone https://github.com/Herrscherd/herrscher.git(For cross-repo development against local checkouts, see Layout &
wiring — use a go.work workspace, not replace directives.)
cd herrscher
go build -o herrscher . # the only binary; plugins are compiled inThe repo ships a PKGBUILD, so on Arch-based systems you can build and install
under pacman management:
makepkg -si # builds and installs /usr/bin/herrscherdThe packaged binary is named herrscherd.
export DISCORD_BOT_TOKEN=... # required
export DCTL_OWNER_ID=... # optional: seeds the allowlist with you
./herrscher serve --health-addr :8787GET /health returns JSON liveness plus a metrics object — runtime counters
(turns started/completed/abandoned, bridge restarts, remote resolve
attempts/failures) and remote-call latency (count, p50/p95). The 60s status embed
carries the turn and restart counts too.
Then in Discord: /set home #your-category, /session create name:hello, and
start talking in the session channel.
herrscher service install writes a native service for your OS and a 0600
secrets template — it never bakes the token into the unit file.
./herrscher service install \
--cmd "claude --model claude-opus-4-8 --effort low" \
--health-addr :8787| OS | What it creates |
|---|---|
| Linux | systemd user unit ~/.config/systemd/user/dctl.service (Restart=always), enables it, runs loginctl enable-linger so it survives logout |
| macOS | launchd LaunchAgent ~/Library/LaunchAgents/com.vskstudio.dctl.plist (RunAtLoad, KeepAlive) |
| Windows | a Task Scheduler task dctl (on-logon trigger) wrapping herrscher serve |
It also scaffolds (never clobbering existing files):
~/.config/dctl/dctl.env— the secrets file the service sources (DISCORD_BOT_TOKEN=,DISCORD_CHANNEL_ID=,DCTL_OWNER_ID=)~/.config/dctl/config.json— the config template (see Configuration)
Then fill the token and (re)start:
$EDITOR ~/.config/dctl/dctl.env # set DISCORD_BOT_TOKEN
./herrscher service restart
./herrscher service statuscd herrscher
./herrscher service update # git pull --ff-only, rebuild the installed binary, restart
./herrscher service update --no-pull # rebuild from local source onlyservice update rebuilds the installed binary (not the one you invoked) and
schedules the restart out-of-band (on Linux via systemd-run), so it survives the
daemon being killed mid-restart.
./herrscher service uninstall # disable + remove the unit (leaves your config/secrets)herrscher <command>. Output is deliberately minimal (ids and one-line
messages) so an agent reading stdout spends few tokens. The host exposes no raw
channel verbs of its own — all chat I/O goes through the active gateway plugin's
Gateway port; the low-level Discord poking lives in dctl, consumed by the
gateway plugin alone.
| Command | What it does |
|---|---|
serve [--config PATH] [--state FILE] [--health-addr ADDR] [--status-channel ID] [--env-file PATH] [--instance SLUG] [--cmd '…'] |
The always-on Gateway daemon: per-session bridge supervision, health endpoint. |
bridge -c CHANNEL --hub-socket SOCK [--cmd '…'] [--backend stream|oneshot] [--model M] [--session N] [--project P] [--agent A] … |
One pure-runner backend over the daemon's control socket. Normally spawned by serve. --project/--agent set the memory scope (the supervisor threads them from the session's own project/agent). |
session <create|close|list|who> [--name N] [--project P] [--clone R] [--cmd '…'] [--backend stream|oneshot] [--shared] [--agent NAME] [--force] |
Manage sessions: create a bridged channel + worktree + backend (optionally provisioned from a durable agent), close one, or list/inspect active ones. |
agent <create|list> [--name N] [--soul '…'] [--mcp '…'] |
Manage durable companion agents: a home with persona + MCP + zero-prompt settings, materialized into a session worktree via session create --agent. |
service <install|uninstall|status|restart|update> [--cmd '…'] [--health-addr ADDR] [--env-file PATH] [--source DIR] [--no-pull] |
Manage the daemon as a native OS service (see Installation). |
The host self-management verbs — init, plugin, update, install — compose
and maintain the compiled-in plugin set; see Managing
plugins.
Environment: the active gateway plugin declares its own required vars (the
Discord gateway needs DISCORD_BOT_TOKEN); the host resolves them generically.
Common ones: DISCORD_BOT_TOKEN, DISCORD_CHANNEL_ID (default channel),
DCTL_OWNER_ID (seed allowlist), DCTL_STATE_DIR (state dir, default
~/.config/dctl), DCTL_INSTANCE_ID (namespace slug for shared resources),
OBSIDIAN_VAULT (memory vault path, optional — auto-provisioned at
~/.herrscher/memory if unset). All
of these can be supplied via the root .env (see Configuration).
Operator logging: HERRSCHER_LOG sets the structured (log/slog) level on stderr
— debug|info|warn|error, default info; the bridge's -v flag forces debug.
Remote transport (opt-in, see Roadmap): HERRSCHER_REMOTE —
comma-separated categories to run out-of-process (memory, orchestrator,
backend; unset ⇒ all in-process) — and HERRSCHER_NATS — the NATS URL (default
nats://127.0.0.1:4222). For multi-machine, set the plugin-host bind with
HERRSCHER_BIND (default 127.0.0.1:0) and turn on mTLS with HERRSCHER_TLS_CA,
HERRSCHER_TLS_CERT, HERRSCHER_TLS_KEY (all three or none — a partial set fails
closed). With TLS unset the transport stays plaintext loopback, exactly as a
single-host deployment.
The same binary manages its own plugin composition (the manage/ package). These
verbs do not run the runtime; they edit plugins.go (the blank-import list
between the herrscher:plugins / herrscher:end markers) and rebuild.
herrscher init builds a fresh plugin stack from a built-in catalog of
published modules, picking one kind per category (gateway / backend / memory /
orchestrator). With no flags it writes the batteries-included default stack:
discord + claude + obsidian + orchestrator (basic). It rewrites
plugins.go to exactly that set, seeds a .env from .env.example (never
clobbering an existing one), then go gets each module, go mod tidys and
rebuilds. If the build fails it restores the original plugins.go.
Interactive wizard. Run on a terminal with no stack flags, herrscher init
prompts for each category (pick a kind or none) then reads the active gateway's
secrets — the Discord bot token is read with echo disabled, so it never
appears on screen — and upserts them into .env (existing keys preserved). Pass
--yes, any stack flag, or run non-interactively (a service/CI pipe) to skip the
wizard and take the flags/defaults silently.
When init finds no source checkout (the common case for a pacman-installed
binary, whose plugins are already compiled in) it runs in config-only mode:
it skips the plugin menu and the rebuild, collecting only the gateway secrets and
writing them to $HERRSCHER_ENV_FILE (else ./.env), then points you at
herrscherd serve.
herrscher init # interactive wizard on a tty, else default stack
herrscher init --yes # the default stack, no prompts, then build
herrscher init --list # print the module catalog and exit
herrscher init --memory none --orchestrator none # drop a category ("none"), no wizard
herrscher init --backend claude --with github.com/acme/extra-plugin # pin an extra module
herrscher init --no-build # rewrite plugins.go + seed .env onlyFlags: --gateway/--backend/--memory/--orchestrator K choose the kind for a
category (or none to drop it), --with MODULE pins an extra module path
verbatim (repeatable), --list prints the catalog, --no-build stops after
writing plugins.go, --yes skips the wizard.
herrscher plugin list # compiled-in plugins
herrscher plugin add github.com/acme/slack-gateway # blank-import, go get, tidy, build
herrscher plugin remove github.com/acme/slack-gateway
herrscher update # go get -u every plugin, tidy, rebuild
herrscher install -- --health-addr :8787 # build the host, then delegate to `service install`herrscher update refreshes the plugin modules (the counterpart to
herrscher service update, which pulls the host's own source). herrscher install builds the binary then forwards everything after -- to herrscher service install — it never reimplements the systemd/launchd glue.
The binary is one Go module (core/, manage/, and the root main package).
The contracts module and the edge plugins are separate modules. There are no
replace directives: the committed go.mod references published, tagged
modules from github.com/Herrscherd, so a fresh clone builds straight from the
module proxy with nothing checked out beside it.
For local multi-repo development you stitch the siblings together with a go.work
workspace (untracked) instead of replaces:
herrscher-dev/
├── go.work ← `go work use ./herrscher ./herrscher-contracts …` (local only)
├── herrscher/ ← the only main(); core + manage + plugins.go (this repo)
│ ├── core/ ← the agnostic domain
│ └── manage/ ← the plugin-composition CLI
├── herrscher-contracts/ ← the ports (separate module)
├── herrscher-discord-gateway/ ← Gateway plugin (separate module)
├── herrscher-claude-backend/ ← Backend plugin (separate module)
├── herrscher-obsidian-memory/ ← Memory plugin (separate module)
├── herrscher-orchestrator/ ← Orchestrator plugin (separate module)
└── dctl/ ← the gateway plugin's Discord REST client
The workspace redirects the published module paths to the local checkouts while
you hack across repos; remove it (or GOWORK=off) to build against the tagged
releases exactly as a fresh clone would.
Precedence, highest first: CLI flag → environment → config.json → built-in
default. The service sources secrets from a separate 0600 env file so they
never live in the unit.
At startup the host auto-loads a project-root .env so every command — and
every plugin's config resolution — sees its vars without an explicit
--env-file. A real environment variable always wins over the file. The path is
overridable with $HERRSCHER_ENV_FILE; it is resolved to an absolute path and
re-exported, so each bridge subprocess (which runs with its working directory
set to a per-session worktree) loads the same file rather than a stray .env
sitting in that tree. The implicit ./.env is best-effort (a missing file is not
an error), while an explicit $HERRSCHER_ENV_FILE is authoritative — a load
failure there is fatal. A .env.example ships as a fill-in skeleton; herrscher init seeds .env from it. Which keys are required depends on the active gateway
plugin (the Discord gateway needs DISCORD_BOT_TOKEN).
~/.config/dctl/config.json:
{
"cmd": "claude --model claude-opus-4-8 --effort low",
"healthAddr": ":8787",
"statusChannel": "",
"instance": "",
"owner": "",
"home": { "id": "category_or_forum_id", "type": "category" },
"workspace": "/path/to/projects",
"source": "/path/to/herrscher/checkout"
}cmd— the base bridged command for new sessions (a per-sessioncmd:overrides it).home— where/session createputs channels (a category or a forum), set via/set home.workspace— root scanned forproject:(the sub-dir a session's backend works on).source— checkout/service updaterebuilds from.
- More catalog kinds — additional gateway/backend/memory/orchestrator modules
in the
herrscher initcatalog beyond the current defaults. - Distributed transport — shipped: any of memory, orchestrator, and
the streaming backend can run as a separate process over NATS (discovery) +
gRPC (port calls), opt-in per category via
HERRSCHER_REMOTE; in-process stays the default. The wire lives in the separateherrscher-transportmodule, and remote mode needs a NATS server at$HERRSCHER_NATS. Remote resolves are bounded by a per-attempt timeout and retried on backoff within a total deadline (in-process stays immediate). The backend streams its turn events; a lost stream abandons the in-flight turn cleanly and the next input proceeds. Multi-machine is a config flip: setHERRSCHER_BINDoff loopback and supply an mTLS CA/cert/key viaHERRSCHER_TLS_*(fail-closed on a partial set) — no contract change.
The platform grew out of a Go monolith (dctl) that bridged Discord to a local
Claude. Herrscher is that monolith decomposed along its natural seams — channel,
model, domain — so each can evolve independently. The contract shapes were chosen
deliberately to make the eventual transport change (in-process → NATS/gRPC) a
detail, not a rewrite.