Two different jobs that happen to share a provider, plus the history the first one leaves behind.

**`POST /ai/chat`** answers questions grounded in a workspace's own Context Index - retrieval over their data, not a general chatbot. Answers cite what they drew on. It streams, so it does not answer with a JSON body; see the endpoint below for how to read it.

**`POST /ai/extract`** pulls structured JSON out of a file or image against a named schema. The schema is platform-managed (Console → Settings → AI) and referenced by id, so extraction shape is reviewed rather than passed inline per request.

**`/ai/conversations`** is what a user asked before. Nothing lands there unless two things are true of the chat turn that made it: it carried a `conversationId` you chose, and it carried the end user's session token. A conversation belongs to a *person*, and a server-to-server call acts as nobody - so a partner's own backend can chat all day and store nothing, which is the behaviour this API has always had and still has.

```ts
// Streams. `messages` is an AI SDK `UIMessage[]`, not a single string.
const stream = await connect.ai.chat({
  workspaceId,
  messages: [{ role: "user", parts: [{ type: "text", text: "What is our refund window?" }] }],
  // Both of these together are what makes the turn resumable.
  conversationId,
  asUser: sessionToken,
});

const { conversations } = await connect.ai.listConversations({ asUser: sessionToken });
const { turns } = await connect.ai.getConversation({ conversationId, asUser: sessionToken });

const { data } = await connect.ai.extract({
  fileUrl,
  schemaId: "invoice_v2",
});
```

If you use `<ConnectAskAi>` none of the above is yours to wire: the panel picks the id, the bridge forwards the token, and History and New are already in its header.

Both generative endpoints are grounded in the customer's data, so both answer only for a workspace that has some. An empty Context Index returns an empty answer rather than a hallucinated one - handle that as a real case.

## Endpoints

### POST `/api/v1/application/ai/chat`

RAG-grounded chat against a workspace's Context Index

**Scope** `ai:chat` &nbsp;·&nbsp; **Rate limit** 30 requests / min per key

Lets your own users chat (RAG-grounded) with a subscribing workspace's Drive-fed Context Index through Connect, without you having to run any AI infrastructure yourself.

#### Implementation

**1. Prerequisites** - an API key scoped to `ai:chat`, and the target workspace must have RAG enabled for its workspace (a workspace admin toggle in their Connect Workspace settings - Connect returns `503 unavailable` if it isn't).

**2. Send the request** - `messages` is a Vercel AI SDK `UIMessage[]` (the same shape `useChat` produces client-side):
```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/ai/chat \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId":"WORKSPACE_ID","messages":[{"role":"user","parts":[{"type":"text","text":"What is our Q3 revenue?"}]}]}'
```

**3. Consume the streamed response** - this is not a single JSON body, it's the AI SDK's UI message stream protocol. If your frontend is React, point the AI SDK's `useChat` hook's `transport` at a thin proxy in your own backend that forwards to this endpoint (never call it directly from a browser - it needs your server-held API key). If you're consuming it server-side instead, use the AI SDK's stream-reading helpers rather than parsing the wire format by hand.

#### Errors
`validation_error` (400 - missing `workspaceId`/`messages`), `forbidden` (403 - key missing `ai:chat`), `rate_limited` (429), `unavailable` (503 - RAG not enabled for this workspace; have the workspace admin enable it first).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `messages` | `array` | yes | Vercel AI SDK UIMessage[] - https://ai-sdk.dev/docs/reference/ai-sdk-ui/ui-message |
| `conversationId` | `string` | no | Your own id for this conversation - generate one per conversation client-side and send it with every turn. Only persisted when the request also carries X-Connect-Session-Token, because a conversation belongs to a user; a server-to-server call stores nothing. Omit it to opt out of history entirely. |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | text/event-stream - AI SDK UI message stream |
| `400` | validation_error - missing workspaceId/messages |
| `403` | forbidden - key missing ai:chat |
| `429` | rate_limited |
| `503` | unavailable - RAG not enabled for this workspace |

### GET `/api/v1/application/ai/conversations`

The signed-in user's past Ask AI conversations

**Scope** `ai:chat` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key

Lists the conversations this user has had with Ask AI **in your application**, most recently used first - the History panel in `<ConnectAskAi>`, and what you would build one from if you are not using the SDK's panel.

A conversation belongs to a **person**, so all three of these require the end user's session token in `X-Connect-Session-Token` as well as your API key - the same token the SDK bridge forwards. The user and the workspace are both read from it, never from the request, and the application is your own key's: your users never see each other's history, another tenant's, or the one they keep in a different product.

#### Implementation

**1. Send a `conversationId` with every `/ai/chat` turn.** Nothing appears here otherwise: history is a byproduct of chatting, and a turn sent without an id is deliberately not stored.

```bash
curl https://connect.cosmoops.com/api/v1/application/ai/conversations \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "X-Connect-Session-Token: END_USER_SESSION_TOKEN"
```

**2. Page with `cursor`**, not an offset. The list is ordered by last use, which moves while it is being read, so an offset would skip and repeat rows. There is no total, for the same reason there is no count on any unbounded list here.

#### Errors
`validation_error` (400 - `limit` out of range), `forbidden` (403 - key missing `ai:chat`, or no/expired `X-Connect-Session-Token`), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `limit` | query | no | 1-50, default 20. |
| `cursor` | query | no | From a previous response's `nextCursor`. |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | One page of conversations |
| `400` | validation_error - limit out of range |
| `403` | forbidden - key missing ai:chat, or no/expired session token |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `conversations` | `array` | yes |  |
| `nextCursor` | `any` | yes | Pass back as ?cursor= for the next page; null at the end. |

### GET `/api/v1/application/ai/conversations/{conversationId}`

One stored transcript, ready to reopen

**Scope** `ai:chat` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key

Returns a conversation and its turns in order, in the AI SDK's own `UIMessage` shape - so you can hand them straight back to your chat UI and carry on writing into the same `conversationId`.

A conversation belongs to a **person**, so all three of these require the end user's session token in `X-Connect-Session-Token` as well as your API key - the same token the SDK bridge forwards. The user and the workspace are both read from it, never from the request, and the application is your own key's: your users never see each other's history, another tenant's, or the one they keep in a different product.

`turns[].parts` is passed through as opaque JSON. It interleaves prose, reasoning and tool calls in the order the model produced them; flattening it to text would make the transcript unfaithful and make resuming it impossible.

A conversation that is not this user's - or not this application's - answers `404` either way. You supplied nothing but an id, and saying which it was would confirm the existence of a transcript you were never given.

#### Errors
`validation_error` (400 - missing `conversationId`), `forbidden` (403 - key missing `ai:chat`, or no/expired `X-Connect-Session-Token`), `not_found` (404), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `conversationId` | path | yes | The id you sent with the chat turns. |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The conversation and its turns |
| `400` | validation_error - missing conversationId |
| `403` | forbidden - key missing ai:chat, or no/expired session token |
| `404` | not_found - no such conversation for this user and application |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `conversation` | `object` | yes |  |
| `turns` | `array` | yes |  |

### DELETE `/api/v1/application/ai/conversations/{conversationId}`

Delete a conversation and its turns

**Scope** `ai:chat` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key

Removes the conversation and every turn beneath it.

A conversation belongs to a **person**, so all three of these require the end user's session token in `X-Connect-Session-Token` as well as your API key - the same token the SDK bridge forwards. The user and the workspace are both read from it, never from the request, and the application is your own key's: your users never see each other's history, another tenant's, or the one they keep in a different product.

Idempotent: deleting something already gone - or belonging to another application - answers `{ "deleted": true }` rather than a 404, so a retried delete after a dropped response does not read as a failure. Someone else's conversation is the one case that refuses, because a silent no-op there would let a caller believe they had deleted it.

#### Errors
`validation_error` (400 - missing `conversationId`), `forbidden` (403 - key missing `ai:chat`, no/expired `X-Connect-Session-Token`, or the conversation belongs to another user), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `conversationId` | path | yes | The id you sent with the chat turns. |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Deleted |
| `400` | validation_error - missing conversationId |
| `403` | forbidden - key missing ai:chat, no/expired session token, or another user's conversation |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `deleted` | `boolean` | yes |  |

### POST `/api/v1/application/ai/generate`

Generate with a model, from your own prompt

**Scope** `ai:generate` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key, and the workspace's monthly token budget

One generation against an allowlisted model: your own system prompt, messages, tool declarations and optional JSON schema. Not `/ai/chat`, which is Connect's assistant grounded in a workspace's Context Index - this is a bare model you prompt yourself.

#### Implementation

**1. Prerequisites** - an API key scoped to `ai:generate`, a live subscription to your application in the workspace you name, and the endpoint enabled for the platform (it answers `503 unavailable` until an operator turns it on).

```bash
curl -X POST "https://connect.cosmoops.com/api/v1/application/ai/generate" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId":"WORKSPACE_ID","system":"Summarise the call in two sentences.","messages":[{"role":"user","content":"TRANSCRIPT"}],"maxOutputTokens":512}'
```

**2. Tools come back to you; Connect never runs them.** Declare `tools` as a name, a description and a JSON Schema for the input. When the model asks for one, it arrives in `toolCalls`; run it, then send the conversation back with the model's turn as an `assistant` message carrying those `toolCalls` and your answer as a `tool` message. One model step per request, so every turn of your loop is rate-limited and budgeted on its own.

**3. Structured answers.** Pass `responseSchema` (a JSON Schema) and the answer comes back parsed in `output` - `null` if the model called a tool or produced something that did not parse.

**4. Bounds.** `model` must be one Connect allows (`gemini-3.7-flash` today, also the default). `maxOutputTokens` defaults to 2048 and may be at most 8192; at most 200 messages, 32 tools and about 200,000 characters of input per request. Usage is counted against the named workspace's monthly AI token budget and refused with `429` once it is spent.

#### Errors
`validation_error` (400; 413 for a request too large), `forbidden` (403), `rate_limited` (429 - this key's rate, or the workspace's monthly budget), `unavailable` (503 - not enabled).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `model` | `string` | no |  |
| `system` | `any` | no |  |
| `messages` | `array` | yes |  |
| `tools` | `array` | no |  |
| `toolChoice` | `any` | no |  |
| `responseSchema` | `any` | no |  |
| `maxOutputTokens` | `integer` | no |  |
| `temperature` | `any` | no |  |
| `thinkingLevel` | `any` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The generation |
| `400` | validation_error |
| `403` | forbidden |
| `413` | validation_error - request too large |
| `429` | rate_limited |
| `503` | unavailable - not enabled |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `model` | `string` | yes |  |
| `text` | `string` | yes |  |
| `output` | `any` | yes |  |
| `toolCalls` | `array` | yes |  |
| `finishReason` | `string` | yes |  |
| `usage` | `object` | yes |  |

### POST `/api/v1/application/ai/call-analysis`

Transcribe and analyze a call recording

**Scope** `ai:generate` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key (shared with /ai/generate), and the workspace's monthly token budget

Connect's call analyzer over a recording your application stored in the workspace's Drive - typically the `fileId` `POST /telephony/recordings/{recordingId}/archive` returned. Answers the transcript as turns (`rep` is the business side, `customer` the other party), a short summary, the caller's intent, sentiment, action items with owners and due dates, and the main language.

Only a file your application uploaded in that workspace can be analyzed; anything else answers `404`. Needs a live subscription to your application in the workspace, and is counted against its monthly AI budget like `/ai/generate` (`429` once spent). Nothing is stored - keep the result on your own call record.

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `fileId` | `string` | yes |  |
| `direction` | `inbound` \| `outbound` | yes |  |
| `repName` | `any` | yes |  |
| `callDate` | `string` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The analysis |
| `400` | validation_error - including a file that is not audio |
| `403` | forbidden |
| `404` | not_found - no such file uploaded by your application |
| `429` | rate_limited - this key's rate, or the workspace's monthly budget |
| `503` | unavailable - this deployment has no call analyzer |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `turns` | `array` | yes |  |
| `summary` | `string` | yes |  |
| `intent` | `string` | yes |  |
| `sentiment` | `positive` \| `neutral` \| `negative` | yes |  |
| `actionItems` | `array` | yes |  |
| `language` | `any` | yes |  |

### POST `/api/v1/application/ai/extract`

Extract structured JSON from a file or image URL

**Scope** `ai:extract` &nbsp;·&nbsp; **Rate limit** 20 requests / min per key

Pulls structured JSON out of a file/image URL, against one of your own admin-defined extraction schemas - a prompt plus a target JSON Schema, run against a file for you.

#### Implementation

**1. Define an extraction schema first** - in this application's Integration tab, create an `AiExtractionSchema`: a name, a `prompt` describing what to extract, and a JSON Schema describing the desired output shape. Note its `id`.

**2. Prerequisite** - an API key scoped to `ai:extract`.

**3. Send the request** - `schemaId` references the schema from step 1 (never sent inline, so it can be reused across requests):
```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/ai/extract \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"schemaId":"YOUR_SCHEMA_ID","fileUrl":"https://example.com/invoice.pdf"}'
```

**4. Read `data`** in the response - shaped exactly per your schema's JSON Schema.

#### Errors
`validation_error` (400 - missing `schemaId`/`fileUrl`; or 422 if the file itself couldn't be read/parsed), `forbidden` (403 - key missing `ai:extract`), `not_found` (404 - unknown `schemaId` for this application), `unavailable` (409 - schema archived, reactivate it in the Integration tab), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `schemaId` | `uuid` | yes | One of this application's own AiExtractionSchema ids, created in the Integration tab |
| `fileUrl` | `uri` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Extracted data |
| `400` | validation_error - missing schemaId/fileUrl |
| `403` | forbidden - key missing ai:extract |
| `404` | not_found - unknown schemaId for this application |
| `409` | unavailable - schema archived |
| `422` | validation_error - file couldn't be read/parsed |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `data` | `object` | yes | Shape defined by the referenced AiExtractionSchema.jsonSchema |

### POST `/api/v1/application/ai/assessment/draft`

Draft every section of an assessment from one sentence

**Scope** `ai:assess` &nbsp;·&nbsp; **Rate limit** 30 requests / min per key

Turns a single sentence into a first pass at every section of a platform-defined assessment - a Business Model Canvas, a readiness scorecard, any questionnaire an admin has defined.

This exists because a blank questionnaire is the reason most people abandon one. Editing a strawman is a far smaller ask than authoring from nothing, so the intended flow is draft first, then let the person correct it section by section.

#### Implementation

**1. Prerequisite** - an API key scoped to `ai:assess`, and the `key` of an active assessment definition.

**2. Send the seed:**
```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/ai/assessment/draft \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"assessmentKey":"business_model_canvas","seed":"A same-day bike repair service for commuters in Bangalore"}'
```

**3. Render `sections` and `answers` together.** The response echoes the definition's sections so you can ask the questions without a second round trip, and the draft is unambiguously keyed against the section list that produced it.

**4. Treat every suggestion as editable.** These are prompts for the person's own thinking, not answers. Present them as something to accept, reject or rewrite.

#### Notes
The seed is capped at 1000 characters - two or three sentences is the honest input here. Drafting runs at low thinking for latency, because somebody is watching a spinner.

#### Errors
`validation_error` (400 - missing or oversized `assessmentKey`/`seed`), `forbidden` (403 - key missing `ai:assess`), `not_found` (404 - unknown `assessmentKey`), `unavailable` (409 - definition archived), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `assessmentKey` | `string` | yes | A platform AssessmentDefinition key, e.g. `business_model_canvas` |
| `seed` | `string` | yes | One or two sentences describing what is being assessed |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The drafted answers and the definition's sections |
| `400` | validation_error - missing assessmentKey/seed |
| `403` | forbidden - key missing ai:assess |
| `404` | not_found - unknown assessmentKey |
| `409` | unavailable - definition archived |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `data` | `object` | yes |  |

### POST `/api/v1/application/ai/assessment/review`

Critique a completed assessment

**Scope** `ai:assess` &nbsp;·&nbsp; **Rate limit** 10 requests / min per key

Reads a completed set of answers and names the tensions **between** them - the claim in one section that nothing in another supports - plus the single assumption the whole thing rests on and one cheap way to test it.

This is the half a template cannot do, and the reason to run an assessment through a model at all. A drawn canvas is a picture; a critiqued one is a tool.

#### Implementation

**1. Prerequisite** - an API key scoped to `ai:assess`.

**2. Send the answers**, keyed by section:
```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/ai/assessment/review \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"assessmentKey":"business_model_canvas","answers":{"customer_segments":["Bike commuters in Bangalore"],"revenue_streams":["Per-repair fee"]}}'
```

**3. Link each finding back to its sections.** Every finding names the section keys it concerns, so the reader can jump straight to the thing that needs changing rather than re-reading everything.

**4. Lead with `riskiestAssumption`.** A list of problems discourages; one testable next step is a plan.

#### Notes
Unknown section keys are ignored rather than rejected - a definition can gain or lose a section between the draft somebody started and the review they ask for later, and losing their work to a 400 at the final step is the worse answer. At least one section must be answered.

Runs at high thinking, which is why its rate limit is a third of `draft`'s: it costs several times more and is a once-per-assessment action rather than a per-edit one.

#### Errors
`validation_error` (400 - missing `assessmentKey`, malformed `answers`, or every section blank), `forbidden` (403 - key missing `ai:assess`), `not_found` (404 - unknown `assessmentKey`), `unavailable` (409 - definition archived), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `assessmentKey` | `string` | yes |  |
| `answers` | `object` | yes | The completed answers, keyed by section key. Unknown keys are ignored. |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Findings, the riskiest assumption, and a score |
| `400` | validation_error - malformed or empty answers |
| `403` | forbidden - key missing ai:assess |
| `404` | not_found - unknown assessmentKey |
| `409` | unavailable - definition archived |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `data` | `object` | yes |  |

### POST `/api/v1/application/enrichment/company`

Look a company up from its domain

**Scope** `enrichment:read` &nbsp;·&nbsp; **Rate limit** 10 requests / min per key

Answers what Connect can find out about the company at a domain, so a record holding nothing but `priya@acme.com` can offer a filled-in account instead of an empty form.

Connect reads the company's **own website** - the domain your input yields, and pages linked from its homepage - and returns the fields it found together with the URL each was read from. It does not search the web, and it never answers from a model's recall: a field the pages did not state comes back `null`. That is deliberate. An invented industry looks exactly like a real one, and an invented address reaches an invoice.

#### Implementation

**1. Prerequisite** - an API key scoped to `enrichment:read`.

**2. Send whatever you hold** - an email address, a domain, or both:
```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/enrichment/company \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"email":"priya@acme.com"}'
```

**3. Branch on `status`.** `enriched` carries a `profile`; `skipped` carries a `reason` and is a normal 200, not an error - most records legitimately have nothing to enrich.

**4. Show it as a suggestion, not a save.** Every value is a claim read off a public page, and `citations` exists so the person confirming it can open the page it came from. `confidence` is derived from how many fields the pages actually supported, not from the model's opinion of itself - treat `low` as "offer it", `high` as "pre-fill it".

#### Skip reasons
`no_domain` (neither input yielded one), `consumer_domain` (gmail.com and friends identify a person, not an employer - never retry), `blocked_domain` (the host is inside a private network), `unreachable` (nothing answered - worth retrying later), `no_signal` (pages were read and said nothing useful).

#### Errors
`validation_error` (400 - neither `email` nor `domain`), `forbidden` (403 - key missing `enrichment:read`), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `email` | `string` | no | A person's work email. Only its domain is used; a consumer provider (gmail.com, ...) yields `consumer_domain`. |
| `domain` | `string` | no | A domain or website in any form - `Acme.com`, `www.acme.com`, `https://acme.com/`. Wins over `email` when both are sent. |
| `workspaceId` | `string` | no | Attribution for the generation. Ignored when `X-Connect-Session-Token` is sent - the workspace comes from the session instead. |
| `refresh` | `boolean` | no | Re-read the site even if a fresh profile is cached. For an operator pressing refresh, not for automatic calls. |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The profile, or the reason there is none |
| `400` | validation_error - neither email nor domain |
| `403` | forbidden - key missing enrichment:read |
| `429` | rate_limited |

**Response body**

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "oneOf": [
    {
      "type": "object",
      "properties": {
        "status": {
          "type": "string",
          "const": "enriched"
        },
        "profile": {
          "type": "object",
          "properties": {
            "domain": {
              "type": "string"
            },
            "name": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "legalName": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "description": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "industry": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "sizeBand": {
              "anyOf": [
                {
                  "type": "string",
                  "enum": [
                    "1-10",
                    "11-50",
                    "51-200",
                    "201-500",
                    "500+"
                  ]
                },
                {
                  "type": "null"
                }
              ]
            },
            "phone": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "email": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "website": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "address": {
              "anyOf": [
                {
                  "type": "object",
                  "properties": {
                    "line1": {
                      "anyOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "line2": {
                      "anyOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "city": {
                      "anyOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "state": {
                      "anyOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "postalCode": {
                      "anyOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "country": {
                      "anyOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    }
                  },
                  "required": [
                    "line1",
                    "line2",
                    "city",
                    "state",
                    "postalCode",
                    "country"
                  ]
                },
                {
                  "type": "null"
                }
              ]
            },
            "confidence": {
              "type": "string",
              "enum": [
                "high",
                "medium",
                "low"
              ]
            },
            "citations": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "source": {
                    "type": "string",
                    "enum": [
                      "domain",
                      "registry",
                      "website"
                    ]
                  },
                  "url": {
                    "anyOf": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "null"
                      }
                    ]
                  },
                  "retrievedAt": {
                    "type": "string"
                  }
                },
                "required": [
                  "source",
                  "url",
                  "retrievedAt"
                ]
              }
            },
            "retrievedAt": {
              "type": "string"
            }
          },
          "required": [
            "domain",
            "name",
            "legalName",
            "description",
            "industry",
            "sizeBand",
            "phone",
            "email",
            "website",
            "address",
            "confidence",
            "citations",
            "retrievedAt"
          ]
        },
        "cached": {
          "type": "boolean",
          "description": "Whether this came from Connect's platform-wide cache rather than a fresh read."
        }
      },
      "required": [
        "status",
        "profile",
        "cached"
      ]
    },
    {
      "type": "object",
      "properties": {
        "status": {
          "type": "string",
          "const": "skipped"
        },
        "reason": {
          "type": "string",
          "enum": [
            "no_domain",
            "consumer_domain",
            "blocked_domain",
            "unreachable",
            "no_signal"
          ],
          "description": "Why there is nothing to report. `consumer_domain` will never succeed on retry; `unreachable` may."
        }
      },
      "required": [
        "status",
        "reason"
      ]
    }
  ]
}
```

### POST `/api/v1/application/enrichment/brand`

Read a company's brand kit and design system

**Scope** `enrichment:brand` &nbsp;·&nbsp; **Rate limit** 3 requests / min per key

Answers what the company at a domain **looks like**: its logo, its palette, its typefaces, and the radius, spacing and breakpoint scales its own stylesheets declare. The sibling of `/enrichment/company`, which answers what it *does*.

#### The one thing to understand before you use this

**These values are measured, not generated.** Colours, radii, spacing steps and font stacks are parsed out of the site's CSS by code. A model is involved in exactly two places: writing the prose fields (`about`, `industry`, `businessModel`, `offerings`), and assigning a *role* to a colour or typeface that was already measured.

That distinction is the whole value of the endpoint. Ask any model for a company's brand colour and it will answer `#3B82F6` with total confidence, because that is the median of every site it has ever seen. Every hex here came out of a stylesheet, and `palette[].source` tells you which parse produced it - `css-var` is the company naming its own colour in its own tokens and is the strongest signal available; `frequency` is a ranked count of declarations and is the weakest.

#### Implementation

**1. Prerequisite** - an API key scoped to `enrichment:brand`. This is a *separate* scope from `enrichment:read` because it is far more expensive: it crawls stylesheets, may render the page in a headless browser, and caches image bytes.

**2. Send a domain or a URL:**
```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/enrichment/brand \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"domain":"acme.com"}'
```

**3. Set your client timeout to at least 90 seconds.** A cold read runs the full extraction before answering and can take up to a minute; many HTTP clients default to 30s and will abandon a request that was about to succeed. A *cached* read returns in milliseconds, and cached is the normal case - profiles are held for 90 days, so you pay the cold path roughly once per domain.

**4. Branch on `status`.** `extracted` carries a `profile`; `skipped` carries a `reason` and is a normal 200, not an error.

**5. Do not store `brandKit.logos[].url`.** It is a short-lived signed URL, minted fresh on every read, and it *will* expire - a stored one becomes a broken image with a plausible-looking address. Render it directly, or re-read the profile, which on a cache hit is a single document read. `sourceUrl` is the stable value and points at the company's own site.

#### Reading the result

`confidence` is derived from how many aspects the sources between them actually filled, never from a model's opinion of itself. `designSystem.framework` is worth checking first: a Tailwind site's spacing scale is Tailwind's defaults rather than a considered choice, and a palette full of `--bs-*` is Bootstrap's.

`officialName` is filled only from a legal disclosure the company published itself - a footer copyright line, or the entity named on its Terms page - and `officialNameSource` says so. It is never a model's recall of a company register.

#### Skip reasons
`no_domain` (the input did not yield one), `consumer_domain` (gmail.com and friends identify a person, not a company - never retry), `blocked_domain` (the host is inside a private network), `unreachable` (nothing answered - worth retrying later), `no_signal` (the site was read and held nothing worth keeping), `no_stylesheet` (it answered but shipped no CSS this reader could measure).

#### Errors
`validation_error` (400 - no `domain`), `forbidden` (403 - key missing `enrichment:brand`), `rate_limited` (429 - a low limit, because this is the most expensive read on the API).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `domain` | `string` | yes | A domain or website in any form - `Acme.com`, `www.acme.com`, `https://acme.com/pricing`. Reduced to the bare host before anything is fetched. |
| `workspaceId` | `string` | no | Attribution for the generation. Ignored when `X-Connect-Session-Token` is sent - the workspace comes from the session instead. |
| `refresh` | `boolean` | no | Re-extract even if a fresh profile is cached. For an operator pressing refresh, not for automatic calls - this is the most expensive read on the API. |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The brand profile, or the reason there is none |
| `400` | validation_error - no domain |
| `403` | forbidden - key missing enrichment:brand |
| `429` | rate_limited |

**Response body**

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "oneOf": [
    {
      "type": "object",
      "properties": {
        "status": {
          "type": "string",
          "const": "extracted"
        },
        "profile": {
          "type": "object",
          "properties": {
            "domain": {
              "type": "string"
            },
            "knownAs": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "officialName": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "officialNameSource": {
              "anyOf": [
                {
                  "type": "string",
                  "enum": [
                    "registry",
                    "website-legal"
                  ]
                },
                {
                  "type": "null"
                }
              ]
            },
            "about": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "industry": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ]
            },
            "businessModel": {
              "anyOf": [
                {
                  "type": "string",
                  "enum": [
                    "product",
                    "service",
                    "hybrid"
                  ]
                },
                {
                  "type": "null"
                }
              ]
            },
            "offerings": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "category": {
                    "anyOf": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "null"
                      }
                    ]
                  },
                  "description": {
                    "anyOf": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "null"
                      }
                    ]
                  },
                  "url": {
                    "anyOf": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "null"
                      }
                    ]
                  }
                },
                "required": [
                  "name",
                  "category",
                  "description",
                  "url"
                ]
              }
            },
            "brandKit": {
              "type": "object",
              "properties": {
                "logos": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "url": {
                        "anyOf": [
                          {
                            "type": "string"
                          },
                          {
                            "type": "null"
                          }
                        ]
                      },
                      "sourceUrl": {
                        "type": "string"
                      },
                      "kind": {
                        "type": "string",
                        "enum": [
                          "primary",
                          "mark",
                          "wordmark",
                          "favicon",
                          "social"
                        ]
                      },
                      "format": {
                        "type": "string"
                      },
                      "width": {
                        "anyOf": [
                          {
                            "type": "number"
                          },
                          {
                            "type": "null"
                          }
                        ]
                      },
                      "height": {
                        "anyOf": [
                          {
                            "type": "number"
                          },
                          {
                            "type": "null"
                          }
                        ]
                      }
                    },
                    "required": [
                      "url",
                      "sourceUrl",
                      "kind",
                      "format",
                      "width",
                      "height"
                    ]
                  }
                },
                "palette": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "hex": {
                        "type": "string"
                      },
                      "role": {
                        "anyOf": [
                          {
                            "type": "string",
                            "enum": [
                              "primary",
                              "secondary",
                              "accent",
                              "background",
                              "surface",
                              "text",
                              "muted"
                            ]
                          },
                          {
                            "type": "null"
                          }
                        ]
                      },
                      "source": {
                        "type": "string",
                        "enum": [
                          "css-var",
                          "computed",
                          "frequency"
                        ]
                      },
                      "variableName": {
                        "anyOf": [
                          {
                            "type": "string"
                          },
                          {
                            "type": "null"
                          }
                        ]
                      },
                      "occurrences": {
                        "type": "number"
                      }
                    },
                    "required": [
                      "hex",
                      "role",
                      "source",
                      "variableName",
                      "occurrences"
                    ]
                  }
                },
                "typography": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "family": {
                        "type": "string"
                      },
                      "role": {
                        "anyOf": [
                          {
                            "type": "string",
                            "enum": [
                              "display",
                              "heading",
                              "body",
                              "mono"
                            ]
                          },
                          {
                            "type": "null"
                          }
                        ]
                      },
                      "weights": {
                        "type": "array",
                        "items": {
                          "type": "number"
                        }
                      },
                      "webfontUrl": {
                        "anyOf": [
                          {
                            "type": "string"
                          },
                          {
                            "type": "null"
                          }
                        ]
                      },
                      "occurrences": {
                        "type": "number"
                      }
                    },
                    "required": [
                      "family",
                      "role",
                      "weights",
                      "webfontUrl",
                      "occurrences"
                    ]
                  }
                },
                "voice": {
                  "type": "object",
                  "properties": {
                    "tagline": {
                      "anyOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "tone": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  },
                  "required": [
                    "tagline",
                    "tone"
                  ]
                }
              },
              "required": [
                "logos",
                "palette",
                "typography",
                "voice"
              ]
            },
            "designSystem": {
              "type": "object",
              "properties": {
                "radiusScale": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "value": {
                        "type": "number"
                      },
                      "unit": {
                        "type": "string"
                      },
                      "occurrences": {
                        "type": "number"
                      }
                    },
                    "required": [
                      "value",
                      "unit",
                      "occurrences"
                    ]
                  }
                },
                "spacingScale": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "value": {
                        "type": "number"
                      },
                      "unit": {
                        "type": "string"
                      },
                      "occurrences": {
                        "type": "number"
                      }
                    },
                    "required": [
                      "value",
                      "unit",
                      "occurrences"
                    ]
                  }
                },
                "shadows": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                },
                "breakpoints": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                },
                "customProperties": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "name": {
                        "type": "string"
                      },
                      "value": {
                        "type": "string"
                      }
                    },
                    "required": [
                      "name",
                      "value"
                    ]
                  }
                },
                "framework": {
                  "anyOf": [
                    {
                      "type": "string",
                      "enum": [
                        "tailwind",
                        "bootstrap",
                        "material",
                        "chakra",
                        "bulma"
                      ]
                    },
                    {
                      "type": "null"
                    }
                  ]
                }
              },
              "required": [
                "radiusScale",
                "spacingScale",
                "shadows",
                "breakpoints",
                "customProperties",
                "framework"
              ]
            },
            "confidence": {
              "type": "string",
              "enum": [
                "high",
                "medium",
                "low"
              ]
            },
            "citations": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "source": {
                    "type": "string",
                    "enum": [
                      "website",
                      "stylesheet",
                      "render"
                    ]
                  },
                  "url": {
                    "type": "string"
                  },
                  "retrievedAt": {
                    "type": "string"
                  }
                },
                "required": [
                  "source",
                  "url",
                  "retrievedAt"
                ]
              }
            },
            "retrievedAt": {
              "type": "string"
            }
          },
          "required": [
            "domain",
            "knownAs",
            "officialName",
            "officialNameSource",
            "about",
            "industry",
            "businessModel",
            "offerings",
            "brandKit",
            "designSystem",
            "confidence",
            "citations",
            "retrievedAt"
          ]
        },
        "cached": {
          "type": "boolean",
          "description": "Whether this came from Connect's platform-wide cache rather than a fresh extraction."
        }
      },
      "required": [
        "status",
        "profile",
        "cached"
      ]
    },
    {
      "type": "object",
      "properties": {
        "status": {
          "type": "string",
          "const": "skipped"
        },
        "reason": {
          "type": "string",
          "enum": [
            "no_domain",
            "consumer_domain",
            "blocked_domain",
            "unreachable",
            "no_signal",
            "no_stylesheet"
          ],
          "description": "Why there is nothing to report. `no_stylesheet` means the site answered but shipped no CSS this reader could measure."
        }
      },
      "required": [
        "status",
        "reason"
      ]
    }
  ]
}
```

### POST `/api/v1/application/ai/readiness`

Assess how ready a website is for AI agents

**Scope** `ai:readiness` &nbsp;·&nbsp; **Rate limit** 2 requests / min per key

Grades a site on what it offers a machine: whether it runs an **MCP server**, publishes an OpenAPI description, serves `llms.txt` and structured data, lets assistants read it, and survives without JavaScript. Returns a score out of 100, a grade, every check with the evidence behind it, and a prioritised list of what to fix.

#### The one thing to understand before you use this

**Every check is measured, not assessed.** Ask a model whether a site is AI-ready and it will tell you - fluently, with a score and a list of recommendations, none of which required looking at the site. A fabricated "no MCP server found" is indistinguishable from a measured one, and worse than no answer, because somebody will act on it.

So the score is computed in code from checks that were settled against the site's real responses. A model contributes exactly two things: the `answerability` judgement, and the prose in `findings`. It cannot change a status or produce a score, and any finding it writes citing a check that does not exist is discarded before you see it.

Two consequences worth knowing:

- **`mcp.server` is a completed JSON-RPC `initialize` handshake**, never a path that answered 200. `/mcp` is a popular marketing URL, and a check that counted files would report a server where there is none. A `401` carrying a `resource_metadata` pointer *passes* - that is a correctly built, authorized server.
- **`content.llms_txt` is graded on the file's format**, not its status code. A site whose catch-all route answers every unknown path with its HTML 404 page at status 200 is reported as *absent*, and `observed` says exactly that.

#### Implementation

**1. Prerequisite** - an API key scoped to `ai:readiness`. It is separate from every other AI scope because it is the widest outbound surface here: it POSTs to candidate MCP endpoints and fetches the home page under two different user agents.

**2. Send a domain or a URL:**
```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/ai/readiness \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"domain":"acme.com"}'
```

**3. Set your client timeout to at least 90 seconds.** A cold assessment runs the whole probe before answering and can take up to a minute; many HTTP clients default to 30s and will abandon a request that was about to succeed. A cached read returns in milliseconds and is the normal case - reports are held for **a week**, deliberately shorter than a brand profile's ninety days, because a readiness report is a to-do list somebody is working through.

**4. Branch on `status`.** `assessed` carries a `report`; `skipped` carries a `reason` and is a normal 200, not an error.

#### Reading the result

**Treat `status: "error"` checks as unknown, never as failures.** They mean Connect could not measure that check - a timeout, a refused connection - and they are excluded from the score rather than counted against it. `dimensions[].unmeasured` counts them so you can say how complete the assessment was.

**`kind` decides how hard to push.** A `core` check that fails is a gap. A `bonus` check that is absent is an opportunity and cost the site nothing - bonus checks raise a score when they pass and never lower it, so a site is not marked down for skipping a convention almost nobody has adopted.

**Do not advise reversing a training block.** The report distinguishes agents that fetch a page because a user asked now (`ChatGPT-User`, `Claude-User`, `PerplexityBot`) from crawlers that collect for training (`GPTBot`, `ClaudeBot`, `CCBot`, `Google-Extended`). Only the first group is scored. Blocking the second is a licensing decision many publishers make deliberately, and `consent.policy_explicit` credits a site for having made it.

`topFix` is the single highest-consequence item, drawn from `findings` so the two never disagree. Lead with it - a list of problems is discouraging, one next step is a plan.

#### Skip reasons
`no_domain` (the input did not yield one), `blocked_domain` (the host is inside a private network), `consumer_domain` (reserved; this endpoint assesses free-mail domains rather than refusing them, since here the domain *is* the subject), `unreachable` (**the site** did not answer - worth retrying later), `provider_unavailable` (**Connect's** assessment service did not answer - retry, and say nothing to the user about their own site).

#### Errors
`validation_error` (400 - no `domain`), `forbidden` (403 - key missing `ai:readiness`), `rate_limited` (429 - the lowest limit on the API, because this is its most expensive call).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `domain` | `string` | yes | A domain or website in any form - `Acme.com`, `www.acme.com`, `https://acme.com/pricing`. Reduced to the bare host before anything is fetched. |
| `workspaceId` | `string` | no | Attribution for the generation. Ignored when `X-Connect-Session-Token` is sent - the workspace comes from the session instead. |
| `refresh` | `boolean` | no | Re-assess even if a fresh report is cached. For an operator who has just shipped a fix - reports are held a week precisely so this is rarely needed. |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The readiness report, or the reason there is none |
| `400` | validation_error - no domain |
| `403` | forbidden - key missing ai:readiness |
| `429` | rate_limited |

**Response body**

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "oneOf": [
    {
      "type": "object",
      "properties": {
        "status": {
          "type": "string",
          "const": "assessed"
        },
        "report": {
          "type": "object",
          "properties": {
            "domain": {
              "type": "string"
            },
            "score": {
              "type": "number"
            },
            "grade": {
              "type": "string",
              "enum": [
                "A",
                "B",
                "C",
                "D",
                "F"
              ]
            },
            "dimensions": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "dimension": {
                    "type": "string",
                    "enum": [
                      "agent_interfaces",
                      "retrievability",
                      "machine_readable",
                      "crawl_consent",
                      "trust"
                    ]
                  },
                  "score": {
                    "type": "number"
                  },
                  "weight": {
                    "type": "number"
                  },
                  "unmeasured": {
                    "type": "number"
                  }
                },
                "required": [
                  "dimension",
                  "score",
                  "weight",
                  "unmeasured"
                ]
              }
            },
            "checks": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string"
                  },
                  "dimension": {
                    "type": "string",
                    "enum": [
                      "agent_interfaces",
                      "retrievability",
                      "machine_readable",
                      "crawl_consent",
                      "trust"
                    ]
                  },
                  "kind": {
                    "type": "string",
                    "enum": [
                      "core",
                      "bonus"
                    ]
                  },
                  "title": {
                    "type": "string"
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "pass",
                      "partial",
                      "fail",
                      "absent",
                      "error"
                    ]
                  },
                  "weight": {
                    "type": "number"
                  },
                  "observed": {
                    "type": "string"
                  },
                  "evidence": {
                    "anyOf": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "null"
                      }
                    ]
                  }
                },
                "required": [
                  "id",
                  "dimension",
                  "kind",
                  "title",
                  "status",
                  "weight",
                  "observed",
                  "evidence"
                ]
              }
            },
            "findings": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "severity": {
                    "type": "string",
                    "enum": [
                      "critical",
                      "warning",
                      "note"
                    ]
                  },
                  "checkIds": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "title": {
                    "type": "string"
                  },
                  "detail": {
                    "type": "string"
                  },
                  "fix": {
                    "type": "string"
                  },
                  "effort": {
                    "type": "string",
                    "enum": [
                      "low",
                      "medium",
                      "high"
                    ]
                  }
                },
                "required": [
                  "severity",
                  "checkIds",
                  "title",
                  "detail",
                  "fix",
                  "effort"
                ]
              }
            },
            "answerability": {
              "anyOf": [
                {
                  "type": "object",
                  "properties": {
                    "offering": {
                      "type": "boolean"
                    },
                    "pricing": {
                      "type": "boolean"
                    },
                    "howToBuy": {
                      "type": "boolean"
                    },
                    "contact": {
                      "type": "boolean"
                    },
                    "summary": {
                      "type": "string"
                    },
                    "gaps": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  },
                  "required": [
                    "offering",
                    "pricing",
                    "howToBuy",
                    "contact",
                    "summary",
                    "gaps"
                  ]
                },
                {
                  "type": "null"
                }
              ]
            },
            "topFix": {
              "anyOf": [
                {
                  "type": "object",
                  "properties": {
                    "checkId": {
                      "type": "string"
                    },
                    "title": {
                      "type": "string"
                    },
                    "fix": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "checkId",
                    "title",
                    "fix"
                  ]
                },
                {
                  "type": "null"
                }
              ]
            },
            "assessedAt": {
              "type": "string"
            }
          },
          "required": [
            "domain",
            "score",
            "grade",
            "dimensions",
            "checks",
            "findings",
            "answerability",
            "topFix",
            "assessedAt"
          ]
        },
        "cached": {
          "type": "boolean",
          "description": "Whether this came from Connect's platform-wide cache rather than a fresh assessment."
        }
      },
      "required": [
        "status",
        "report",
        "cached"
      ]
    },
    {
      "type": "object",
      "properties": {
        "status": {
          "type": "string",
          "const": "skipped"
        },
        "reason": {
          "type": "string",
          "enum": [
            "no_domain",
            "blocked_domain",
            "consumer_domain",
            "unreachable",
            "provider_unavailable"
          ],
          "description": "Why there is nothing to report. `unreachable` is about the site; `provider_unavailable` is about Connect - only the second is worth retrying silently."
        }
      },
      "required": [
        "status",
        "reason"
      ]
    }
  ]
}
```

### POST `/api/v1/application/enrichment/presence`

Score how visible a business is online

**Scope** `enrichment:presence` &nbsp;·&nbsp; **Rate limit** 2 requests / min per key

Scores the business at a domain out of 100 on how findable it is - by a search engine, by a customer, and by the assistants that increasingly answer questions about a business before anybody visits its website. The third sibling of `/enrichment/company` (what it *does*) and `/enrichment/brand` (what it *looks like*).

Returns the score, a breakdown across five categories, every check with the evidence behind it, every social profile linked from the site, and a prioritised list of what to fix.

#### The one thing to understand before you use this

**The score is computed, not generated.** Every point comes from a check run against something a fetcher actually observed. A model is involved in exactly one place: ordering the findings and writing why each matters for *that* business. It cannot score, cannot invent a finding, and cannot write the remediation - `fix` is Connect's own text, reviewed by a person.

That distinction is the whole value of the endpoint. Ask any model how good a company's web presence is and it answers 72 out of 100 with complete confidence, for a site it never read. The number is plausible, it moves when nothing changed, and it is indistinguishable from a real one.

Because the rubric ships in the response - `criteria[].weight`, `observed` and `citation` - the total is reconstructable. A score nobody can reconstruct is a score nobody trusts.

#### The five categories

| Category | Weight | The question |
|---|---|---|
| `findability` | 20 | Can a crawler reach, read and index the site at all? |
| `identity` | 20 | Does it say who the business is, machine-readably? |
| `social` | 30 | Does the business exist off its own domain, and can that be found? |
| `contactability` | 15 | Can somebody who wants to buy actually reach them? |
| `trust` | 15 | Does it look like a real business to a person and to a bot? |

Social carries the most because it is the half a website cannot fix on its own, and because a profile that exists but is linked from nowhere is invisible to exactly the systems now answering questions about businesses.

#### Social profiles are **claimed**, never verified

`scan.social` lists profiles the subject's own site linked or declared. Nothing here proves the account on the other end belongs to that business - that needs a credential for each network. Render it as "linked from their site", never as a confirmed account.

`machineReadable` is the one to read closely: `true` means the site declared the profile in its `schema.org` `sameAs` markup, which is how a search engine connects the account to the business. `false` means it was only a link in the page - a real presence with none of the discoverability, and one of the most common findings this endpoint produces.

#### Implementation

**1. Prerequisite** - an API key scoped to `enrichment:presence`.

**2. Send the domain:**
```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/enrichment/presence \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"domain":"acme.com"}'
```

**3. Set your client timeout to at least 90 seconds.** A cold scan runs roughly twenty guarded fetches across the site's well-known paths, sometimes a headless browser, and a model turn. Many HTTP clients default to 30s and abandon a request that was about to succeed. Cached scans return in milliseconds, and cached is the normal case.

**4. Branch on `status`** - `scanned` carries the report, `skipped` carries a reason.

**5. Render `recommendations` before `criteria`.** `priority` is always 1..n with no gaps, `fix` says what to change, and `why` says what it costs this particular business. The full `criteria` array is the audit trail behind the number, which is worth showing on demand rather than by default.

#### Reading the result

`status` on a criterion is `pass`, `partial` or `fail`. `partial` is a real third state, not a rounded failure - a site with `og:title` but no `og:image` has done most of the work.

A criterion that passed carries `fix: null`. `earned` against `weight` is how a `partial` that cost two points is told apart from one that cost six.

**A low score is a successful scan, not a skip.** Every check failing is the most useful report this endpoint produces. Only `unreachable` means the site itself did not answer.

#### Skip reasons
`no_domain` (the input did not yield one), `consumer_domain` (gmail.com and friends identify a person, not a business - never retry), `blocked_domain` (the host is inside a private network), `unreachable` (the home page never answered - a fact about *their* site, worth retrying later), `provider_unavailable` (Connect's own scanner was down - a fact about *us*, and the one to retry first; do not tell your user their site is unreachable on this reason).

#### Errors
`validation_error` (400 - no `domain`), `forbidden` (403 - key missing `enrichment:presence`), `rate_limited` (429 - the tightest limit on the API, because this is the widest outbound read).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `domain` | `string` | yes | A domain or website in any form - `Acme.com`, `www.acme.com`, `https://acme.com/pricing`. Reduced to the bare host before anything is fetched. |
| `workspaceId` | `string` | no | Attribution for the generation. Ignored when `X-Connect-Session-Token` is sent - the workspace comes from the session instead. |
| `refresh` | `boolean` | no | Re-scan even if a fresh result is cached. For an operator who has just shipped a fix, not for automatic calls. |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The presence scan, or the reason there is none |
| `400` | validation_error - no domain |
| `403` | forbidden - key missing enrichment:presence |
| `429` | rate_limited |

**Response body**

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "oneOf": [
    {
      "type": "object",
      "properties": {
        "status": {
          "type": "string",
          "const": "scanned"
        },
        "scan": {
          "type": "object",
          "properties": {
            "domain": {
              "type": "string"
            },
            "score": {
              "type": "number"
            },
            "band": {
              "type": "string",
              "enum": [
                "absent",
                "emerging",
                "established",
                "strong"
              ]
            },
            "categories": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "category": {
                    "type": "string",
                    "enum": [
                      "findability",
                      "identity",
                      "social",
                      "contactability",
                      "trust"
                    ]
                  },
                  "score": {
                    "type": "number"
                  },
                  "weight": {
                    "type": "number"
                  },
                  "earned": {
                    "type": "number"
                  }
                },
                "required": [
                  "category",
                  "score",
                  "weight",
                  "earned"
                ]
              }
            },
            "criteria": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "key": {
                    "type": "string"
                  },
                  "category": {
                    "type": "string",
                    "enum": [
                      "findability",
                      "identity",
                      "social",
                      "contactability",
                      "trust"
                    ]
                  },
                  "label": {
                    "type": "string"
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "pass",
                      "partial",
                      "fail"
                    ]
                  },
                  "weight": {
                    "type": "number"
                  },
                  "earned": {
                    "type": "number"
                  },
                  "observed": {
                    "type": "string"
                  },
                  "citation": {
                    "anyOf": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "null"
                      }
                    ]
                  },
                  "fix": {
                    "anyOf": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "null"
                      }
                    ]
                  }
                },
                "required": [
                  "key",
                  "category",
                  "label",
                  "status",
                  "weight",
                  "earned",
                  "observed",
                  "citation",
                  "fix"
                ]
              }
            },
            "social": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "network": {
                    "type": "string",
                    "enum": [
                      "linkedin",
                      "instagram",
                      "facebook",
                      "youtube",
                      "google_business",
                      "crunchbase",
                      "x",
                      "tiktok",
                      "github",
                      "other"
                    ]
                  },
                  "url": {
                    "type": "string"
                  },
                  "handle": {
                    "anyOf": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "null"
                      }
                    ]
                  },
                  "machineReadable": {
                    "type": "boolean"
                  }
                },
                "required": [
                  "network",
                  "url",
                  "handle",
                  "machineReadable"
                ]
              }
            },
            "summary": {
              "type": "string"
            },
            "recommendations": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "criterionKey": {
                    "type": "string"
                  },
                  "category": {
                    "type": "string",
                    "enum": [
                      "findability",
                      "identity",
                      "social",
                      "contactability",
                      "trust"
                    ]
                  },
                  "priority": {
                    "type": "number"
                  },
                  "title": {
                    "type": "string"
                  },
                  "why": {
                    "type": "string"
                  },
                  "fix": {
                    "type": "string"
                  }
                },
                "required": [
                  "criterionKey",
                  "category",
                  "priority",
                  "title",
                  "why",
                  "fix"
                ]
              }
            },
            "scannedAt": {
              "type": "string"
            }
          },
          "required": [
            "domain",
            "score",
            "band",
            "categories",
            "criteria",
            "social",
            "summary",
            "recommendations",
            "scannedAt"
          ]
        },
        "cached": {
          "type": "boolean"
        }
      },
      "required": [
        "status",
        "scan",
        "cached"
      ]
    },
    {
      "type": "object",
      "properties": {
        "status": {
          "type": "string",
          "const": "skipped"
        },
        "reason": {
          "type": "string",
          "enum": [
            "no_domain",
            "consumer_domain",
            "blocked_domain",
            "unreachable",
            "provider_unavailable"
          ]
        }
      },
      "required": [
        "status",
        "reason"
      ]
    }
  ]
}
```