An embedded, Phoenix-native customer support automation layer for Elixir applications.
Cairnloop turns support conversations into answers, product signals, knowledge-base improvements, and safe automated actions—directly inside your existing monolith. Deflect what can be deflected, draft what cannot, and escalate risks cleanly.
Want the practical library-user view? Read Cairnloop, From a Phoenix SaaS Builder's Perspective.
⚡️ Why Cairnloop?
- SaaS in a Box: Don't build a brittle syncing layer to external CRMs. Embed the support widget directly into your LiveView app.
- Strict Decoupling: Emits Elixir native
:telemetryevents for observability without blocking the request. - Safe Automation: Human-in-the-loop (HITL) by default. The AI drafts; your operators approve.
- Customer-Led Growth: Capture sentiment (CSAT/CES) frictionlessly at the exact moment of resolution.
🏗️ Architecture at a Glance
graph TD
User[End User Widget] -->|WebSockets| Ingress[Phoenix Channels]
Email[Inbound Email] -->|Webhook| Ingress
Ingress --> Conv[(Conversations / Ecto)]
Conv -->|Oban Async| Draft[AI Drafting Engine]
Draft -. Pending Approval .-> Operator[LiveView Dashboard]
Operator -->|Approve/Send| Conv
Conv -->|Resolved| Telemetry[Telemetry & Oban Callbacks]
Telemetry -. CSAT / Growth Actions .-> Host[Host Application]🚀 Installation
If available in Hex, the package can be installed by adding cairnloop to your list of dependencies in mix.exs:
def deps do
[
{:cairnloop, "~> 0.1.0"}
]
endDocumentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/cairnloop.
🔌 Host Integration: Wiring It Up
Cairnloop explicitly separates Observability (metrics, tracing) from Business Logic (side-effects, CRM sync, state changes). When a conversation is resolved, Cairnloop exposes two integration points for host applications.
1. Observability & Domain Events (Telemetry)
Cairnloop uses a Dual Emission architecture via :telemetry to separate performance tracing from domain business logic. This ensures non-blocking execution while providing rich extensibility.
A. Tracing Spans (Performance & APMs)
Use the span lifecycle events (:start, :stop, :exception) to capture execution metrics. This is ideal for exporting to APMs (DataDog, Prometheus) or logging function execution duration.
:telemetry.attach(
"cairnloop-apm-tracker",
[:cairnloop, :conversation, :resolve, :stop],
fn _event, measurements, metadata, _config ->
require Logger
# Execution time is available in `measurements.duration`
Logger.info("Resolve function took #{System.convert_time_unit(measurements.duration, :native, :millisecond)}ms")
end,
nil
)B. Domain Events (Business Logic & Extensibility)
Use past-tense domain events to hook into successful business actions. This is the recommended approach for reacting to support lifecycle changes, such as triggering an in-app "App Store Rating" prompt when an issue is successfully resolved.
:telemetry.attach(
"cairnloop-domain-hooks",
[:cairnloop, :conversation, :resolved],
fn _event, measurements, metadata, _config ->
require Logger
# metadata.conversation contains the fully updated Ecto struct
conversation = metadata.conversation
Logger.info("Conversation #{conversation.id} resolved by #{metadata.actor.id} in #{measurements.duration_seconds}s at #{conversation.resolved_at}")
# Example: Broadcast to LiveView to show a CSAT modal or App Store prompt
# Phoenix.PubSub.broadcast(MyApp.PubSub, "user_sessions:#{metadata.host_user_id}", :support_issue_resolved)
end,
nil
)2. Business Logic (Notifier Behaviour)
For critical side-effects like syncing with a CRM or sending an email, use the Cairnloop.Notifier behaviour. These are executed asynchronously via Oban, ensuring data consistency and reliable retries.
The easiest way to get started is to use the included generator:
mix cairnloop.gen.notifier
This will automatically scaffold a Notifier module in your host application and inject the required configuration.
Alternatively, you can implement it manually:
defmodule MyApp.CairnloopNotifier do
@behaviour Cairnloop.Notifier
@impl true
def on_conversation_resolved(conversation, metadata) do
actor = metadata[:actor]
# Safely trigger a background job
%{conversation_id: conversation.id, resolved_by_id: actor && actor.id}
|> MyApp.Workers.SyncCRMSyncJob.new()
|> Oban.insert()
:ok
end
@impl true
def on_sla_breach(conversation, sla, _metadata) do
# Handle SLA breach notifications
:ok
end
endThen configure it in your config/config.exs:
config :cairnloop, :notifier, MyApp.CairnloopNotifier