On this page

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 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:

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 GETs 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.

[
  {
    "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:

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.

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:

{
  "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

This page is maintained next to the service it describes and rendered here. Read it as markdown if you are pointing an agent at it.