# Host Integration

Cairnloop exposes four behaviour contracts that let your host application control the
support lifecycle without giving up ownership of your data or business logic. Each behaviour
is a plain Elixir `@behaviour` module — implement the callbacks, configure the module, and
Cairnloop uses your implementation at the right moment.

This guide documents all four behaviours and their full callback sets, in the order a new
adopter would typically implement them. It also covers Cairnloop's telemetry emission points
as an observability reference.

---

## ContextProvider

**Module:** `Cairnloop.ContextProvider`

**When to implement:** Always — every operator conversation workspace pulls context from this
behaviour. Without a configured provider, the context rail renders "Context Unavailable".

`ContextProvider` is how Cairnloop achieves a zero-API-sync design. Your implementation
returns a map of host-owned facts for the given support actor. Cairnloop renders that map
recursively as categorized UI sections in the conversation workspace's right rail — no
frontend code required on your end. This is the "Zero-Config UI" feature.

**Callback:**

```elixir
@callback get_context(actor_id :: String.t(), opts :: keyword()) ::
            {:ok, map()} | {:error, term()}
```

The `actor_id` is the raw string from the Cairnloop conversation. Your implementation maps
it to your internal domain — a UUID, integer ID, email, or external identifier. The opts
keyword list is reserved for future extension and can be ignored.

Return a tagged tuple. Never raise. If your database or external service is unavailable,
return `{:error, reason}` — the dashboard degrades to "Context Unavailable" rather than
crashing the operator's session.

**Example implementation:**

```elixir
defmodule MyApp.CairnloopContext do
  @behaviour Cairnloop.ContextProvider

  @impl true
  def get_context(actor_id, _opts) do
    case MyApp.Accounts.get_user(actor_id) do
      nil ->
        {:ok, %{}}

      user ->
        {:ok, %{
          "User Details" => %{name: user.name, lifetime_value: "$#{user.ltv}"},
          "Active Plan"  => %{tier: user.plan, status: user.billing_status}
        }}
    end
  end
end
```

**Configuration** (`config/config.exs`):

```elixir
config :cairnloop, :context_provider, MyApp.CairnloopContext
```

The returned map is rendered as grouped sections in the support workspace right rail. Top-level
string keys become section headers; nested maps render as key-value pairs within that section.

---

## Notifier

**Module:** `Cairnloop.Notifier`

**When to implement:** Required when you need side effects on conversation events, and required
for the outbound delivery lane (`Outbound.trigger/2` and `bulk_trigger/2` route through
`on_outbound_triggered/2`).

`Notifier` is the business-logic integration point: CRM sync, email, webhooks, background
jobs, or any other side effect your app needs to trigger when support events occur. Cairnloop
calls these callbacks from within Oban workers — `on_conversation_resolved/2` and
`on_outbound_triggered/2` each have a dedicated Oban worker, while `on_sla_breach/3` is
called directly within the SLA countdown worker. Raising in any callback retries the
enclosing Oban job.

**Callbacks (all three — implement all of them):**

```elixir
@callback on_conversation_resolved(conversation :: struct(), metadata :: map()) ::
            :ok | any()

@callback on_sla_breach(conversation :: struct(), sla :: struct(), metadata :: map()) ::
            :ok | {:error, term()} | any()

@callback on_outbound_triggered(message :: struct(), conversation :: struct()) ::
            :ok | {:error, term()} | any()
```

**Example implementation:**

```elixir
defmodule MyApp.CairnloopNotifier do
  @behaviour Cairnloop.Notifier

  @impl true
  def on_conversation_resolved(conversation, metadata) do
    actor = metadata[:actor]

    # Enqueue a background job rather than performing the side effect inline.
    %{conversation_id: conversation.id, resolved_by_id: actor && actor.id}
    |> MyApp.Workers.CRMSyncJob.new()
    |> Oban.insert()

    :ok
  end

  @impl true
  def on_sla_breach(_conversation, _sla, _metadata) do
    # Notify your on-call channel, update a dashboard, or page an operator.
    :ok
  end

  @impl true
  def on_outbound_triggered(_message, _conversation) do
    # Route the outbound message through your delivery provider (email, SMS, etc.).
    # Return :ok on success or {:error, reason} to signal a delivery failure to Oban.
    :ok
  end
end
```

**Generator escape hatch:** Rather than writing this module by hand, run:

```bash
mix cairnloop.gen.notifier
```

This scaffolds a `MyApp.CairnloopNotifier` module with all three callbacks and automatically
injects the configuration line into your `config/config.exs`.

**Manual configuration** (`config/config.exs`):

```elixir
config :cairnloop, :notifier, MyApp.CairnloopNotifier
```

> The README's earlier examples showed only two Notifier callbacks. The full behaviour
> defines three: `on_conversation_resolved/2`, `on_sla_breach/3`, and
> `on_outbound_triggered/2`. Implement all three — missing callbacks cause a compile-time
> warning about incomplete behaviour implementation.

---

## AutomationPolicy

**Module:** `Cairnloop.AutomationPolicy`

**When to implement:** When you want to control how Cairnloop handles AI draft proposals.
Without a configured policy, the default posture is `:require_approval` — every AI draft
waits for explicit operator review before anything is sent.

`AutomationPolicy` is the governance boundary for AI drafting. Your implementation receives
a proposal map and opts, and returns one of four atoms that determine what Cairnloop does
with the AI-generated content.

**Callback:**

```elixir
@callback decide(proposal :: map(), opts :: map()) ::
            :allow | :draft_only | :require_approval | :deny
```

**Return values:**

| Atom | Meaning |
|------|---------|
| `:allow` | The draft may be sent without operator review (use with caution — bypasses HITL). |
| `:draft_only` | Cairnloop prepares the draft but does not surface an approval prompt. Operator must retrieve and act on it manually. |
| `:require_approval` | The draft requires explicit operator approval before it becomes an outgoing reply. This is the recommended default. |
| `:deny` | Cairnloop discards the draft. No AI content is presented to the operator for this proposal. |

**Example implementation (recommended — approval-gated):**

```elixir
defmodule MyApp.CairnloopPolicy do
  @behaviour Cairnloop.AutomationPolicy

  @impl true
  def decide(_proposal, _opts), do: :require_approval
  # Returns :allow | :draft_only | :require_approval | :deny
end
```

Start with `:require_approval`. This mirrors Cairnloop's "safe-by-default, not
autonomous-by-default" posture — operators review before anything reaches a customer.
Graduate to finer-grained logic (inspecting `proposal.risk_tier`, conversation tags, or
account properties) only after you have validated quality in your specific context.

**Configuration** (`config/config.exs`):

```elixir
config :cairnloop, :automation_policy, MyApp.CairnloopPolicy
```

---

## SLAPolicyProvider

**Module:** `Cairnloop.SLAPolicyProvider`

**When to implement:** When your support team has SLA commitments — response time targets,
breach thresholds, or priority tiers — that you want Cairnloop to track and enforce.

`SLAPolicyProvider` supplies Cairnloop with the active SLA rule set at runtime. Policies can
be stored in your database and retrieved dynamically, letting you change SLA terms without
redeploying.

**Callbacks:**

```elixir
@callback get_active_policies() :: {:ok, list(map())} | {:error, term()}

@callback set_policy(priority :: atom(), attrs :: map()) :: {:ok, map()} | {:error, term()}
```

**Example implementation:**

```elixir
defmodule MyApp.CairnloopSLA do
  @behaviour Cairnloop.SLAPolicyProvider

  @impl true
  def get_active_policies do
    # Return a list of SLA policy maps from your database or config.
    # Return {:ok, []} to disable SLA tracking.
    {:ok, [
      %{priority: :high, response_minutes: 60, breach_minutes: 240},
      %{priority: :normal, response_minutes: 240, breach_minutes: 1440}
    ]}
  end

  @impl true
  def set_policy(_priority, _attrs) do
    # Persist a new or updated SLA policy. Return {:ok, policy_map} on success.
    {:error, :not_implemented}
  end
end
```

**Configuration** (`config/config.exs`):

```elixir
config :cairnloop, :sla_policy_provider, MyApp.CairnloopSLA
```

When `get_active_policies/0` returns `{:ok, []}`, Cairnloop disables SLA breach tracking
rather than raising — the support workflow continues without SLA enforcement.

---

## Operations endpoints (health & metrics)

Cairnloop ships two plain plugs for infrastructure monitoring, mounted with one router
helper:

- `GET /health` → `Cairnloop.Web.HealthPlug` — liveness/readiness probe; returns
  `200` with `{"status": "ok"}`.
- `GET /metrics` → `Cairnloop.Web.MetricsPlug` — Prometheus text exposition. Returns the
  scrape when `:telemetry_metrics_prometheus_core` is running, or `501` until you add and
  start it.

**Mounting** (`MyAppWeb.Router`):

```elixir
defmodule MyAppWeb.Router do
  use MyAppWeb, :router
  require Cairnloop.Router

  # Mount outside your auth pipeline so infrastructure can reach the probes.
  scope "/" do
    Cairnloop.Router.cairnloop_operations()
  end
end
```

Override the paths if `/health` or `/metrics` collide with your own routes:

```elixir
Cairnloop.Router.cairnloop_operations(health_path: "/healthz", metrics_path: "/internal/metrics")
```

**Enabling `/metrics`:** add the optional dependency and start the reporter in your
supervision tree so the plug has metrics to scrape:

```elixir
# mix.exs
{:telemetry_metrics_prometheus_core, "~> 1.2"}
```

```elixir
# application.ex children
{TelemetryMetricsPrometheus.Core, metrics: MyApp.Telemetry.metrics()}
```

Without the reporter, `/health` still works and `/metrics` returns `501` with guidance
rather than crashing.

---

## Telemetry (observability only)

Cairnloop emits `:telemetry` events for observability. Per the project's architecture
posture: **telemetry is observability only — never a UI or display source**. Do not build
logic that reads Cairnloop telemetry to drive LiveView state; use the `Notifier` behaviour
for business-logic side effects.

Cairnloop uses a **dual emission** architecture: bounded `:telemetry.span/3` events for
APM tracing alongside past-tense domain events for business logic hooks.

### A. Tracing spans (performance and APMs)

Use the span lifecycle events (`:start`, `:stop`, `:exception`) to capture execution
metrics. These are appropriate for exporting to APMs (Datadog, Prometheus) or logging
function execution duration.

```elixir
:telemetry.attach(
  "cairnloop-apm-tracker",
  [:cairnloop, :conversation, :resolve, :stop],
  fn _event, measurements, _metadata, _config ->
    require Logger
    # Execution time is available in measurements.duration (native units).
    Logger.info(
      "Resolve took #{System.convert_time_unit(measurements.duration, :native, :millisecond)}ms"
    )
  end,
  nil
)
```

### B. Domain events (business lifecycle hooks)

Use past-tense domain events to observe successful business actions. These events carry
the resolved `Conversation` struct and actor metadata.

```elixir
:telemetry.attach(
  "cairnloop-domain-hooks",
  [:cairnloop, :conversation, :resolved],
  fn _event, measurements, metadata, _config ->
    require Logger

    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 a LiveView session to surface a CSAT prompt.
    # Phoenix.PubSub.broadcast(
    #   MyApp.PubSub,
    #   "user_sessions:#{metadata.host_user_id}",
    #   :support_issue_resolved
    # )
  end,
  nil
)
```

Telemetry event names follow the `[:cairnloop, :domain, :action, :lifecycle]` convention.
The events are non-blocking — they do not delay the conversation resolve path. For
side-effects that need reliability guarantees (retries, durability), use `Notifier`
instead.
