# Geysera Signal — Developer Guide

How to use Geysera from code: the read API, the webhooks, and the copilot —
the endpoint that can both answer questions about a workspace and change it.

Written for two readers who want the same things: an engineer wiring up an
integration, and an AI agent operating the platform on someone's behalf.
Everything a person can do in the dashboard is reachable here.

For endpoint-by-endpoint detail — paths, parameters, error codes, exact limits —
see the [API reference](https://www.geysera.com/developers), generated from the
running service. This page explains how the product behaves; that one is the
lookup.

**Contents**

1. [The three surfaces](#1-the-three-surfaces)
2. [Authentication](#2-authentication)
3. [Conventions](#3-conventions-read-this-before-your-first-call)
4. [The public read API](#4-the-public-read-api)
5. [Plan caps, and why your totals may be smaller than reality](#5-plan-caps-and-why-your-totals-may-be-smaller-than-reality)
6. [The copilot: asking questions](#6-the-copilot-asking-questions)
7. [The copilot: making changes](#7-the-copilot-making-changes)
8. [The tool catalogue](#8-the-tool-catalogue)
9. [Webhooks](#9-webhooks)
10. [Capability discovery](#10-capability-discovery)
11. [Errors](#11-errors)
12. [Things that will surprise you](#12-things-that-will-surprise-you)

---

## 1. The three surfaces

| Surface | Base | Auth | What it is |
|---|---|---|---|
| **Public read API** | `/signal-api/v1` | API key | Four stable `GET`s. Versioned, documented, safe to build on. |
| **Copilot** | `/signal/copilot` | Clerk JWT | Ask in English; read 30 tools worth of data and perform 26 kinds of change. |
| **Webhooks** | your endpoint | HMAC signature | Geysera calls you when a visitor is identified. |

Pick the read API when you know what you want and need a stable contract. Pick
the copilot when you want the platform to work out *how* to answer, or when you
want to act.

> **Auth asymmetry, stated up front.** The public read API takes an API key.
> The copilot takes a Clerk session JWT and is **not** reachable with an
> `sk_sig_` key today. A headless integration can therefore read but not act.
> If you need programmatic writes, say so — the constraint is the auth wiring,
> not the design.

---

## 2. Authentication

### API keys (`sk_sig_…`)

Mint one in the dashboard under **Exports & API**, or ask the copilot to
(`create_api_key`). Send it as a bearer token:

```bash
curl https://app.signal.geysera.com/signal-api/v1/accounts \
  -H "Authorization: Bearer sk_sig_your_key_here"
```

- **The plaintext key is shown exactly once**, at creation. Only a SHA-256 hash
  and a display prefix are stored, so nobody — including support — can recover
  it. Lose it and you mint a new one.
- A key is scoped to **one workspace** and is **read-only**: it reaches the four
  public `GET`s and nothing else.
- Maximum **10 active keys** per workspace. Revocation is immediate and
  permanent.

Missing, malformed, unknown or revoked keys all return `401` with the same
body. That is deliberate — a distinct "this key existed once" would confirm
which keys are real.

### Clerk JWT

The dashboard and the copilot use your session token. Browser clients get this
automatically; there is no documented way to mint one from a script.

---

## 3. Conventions — read this before your first call

**Base URL.** Read it from `GET /capabilities` (`spec_url`, `servers`) rather
than hardcoding it. It is configuration on our side and it will move.

**Headers**

| Header | Required | Why |
|---|---|---|
| `Authorization: Bearer sk_sig_…` | yes | Identifies the workspace. |
| `X-Correlation-ID` | no | Any string. Generated if absent, echoed on every response and in our logs. Quote it in support requests. |
| `X-Idempotency-Key` | no | On mutating calls, makes a retry safe. Send the same key and the original result is returned instead of a second write. |

**Pagination.** `page` (1-based) and `page_size`. Responses carry `total`,
`page`, `page_size`. `total` is what you may access, **not** the raw row count —
see §5.

**Rate limit.** 300 requests per minute per workspace. Over it you get `429`.
The dashboard itself issues 6+ parallel calls per page load and shares your
budget, so leave headroom.

**Time.** All timestamps are ISO 8601 UTC. `days` parameters are trailing
windows ending today.

---

## 4. The public read API

Four endpoints. All `GET`, all paginated where a list is returned.

### `GET /accounts`

Organisations that visited, ranked by intent.

| Param | Type | Default |
|---|---|---|
| `page` | integer | 1 |
| `page_size` | integer | 50 |
| `classification` | `lead`\|`customer`\|`competitor`\|`excluded` | all |

```bash
curl "https://app.signal.geysera.com/signal-api/v1/accounts?classification=lead&page_size=10" \
  -H "Authorization: Bearer $GEYSERA_KEY"
```

```json
{
  "accounts": [
    {
      "company_domain": "acme.com",
      "company_name": "Acme Corp",
      "intent_score": 82,
      "visitor_count": 4,
      "visit_count": 11,
      "classification": "lead",
      "first_seen_at": "2026-07-02T09:14:00Z",
      "last_seen_at": "2026-09-01T16:03:00Z"
    }
  ],
  "total": 137,
  "page": 1,
  "page_size": 10
}
```

`intent_score` is 0–100, the same scale the dashboard shows.

**Every field, and which can be null.** Nullability is not cosmetic here — the
identity fields are the ones most likely to be absent, and a client that
assumes otherwise breaks on real data rather than on the example above.

| Field | Type | Null? | What it is |
|---|---|---|---|
| `company_domain` | string | no | The natural key. Join on this, never on the name. `personal` is a real value — see below. |
| `company_name` | string | **yes** | Often absent. Fall back to the domain. |
| `intent_score` | integer | no | 0–100. |
| `visitor_count` | integer | no | Distinct people from this company. |
| `visit_count` | integer | no | Total visits across those people. |
| `classification` | string | no | `lead`, `customer`, `competitor` or `excluded`. |
| `first_seen_at` | ISO 8601 | no | |
| `last_seen_at` | ISO 8601 | no | |

**`company_domain` can be the string `personal`.** Consumer mailbox domains —
gmail, icloud and the rest — all roll up under that one sentinel, because a
consumer address carries no company. On a DTC site that single "account" can be
the majority of your visitors. Filter it out of anything B2B.

### `GET /visitors`

Individual identified people.

| Param | Type | Default |
|---|---|---|
| `page`, `page_size` | integer | 1, 50 |
| `min_intent` | integer 0–100 | none |
| `classification` | as above | all |

Fields include `resolved_email`, `resolved_name`, `resolved_title`, `employer`,
and `is_locked`. **When `is_locked` is true, `resolved_email` is `null` and
`resolved_name` is masked** — the plan is withholding them. Do not record that
person as having no email; see §5.

**Every field, and which can be null.**

| Field | Type | Null? | What it is |
|---|---|---|---|
| `company_domain` | string | **yes** | `personal` for consumer mailboxes. |
| `company_name` | string | **yes** | |
| `intent_score` | integer | no | 0–100. |
| `classification` | string | **yes** | |
| `resolved_email` | string | **yes** | The identity. Present far more often than the three below. |
| `resolved_name` | string | **yes** | Frequently absent. |
| `resolved_title` | string | **yes** | Frequently absent. |
| `employer` | string | **yes** | Frequently absent. |
| `first_visit_at` | ISO 8601 | **yes** | |
| `last_visit_at` | ISO 8601 | **yes** | |
| `visit_count` | integer | no | |
| `is_locked` | boolean | no | `true` means this visitor is beyond your plan's monthly resolution cap: you can see that they exist, not who they are. The identity fields are null. Filter these out of anything a human is meant to action. |

Identification gives you an email reliably. Name, title and employer are
enrichment on top of that and are absent more often than they are present —
write your templates and your CRM sync to survive nulls in all three.

### `GET /attribution`

Channel and campaign rollup for a window. Takes `days` (default 30).

Served from a nightly rollup, not computed live, so it is fast and may lag by up
to a day. It describes **only sessions that carried a campaign context**, which
on most workspaces is a minority of traffic. Read the percentages as shares of
attributed sessions, not of all visitors.

### `GET /recommendations`

Open recommended actions. No parameters.

Read-only in v1: status changes carry an optimistic-locking contract that was
deliberately not bolted onto a read API. Use the copilot
(`set_recommendation_status`) or the dashboard to act on one.

---

## 5. Plan caps, and why your totals may be smaller than reality

Every workspace has a monthly **resolution cap**:

| Plan | Accounts resolvable per month |
|---|---|
| free | 150 |
| starter | 300 |
| pro | 1,000 |
| pro_plus | 2,500 |
| enterprise | 1,000,000 |

New workspaces get 7 days at `pro_plus` limits.

Beyond the cap, records are withheld. The three surfaces express that
differently, and you need to know which one you are reading:

| Surface | Over-cap behaviour |
|---|---|
| Public API | Records are **absent**. `total` is capped, so it can be lower than the true count. |
| Dashboard | Records are **present but masked** — `is_locked: true`, name `•••`. |
| Copilot | Masked, **and it tells you how many were withheld** rather than describing the list as complete. |

**The practical consequence:** on the public API, `total` is not "how many
accounts exist", it is "how many you may see". Do not compute conversion rates
against it and do not treat a locked visitor's `null` email as "this person has
no email". Both are the plan talking, not the data.

---

## 6. The copilot: asking questions

```
POST /signal/copilot/ask      one JSON response
POST /signal/copilot/stream   the same thing, as SSE
```

`/ask` is complete on its own. The stream exists because humans want progress
feedback; a script never needs it.

```bash
curl -X POST https://app.signal.geysera.com/agent-api/signal/copilot/ask \
  -H "Content-Type: application/json" \
  -d '{"question": "Which companies visited pricing last week and have not bought?"}'
```

**Request**

| Field | Type | Notes |
|---|---|---|
| `question` | string, 3–2000 chars | required |
| `thread_id` | string | Opaque. Reuse it across turns for follow-ups ("now break that down by month"). Omit for one-shot. |
| `turn_id` | string | Your id for this turn. Stored with it in history so a later edit can name it. Send one on every turn — a turn you never identified cannot be replaced later. |
| `replaces_turn_id` | string | Edit-and-resend: drops that turn and everything after it. Names the turn rather than counting to it; if it cannot be found, **all** history is dropped. |
| `approved_plan` | object | Authorises changes. See §7. |

**Response**

```json
{
  "answer": "Four companies viewed pricing…",
  "refusal": null,
  "clarification": null,
  "question_kind": "descriptive",
  "plan": { "reasoning": "…", "calls": ["search_accounts", "page_funnel"] },
  "disclosures": ["…"],
  "limits": ["…"],
  "warnings": [],
  "pending_confirmation": null,
  "trace": [ { "tool": "…", "sql": "…", "coverage": {…}, "caveats": [], "window": {…}, "data": {…} } ],
  "thread_id": "…",
  "message_id": "…"
}
```

Exactly one of `answer`, `refusal` or `clarification` is set.

- **`refusal`** is the product working, not an error. It means the data cannot
  support the claim you asked for. Do not retry it.
- **`clarification`** is a question back. Answer it on the same `thread_id`.
- **`trace`** is the audit trail: every tool that ran, the SQL it executed, its
  coverage, and the window it covered. **`data` holds the rows** — parse those
  rather than the prose if you want structure.
- **`limits` and `disclosures`** are the constraints the answer was written
  under. If you are re-publishing the answer, carry them.

**Every number in `answer` is guaranteed to appear in `trace`.** A numeric guard
truncates the answer rather than let a figure through that no tool produced. So
prose and trace cannot disagree.

---

## 7. The copilot: making changes

The copilot can change things. Nothing changes without a round trip.

**Ask.** If the request would change something, **nothing runs** and you get:

```json
{
  "answer": null,
  "pending_confirmation": {
    "actions": [
      {
        "tool": "run_sync_rule",
        "arguments": { "rule_id": "b3f1…" },
        "effect": "Push this rule's matching contacts to the connected ESP NOW. Contacts that arrive there cannot be recalled.",
        "irreversible": true
      }
    ],
    "irreversible": true,
    "approve_with": { "question_kind": "action", "calls": [ … ] }
  }
}
```

**Approve.** Re-send the same question with `approve_with` copied verbatim into
`approved_plan`:

```bash
curl -X POST .../copilot/ask -H "Content-Type: application/json" -d '{
  "question": "push that rule now",
  "thread_id": "t-42",
  "approved_plan": { "question_kind": "action", "calls": [ … ] }
}'
```

### Why it is the plan and not a boolean

The planner is a sampled model. Approving a flag would authorise whatever it
decides on the *second* pass, which need not be what you were shown. Sending the
plan back makes the approved actions and the executed actions the same object —
and an approved plan is replayed verbatim, with the planner skipped entirely.

### Rules worth knowing

- **Approval is good for one request.** It is not stored, not remembered, and
  does not persist on a thread. The next turn re-gates.
- **A mixed plan runs none of it.** A read bundled with a write waits for the
  same yes.
- **`irreversible: true`** means the product cannot undo it: credits spent with
  a vendor, contacts pushed to an ESP, a secret shown once, a row hard-deleted.
- **Show `effect` to your user, not `tool`.** It is written to be consented to.
- A malformed `approved_plan` returns `422`, not a silent no-op.

### If you are automating this

Be honest with yourself about what the gate is doing for you. In the dashboard
it is a human reading a sentence and clicking. In a script, echoing
`approve_with` back is three lines, and the gate becomes an **intent check and
an audit record** rather than human oversight. If your agent is choosing its own
actions, put your own review in front of the irreversible ones — particularly
`run_enrichment_pipeline` (spends vendor credits across the workspace),
`run_sync_rule` (pushes contacts you cannot recall) and the credential tools.

---

## 8. The tool catalogue

68 tools. You do not call them directly — you ask a question and the planner
chooses — but knowing what exists tells you what is answerable.

### Commerce analytics (11)

`data_coverage` · `revenue_and_aov` · `aov_decomposition` · `product_performance`
· `customer_counts` · `price_history` · `page_funnel` ·
`page_to_product_attribution` · `membership_conversion_paths` ·
`visiting_accounts` · `causal_impact`

`causal_impact` is the only one that can support a claim about cause — a
Bayesian interrupted time series that returns "inconclusive" when the data
cannot carry the claim. Everything else measures association.

### The product (22 reads)

`search_accounts` · `get_account` · `search_visitors` · `visitor_journey` ·
`journey_paths` · `attribution_overview` · `open_recommendations` ·
`workspace_setup` · `intent_pages` · `enrichment_config` · `sync_rules` ·
`dry_run_sync_rule` · `list_api_keys` · `list_webhooks` ·
`list_alert_destinations` · `identification_status` · `list_conversations` ·
`get_conversation` · `list_team` · `subscription` · `esp_connection` ·
`recall_facts`

**`recall_facts`** and **`forget_fact`** are the assistant's memory. It keeps
short facts about your workspace and feeds them into the planner on later
turns, so a wrong one shapes answers you have not asked yet. `recall_facts`
lists them; `forget_fact` stops one being used. Ask "what have you remembered
about us" if an answer keeps coming out wrong in the same way.

**`dry_run_sync_rule`** is the one to reach for before any push: it reports
exactly which contacts a rule WOULD send, and changes nothing. It reads like a
write and is not one.

Start with **`workspace_setup`** when data looks wrong or missing — it returns
store connection, onboarding progress, pixel status, identification health and
plan usage in one call, and distinguishes "nothing happened" from "we are not
collecting".

### Changes (35)

| Tool | Reversible? |
|---|---|
| `classify_account` | yes |
| `set_visitor_contacted` | yes |
| `set_recommendation_status` | yes |
| `update_enrichment_config` | yes |
| `create_sync_rule` | yes — created paused, sends nothing until activated |
| `set_sync_rule_state` | pause/activate yes; **no** on delete — hard delete, run history goes too |
| `replace_intent_pages` | yes, but it **replaces the whole set**, and scores recompute on the next run, not immediately |
| `dry_run_sync_rule` | not a change — previews who would be pushed |
| `run_enrichment_pipeline` | **no** — spends vendor credits, workspace-wide |
| `run_sync_rule` | **no** — contacts reaching an ESP cannot be recalled |
| `create_api_key` | **no** — secret shown once |
| `revoke_api_key` | **no** — breaks live integrations immediately |
| `create_webhook` | **no** — signing secret shown once |
| `set_webhook_state` | **no** on delete — hard delete, history goes too |
| `verify_pixel` | yes — re-checks the pixel and stamps the result |
| `test_pixel` | yes — sends a synthetic hit; it is recorded like any other event |
| `connect_esp` | yes — returns a consent link; nothing is connected until the customer authorizes |
| `verify_esp` | yes — re-checks the email platform and stamps the result |
| `skip_esp` | yes — connect an ESP later with `connect_esp` |
| `complete_intent_step` | yes — reconfigure pages and confirm again |
| `activate_workspace` | **no** — nothing in the product returns a workspace to onboarding |
| `authorize_store` | yes — returns a link; nothing is connected until the store owner approves it in WordPress |
| `claim_domain` | **no** on a change — clears verification, and the pixel check cannot confirm a tag-manager install, so that flag may not come back |
| `invite_team_member` | **no** — an email reaches a person and cannot be unsent |
| `update_team_member` | yes — set the role back |
| `remove_team_member` | **no** — access is revoked at once; re-inviting needs them to accept again |
| `cancel_invitation` | yes — invite again |
| `start_checkout` | **no** — once the checkout is completed the bill changes |
| `open_billing_portal` | yes — a link; changes happen at Stripe |
| `update_sync_rule` | yes — retunes filters in place, keeping run history |
| `create_alert_destination` | yes — created paused, sends nothing until activated |
| `set_alert_destination_state` | yes — activate/pause/delete only |
| `test_alert_destination` | **no** — posts a **real** message to the Slack channel; everyone in it sees it |
| `rename_conversation` | yes |
| `delete_conversation` | **no** — soft-deleted for audit, but nothing in the product restores it |
| `forget_fact` | **no** — soft-deleted for audit; nothing in the product puts it back |

### Access and money need the workspace to opt in

Six tools grant workspace access or change what is billed:
`invite_team_member`, `update_team_member`, `remove_team_member`,
`cancel_invitation`, `start_checkout`, `open_billing_portal`.

They are **off unless the workspace turns them on**
(`guardrails.copilot_privileged_actions_enabled`, default `false`). Nothing in
the copilot can set that column — it is enabled deliberately, out of band, by
someone who has decided an agent holding a session may act on their behalf.

Why these and not the rest: everything else the assistant changes is workspace
data, where wrong is recoverable. These decide who can reach the workspace and
what it costs, and the confirmation gate does not protect them the way it
protects the rest — for a script, approving is echoing the plan back (§7). So
three further checks apply, all failing closed:

| Check | Team | Billing |
|---|---|---|
| Workspace opt-in | required | required |
| Caller's role, read fresh per call | admin or owner | **owner only** |
| Confirmation gate | yes | yes |

And some targets are refused whatever your role, because they are the changes
that let one compromised session lock everyone else out:

- you cannot act on **yourself**;
- an **owner** cannot be removed or demoted;
- nobody can be promoted **to owner**.

Those remain dashboard-only, on purpose.

`start_checkout` and `open_billing_portal` return their links under
**checkout_url** and **portal_url** in the response's `secrets` field, not in the
answer text — a live payment link does not belong in a conversation that is
replayed into later prompts and can be exported.

### One plan cannot look things up for itself

The calls in a plan run concurrently with static arguments — **no call reads
another's output**. A tool cannot be handed an id that a different call in the
same plan was meant to fetch.

Tools are therefore addressed by the identifier you already have, never by an
opaque id:

| Tool | Selector | Value |
|---|---|---|
| `classify_account` | `domain` | `acme.com` |
| `set_sync_rule_state`, `run_sync_rule`, `dry_run_sync_rule`, `update_sync_rule` | `rule` | the rule's name from `sync_rules` |
| `set_alert_destination_state` | `destination` | the name from `list_alert_destinations` |
| `revoke_api_key` | `key` | the name from `list_api_keys` |
| `set_webhook_state` | `url` | the endpoint URL from `list_webhooks` — a webhook has no name |

That is not a style preference: when `classify_account` took a UUID, "classify
acme.com as a competitor" came back as an *answer about* acme.com's current
classification, having changed nothing, because the planner could not resolve
the id and fell back to a read.

`update_sync_rule` takes both `rule` (which one) and `name` (rename it to).

**Matching is exact**, ignoring case and surrounding whitespace only. Account
domains are unique per workspace and always resolve. Rule, destination and key
names are **not** unique — nothing in the schema prevents two rules sharing a
name — so if a name matches nothing you get a refusal listing what does exist,
and if it matches more than one you get a refusal saying it is ambiguous.
Neither case changes anything. There is no fuzzy matching: the approved plan
carries the name and re-resolves it at execution, so a loose match could act on
a different object than the one whose effect you approved.

`set_visitor_contacted` and `set_recommendation_status` take the visitor's
email address and the recommendation's subject respectively — the thing a
person would actually type, not a row id.

If you need two steps, take two turns: run the read, then send a second
question using what it returned.

---

## 9. Webhooks

Register an endpoint (dashboard, or `create_webhook`) and Geysera posts to it
when a visitor is identified. Max 5 per workspace.

Delivery carries a Stripe-style signature:

```
X-Signal-Timestamp: 1756742400
X-Signal-Signature: t=1756742400,v1=<hex hmac-sha256>
```

The signed payload is `"{timestamp}.{raw_body}"`, keyed by your `whsec_…`
secret. **Verify against the raw body**, before any JSON parsing — re-serialising
changes the bytes and the signature will not match.

```python
import hashlib, hmac, time

def verify(secret: str, header: str, timestamp: str, raw_body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    expected = hmac.new(
        secret.encode(), f"{timestamp}.{raw_body.decode()}".encode(), hashlib.sha256
    ).hexdigest()
    # Constant-time: a fast-failing comparison leaks the signature a byte at a time.
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        return False
    # Reject stale timestamps, or a captured delivery can be replayed forever.
    return abs(time.time() - int(timestamp)) < 300
```

```json
{
  "type": "signal.visitor_identified",
  "created": 1756742400,
  "data": { "…": "…" }
}
```

Notes:

- The signing secret is shown **once**, at registration. Rotating issues a new
  one and invalidates the old.
- Endpoints receive **every** event type — there is no per-endpoint
  subscription filter. Branch on `type`.
- Return 2xx quickly. Deliveries retry.
- `signal.test` arrives from the "send test ping" action.

---

## 10. Capability discovery

```bash
curl https://app.signal.geysera.com/agent-api/capabilities
```

Unauthenticated, and enough to bootstrap without reading this document: the
public route list, the auth model, rate limits, pagination and error
conventions, and `spec_url` pointing at the OpenAPI 3.1 spec.

```bash
curl https://app.signal.geysera.com/signal-api/v1/openapi.json
```

The spec is **derived from the live application**, not hand-maintained, so it
cannot drift from what the endpoints do.

Two deliberate omissions, so you do not go looking:

- The **internal** schema (~326 paths, including `/admin` and `/observability`)
  is not published. Serving it unauthenticated would hand out a map of the
  attack surface. `/capabilities` describes the public surface only, by
  allowlist, so a new internal route is invisible here until someone
  deliberately publishes it.
- There is **no checked-in spec**. One used to sit in `public/openapi.json`,
  describing nine paths of which six had been deleted, served publicly at
  `/openapi.json` — it is gone, and a test stops another appearing. The spec you
  want is generated from the live routes at `/signal-api/v1/openapi.json`.

---

## 11. Errors

Every error has the same shape:

```json
{
  "error_code": "RATE_LIMITED",
  "message": "Too many requests for this workspace.",
  "correlation_id": "3f2c…",
  "details": null
}
```

| Status | `error_code` | What to do |
|---|---|---|
| 400 / 422 | `VALIDATION_ERROR` | Fix the request. `details` says which field. |
| 401 | — | Key missing, malformed, unknown or revoked. |
| 402 | — | Plan cap reached. `details` carries the upgrade path. |
| 403 | `FORBIDDEN` | Authenticated, not allowed. |
| 404 | `NOT_FOUND` | Also returned for another workspace's resources — we do not confirm they exist. |
| 409 | `CONFLICT` | Optimistic-lock failure: someone changed it since you read it. Re-read and retry. |
| 429 | `RATE_LIMITED` | Back off. 300/min per workspace. |
| 5xx | `INTERNAL` | Retry with backoff. Quote `correlation_id`. |

Retry 429 and 5xx with exponential backoff. Do not retry 4xx — send an
`X-Idempotency-Key` on writes so a network-level retry is safe.

### When the fix is somewhere in the dashboard

Some failures cannot be fixed by changing the request. Pushing to an ESP you
have not connected, or using a key that was never granted a scope, needs a
person in the dashboard. Those errors carry the address:

```json
{
  "error_code": "CONFLICT",
  "message": "Connect Klaviyo on Auto-sync Rules first.",
  "details": {
    "code": "klaviyo_not_connected",
    "fix_url": "/rules",
    "fix_label": "Auto-sync Rules"
  }
}
```

`fix_url` is relative to your dashboard origin (`https://app.signal.geysera.com`).
`fix_label` is the wording that appears in the dashboard's own navigation, so
telling someone to open "Auto-sync Rules" matches what they will see.

This exists because an agent cannot look around. Before these fields, the same
errors said "Settings → Integrations" — a tab that has never existed — and the
only way to discover that was to follow the instruction and find nothing.

---

## 12. Things that will surprise you

Collected because each one has already produced a wrong conclusion.

**`total` is not a row count.** It is capped at what your plan may access. Never
use it as a denominator.

**A masked record is not an empty one.** `is_locked: true` means the plan is
withholding the name and email. Recording "no email on record" is wrong, and it
is wrong about a real person.

**A refusal is a real answer.** The copilot refuses when the data cannot support
the claim. Retrying the same question gets the same refusal.

**Attribution covers a minority of traffic.** Only sessions carrying a campaign
context. On some workspaces that is a few percent. The response says so; read
the caveats.

**Intent scores can be meaningless.** They rank visitors by which "intent pages"
a workspace has configured. If those rules match little of the real traffic,
every visitor scores alike and the ranking is close to arbitrary. `intent_pages`
reports the coverage — check it before trusting a ranking.

**`replace_intent_pages` replaces.** Pages absent from your list are deleted,
and any `score_weight` you omit resets to 1. Read the current set, modify it,
send it all back.

**Empty `page_patterns` matches nobody.** In `update_enrichment_config`, an
empty list is not "no restriction" — it switches enrichment off in practice.

**`run_enrichment_pipeline` is not one visitor.** It takes no arguments, because
it acts on the whole workspace: every eligible abandoner is bought from a data
provider, one credit each. It used to take a visitor's email, which bounded
nothing — the route checked that visitor's ownership and then ran the same
workspace-wide job. There is no single-visitor enrichment.

**Bot traffic is filtered, historically imperfectly.** Some older rows have an
unset bot flag. If a visitor count looks implausibly large for the number of
distinct people, that is why.

**The attribution rollup can lag a day.** It is nightly. `attribution_overview`
reads the same cached rows the dashboard does, so the two agree — with each
other, and with yesterday.

---

*Questions, or you need programmatic write access with an API key? Include the
`correlation_id` from a recent response — it is the fastest way for us to see
exactly what you saw.*
