# WayWiser for agents

WayWiser is the bid war-room for facilities-management contractors bidding for UK public-sector work: it finds the tender, helps the team qualify it, analyses the ITT pack into a bid plan, drafts answers grounded in the company's own library and facts with citations, reviews them against the buyer's rubric, benchmarks the price and learns from every result. Everything a member can do in the product is an HTTP route on this api, and every route accepts an API key. Outputs are decision support with citations, never advice; evaluator output is a _review_ (gaps and coverage), never a score forecast.

- Reference: [`https://waywiser-api-zxgq66afoq-nw.a.run.app/openapi.json`](https://waywiser-api-zxgq66afoq-nw.a.run.app/openapi.json) (OpenAPI 3.1, generated from the same validation schemas the routes run).
- Short index: [`https://waywiser-api-zxgq66afoq-nw.a.run.app/llms.txt`](https://waywiser-api-zxgq66afoq-nw.a.run.app/llms.txt).
- This guide: [`https://waywiser-api-zxgq66afoq-nw.a.run.app/docs/agents.md`](https://waywiser-api-zxgq66afoq-nw.a.run.app/docs/agents.md).

## 1. API keys

An API key is a second credential beside the sign-in session. A **company admin** mints one in the web app at **Company → API keys** (`/admin/api-keys`) for any member of the company:

- it **acts as that member** — every audit row names the person, and says it was their key;
- its **role ceiling** is `member` or `admin`, never higher than the member's own role (`min(role, ceiling)`), and never a WayWiser super admin;
- it can be **read-only** (every non-GET is refused);
- it **expires** after 90 days by default, 365 at most; it can be revoked at any time and stops within the same second on every instance — the key row is read on every request, outside any cache. What the key may do (the member's role, corpus grants) is cached for up to 30 s per instance: a change made through the product's own routes — a role change, a removal — clears that cache at once; one made outside them reaches a key within 30 s.

The secret is shown **once**, in the 201 body and on that page, and never stored — only its SHA-256 is. It looks like `ww_live_` followed by 43 URL-safe characters (`ww_dev_…` on a local stack; the other environment's prefix is refused).

## 2. Authentication and the first call

```http
Authorization: Bearer ww_live_…
```

Call `GET /me` first. It returns who the key acts as, `activeCompanyId`, `role`, and `actor` — the key's id, name, `readOnly` flag and `expiresAt`. Under a key, `companies` lists the pinned company only: the person may belong to others, but the key cannot act there and is told nothing about them. A person's `/me` has `actor: null` and every company they belong to.

```bash
curl -s https://waywiser-api-zxgq66afoq-nw.a.run.app/me -H 'Authorization: Bearer ww_live_…'
```

## 3. One key, one company

A key is pinned to the company it was minted in. There is nothing to switch: every row you read or write is that company's, and another company's rows answer 404 exactly as if they did not exist. The optional `X-Company-Id` header a browser session uses is accepted only when it names the key's own company (anything else is 400 `company_mismatch`) — leave it out.

## 4. What a key can never do

The identity and tenancy plane is for people, whatever the key's ceiling: members and invitations (`/members*`), corpus grants (`/corpora/:id/grants`), keys themselves (`/company/api-keys*`), the tenant's deletion and export (`/company/delete*`, `/company/export*`), and the whole super-admin plane (`/admin/*`). Each answers 403 `human_only_route`. These routes are not in `/openapi.json`.

Company configuration stays open to an admin-ceiling key: the profile (`/profile`), settings and the export logo (`/settings`, `/settings/logo`), corpora (create, rename, delete).

**A key minted for a WayWiser bid writer acts as that bid writer.** `/me` says `role: "bidwriter"`. What it creates — documents, facts, tenders and their questions — is born private to the writer (`visibility: "bidwriter"`) and invisible to the company's own members until shared: `PATCH /documents/:id/visibility`, `PATCH /tenders/:id/visibility` and `PATCH /facts/:id/visibility` with `{ "visibility": "client" }` share a row and everything derived from it (chunks, response units, the fact riding with an upload; a bid's questions, their drafts, reviews, comments and clarifications, the bid's own interview answers and the answers harvested from it; a hand-entered fact, or a buyer- or company-scope answer given on a bid, stands alone), and `"bidwriter"` takes them back — including the persisted evidence rows that had copied a passage from the row, and a bid's own evidence rows in both directions (`evidenceDropped` in the answer), so nothing drawn from it stays readable. A document mid-ingest answers 409; try again when it is ready. All three are content routes, open to the key; adding or removing bid writers (`/members/bidwriters*`) and creating a client company (`/companies`) stay people-only. Any other key sees shared rows only, and those three routes answer it 403. The flag that makes a person WayWiser staff never travels on a key: `/me` reports `isStaff: false` and `platformCompany: null` under every key.

## 5. Rate limits and 429s

Limits are **per key** (an agent never eats its person's browser quota, nor the reverse), per api instance, per minute:

| Bucket | Routes | Limit |
| --- | --- | --- |
| global | every route | 300 requests per minute per key (RATE_LIMIT_GLOBAL_MAX) |
| ai | analyse, draft, review, fix, tighten, redraft, batch email/reply, interview, incumbent search, reingest, regrade | 10 per minute per key, and the company daily AI token ceiling |
| heavy | plan, fast path, export, evidence refresh | 10 per minute per key |
| upload | the raw-bytes uploads, document registration, pursue | 30 per minute per key |
| search | `POST /search` | 60 per minute per key |

A refusal is `429` with a `Retry-After` header and `{ error, code: "rate_limited", retryAfterSeconds }`. Wait that long and retry the same call — every repeatable route is idempotent (§8), so the retry is safe. The company also has a **daily AI token ceiling**: once it is spent, paid routes answer `429` with `code: "ai_budget_exceeded"` and `retryAfterSeconds: 3600` — back off for an hour rather than hammering; tokens age out of a rolling 24-hour window. A flood of requests that never authenticates is shed before it reaches the database, keyed on the client address — a key-shaped bearer is unverified at that point and counts by address like an anonymous request; it looks like the same 429.

## 6. Long-running work: 202 and `operation`

Anything that parses a pack or calls a model runs as a background job. The route answers **202** at once and the body carries an `operation` pointer — here `POST /questions/:id/draft`:

```json
{ "status": "queued", "alreadyQueued": false,
  "operation": { "kind": "draft", "targetId": "<questionId>", "poll": "/activity?target=<questionId>" } }
```

`POST /tenders/:id/extract` answers with the extraction row itself plus the pointer, and no `alreadyQueued` — a repeat while it runs is 409 (below):

```json
{ "id": "<extractionId>", "tenderId": "<tenderId>", "status": "queued", "startedAt": null, "finishedAt": null,
  "operation": { "kind": "extraction", "targetId": "<extractionId>", "poll": "/activity?target=<extractionId>" } }
```

GET `operation.poll` until the work is done. For the kinds the Activity view derives (`extraction`, `document`, `draft`, `review`, `clarification`, `export`), `GET /activity?target=<id>` returns that one row:

```json
{ "items": [ { "kind": "extraction", "id": "…", "state": "running", "stale": false,
               "progress": { "mode": "elapsed", "elapsedMs": 41000, "typicalMs": 290000, "upperMs": 480000, "basis": "observed", "samples": 31 },
               "error": null, "retryHref": null, "requestedAt": "…", "finishedAt": null } ],
  "inFlight": 1, "window": "7d", "state": "all" }
```

- `state` is `queued` | `running` | `done` | `failed`. `stale: true` means it has run past the kind's threshold and is treated as failed; `retryHref` is the route that retries it.
- `progress` is honest: real stages where the row has them, elapsed against the observed typical duration otherwise, a queue wait when nothing has started. There is never a percentage.
- Other kinds name the object's own status route: a fast-path run on `/tenders/:id/fast-path`, an interview on `/tenders/:id/gng/interview`, an incumbent search on `/tenders/:id/gng/prefill`, a block fix or tighten on `/questions/:id/paragraphs` (the block's `aiRequestedAt` / `aiError` and its rewritten text), a clarification email or reply on `/tenders/:id` (`clarificationBatches`).

Etiquette: poll every 3–5 s for the first minute, then every 15–30 s; an extraction typically takes 4–8 minutes, a draft or review under a minute. A repeat of the same POST while the job is in flight returns the same 202 with `alreadyQueued: true` — one paid job, never two — except on `POST /tenders/:id/extract`, `POST /clarifications/:id/redraft` and `POST /documents/:id/reingest`, which answer 409 `conflict` while a run is in flight: do not retry unchanged; poll the run you already hold (`GET /tenders/:id/extractions` names it). If the enqueuing POST answers 503 `queue_unavailable`, nothing was queued: retry that POST later.

## 7. Uploads

Uploads are raw bytes, not multipart:

```bash
curl -s -X POST https://waywiser-api-zxgq66afoq-nw.a.run.app/tenders/<tenderId>/documents/upload \
  -H 'Authorization: Bearer ww_live_…' \
  -H 'Content-Type: application/octet-stream' \
  -H 'X-Filename: ITT%20pack.pdf' \
  --data-binary @ITT-pack.pdf
```

- `X-Filename` is percent-encoded (`encodeURIComponent`); the extension decides the parser: `.pdf`, `.docx`, `.xlsx`, `.pptx`, `.odt`, `.ods` (the facts bank takes `.pdf` and `.docx` only).
- The bytes are sniffed: the container must match the extension. An `.html` renamed `.pdf` is 422 `unsupported_file` and nothing is stored.
- 60 MiB per file (413 `payload_too_large` above it; the transport allows 64 MiB so that ceiling answers with its own message). Three routes take bytes: `/tenders/:id/documents/upload` (the ITT pack), `/corpora/:id/documents/upload` (the library; `X-Doc-Type` optional), `/facts/upload` (the facts bank; `X-Fact-Type` optional).
- Ingestion runs in the background; a library document is a `document` operation on `/activity?target=<documentId>`. The pack itself is parsed when you analyse the tender.

## 8. Idempotency: natural keys, no `Idempotency-Key` header

Every POST a person could repeat is idempotent at the api by the action's natural identity (ADR-0012), so an agent that times out simply retries the same call:

- **creates** dedupe by content and return the existing row with **200** instead of 201: a tender by title + buyer within a minute, a corpus by name, a fact by type + label + value, a draft block by text + first citation, a comment by author + body + thread, a clarification by topic;
- **enqueues** dedupe by the row's own marker: a repeat while queued or running is the same 202 with `alreadyQueued: true` — except extract, clarification redraft and reingest, which refuse the repeat with 409 `conflict` (§6);
- **state transitions** keep the first stamp (sign-off, comment resolve, the go/no-go decision, submission);
- **ordered mutations** carry the position you saw (`expectedIdx` on a block move) so a stale repeat is a no-op;
- **raw-byte uploads** are the one exception — their identity is the bytes, so the same file twice is two documents.

Each operation in `/openapi.json` states its rule as `x-waywiser-idempotency`.

## 9. Errors

Every non-2xx body has the same shape:

```json
{ "error": "tender not found", "code": "not_found" }
{ "error": "title: Required; buyerType: Invalid enum value", "code": "validation_failed",
  "issues": [ { "path": "title", "message": "Required" }, { "path": "buyerType", "message": "Invalid enum value …" } ] }
{ "error": "Too many requests — try again in 1 minute", "code": "rate_limited", "retryAfterSeconds": 60 }
```

`error` is the sentence a person sees; `code` is closed and is what to branch on; `issues` comes with `validation_failed`; `retryAfterSeconds` with every 429; `hint` when the api has more to say than the message.

| `code` | Status | What it means and what to do |
| --- | --- | --- |
| `validation_failed` | 400 | A body, query or header failed validation; `issues[]` names each path and message. Fix the arguments — do not retry unchanged. |
| `unauthenticated` | 401 | No usable credential: the header is missing, the secret is unknown, or a WorkOS token is invalid. Check the `Authorization` header. |
| `forbidden` | 403 | Known identity, not allowed: the row belongs to no company you can act in, or the route wants a company admin and the key’s ceiling is `member`. |
| `no_active_company` | 403 | The member the key acts as has no active company (removed, or never invited). Nothing an agent can fix — tell the person. |
| `read_only_key` | 403 | The key is read-only and the request writes. Ask for a key without `readOnly`. |
| `human_only_route` | 403 | The route is the identity/tenancy plane (members, invitations, grants, keys, company delete/export, /admin/*), refused to every key. A person does this in the web app. |
| `company_mismatch` | 400 | `X-Company-Id` names a company other than the key’s. Drop the header — a key is pinned to one company. |
| `api_key_required` | 403 | The MCP endpoint takes an API key only; a signed-in session (a WorkOS token) is refused there. Mint a key at /admin/api-keys. |
| `key_revoked` | 401 | The key was revoked. Ask an admin for a new one. |
| `key_expired` | 401 | The key passed its expiry. Ask an admin for a new one. |
| `key_membership_gone` | 401 | The member the key acts as is no longer in the company. The key is dead. |
| `not_found` | 404 | No such row in this company. Another company’s rows look exactly like this — never a hint that they exist. |
| `conflict` | 409 | Well-formed, but the resource is not in the right state. `error` says what to do (analyse first, create the plan first, already running…). Do not retry unchanged. |
| `payload_too_large` | 413 | JSON bodies are capped at 4 MiB, uploads at 60 MiB per file, the MCP envelope at 24 MiB. |
| `unsupported_file` | 415 / 422 | The upload is a kind the reader cannot take (extension), or its bytes are not what the name claims. Nothing was stored. |
| `rate_limited` | 429 | A per-key bucket is spent. Wait `retryAfterSeconds` (also the `Retry-After` header), then retry the same call. |
| `ai_budget_exceeded` | 429 | The company’s daily AI token ceiling is spent; `retryAfterSeconds` is 3600 as an honest granularity, not a promise. Paid actions (analyse, draft, review, fix, tighten, interview) fail closed until tokens age out of the rolling 24-hour window. |
| `queue_unavailable` | 503 | The job queue is down; nothing was queued and any marker was rolled back. Retry later — it is safe. |
| `service_unavailable` | 503 | A dependency the route needs is not configured or not reachable (object storage, a model provider). Retry later. |
| `internal` | 5xx | Ours. The response carries no detail; the request id is in our logs. Retry once, then stop and report it. |

## 10. The workflows

1. **Qualify a notice → a bid.** `GET /notices` (fit-ranked against `/profile`) → `POST /notices/:id/pursue` (a qualifying tender; the buyer documents are fetched into its pack) or `POST /tenders` by hand → upload the ITT pack → `POST /tenders/:id/extract` (202; poll) → `GET /tenders/:id/gng/prefill`, `POST /tenders/:id/gng/auto` or `/gng`, optionally the interview (`POST /tenders/:id/gng/interview`, answers) → `POST /gng/:id/decide` (**ask the person**) → `POST /tenders/:id/plan` → per question: `GET /questions/:id/workspace`, `GET /questions/:id/evidence`, `POST /questions/:id/draft` (202), edit blocks, `POST /questions/:id/review` (202), `GET /questions/:id/review`, fix flagged claims → `GET /tenders/:id/readiness` → `POST /tenders/:id/export` (.docx) → `POST /tenders/:id/submit` (**ask the person**; never set `force` yourself) → `POST /tenders/:id/outcome` when the result is known.
2. **Maintain the library.** `GET /corpora`, `GET /corpora/:id/documents`, upload, `PATCH /documents/:id`, retry a failed ingest with `POST /documents/:id/reingest`; `POST /search` to see what the drafts would be grounded in.
3. **Maintain the facts bank.** `GET /facts` (expired facts are marked — they never enter a draft and they block the checklist), `POST /facts`, `PATCH /facts/:id`, `POST /facts/upload`, `POST /documents/:id/regrade`.
4. **Clarifications.** `POST /tenders/:id/clarifications` → `POST /tenders/:id/clarification-batches` (202: one email for everything open) → `POST /clarification-batches/:id/sent` → `POST /clarification-batches/:id/reply` (202: the buyer's reply mapped onto the items) → `POST /clarifications/:id/answer`.
5. **Report on the pipeline.** `GET /tenders` (each row carries `nextStep` and step statuses), `GET /tenders/:id/page` for the whole picture in one call, `GET /tenders/:id/activity` for who did what, `GET /activity` for what is running, `GET /benchmarks` and `GET /cost-floor` for pricing.

Reads are cheap; prefer the aggregates (`/tenders/:id/page`, `/questions/:id/workspace`) over several per-resource calls — each request holds one database connection for its duration.

## 11. Quick starts

### curl

```bash
export WAYWISER_API_KEY=ww_live_…
curl -s https://waywiser-api-zxgq66afoq-nw.a.run.app/me -H "Authorization: Bearer $WAYWISER_API_KEY"
curl -s "https://waywiser-api-zxgq66afoq-nw.a.run.app/tenders?stage=live" -H "Authorization: Bearer $WAYWISER_API_KEY"
curl -s -X POST https://waywiser-api-zxgq66afoq-nw.a.run.app/tenders/<tenderId>/extract -H "Authorization: Bearer $WAYWISER_API_KEY"
curl -s "https://waywiser-api-zxgq66afoq-nw.a.run.app/activity?target=<extractionId>" -H "Authorization: Bearer $WAYWISER_API_KEY"
```

### Python

```python
import os, time, requests

API = "https://waywiser-api-zxgq66afoq-nw.a.run.app"
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['WAYWISER_API_KEY']}"

me = s.get(f"{API}/me").json()
print(me["activeCompanyId"], me["role"], me["actor"]["name"])

tenders = s.get(f"{API}/tenders", params={"stage": "live"}).json()["items"]
tender_id = tenders[0]["id"]

r = s.post(f"{API}/tenders/{tender_id}/extract")
if r.status_code == 429:
    time.sleep(r.json()["retryAfterSeconds"]); r = s.post(f"{API}/tenders/{tender_id}/extract")
r.raise_for_status()
op = r.json()["operation"]

while True:
    item = s.get(f"{API}{op['poll']}").json()["items"][0]
    if item["state"] in ("done", "failed"):
        break
    time.sleep(5)
print(item["state"], item.get("error"))
```

Any OpenAPI client generator works against the reference, e.g. `openapi-python-client generate --url https://waywiser-api-zxgq66afoq-nw.a.run.app/openapi.json`.

### Claude Code (MCP)

See §12: the same key, one `claude mcp add` command, and the tools it gets.

## 12. MCP

The same key drives an MCP server (Streamable HTTP, stateless) at `POST /mcp`: a curated set of tools, each of which is one of the routes above called in-process with your key — so the company pinning, the role ceiling, read-only keys, rate limits, the AI budget and the audit trail are exactly those of §2–§8, and a call a route refuses returns the same `{ code, error, hint, issues, retryAfterSeconds }` body as REST, flagged `isError`. The endpoint takes a key only: a signed-in session is refused with `api_key_required`.

One JSON-RPC message per POST: a batch array is refused with 400 — a JSON-RPC error whose message says so, `code: "validation_failed"` beside it — as MCP 2025-06-18 removed batching. The envelope has a bucket of its own, 240 calls per minute per key, on top of each tool's route bucket; a 429 there carries `Retry-After` like any other. A few refusals come from the MCP SDK rather than a route and carry no `code`: an unknown tool name, and arguments the tool's input schema rejects, are JSON-RPC errors (`-32602`, the message names the field); a request without `Accept: application/json, text/event-stream` is 406; an unsupported `MCP-Protocol-Version` header is 400. `GET` and `DELETE /mcp` answer 405 with `code: "validation_failed"`.

Connect from Claude Code:

```bash
claude mcp add --transport http waywiser https://waywiser-api-zxgq66afoq-nw.a.run.app/mcp \
  --header "Authorization: Bearer ww_live_…"
```

Or, checked into a project without the secret, `.mcp.json` with the key in the environment:

```json
{
  "mcpServers": {
    "waywiser": {
      "type": "http",
      "url": "https://waywiser-api-zxgq66afoq-nw.a.run.app/mcp",
      "headers": { "Authorization": "Bearer ${WAYWISER_API_KEY}" }
    }
  }
}
```

Any MCP client that speaks Streamable HTTP works the same way: JSON-RPC over POST with `Accept: application/json, text/event-stream`; responses are JSON (no SSE stream, no session — `GET` and `DELETE /mcp` answer 405). Rules of the road, which each tool's description repeats where it applies:

- **Start with `whoami`** — the member, the company and the key's `readOnly` flag. A read-only key reaches every tool whose route is a GET and is refused `read_only_key` on the rest, which `whoami` lists — `search-knowledge` and `export-tender` included: POSTs that read (24 of the 41 tools are refused).
- **Background work** comes back at once as `operation: { kind, targetId }`; poll `get-operation` with that `targetId` (every 3–5 s for the first minute, then every 15–30 s). Nothing blocks server-side.
- **Lists page by cursor**: pass a result's `nextCursor` back as `cursor`; `null` is the last page. Pages default to 25 rows.
- **Uploads are base64**, capped at 10 MB decoded and refused before any request above that; the refusal's `hint` is the curl one-liner of §7 for the same file with the same key. A body over 24 MiB (a file of 18 MB or more) is refused at the transport instead — HTTP 413 with a JSON-RPC error whose message carries the same one-liner.
- **`export-tender` returns the .docx** as an embedded resource (base64 blob) beside a text part naming the file and its size.
- **Irreversible tools** — `decide-go-no-go`, `submit-tender` — are the person's decision: ask, then pass what they said. `submit-tender` without `force` reports the undrafted questions instead of submitting; never set `force` on your own.
- Everything else a key can do (settings, snapshots, reingest, corpus management…) is REST-only: use §11 with the same key.

| Tool | Wraps | What it does |
| --- | --- | --- |
| `whoami` | `GET /me` | Who am I: the member this key acts as, the company it is pinned to, the role and the key itself |
| `list-activity` | `GET /activity` | Background work, derived from the status machines: extractions, documents, drafts, reviews, clarification drafts, exports |
| `get-operation` | `GET /activity` | Background work, derived from the status machines: extractions, documents, drafts, reviews, clarification drafts, exports |
| `list-notices` | `GET /notices` | Live tender notices for this company, fit-ranked by default |
| `pursue-notice` | `POST /notices/:id/pursue` | Pursue a notice: create a qualifying tender from it (one click from Find to the pipeline) |
| `dismiss-notice` | `POST /notices/:id/dismiss` | "Not for us": hide a notice from the feed |
| `list-tenders` | `GET /tenders` | The pipeline: every tender with where it is on the bid journey |
| `get-tender` | `GET /tenders/:id` | A tender in full: questions, checklist, clarifications and batches, pack documents, go/no-go, submission, extraction flags, progress |
| `create-tender` | `POST /tenders` | Create a tender by hand (not from a notice) |
| `set-tender-stage` | `PATCH /tenders/:id/stage` | Move a tender between stages (qualifying, live, submitted, archived) |
| `upload-tender-document` | `POST /tenders/:id/documents/upload` | Upload an ITT pack file to a tender (raw bytes) — the entry point to analysis, plan, workspace and drafting |
| `analyse-tender` | `POST /tenders/:id/extract` | Analyse the pack: parse, extract questions, weighting, deadlines and compliance items, flag low confidence |
| `list-extractions` | `GET /tenders/:id/extractions` | Every extraction run for the tender, newest first (status, flags, error) |
| `create-bid-plan` | `POST /tenders/:id/plan` | Create the bid plan from the latest complete extraction (questions + checklist), once |
| `get-readiness` | `GET /tenders/:id/readiness` | Bid readiness: weight-averaged review coverage, 0–100 (a review measure, never a score forecast) |
| `get-question` | `GET /questions/:id/workspace` | A question in one call: the question, sibling list, draft blocks, comments, snapshots, latest review, members |
| `get-question-evidence` | `GET /questions/:id/evidence` | The persisted evidence panel: passages retrieved for this question, filtered to what the caller may read |
| `draft-question` | `POST /questions/:id/draft` | Draft the answer: a grounded first draft from the evidence, as a background job |
| `review-question` | `POST /questions/:id/review` | Run the evaluator review: criteria, gaps and coverage against the rubric (background job) |
| `get-review` | `GET /questions/:id/review` | The latest review: per-criterion findings, gaps, strengths, coverage |
| `add-paragraph` | `POST /questions/:id/paragraphs` | Append a draft block (prose, heading, bullets, table or exhibit) with its citations |
| `update-paragraph` | `PATCH /paragraphs/:id` | Edit a block (text, kind, citations, or clear/set the unverified flag) |
| `fix-paragraph` | `POST /paragraphs/:id/fix` | Fix an amber-flagged claim from the facts bank (background job) |
| `log-clarification` | `POST /tenders/:id/clarifications` | Log a clarification to raise with the buyer; the portal question is drafted in the background unless `draftQuestion` is given |
| `answer-clarification` | `POST /clarifications/:id/answer` | Record the buyer's answer, optionally applying it to the affected question's weighting or word limit with provenance |
| `draft-clarification-email` | `POST /tenders/:id/clarification-batches` | Compose one email covering every open clarification (background job) |
| `get-go-no-go` | `GET /tenders/:id/gng/prefill` | What the platform can score before anyone decides: factors with evidence, coverage, the incumbent search |
| `run-go-no-go` | `POST /tenders/:id/gng/auto` | One-click go/no-go: score every factor with evidence, name what could not be scored |
| `decide-go-no-go` (ask the person) | `POST /gng/:id/decide` | Record the bid / no-bid decision on a run (the only route that archives a live bid) — ask the person first |
| `export-tender` | `POST /tenders/:id/export` | Export the submission as .docx (the whole bid, or one question) |
| `submit-tender` (ask the person) | `POST /tenders/:id/submit` | Mark the bid submitted — freezes the decision and moves it out of Live (irreversible; ask the person first) |
| `record-outcome` | `POST /tenders/:id/outcome` | Label the outcome (won / lost) with the buyer scores — closes the learning loop |
| `list-corpora` | `GET /corpora` | The corpora the caller may browse (never the central proprietary corpus) |
| `list-documents` | `GET /corpora/:id/documents` | Documents in a corpus, paginated, with status and quality counts |
| `upload-document` | `POST /corpora/:id/documents/upload` | Upload a document into a corpus (raw bytes); ingestion — parse, chunk, embed — runs in the background |
| `search-knowledge` | `POST /search` | Hybrid retrieval over what the caller may read: passages, past answers, facts — with provenance |
| `list-facts` | `GET /facts` | The facts bank (current rows by default) with each upload’s grade; expired facts are marked and never enter drafts |
| `create-fact` | `POST /facts` | Add a fact by hand (label, type, value, scope, expiry) |
| `upload-facts-document` | `POST /facts/upload` | Upload a facts-bank document (raw bytes): ingested like any document AND read against the fact type’s required fields, then graded |
| `get-company-profile` | `GET /profile` | The company profile that drives tender matching (service lines, regions, value bands, buyer types) |
| `get-benchmarks` | `GET /benchmarks` | Winning-price distribution for comparable lots (public award data, or the company’s own wins) |
