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
| Endpoint | For |
|---|---|
POST /signal/copilot/ask | one answer, when the whole response is ready |
POST /signal/copilot/stream | the same answer as SSE, as it is produced |
GET /signal/copilot/threads | your 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/capabilitieslists 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[].coveragetells 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