Cairnloop's core design philosophy is "host-owned data and logic." Instead of forcing you to sync your data into a SaaS product, Cairnloop runs inside your Phoenix application and relies on @callback behaviours to interact with your domain.
This guide outlines the primary extension points available to customize Cairnloop's behavior.
Core Behaviours
If you haven't yet, read the Host Integration guide for detailed walkthroughs of the first four core behaviours:
-
Cairnloop.ContextProvider: Injects host domain state (like user LTV, billing status, or active plan) directly into the operator workspace UI. No frontend code is required; you return a map, and Cairnloop renders it. -
Cairnloop.Notifier: Handles side effects. This is where you trigger webhooks, sync to your CRM, or send emails when a conversation resolves or an SLA breaches. -
Cairnloop.AutomationPolicy: Gatekeeps AI drafting. You decide if an AI draft requires human approval (:require_approval), is generated silently (:draft_only), is automatically sent (:allow), or is discarded (:deny). -
Cairnloop.SLAPolicyProvider: Supplies dynamic SLA rules (response time targets, breach thresholds) at runtime.
Defining Custom Tools (Cairnloop.Tool)
The most powerful way to extend Cairnloop is by writing your own operator tools. Tools are discrete, idempotent actions that can be executed by human operators, triggered by AI drafts, or invoked via external MCP clients.
To create a tool, implement the Cairnloop.Tool behaviour:
defmodule MyApp.Tools.IssueRefund do
use Cairnloop.Tool
@impl true
def spec do
%Cairnloop.Tool.Spec{
name: "issue_refund",
description: "Issues a refund to the customer's payment method.",
parameters: %{
type: "object",
properties: %{
"amount" => %{type: "number", description: "Amount to refund"},
"reason" => %{type: "string", description: "Reason for the refund"}
},
required: ["amount", "reason"]
},
risk_tier: :requires_approval # Requires human review when proposed by AI
}
end
@impl true
def run(params, context, _opts) do
# Implement your business logic here.
# Return {:ok, result_string} or {:error, reason}
case MyApp.Billing.refund(context.account_id, params["amount"]) do
:ok -> {:ok, "Refund of $#{params["amount"]} issued successfully."}
{:error, reason} -> {:error, "Failed to issue refund: #{reason}"}
end
end
endOnce defined, register your tool in your config.exs:
config :cairnloop, tools: [MyApp.Tools.IssueRefund]Cairnloop automatically handles projecting this tool to the LLM context, rendering its inputs in the UI, enforcing governance policies, and dispatching execution via background workers.
Advanced Extension Points
Cairnloop.Embedder
By default, Cairnloop relies on pgvector for its Knowledge Base embeddings. If you need to customize how text is vectorized (for instance, switching from the default OpenAI text-embedding-3-small to a local model or another provider), you can implement the Cairnloop.Embedder behaviour.
@callback embed(text :: String.t(), opts :: keyword()) :: {:ok, list(float())} | {:error, term()}Configure it in your config.exs:
config :cairnloop, :embedder, MyApp.CustomEmbedderCairnloop.Automation.DraftGenerator
Cairnloop turns a retrieval grounding bundle into an operator-reviewable reply draft through a swappable engine. The library default, Cairnloop.Automation.ScoriaEngine, is deterministic and dependency-free: it needs no API key and never calls out to a model, composing a citation-anchored reply from the canonical Knowledge Base evidence (and asking for the missing detail or recommending a handoff when grounding is weak).
To have a real model compose the customer-facing reply, configure the bundled Anthropic adapter:
# config.exs — choose the engine
config :cairnloop, :draft_generator, Cairnloop.Automation.DraftGenerator.Anthropic
# runtime.exs — read the secret at boot, never compile it in
config :cairnloop, :anthropic_api_key, System.fetch_env!("ANTHROPIC_API_KEY")Optional knobs: :anthropic_model (default "claude-sonnet-4-6") and :anthropic_max_tokens (default 1024). The API key is also read from the ANTHROPIC_API_KEY environment variable when not set in config.
The adapter is fail-closed by design. It only asks Claude to compose a reply when the grounding assessment is :strong; for :clarification/:escalation grounding, a missing API key, or any API error it transparently delegates to ScoriaEngine — so a model never guesses past the available evidence, and a draft always appears for the operator. Every draft is still human-in-the-loop: it lands as a :pending draft an operator reviews (and Cairnloop.AutomationPolicy decides whether it can be auto-sent) before anything reaches the customer.
To build your own engine (a different provider, a local model, or custom prompting), implement the one-callback behaviour:
@callback generate_draft(conversation_id :: String.t(), grounding_bundle :: map()) ::
{:ok, proposal :: map()} | {:error, term()}config :cairnloop, :draft_generator, MyApp.CustomDraftGeneratorReturn a proposal map with :proposal_type, :operator_summary, :customer_reply, :content, :evidence, :grounding_metadata, and :clarification_attempts. See Cairnloop.Automation.DraftGenerator for the full contract and trust posture.
Cairnloop.Auditor
Cairnloop maintains a rigorous audit trail of all governance decisions, tool executions, and system events. If you need to route these audit logs to an external compliance system (like Datadog, AWS CloudTrail, or an internal SIEM), implement the Cairnloop.Auditor behaviour.
@callback log_event(event_type :: atom(), payload :: map(), metadata :: map()) :: :okConfigure it in your config.exs:
config :cairnloop, :auditor, MyApp.ComplianceAuditorWhenever an operator approves a tool execution or an AI draft is generated, your auditor will receive the structured event synchronously before the action completes, ensuring your external records are always complete.