Hex.pm HexDocs

Autonomous agent framework for Elixir — actors monitor domains, run multi-turn episodes with budget enforcement, and produce persistent findings and typed outputs.

Cyclium is an Elixir library for building agentic systems that monitor domains, run multi-turn episodes, classify situations, and produce typed outputs. Actors declare expectations — named, triggerable processes; when a trigger fires, an episode executes the expectation's strategy, which can gather data, call tools, synthesize with LLMs, and converge into findings and outputs. Think of it as an OTP-native agent framework where the episode — not the request — is the unit of work.

The developer is the router: your next_step/2 is a deterministic state machine that decides what happens next. The LLM is a tool you call at specific points via :synthesize, never the control plane — so you get repeatability, testability, and full visibility while still using AI where it adds value. See Actors & Strategies for the full philosophy.

Key features

  • Declarative Actor DSL — Define actors, expectations, triggers, and budgets in a compact macro-based syntax
  • Strategy Pattern — Pluggable investigation logic with a clear init → observe → converge lifecycle
  • Episode Runner — Budget-enforced execution loop with step journaling, checkpointing, and crash recovery
  • Findings Lifecycle — Persistent observations with raise/update/clear semantics, upsert-by-key, causality chains, TTL expiration, severity escalation, and post-raise enrichment hooks
  • Output Router — Deduplicated, adapter-based delivery (email, Slack, webhooks) with approval gates and adapter registry
  • Event Bus — Phoenix.PubSub-backed event system connecting actors without coupling
  • Workflow Engine — Multi-actor coordination with dependency graphs, failure policies, retry with backoff, cross-workflow episode dedup, and cancellation cascade
  • Circuit Breaker — Per-expectation circuit breaker with configurable thresholds, half-open recovery, and optional in-flight episode cancellation
  • Episode Sampling — Probabilistic firing control via sample_rate on expectations
  • Service Level Tracking — Declarative performance objectives with breach detection and telemetry
  • Adaptive Budgets — Advisory budget recommendations based on historical episode usage (p95 with headroom)
  • Backpressure Controls — Per-actor concurrency limits with queue, drop, or shed-oldest overflow policies
  • Debounce and Cooldown — Temporal controls to coalesce rapid-fire events and enforce minimum gaps
  • Log Projection — Materialized human-readable logs at configurable verbosity (none → full_debug)
  • Telemetry — structured telemetry events for observability, plus span-shaped background-work events (:start/:stop/:exception) that let a host application nest each self-scheduled worker's queries under a single trace
  • OTP-Native — No Oban or external job queue required; episodes run as Tasks under DynamicSupervisor
  • Distributed coordination — DB-based dedup, lease-based work claims, crash recovery, and multi-stack/trigger-only deployment modes — no Redis or leader election
  • Test Kit — Assertion macros and fakes for validating actors, strategies, synthesizers, output adapters, workflows, and checkpoint migrations in host apps
  • Database Compatibility — Transaction-based upserts, denormalized query columns, no JSON operators in DDL

Who is this for?

Cyclium is designed for Elixir teams building autonomous agent systems where:

  • Recurring evaluations run on events or schedules (SLAs, health checks, compliance checks)
  • Episodes involve multiple steps: data gathering, LLM synthesis, tool calls, human approval
  • Findings need to persist and evolve over time (raised → updated → cleared)
  • Actions need deduplication, audit trails, and typed delivery through adapters
  • Multiple actors need to coordinate through workflows with dependency ordering
  • Real-time visibility into agent state is essential (Phoenix LiveView integration via Bus)

If you need a simple cron job or a one-shot script, Cyclium is overkill. Cyclium shines when you have ongoing, stateful processes that produce findings and outputs — and need the lifecycle, audit trail, and coordination to go with them.

How Cyclium differs

  • vs. Oban — Oban is a job queue: enqueue work, run it, done. Cyclium manages stateful, multi-turn episodes with budgets, findings, outputs, and workflows. Episodes happen to run as OTP Tasks, so you don't need Oban — but the two solve different problems and can be used together.
  • vs. chat-agent frameworks — Those are built for interactive, LLM-routed conversations. Cyclium is built for autonomous operational agents that monitor domains and act, with or without an LLM in the loop, under deterministic developer control.
  • vs. raw GenServers — You could build all of this yourself, but Cyclium gives you the episode lifecycle, findings system, output router, workflow engine, and event bus — wired together with telemetry and audit trails.

Architecture

The guides use a generic resource monitoring example: a ResourceMonitor actor that evaluates a resource's usage against a limit and classifies status, plus a ResourceAdvisorActor that produces an LLM-powered summary.

Supervision tree

YourApp.Supervisor
├── YourApp.Repo
├── Phoenix.PubSub
├── YourApp.Actors.ResourceMonitor (GenServer)
├── YourApp.Actors.ResourceAdvisorActor (GenServer)
├── Cyclium.Supervisor
│   ├── Cyclium.ActorSupervisor (DynamicSupervisor)
│   ├── Cyclium.EpisodeSupervisor (DynamicSupervisor)
│   │   └── Cyclium.EpisodeTask (one per running episode)
│   ├── Cyclium.TaskSupervisor (Task.Supervisor)
│   ├── Cyclium.Reconciler (optional — spec change detection)
│   ├── Cyclium.WorkflowEngine (optional — multi-actor workflows)
│   └── Cyclium.Findings.FindingSweep (optional — expiration + escalation)
└── YourAppWeb.Endpoint

Actors are GenServers that subscribe to Bus events and manage episode lifecycle. They're started by the consuming app's supervision tree, above Cyclium's supervisors. When a trigger fires, the actor creates an Episode row and starts an EpisodeTask under the EpisodeSupervisor. Each task resolves a strategy from the registry and runs the episode loop.

Key design principles:

  • Actors own concurrency limits — they track active/queued episodes in-process
  • Episodes are durable — the cyclium_episodes table is itself a work queue
  • The Bus connects everything — actors, LiveViews, and workflows all subscribe to the same event stream
  • Strategies are stateless modules — all state lives in the episode's strategy state map

Execution model

Bus event arrives
  → Actor.handle_info matches expectation trigger
  → Check debounce/cooldown → circuit breaker → sample_rate
  → Check concurrency (active < max?)
    → yes: create Episode row, start EpisodeTask under DynamicSupervisor
    → no:  apply overflow policy (queue / drop / shed_oldest)

EpisodeTask starts
  → Resolve strategy (persistent_term registration from actor boot → registry override)
  → strategy.init(episode, trigger)
  → EpisodeRunner.execute_loop:

    ┌─────────────────────────────────────────────┐
    │  check_budget → check_loop → increment_turn │
    │  strategy.next_step(state, ctx)             │
    │    :done         → journal, set done        │
    │    :converge     → run converge pipeline    │
    │    {:tool_call}  → exec tool, handle_result │
    │    {:observe}    → journal, handle_result   │
    │    {:synthesize} → journal, handle_result   │
    │    {:checkpoint} → save state, loop         │
    │    {:approval}   → block, wait for human    │
    │    {:wait}       → block, wait for external │
    │    ...           → loop                     │
    └─────────────────────────────────────────────┘

  Converge pipeline (post_converge):
    1. Persist findings (raise/update/clear) → enrich → Bus events per finding
    2. Route outputs through adapters → dedup by dedupe_key, deliver
    3. Compute final episode status from delivery outcomes
    4. Journal completion/failure step
    5. Project log via LogProjector
    6. Record service levels + check for breach
    7. Record adaptive budget sample (if enabled)
    8. Broadcast episode.completed/failed on Bus
    9. Emit telemetry

Core concepts

ConceptWhat it is
ActorA GenServer that owns expectations and fires episodes when triggers match. → Actors & Strategies
ExpectationA named, triggerable process — binds a trigger to a strategy and budget
StrategyThe brain of an episode — a stateless init → next_step → handle_result → converge module
EpisodeOne execution of a strategy, with budget tracking, a step journal, and a status lifecycle
FindingA persistent observation with raise/update/clear semantics. → Findings & Outputs
OutputA typed proposal delivered through deduplicated adapters
BusA Phoenix.PubSub event stream connecting actors, LiveViews, and workflows
WorkflowMulti-actor coordination over a dependency graph. → Workflows

Guides

GuideCovers
Actors & StrategiesActor DSL, expectation options, strategy lifecycle, multi-turn strategies, episodes, budgets, deduplication, tools, synthesizers
Findings & OutputsFindings lifecycle, causality chains, TTL/escalation/enrichment, output router & adapters, Bus events, LiveView, Window helpers
WorkflowsCompiled and dynamic (DB-defined) workflows, data passing, failure policies
Dynamic ActorsDB-stored actor definitions, strategy templates, gatherers, lifecycle & draining
ObservabilityLog strategies, synthesis storage pipeline, telemetry, step journal, sampling, service levels, adaptive budgets
Distributed OpsCrash recovery, work claims, node identity, multi-stack deployments, trigger-only mode
AdvancedCheckpointing, circuit breaker, step retry, reconciler, dry runs, batch & per-item processing, test kit
Interactive ActorsHuman-in-the-loop episodes (approvals, waits)
Conversation UIBuilding conversational front-ends over Cyclium

Setup

1. Add dependency

# mix.exs
def deps do
  [{:cyclium, "~> 0.4.4"}]
end

Dependencies pulled in: ecto, ecto_sql, jason, phoenix_pubsub.

2. Run migrations

Each schema version is dispatched by passing its number to Cyclium.Migrations.up(version: n) / down(version: n) — no need to name the V<n> modules. For a fresh install, run every version in order via versions/0 (this list stays current as new versions ship):

# In a migration file:
defmodule MyApp.Repo.Migrations.InstallCyclium do
  use Ecto.Migration

  def up do
    Enum.each(Cyclium.Migrations.versions(), &Cyclium.Migrations.up(version: &1))
  end

  def down do
    Cyclium.Migrations.versions()
    |> Enum.reverse()
    |> Enum.each(&Cyclium.Migrations.down(version: &1))
  end
end

When upgrading an existing install, add one migration file per new version (so Ecto tracks each independently):

defmodule MyApp.Repo.Migrations.CycliumV22 do
  use Ecto.Migration
  def up, do: Cyclium.Migrations.up(version: 22)
  def down, do: Cyclium.Migrations.down(version: 22)
end

See the Interactive Actors guide for the per-version-file template.

Authoring migrations: do not use bare :text. On Ecto.Adapters.Tds it emits SQL Server's legacy non-Unicode TEXT type, which silently replaces emoji and other non-CP1252 characters with ?. Use {:string, size: :max} (which becomes nvarchar(max) on Tds and TEXT on Postgres/SQLite), or branch on repo().__adapter__() for finer control. V19 is the one-shot repair migration for columns that were already declared :text.

3. Configure

# config.exs
config :cyclium, :repo, MyApp.Repo

# Optional: registry for strategy/synthesizer overrides (see Actors & Strategies guide)
# config :cyclium, :strategy_registry, MyApp.StrategyRegistry

# Optional: episode runner (default: Cyclium.Runner.OTP)
# Use Cyclium.Runner.Deferred for trigger-only mode (see Distributed Ops guide)
config :cyclium, :runner, Cyclium.Runner.OTP

# Optional: node identity override for shared-name environments (see Distributed Ops guide)
# config :cyclium, :node_identity, "my-unique-node-name"

# Optional: tool capabilities
config :cyclium, :capability_registry, %{
  data_source: MyApp.Tools.DataSource,
  notifier: MyApp.Tools.Notifier
}

# Optional: output adapters
config :cyclium, :output_adapters, %{
  email: MyApp.Adapters.Email,
  slack: MyApp.Adapters.Slack
}

# Optional: checkpoint schema overrides for versioned state migration
# (prefer declaring `checkpoint_schema: MyModule` on the expectation itself;
# this config takes precedence when both are set)
config :cyclium, :checkpoint_schemas, %{
  {"resource_monitor", "check_resource_limits"} => MyApp.Checkpoints.ResourceCheck
}

# Optional: enable reconciler for hot spec changes
config :cyclium, :reconciler, true

# Optional: register workflows
config :cyclium, :workflows, [MyApp.Workflows.ResourceReview]

4. Declare strategies on expectations

The preferred approach is declaring the strategy module directly on each expectation in the actor DSL. Cyclium registers the mapping automatically when the actor GenServer boots — no separate registry needed:

defmodule MyApp.Actors.ResourceMonitor do
  use Cyclium.Actor

  actor do
    domain(:resource)
    spec_rev("v0.1.0")
    synthesizer(MyApp.Synthesizers.ResourceAnalysis)  # actor-level default
    max_concurrent_episodes(5)
    episode_overflow(:queue)

    expectation(:check_resource_limits,
      strategy: MyApp.Strategies.ResourceLimits,
      trigger: {:event, "resource.updated"},
      subject_key: :resource_id,
      debounce_ms: :timer.seconds(3),
      budget: %{max_turns: 3, max_tokens: 1_000, max_wall_ms: 10_000}
    )

    expectation(:resource_advisory,
      strategy: MyApp.Strategies.ResourceAdvisor,
      synthesizer: MyApp.Synthesizers.FastSummary,  # override for this expectation
      trigger: {:event, "resource.advisory_requested"},
      budget: %{max_turns: 5, max_tokens: 5_000, max_wall_ms: 60_000}
    )
  end
end

See the Actors & Strategies guide for the optional strategy registry used for environment/A-B overrides.

5. Supervision tree

# application.ex
children = [
  MyApp.Repo,
  {Phoenix.PubSub, name: MyApp.PubSub},
  {Cyclium.Supervisor, pubsub: MyApp.PubSub},
  MyApp.Actors.ResourceMonitor,
  MyApp.Actors.ResourceAdvisorActor,
  MyAppWeb.Endpoint
]

Cyclium.Supervisor starts the DynamicSupervisors, TaskSupervisor, and optionally the Reconciler and WorkflowEngine.

Database tables

All tables use binary_id primary keys (no JSON operators, application-layer upserts, denormalized columns for indexed queries).

TableMigrationPurpose
cyclium_episodesV1Episode lifecycle, budget tracking, classification
cyclium_episode_stepsV1Step-by-step journal (16 step kinds)
cyclium_episode_checkpointsV1Versioned strategy state snapshots (deleted on successful completion; kept for failed/blocked episodes so resume: true works)
cyclium_findingsV1Persistent observations with raise/update/clear lifecycle
cyclium_outputsV1Output proposals, delivery status, deduplication
cyclium_episode_logsV2Materialized human-readable logs
cyclium_workflow_instancesV3, V9Workflow execution tracking, step states, dry run mode
cyclium_work_claimsV6Lease-based distributed work coordination
cyclium_agent_definitionsV7DB-stored actor definitions for dynamic actors
cyclium_workflow_definitionsV8DB-stored workflow definitions for dynamic workflows
cyclium_trigger_requestsV14Deferred episode execution for trigger-only nodes

V4 adds archived_at to episodes and findings. V5 replaces the non-unique dedupe_key index on episodes with a filtered unique index (WHERE dedupe_key IS NOT NULL AND archived_at IS NULL) for multi-node coordination. V6 adds the cyclium_work_claims table. V7 adds cyclium_agent_definitions and mode/dry_run_opts columns to episodes. V8 adds cyclium_workflow_definitions. V10 adds caused_by_key (finding causality chains) and expires_at (TTL-based expiration) to cyclium_findings.

Development

# Install dependencies
mix deps.get

# Run tests
mix test

# Dialyzer (static analysis)
mix dialyzer

# Compile
mix compile --warnings-as-errors