AI
RAG chat and structured extraction against a workspace's data, and company enrichment from a domain.
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.
// 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 · 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):
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 · 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.
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 · 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 · 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 · 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).
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 · 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 · 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):
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 · 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:
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 · 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:
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 · 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:
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
{
"$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 · 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:
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
{
"$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 · 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.serveris a completed JSON-RPCinitializehandshake, never a path that answered 200./mcpis a popular marketing URL, and a check that counted files would report a server where there is none. A401carrying aresource_metadatapointer passes - that is a correctly built, authorized server.content.llms_txtis 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, andobservedsays 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:
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
{
"$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 · 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:
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
{
"$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"
]
}
]
}