# 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

```bash
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}'
```

```json
{
  "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:

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

```python
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.
