# Give Signal to any LLM as tools

**The job:** let Claude, GPT, Gemini, a local model, or anything behind
OpenRouter answer questions about your pipeline and act on them — without you
writing a prompt that describes Signal.

**Who this is for:** anyone who already runs an LLM with tool calling and wants
it to see who is on their site.

**What it costs you:** four tool definitions. The model does the rest.

---

## Before you start

- You need an **API key** (`sk_sig_…`) — mint one under **Exports & API** in
  the dashboard. The plaintext is shown exactly once; store it as
  `SIGNAL_API_KEY`.
- If you want the `/ask` section at the end, that key needs the **`copilot`
  scope**. Scopes are fixed at creation, so choose it up front — a `read`-only
  key calling `/ask` returns `403` and the only fix is a new key. The four
  tools below need `read` alone.
- You need the **pixel live on your site**, or every tool returns an empty
  list and your model will correctly tell you there is nothing to report. The
  [quickstart](../quickstart.md) covers install and verification.
- You need a model that supports **tool calling**. Every major provider does;
  the shapes differ, the tools below do not.

---

## The short version

Signal publishes what it can do at a URL. Point your agent at it once, at
startup, and build the tool list from the response instead of hand-writing it:

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

No key needed for that call. It returns the endpoints, the auth scheme, the
pagination rules, the error vocabulary and the rate limit — **derived from the
running service**, so it cannot describe a version that is not deployed.

That is the whole integration story. The rest of this page is the detail.

## The four tools

Every read your agent needs is one of these. They are plain `GET`s with query
parameters, so the tool definition is mechanical in any provider's schema
format.

| Tool | Endpoint | Answers |
|---|---|---|
| `list_accounts` | `GET /signal-api/v1/accounts` | Which companies are visiting, ranked by intent |
| `list_visitors` | `GET /signal-api/v1/visitors` | Which people, with the email we resolved |
| `get_attribution` | `GET /signal-api/v1/attribution` | Which channels and pages precede revenue |
| `list_recommendations` | `GET /signal-api/v1/recommendations` | What Signal thinks you should act on |

Here they are as JSON Schema. This is the OpenAI / OpenRouter shape; Anthropic
uses `input_schema` and Gemini uses `parameters` with the same body, so the
translation is a rename.

```json
[
  {
    "name": "list_accounts",
    "description": "Companies that visited this site, newest activity first, ranked by intent score 0-100. Use for 'which companies are interested', 'who should we call', 'is anyone from X looking at us'.",
    "parameters": {
      "type": "object",
      "properties": {
        "classification": {
          "type": "string",
          "enum": ["lead", "customer", "competitor", "excluded"],
          "description": "Omit to get all four."
        },
        "page": { "type": "integer", "description": "1-based." },
        "page_size": { "type": "integer", "description": "Default 50, maximum 200." }
      }
    }
  },
  {
    "name": "list_visitors",
    "description": "Individual identified people. Use when the question is about a person rather than a company, or when you need an email address to act on.",
    "parameters": {
      "type": "object",
      "properties": {
        "min_intent": { "type": "integer", "description": "0-100. Filter to warmer visitors." },
        "classification": { "type": "string", "enum": ["lead", "customer", "competitor", "excluded"] },
        "page": { "type": "integer" },
        "page_size": { "type": "integer", "description": "Default 50, maximum 200." }
      }
    }
  }
]
```

Fill in the other two the same way from `/capabilities`.

## The loop

Provider-agnostic, because the only Signal-specific part is one `fetch`:

```python
import os, requests

BASE = "https://app.signal.geysera.com/signal-api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SIGNAL_API_KEY']}"}

PATHS = {
    "list_accounts": "/accounts",
    "list_visitors": "/visitors",
    "get_attribution": "/attribution",
    "list_recommendations": "/recommendations",
}

def call_signal(name: str, args: dict) -> dict:
    """Run one tool call. Returns the parsed body either way — an error is a
    result the model should see and reason about, not an exception that kills
    the turn."""
    r = requests.get(BASE + PATHS[name], headers=HEADERS, params=args, timeout=30)
    body = r.json()
    if r.status_code >= 400:
        # Every error has the same shape. Hand it back rather than raising:
        # `VALIDATION_ERROR` tells the model which parameter it got wrong, and
        # it will usually fix itself on the next call.
        return {
            "error_code": body.get("error_code"),
            "message": body.get("message"),
            "correlation_id": body.get("correlation_id"),
        }
    return body
```

Then the usual agent loop: send the tools, get a tool call, run `call_signal`,
feed the result back, repeat until the model answers.

**Return errors to the model.** It is the single highest-leverage choice here.
A `VALIDATION_ERROR` names the offending parameter, so a model that sees it
corrects itself; a model that sees an exception stops.

## Four things that will bite you if nobody says them

**`company_domain` can be the literal string `personal`.** Consumer mailboxes —
gmail, icloud and the rest — all roll up under one sentinel, because a consumer
address carries no company. On a DTC site that single "account" can be the
majority of your visitors. Tell your model to exclude it for B2B questions, or
it will confidently report that your biggest prospect is `personal`.

**Locked rows are not empty rows.** Past your plan's resolution cap,
`resolved_email` comes back `null` and `resolved_name` is masked, with
`is_locked: true`. A model that has not been told this will report those people
as unidentifiable. They are identified; the plan is withholding them.

**`total` is what you may access, not what exists.** Same cause. It is not a
count of your traffic.

**The rate limit is per key, not per workspace** — 300 requests a minute. A
second integration gets its own budget, so give each agent its own key. That
also means one runaway loop cannot starve the rest of your tooling, and you can
revoke it without touching anything else.

## If you would rather not write the loop

Signal has its own assistant, and it is one POST — with a key carrying the
**`copilot` scope**, per the prerequisites above.

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

It plans its own tool calls over the full internal surface — more than the four
endpoints above — and returns an answer with the data behind it:

```json
{
  "answer": "Four companies viewed pricing…",
  "question_kind": "descriptive",
  "plan": { "calls": ["search_accounts", "page_funnel"] },
  "trace": [ { "tool": "…", "coverage": {…}, "data": {…} } ],
  "disclosures": ["…"],
  "limits": ["…"]
}
```

**With a key it is read-only**, whatever you ask it. Tools that change the
workspace are filtered out of its plan schema entirely for key-authed callers,
so it cannot be talked into one. `GET /capabilities` reports how many exist and
what reaches them, in `copilot_tool_reach`.

Which to choose:

- **Your own loop** when the model needs to combine Signal with your CRM, your
  billing, your calendar. Signal becomes four tools among many.
- **`/ask`** when the question is only about Signal. It plans over more tools
  than the public API exposes, and `trace` gives you the coverage behind every
  number — which is what you need to know whether to trust it.

There is no wrong answer. Plenty of people run both.

## What to do with it

The integration is the easy part. What makes it worth doing:

- **Put it where decisions happen.** A Slack bot your team can ask "who's hot
  today" beats a dashboard nobody opens.
- **Let it draft, not send.** "Write me a first email for each of today's
  top five accounts, citing what they actually read" is a good use. Sending
  automatically is how you burn a domain.
- **Give it the sceptical question too.** "How much of last week's revenue can
  attribution actually explain?" is worth asking, and `/ask` answers it
  honestly — including when the answer is "less than you think".

---

## Next

- [Ask questions in natural language](./ask-questions-in-natural-language.md) —
  the `/ask` surface in depth, including what it refuses and why.
- [Let an LLM run your workspace](../playbooks/let-an-llm-run-your-workspace.md)
  — the same idea with write access, from a signed-in session.
- [A weekly revenue brief your LLM writes](./a-weekly-revenue-brief-your-llm-writes.md)
  — a concrete scheduled job built on this.
