Skip to content

OTP Supervision

The agent daemon uses a two-level supervisor tree for coordinated startup, shutdown, and health monitoring. Every component that needs long-lived process management declares itself via a supervised flag on its shape record.

Supervisor Tree

agent_supervisor.gleam (root)
  |
  |-- 1. Starts Logger (registered as agent_logger)
  |
  |-- 2. plugins/gateways/supervisor.gleam
  |     |-- Starts gateways with supervised: True
  |     |   (e.g., Telegram poller)
  |
  |-- 3. services/supervisor/supervisor.gleam
        |-- Starts services with supervised: True
            |-- Cron (conditional on config)
            |-- Pulse (conditional on config)
            |-- Also initializes: Harness, Notifications (utility, not supervised)

Start Order

  1. Logger starts first and registers as agent_logger, a named Erlang process accessible globally. All subsequent log calls throughout the system route through this process.

  2. Gateway supervisor starts next, before the service supervisor. This is deliberate: services like Pulse (scheduled prompts) and Cron (scheduled jobs) may produce messages at startup, and those messages need to be deliverable via gateways like Telegram. Starting gateways first ensures the delivery channel is ready.

  3. Service supervisor starts last, bringing up supervised services (cron, pulse) and initializing utility services (harness, notifications).

The Service Shape

Every built-in service conforms to the Service type, defined in core/service.gleam:

gleam
pub type Service {
  Service(
    name: String,
    supervised: Bool,
    start: fn() -> Result(Nil, String),
    stop: fn() -> Nil,
    health: fn() -> Bool,
  )
}
FieldPurpose
nameHuman-readable identifier for logging and diagnostics
supervisedIf True, the supervisor starts and monitors this service as a long-running child
startBoot function -- returns Ok(Nil) on success or Error(String) with a diagnostic message
stopTear-down function -- called during graceful shutdown
healthLightweight liveness check -- returns True if the service is operational

A service with supervised: False is a pure module. Its start is never called by the supervisor -- it exists only to satisfy the shape contract.

The Plugin Shape

Every plugin (built-in or user-provided) conforms to the Plugin type, defined in plugins/types.gleam:

gleam
pub type Plugin {
  Plugin(
    name: String,
    description: String,
    plugin_type: PluginType,
    supervised: Bool,
    start: fn() -> Result(Nil, String),
    stop: fn() -> Nil,
    health: fn() -> Bool,
  )
}

pub type PluginType {
  Tool
  Gateway
  Hook
  MemoryProvider
}

The Plugin shape extends Service with two additional fields:

Extra fieldPurpose
descriptionUser-facing summary shown in help text and plugin listings
plugin_typeDiscriminates between Tool, Gateway, Hook, and MemoryProvider

Like services, plugins self-declare whether they need supervision via the supervised flag. The gateway supervisor iterates over all registered plugins and starts only those with supervised: True.

The supervised Flag

The supervised boolean on both Service and Plugin is a self-declaration. Each component decides whether it needs OTP process management:

  • supervised: True -- The component spawns long-running work (a polling loop, a timer, a TCP listener). The supervisor starts it, monitors its health, and can restart it on failure.
  • supervised: False -- The component is a pure module. It exposes functions that are called on-demand, with no persistent process state.

Examples of supervised components:

  • Cron -- runs a polling loop that checks for due cron jobs on a timer
  • Pulse -- runs a timer loop that fires scheduled prompts
  • Telegram gateway -- runs a long-polling fetch loop for incoming messages

Examples of non-supervised components:

  • Logger -- registers a named process at startup but doesn't run a loop (started separately, outside the supervisor's child management)
  • Harness -- pure validation logic called on-demand
  • Notifications -- DND rule coordination, no persistent loop
  • Tokens -- stateless token counting functions
  • Context -- stateless system prompt assembly

SQLite Process Isolation

SQLite NIFs in the BEAM are process-specific. A connection opened in process A cannot be used from process B. This has important implications for the supervisor tree:

  • The main process (the CLI loop or gateway handler) opens a primary SQLite connection via DbConnector.open().
  • Spawned service processes (cron, pulse, titler) must open their own independent connections. They cannot share the main process's connection.
  • The reflection hook spawns a background process for memory consolidation -- it opens its own connection via DbConnector.open() so it does not share state with the conversation loop.

The DbConnector.open(db_path) function is part of the DbConnector behaviour record, passed to every component that needs database access. Each component calls open in its own process context, producing a process-local connection.

Built with Gleam on the BEAM/Erlang VM.