cambium ▸ docs/superpowers/specs/2026-04-11-cambium-spore-and-discord-routing-design.md
updated 2026-04-12

Cambium Spore + Discord Routing — Design

Date: 2026-04-11 Status: Approved for implementation planning New package: @cambium/spore Touches: @cambium/cortex, deployments/obadiah/bridge/start-rhythms.ts, existing packages/integrations/src/discord/

Purpose

Two tightly-coupled concerns that earn one spec:

  1. Deployment identity as config, not code. Cambium is being productized. Every deployment has a name, an owner, paths, and an identity — and none of those values should be literal strings baked into packages/* or entry-point scripts. Introduce spore.yaml at the root of each deployment as the single declarative source of truth for who this deployment is and where its things live.

  2. Grow Obadiah’s brain a new organ. Right now start-rhythms.ts is the Obadiah brain process, and it only hosts the rhythms tick loop. Add Discord ingestion and a persona router so the same process also listens to Discord, matches messages against persona domain_triggers from the existing agents.yaml, and logs the routing decision. No cortex invocation yet — v1 only gets the wiring live and observable. Spawning an agent in response to a routed message is the next spec.

The first concrete goal: after deployment, a Discord message sent to Joshua’s channel produces a log line in ~/Library/Logs/cambium-obadiah-rhythms.out that reads something like [router] matched Arnold (health): triggers [sleep] → discord.message_created sig-abc123. No agent spawned, no reply sent, but the route is chosen and observable.

Framing: One body, many organs. Configuration is DNA, not code.

Organisms vs. microservices. A bear doesn’t run separate processes for hearing, digestion, and navigation — it has one body where organs share the same bloodstream (signal bus) and coordinate through it. Obadiah’s brain is one process. Rhythms was the first organ; Discord ingestion and the persona router are organs two and three. Future organs (memory receptors, compost watchers, future adapters) join the same process and register on the same shared SignalBus. If the brain crashes, launchd restarts the whole organism together.

Spore as DNA. A spore carries the full identity of a potential organism in a self-contained, portable package — you can send a spore anywhere and it grows into an instance of the same species with the same characteristics. spore.yaml is that package for a Cambium deployment. It holds the workspace ID, the owner, the paths to memory and personas, the launchd label prefix, and pointers to every other deployment-local resource. Everything the brain needs to know about which deployment it is comes from the spore. Every organ reads from the spore at boot, not from hardcoded literals.

The non-negotiable productization rule (see memory/feedback_deployment_parameterization.md): nothing in packages/* or generic start-*.ts knows the deployment’s name, the owner’s name, or user-specific filesystem paths. Those values flow in from the spore at boot and are passed as arguments to organs. A new customer clones deployments/obadiah/ to deployments/their-name/, rewrites their spore.yaml, and everything downstream adapts without touching any package code.

Core Concepts

Spore. A YAML file at deployments/<name>/spore.yaml holding the deployment’s identity and paths. Loaded once at boot by the brain process; every organ receives a parsed Spore object (or the subset it needs) as a constructor argument.

Brain process. The single long-running process hosting a deployment’s organs. Currently start-rhythms.ts (filename stays the same in v1 for continuity with what’s already shipped). Holds one SignalBus, one memory-watcher, one rhythms tick loop, and — after this spec — one Discord adapter and one persona router. Managed by launchd on macOS with KeepAlive.

Organ. A module registered on the brain’s signal bus at boot. Organs communicate exclusively through the bus — they don’t import each other. Examples: the rhythms tick loop is an organ; the Discord adapter is an organ; the persona router is an organ. Adding a new organ is a matter of writing its module and registering it in the brain’s bootstrap function.

Persona router. A new cortex module at packages/cortex/src/persona-router.ts that reads personas from the path named in the spore, registers one receptor per persona bound to discord.message_created, matches the message payload content against each persona’s domain_triggers (keyword match for v1), and logs the routing decision. Does NOT yet spawn agents or invoke the rest of the cortex pipeline — that’s a follow-up spec.

Domain triggers. The existing field in deployments/obadiah/agents.yaml — each persona has a list of keywords that signal its domain (e.g., Arnold: [health, fitness, workout, nutrition, sleep]). V1 router matches message content against these with simple word-boundary matching. V2 will upgrade to memory-informed LLM classification when the memory substrate is live.

Data Model

spore.yaml schema

# deployments/<name>/spore.yaml — the DNA of one Cambium deployment.
# Everything the brain process needs to know about which deployment
# this is and where its things live.

deployment:
  name: obadiah                 # becomes the workspace_id in SignalBus / marks / etc.
  owner: joshua                 # human display name; no code branches on this
  description: "Chief of staff AI for Joshua Haynes"

paths:
  # All paths are either absolute OR relative to the spore.yaml file's directory.
  # The loader resolves relative paths against the spore file's location at load time.

  # Substrate — the handwritten, long-lived, DNA-like layer. A LIST because
  # it can come from multiple places. Each entry is EITHER a directory (all
  # .md files in it are substrate) OR a single file (that specific file is
  # substrate). The loader stat-checks each entry and handles both.
  #
  # Joshua's current substrate has four entries:
  # - memory/long-term/    — personal reference notes (boundaries, decisions, ...)
  # - personal-context/    — agent-briefing files (10 numbered files)
  # - SOUL.md              — Obadiah's identity/values DNA
  # - USER.md              — operational context (daily schedule, reporting)
  #
  # All are semantically identical: handwritten, read-only to agents, changed
  # only by Joshua's hand in a molting ritual. The Memory package's Substrate
  # reader walks all entries uniformly and merges the parsed content.
  substrate:
    - /Users/joshua/Projects/Obadiah/memory/long-term
    - /Users/joshua/Projects/Obadiah/personal-context
    - /Users/joshua/Projects/Obadiah/SOUL.md
    - /Users/joshua/Projects/Obadiah/USER.md

  # Daily — the working-log layer. Single directory containing
  # YYYY-MM-DD.md files. Nightly consolidation archives to an
  # archive/ subdir inside this directory.
  daily: /Users/joshua/Projects/Obadiah/memory/daily

  personas: ./agents.yaml                          # relative; resolves to deployments/obadiah/agents.yaml
  rhythms: ./rhythms                               # directory containing rhythm template files

# Hints for the install step that templates a launchd (or systemd) file.
# The brain process doesn't actually use these at runtime — they're
# consumed by an installer script that writes the platform-specific
# launch configuration. Kept here so one file is the source of truth.
daemon:
  label_prefix: land.unicorn.cambium.obadiah      # launchd label will be "<prefix>.rhythms"
  log_dir: ~/Library/Logs                          # tilde expansion happens at install time

Spore TypeScript type

// packages/spore/src/types.ts
import type { WorkspaceId } from '@cambium/core';

export interface Spore {
  deployment: {
    name: WorkspaceId;
    owner: string;
    description: string;
  };
  paths: {
    substrate: string[];   // list of absolute directory paths, each containing DNA-like markdown
    daily: string;         // absolute path to the daily-log directory
    personas: string;      // absolute path to the personas YAML file
    rhythms: string;       // absolute path to the rhythms template directory
  };
  daemon: {
    label_prefix: string;
    log_dir: string;
  };
  /** The directory the spore was loaded from. Organs can use this
   *  as a base for resolving deployment-local files not listed in
   *  the schema. */
  source_dir: string;
}

Validation at load time: - deployment.name is non-empty, URL-safe (no slashes or spaces — it becomes a workspace ID) - paths.substrate is a non-empty array. Each entry is either a directory or a regular file, resolved to absolute, and checked with stat. A missing entry produces a warning, not an error (unless every entry is missing, which is an error). Directories and individual files may be mixed in the same list — the loader doesn’t care which, and the Memory package’s Substrate reader walks each entry appropriately (directory: read *.md children; file: read the file directly). - paths.daily, paths.personas, paths.rhythms are readable (stat check; if missing, log a warning but don’t fail unless the consuming organ actually needs them) - Relative paths (including each entry in paths.substrate) resolve against the spore file’s directory. Absolute paths are used as-is. - ~ in daemon.log_dir is NOT expanded at load time — that’s the installer’s job. - Unknown top-level keys are preserved in a passthrough field so future versions can add keys without breaking older loaders.

Persona router data flow

discord event
     │
     ▼
Discord adapter (existing)
     │ emits: discord.message_created signal with payload.content
     ▼
SignalBus (shared with rhythms, memory, etc.)
     │ broadcasts to matching receptors
     ▼
Persona router receptors (one per persona, all bound to 'discord.message_created')
     │
     ├── Arnold receptor: matches payload.content against triggers [health, sleep, ...] → match/no-match
     ├── MacKenzie receptor: matches against triggers [finance, money, ...] → match/no-match
     ├── (8 total, one per persona in agents.yaml)
     │
     ▼
For each matching receptor:
     logger.info("[router] matched <persona> (<domain>): triggers [<matched_words>] → <signal_type> <signal_id>")
     (v1 STOPS HERE — no cortex invocation)

Matching logic v1: case-insensitive word-boundary regex match. A persona matches if any of its domain_triggers appears as a whole word in the message content. Multiple personas can match the same message (e.g., “I had a stressful meeting about money and couldn’t sleep” matches MacKenzie AND Arnold). v1 logs ALL matches; v2 tiebreaks with memory-informed scoring.

Nothing is routed to a persona that doesn’t match. The router doesn’t attempt fallback, doesn’t default to a “general” persona, doesn’t LLM-classify. If no persona matches, the router logs [router] no persona matched for discord.message_created sig-abc123 and that’s it.

Package Boundaries

New package: @cambium/spore - Depends on: @cambium/core (for WorkspaceId), yaml (for parsing), node built-ins - Exports: Spore type, loadSpore(spore_file_path: string): Promise<Spore>, validation error types - Why a package, not a utility in deployments/: Spore is consumed by every deployment (obadiah, blo, kit, future users). Package is the correct sharing boundary. It’s tiny (~200 lines) but the alternative — duplicating the loader in every deployment — is worse.

Touched package: @cambium/cortex - New module: packages/cortex/src/persona-router.ts exporting createPersonaRouter({ personasPath, signalBus, logger }): Promise<void> which registers one receptor per persona on the bus. The router itself has no state beyond the receptor registrations. - Why cortex: The persona router is the entry point to the cortex pipeline — it decides which persona should take an intent before cortex decomposes it. Putting it in @cambium/cortex keeps the cortex abstraction whole. A separate @cambium/router package would fragment the concern for no benefit.

Touched: packages/integrations/src/discord/ - No code changes to the adapter itself. The brain process imports it and instantiates it with the shared bus + bot token from env.

Touched: deployments/obadiah/bridge/start-rhythms.ts - Loads spore.yaml before anything else - Passes spore-derived values to rhythms engine (replacing hardcoded WORKSPACE = 'obadiah') - Instantiates Discord adapter against the shared bus - Instantiates persona router against the shared bus - Existing rhythms functionality unchanged

Does NOT touch: - @cambium/signals — the router uses it as a consumer only - @cambium/rhythms — retroactively gets the spore values from start-rhythms.ts, but the rhythms package itself doesn’t need to know about spore (it already takes workspace as a constructor arg) - @cambium/genesis, @cambium/hypha — not called in v1 router

Coordinates with but does not touch: - @cambium/memory — memory isn’t shipped yet, but its spec (docs/superpowers/specs/2026-04-11-cambium-memory-design.md) and its pending plan (docs/superpowers/plans/2026-04-11-cambium-memory.md) both need a corresponding tweak: the Substrate layer reads from a LIST of directories (from spore.paths.substrate), not a single hardcoded memory/long-term/*.md path. The Memory spec also needs to expand its file-layout section to note that /Obadiah/personal-context/*.md is first-class substrate alongside /Obadiah/memory/long-term/*.md. Because memory implementation hasn’t started, this is a cheap spec+plan edit, not a refactor. Flagged below in Open Questions and to be applied before memory implementation kicks off.

What Ships in v1

  1. @cambium/spore package scaffolded matching the @cambium/rhythms v1 convention: package.json, tsconfig, vitest config, src/index.ts barrel
  2. Spore types at packages/spore/src/types.ts
  3. Spore loader at packages/spore/src/loader.tsloadSpore(path) that parses, validates, resolves paths, returns Spore. Includes test fixtures covering: valid minimal spore, valid spore with all fields, missing required field, non-existent path in paths, unreadable spore file
  4. Tests for the loader with fixtures in packages/spore/test/fixtures/ (valid, minimal, invalid-missing-name, invalid-bad-path)
  5. deployments/obadiah/spore.yaml — the actual spore for Obadiah, with values extracted from what’s currently hardcoded
  6. packages/cortex/src/persona-router.ts — the createPersonaRouter({...}) function that reads personas, registers per-persona receptors, logs matches
  7. Persona router tests — fake signal bus, fake personas fixture, assertion that a discord.message_created signal with content matching Arnold’s triggers produces exactly one match log; assertion that no-match produces a no-match log; assertion that multi-persona matches log all matches
  8. Refactored deployments/obadiah/bridge/start-rhythms.ts — loads spore first, passes values to rhythms engine, registers Discord adapter, registers persona router. Backward compatible behavior: rhythms still tick, heartbeat still fires, nothing regressed
  9. Integration test — start the brain process with a minimal spore, emit a fake discord.message_created signal directly onto the bus with content “I slept terribly last night”, verify the log output contains a match for the health persona
  10. Manual smoke test on Joshua’s Mac Mini — restart the launchd daemon, send a real Discord message in the Obadiah channel, verify the log shows the routing decision

Deferred (intentional)

Each item has an intentional reason and a trigger for pickup. Captured in GSD seeds/backlog separately.

Item Why deferred Trigger for pickup
Actual cortex invocation from the router v1 router logs routing decisions without spawning agents. The cortex pipeline (intent decompose → capability resolve → genesis spawn → hypha execute) has edge cases that deserve their own spec. Immediately after v1 ships and the logs show the right personas matching the right messages.
Retrofit rhythms to read from spore start-rhythms.ts in v1 will read the spore and pass values through, but the @cambium/rhythms package itself still has no concept of spore. That’s fine — it takes workspace as a constructor arg, and the entry script is where spore integration happens. A deeper retrofit (e.g. template-loader reading spore for the rhythms dir path) is followup. First time a new deployment needs to load rhythms from a non-default location.
LLM-based persona classification v1 uses keyword matching on domain_triggers, which is brittle but deterministic. Memory-informed LLM classification waits for memory to be live. Cambium Memory v1 is running in Obadiah and the recall() API is available.
Installer that templates launchd/systemd from spore v1 has a hand-written plist on Joshua’s Mac Mini with hardcoded paths. An installer that generates a platform-specific launch file from spore.yaml’s daemon: section is productization work. First time Cambium is installed on a non-Joshua machine (ties to BL-004).
Telegram adapter Telegram adapter code doesn’t exist yet. v1 scope is explicitly Discord only. User decides Telegram is a daily-driver surface worth building.
Gmail / Calendar organs Resource-heavy replication model explicitly rejected. Memory-mediated model (Claude Code’s MCP reads, writes distillations into memory, Obadiah reads from memory) is the v1 approach — no Gmail organ in the brain at all. First time a workflow inside the brain demonstrably needs live Gmail access that memory-mediated can’t provide.
Cross-process signal bus (multi-organ daemons) Explicitly rejected — one body, one process, one bus. Not planned. Revisit only if a specific organ can’t live in the main process (e.g. a heavy ML worker that must isolate).
Persona router multi-match tiebreaking V1 logs all matches; caller can decide. Tiebreaking is a V2 concern when matches actually do work downstream. When cortex invocation lands in the next spec and multi-match produces a real UX problem.

Non-Goals

Open Questions

None blocking. Three implementation details get answered during planning:

  1. Where does the Discord bot token come from? Probably process.env['DISCORD_BOT_TOKEN'] at boot. The spore doesn’t hold secrets — secrets stay in env. The plan will confirm and document the env var name.
  2. Which logger does the persona router use? The brain process already uses console.log for rhythms output. Plan will either keep console or introduce a tiny structured logger if the existing code has one. Not worth pre-deciding.
  3. Memory spec + plan update for multi-dir substrate. Before the Memory v1 plan executes, both its spec and its plan need to be updated so the Substrate layer reads from spore.paths.substrate (a list) rather than a single hardcoded directory. This is a one-paragraph spec edit and a one-task plan edit; it happens as a prerequisite to the memory implementation but doesn’t block the Spore + Discord Routing spec from shipping. Tracked here so it doesn’t drop.

Success Criteria

  1. pnpm --filter @cambium/spore test passes
  2. pnpm --filter @cambium/cortex test passes (with new persona-router tests)
  3. pnpm turbo typecheck passes across the whole monorepo
  4. pnpm turbo build passes across the whole monorepo
  5. Loader correctness: A valid spore.yaml with relative paths resolves them correctly to absolute paths. A missing required field produces a clear error citing the field name and the spore file path. A spore pointing at a non-existent personas path produces a warning at load time (organs consuming it decide whether to fail).
  6. Persona router correctness: Given a personas fixture with Arnold’s triggers [health, sleep], a signal discord.message_created with payload content "I slept terribly last night" produces exactly one router log line matching [router] matched Arnold (health). A message with content "What's the weather?" produces a no-match log. A message with content "I couldn't sleep because I was stressed about money" produces TWO match log lines — Arnold AND MacKenzie.
  7. End-to-end on Obadiah: After restarting the launchd daemon, sending a Discord message in the Obadiah channel produces a log line in ~/Library/Logs/cambium-obadiah-rhythms.out showing the router’s decision. No agent is spawned. Rhythms tick loop continues uninterrupted. Heartbeat continues.
  8. No regression: All 25 rhythms tests still pass. The existing heartbeat and morning.pulse still work. pnpm turbo test across the monorepo is still 43/43 green.
  9. No hardcoded 'obadiah' appears anywhere in packages/* code introduced by this spec. The only places “obadiah” is literal are deployments/obadiah/spore.yaml itself and the example paths in documentation.