Ask questions in natural language

Instead of assembling a query, ask. A copilot-scoped key reaches an assistant that reads your workspace's data and answers with the figures it used.

This is the part of the API most likely to be used by another agent, so the contract below is written for one.


One question

curl https://app.signal.geysera.com/agent-api/signal/copilot/ask \
  -H "Authorization: Bearer sk_sig_…" \
  -H "Content-Type: application/json" \
  -d '{"question": "how many accounts visited in the last 30 days?",
       "thread_id": null}'
{
  "answer": "Between 2026-08-13 and 2026-09-11, the table covers 481 identified accounts…",
  "refusal": null,
  "question_kind": "descriptive",
  "plan": { "reasoning": "…", "calls": ["visiting_accounts"] },
  "trace": [ { "tool": "visiting_accounts", "sql": "…", "coverage": …, "has_data": true } ],
  "thread_id": null,
  "warnings": []
}

Read trace before you trust answer. It names every tool that ran, the SQL behind it, and its coverage. An answer with an empty trace is an answer with nothing behind it.

The four endpoints

EndpointFor
POST /signal/copilot/askone answer, when the whole response is ready
POST /signal/copilot/streamthe same answer as SSE, as it is produced
GET /signal/copilot/threadsyour conversations
GET /signal/copilot/threads/{id}one conversation's turns

ask can take 10–30 seconds for a question needing several tools. Use stream for anything a person is waiting on.


What it will not do, by construction

It is read-only, whatever it is asked. A key reaches the assistant's read tools and never the ones that change a workspace. This is not a prompt instruction it might be talked out of — the tool list handed to the planner is filtered by caller type, so an API key cannot even name a mutating tool.

Verified behaviour, asking it to do something it cannot:

Q: "invite alice@example.com to my team as an admin"

  plan.calls            []
  pending_confirmation  false
  answer                "That is outside what I can do here. I can read this
                         workspace's commerce data…"

No call was planned. Changing anything requires a signed-in session.

It refuses causal questions. Ask "did the price change drive up AOV" and you get a refusal, not a number. A correlation presented as a cause is worse than no answer, and the gate that enforces this is code rather than a prompt — deliberately, because a rule a model can be argued out of is not a rule.

It refuses rather than guesses. When the data cannot honestly support an answer, refusal is populated and answer is null. A real example:

{"refusal": "There are only 13 days of data after 2026-08-26 and I need at
least 14. Too little has happened since the change to separate…"}

That is the system working. Treat refusal as a first-class outcome, not an error — retrying will not help, and neither will rephrasing.


Follow-up questions need a thread

import uuid

thread = str(uuid.uuid4())        # you invent it, once, per conversation

first  = ask("what was revenue in the last 90 days?", thread_id=thread)
second = ask("and the 90 days before that?",          thread_id=thread)

You choose the thread_id; the server does not issue one. It is an opaque string scoped to your workspace — reuse it across turns and the assistant can see what it just said. The response echoes back whatever you sent, so reading thread_id out of a reply you made with "thread_id": null gives you null, and every follow-up then starts cold. Nothing errors when that happens; the answers just quietly lose their context.

Omit it (or send null) for a genuinely one-shot question. Those are not recorded in the conversation list, which is deliberate — a scripted caller would otherwise fill the sidebar that exists for people.

One caveat worth knowing if you are building on this: the assistant's view of a thread is the narrated replies, not the raw tool output. It will not remember an id it printed three turns ago. If you need to act on a specific account, name it — by domain, not by an identifier you saw in a previous response.


Using it from another agent

The properties that matter if you are wiring this into your own tool loop:

  • Discoverable. GET /agent-api/capabilities lists these endpoints and the scope each needs, generated from the routes themselves. You do not need this document to find them.
  • Bounded. A plan runs at most four tools. Answers do not wander.
  • Traceable. Every figure in the prose is checked against the tool results that produced it before you see it. A number that cannot be traced does not get narrated.
  • Honest about coverage. trace[].coverage tells you what fraction of the data the answer describes. Our attribution, for instance, covers a minority of traffic on most accounts — an answer that did not say so would be worse than useless.

A reasonable pattern for an agent: call ask, check refusal first, then trace for coverage, and only then use answer. If you need the underlying numbers rather than prose, take them from trace — they are the same values, already structured.


Rate limits and errors

Same as the REST API: 300 requests per 60 seconds per key, 429 with Retry-After. A copilot call is far more expensive than a REST read, so if you are batching questions, serialise them rather than fanning out — you will hit the limit long before the answers arrive.

403 means your key lacks the copilot scope. Scopes are fixed when a key is created; make a new key rather than trying to widen this one.

Markdown source: /developers/workflows/ask-questions-in-natural-language.md