# Completions and data extraction

> POST /v1/ai/complete runs a masked, routed, metered completion with optional context blocks, JSON mode and automatic fact extraction.

`POST /v1/ai/complete` is the native Subsidia completion endpoint. It goes through the same pipeline as the [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md) (PII shield, routing by task complexity, metering against your question allowance) but takes a simpler body, and adds two things the OpenAI dialect has no place for:

- **Supplementary context**: structured data blocks (a customer record, a price list) that are rendered into the system prompt in a consistent, prioritised format.
- **Automatic extraction**: after the answer, a second light call pulls typed facts out of the user message and stores them, so a chat turns into structured records as a side effect.

Use the Gateway when you want drop-in compatibility with an existing SDK. Use this endpoint when you are writing new code and want context injection, JSON output or extraction in one call.

> **WARNING: Scope and differences from the Gateway**
> Needs the `engine` scope, like the Gateway; the extracted-data routes below too. This endpoint does **not** return an `x-pulse-proof` certificate and has no model addressing (`pulse-auto`, `agent:`, `+cortex`): routing is decided by Synapse from the request. If you need a certificate or knowledge retrieval, use the [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md) or [`/v1/knowledge/query`](https://dev.subsidia.protypa.fr/docs/knowledge-query.md).

### POST /v1/ai/complete

Run a completion, optionally with context blocks, JSON output and fact extraction.

Runs one chat completion. Personal data is masked before an external provider sees it and restored in the answer; detected-sensitive requests are kept on a local model. The call is metered against the workspace allowance of questions.

The answer comes back whole (no streaming). For streaming, use the [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md).

- **Authentication:** API key (Bearer or x-api-key)
- **Scopes:** `engine`

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `systemPrompt` | `string` | yes |  | The instructions. Must not be empty. |
| `messages` | `object[]` | yes |  | The conversation so far. Must be a non-empty array; the last `user` message drives routing and extraction. |
| `messages.role` | `string` | yes |  | Author of the message. One of: `user`, `assistant`, `system`. |
| `messages.content` | `string` | yes |  | Text of the message. |
| `supplementary` | `object[]` | no |  | Structured context appended to the system prompt under a `SUPPLEMENTARY CONTEXT` heading. Items are sorted by priority, high first; each becomes a section titled by its label or category. |
| `supplementary.category` | `string` | yes |  | Kind of data, for example `customer` or `pricing`. Used as the section title when there is no `label`. |
| `supplementary.label` | `string` | no |  | Human title of the section. |
| `supplementary.data` | `object | string` | yes |  | The content. Objects are serialised as indented JSON, strings are inserted as is. |
| `supplementary.priority` | `string` | no | `medium` | Ordering of the sections. `high` is flagged as high priority to the model. One of: `high`, `medium`, `low`. |
| `extraction` | `object` | no |  | Extract typed facts from the last user message, after the answer. Extraction cost is a system cost and is not added to the question count of the call. |
| `extraction.source` | `string` | yes |  | A label of your choosing that groups the facts (a customer id, a form name). Used for filtering and for deduplication. |
| `extraction.categories` | `string[]` | yes |  | Allowed categories, non-empty, for example `["contact", "preferences"]`. Facts in any other category are dropped. |
| `extraction.persist` | `boolean` | no | `true` | Store the facts so they can be listed later. With `false` they are only returned in `extracted`. |
| `json` | `boolean` | no |  | Ask for a JSON object as the answer. For models without native JSON mode, an instruction is appended to the system prompt. Still validate what you receive. |
| `max_tokens` | `number` | no |  | Output ceiling. |
| `temperature` | `number` | no |  | Sampling temperature. Lower it (0 to 0.2) for extraction and classification. |

#### Request examples

_curl_

```bash
curl https://api.subsidia.protypa.fr/v1/ai/complete \
  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "systemPrompt": "You are a support assistant. Answer briefly.",
    "messages": [
      {"role": "user", "content": "Hi, I am Marie Lambert, please call me on 06 12 34 56 78 after 5pm."}
    ],
    "supplementary": [
      {"category": "customer", "label": "Account", "priority": "high", "data": {"plan": "Pro", "since": "2024-03"}}
    ],
    "extraction": {"source": "ticket-4812", "categories": ["contact", "preferences"]},
    "temperature": 0.2
  }'
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr/v1/ai/complete', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    systemPrompt: 'You are a support assistant. Answer briefly.',
    messages: [{ role: 'user', content: 'Hi, I am Marie Lambert, please call me on 06 12 34 56 78 after 5pm.' }],
    supplementary: [{ category: 'customer', label: 'Account', priority: 'high', data: { plan: 'Pro', since: '2024-03' } }],
    extraction: { source: 'ticket-4812', categories: ['contact', 'preferences'] },
    temperature: 0.2,
  }),
})
if (res.status === 402) throw new Error('Out of questions')
const { content, usage, extracted } = await res.json()
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr/v1/ai/complete",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    json={
        "systemPrompt": "You are a support assistant. Answer briefly.",
        "messages": [
            {"role": "user", "content": "Hi, I am Marie Lambert, please call me on 06 12 34 56 78 after 5pm."}
        ],
        "supplementary": [
            {"category": "customer", "label": "Account", "priority": "high", "data": {"plan": "Pro", "since": "2024-03"}}
        ],
        "extraction": {"source": "ticket-4812", "categories": ["contact", "preferences"]},
        "temperature": 0.2,
    },
)
body = res.json()
print(body["content"], body["extracted"])
```

#### Responses

**200**: `content` is the answer with personal data restored. `usage` is the token usage of the main call. `extracted` lists the facts found in the user message (empty when `extraction` was not requested, or nothing matched). The body also carries diagnostic fields about routing and latency; do not depend on them.

```json
{
  "content": "Thanks Marie, we will call you after 5pm.",
  "usage": { "promptTokens": 142, "completionTokens": 14, "totalTokens": 156 },
  "extracted": [
    { "category": "contact", "key": "phone", "value": "06 12 34 56 78", "confidence": 0.97 },
    { "category": "preferences", "key": "call_window", "value": "after 5pm", "confidence": 0.9 }
  ]
}
```

**400**: `systemPrompt` missing, `messages` missing or empty, `extraction.source` missing, or `extraction.categories` empty.

```json
{ "error": "messages array is required and must not be empty" }
```

**402**: The workspace has no questions left. Nothing was consumed.

```json
{ "error": "..." }
```

**500**: The model call failed.

```json
{ "error": "AI completion failed" }
```

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 400 |  | Invalid body, see above. |
| 401 |  | Missing or invalid key. |
| 402 |  | Workspace allowance exhausted. |
| 402 | `API_KEY_BUDGET_EXCEEDED` | The key reached its own monthly question ceiling. |
| 403 | `scope_denied` | The key lacks the `engine` scope. |
| 429 | `rate_limited` | More than 30 requests per minute (also the per-key limit when one is set). |
| 500 |  | Provider or internal failure. |

## Use cases

### JSON output for a pipeline

Set `json: true` and describe the shape in `systemPrompt`. Keep the temperature low and parse defensively: a model can still return something that is not the shape you asked for, so validate before you trust it.

**Classify an incoming email into a typed object**

_TypeScript_

```typescript
type Triage = { category: 'invoice' | 'contract' | 'other'; urgent: boolean; summary: string }

async function triage(emailText: string): Promise<Triage | null> {
  const res = await fetch('https://api.subsidia.protypa.fr/v1/ai/complete', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      systemPrompt:
        'Classify the email. Reply with a JSON object: ' +
        '{"category": "invoice" | "contract" | "other", "urgent": boolean, "summary": string (max 20 words)}.',
      messages: [{ role: 'user', content: emailText }],
      json: true,
      temperature: 0,
      max_tokens: 300,
    }),
  })
  const { content } = await res.json()
  try {
    const parsed = JSON.parse(content)
    const ok = ['invoice', 'contract', 'other'].includes(parsed.category) && typeof parsed.urgent === 'boolean'
    return ok ? (parsed as Triage) : null
  } catch {
    return null // send to a human instead of guessing
  }
}
```

_Python_

```python
import json, os, requests

def triage(email_text):
    res = requests.post(
        "https://api.subsidia.protypa.fr/v1/ai/complete",
        headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
        json={
            "systemPrompt": 'Classify the email. Reply with a JSON object: '
            '{"category": "invoice" | "contract" | "other", "urgent": boolean, "summary": string (max 20 words)}.',
            "messages": [{"role": "user", "content": email_text}],
            "json": True,
            "temperature": 0,
            "max_tokens": 300,
        },
    )
    try:
        parsed = json.loads(res.json()["content"])
    except (ValueError, KeyError):
        return None  # send to a human instead of guessing
    if parsed.get("category") not in ("invoice", "contract", "other") or not isinstance(parsed.get("urgent"), bool):
        return None
    return parsed
```

### Fact extraction as a side effect of a chat

With `extraction`, each call that includes a customer message also leaves behind structured facts, grouped by your `source` label and one of your `categories`. Facts are upserted on the combination of source, category and key, so a customer who corrects their phone number updates the record rather than adding a second one. Read them back with the routes below.

> **INFO: Facts here are not Cortex facts**
> Extracted data belongs to the **user** of the key and holds what people say in conversations. The facts of [`/v1/knowledge/facts`](https://dev.subsidia.protypa.fr/docs/knowledge-insights.md) belong to the **workspace** and describe what its documents assert.

## Extracted data

### GET /v1/ai/extracted-data

List the facts extracted by your completions.

Newest first. Scoped to the user the key belongs to, not to the whole workspace. Filter by `source` to retrieve everything gathered for one customer or one ticket.

- **Authentication:** API key
- **Scopes:** `engine`

#### Query parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `source` | `string` | no |  | Only facts stored under this `extraction.source`. |
| `category` | `string` | no |  | Only this category. |
| `limit` | `number` | no | `100` | Maximum number of facts, capped at 500. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/v1/ai/extracted-data?source=ticket-4812&category=contact" \
  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const url = new URL('https://api.subsidia.protypa.fr/v1/ai/extracted-data')
url.searchParams.set('source', 'ticket-4812')
const { data, count } = await fetch(url, {
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
}).then((r) => r.json())
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr/v1/ai/extracted-data",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    params={"source": "ticket-4812"},
).json()
print(res["count"], res["data"])
```

#### Responses

**200**: The facts and their number.

```json
{
  "data": [
    {
      "id": "clx9m1zq20003xyz",
      "source": "ticket-4812",
      "category": "contact",
      "key": "phone",
      "value": "06 12 34 56 78",
      "confidence": 0.97,
      "createdAt": "2026-10-09T09:31:10.000Z",
      "updatedAt": "2026-10-09T09:31:10.000Z"
    }
  ],
  "count": 1
}
```

### GET /v1/ai/extracted-data/:id

Get one extracted fact.

- **Authentication:** API key
- **Scopes:** `engine`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | Fact id. |

#### Request examples

_curl_

```bash
curl https://api.subsidia.protypa.fr/v1/ai/extracted-data/$FACT_ID -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_Python_

```python
import os, requests

fact = requests.get(
    "https://api.subsidia.protypa.fr/v1/ai/extracted-data/" + fact_id,
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
).json()["data"]
```

#### Responses

**200**: The fact.

```json
{ "data": { "id": "clx9m1zq20003xyz", "source": "ticket-4812", "category": "contact", "key": "phone", "value": "06 12 34 56 78", "confidence": 0.97 } }
```

**404**: Unknown id, or the fact belongs to another user.

```json
{ "error": "Not found" }
```

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 404 |  | No such fact for this user. |

### DELETE /v1/ai/extracted-data/:id

Delete one extracted fact.

Use it to honour an erasure request about a person whose details you extracted: delete by `source` listing, fact by fact.

- **Authentication:** API key
- **Scopes:** `engine`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | Fact id. |

#### Request examples

_curl_

```bash
curl -X DELETE https://api.subsidia.protypa.fr/v1/ai/extracted-data/$FACT_ID -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_Python_

```python
import os, requests

headers = {"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]}
base = "https://api.subsidia.protypa.fr/v1/ai/extracted-data"
for fact in requests.get(base, headers=headers, params={"source": "ticket-4812"}).json()["data"]:
    requests.delete(base + "/" + fact["id"], headers=headers)
```

#### Responses

**204**: Deleted. No body.

**404**: No such fact for this user.

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 403 | `read_only_key` | The key is read-only. |

## Tips

| Goal | Advice |
| --- | --- |
| Reliable extraction | Keep `categories` short and meaningful; anything outside them is discarded. Use a stable `source` so repeated mentions update the same record. |
| Reliable JSON | Describe the exact shape, set `temperature` to 0, validate, and have a fallback path to a person. |
| Context from your systems | Put records in `supplementary` rather than pasting them into the user message: they are labelled, ordered by priority and kept apart from the question. |
| Personal data | Masking applies to external providers and is restored in `content`. Persisted facts hold the values people gave you (here, a phone number): treat the extracted-data store as personal data and delete on request. |
| Grounding in documents | This endpoint does not retrieve from your knowledge base. Retrieve with [`/v1/knowledge/query`](https://dev.subsidia.protypa.fr/docs/knowledge-query.md) and pass the passages in `supplementary`, or use [Light](https://dev.subsidia.protypa.fr/docs/light.md). |
