diff --git a/.changeset/config.json b/.changeset/config.json index 296f51f81..a071e56bc 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -13,7 +13,8 @@ "@cossistant/react", "@cossistant/types", "@cossistant/next", - "@cossistant/browser" + "@cossistant/browser", + "@cossistant/protocol" ] ], "linked": [], diff --git a/.changeset/wild-mice-shake.md b/.changeset/wild-mice-shake.md new file mode 100644 index 000000000..e6eb3f1b3 --- /dev/null +++ b/.changeset/wild-mice-shake.md @@ -0,0 +1,23 @@ +--- +"@cossistant/protocol": minor +--- + +Add `@cossistant/protocol`, the canonical HTTP and WebSocket wire contract for +the Cossistant API. + +The package owns all 65 public operations (64 REST routes plus the `/ws` +handshake) as `@hono/zod-openapi` route descriptors, along with the shared +security schemes, protocol headers, error responses, and operation-ID/tag +normalization. It is side-effect free: importing it initializes no database, +Redis, S3, billing, auth, or queue clients, and reads no environment variables. + +- `@cossistant/protocol/routes` — descriptors and the recursive mount table, + registerable with `app.openapi(route, yourHandler)` +- `@cossistant/protocol/openapi` — `buildCossistantOpenApiDocument()`, pure + and deterministic, with server URLs as arguments +- `@cossistant/protocol/testkit` — compatibility checking so a second + implementation can prove in its own CI that it still matches the contract +- `@cossistant/protocol/openapi.json` — the generated specification + +`apps/api` now imports its descriptors from this package and keeps its +handlers. The served document is byte-identical to before the extraction. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94966fb60..a2a99729a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,18 @@ jobs: - name: Check types run: bun run check-types + - name: Check protocol OpenAPI document is up to date + run: bun run check:openapi + + - name: Test protocol package + run: bun run test --filter @cossistant/protocol + + - name: Test REST OpenAPI contract + # Run this file on its own: bun's mock.module registrations are global to + # the test process, so partial factories in other files leak across a + # combined `bun test src` run. + run: cd apps/api && bun test src/rest/openapi-contract.test.ts + - name: Build browser embed package run: bun run build --filter @cossistant/browser diff --git a/apps/api/package.json b/apps/api/package.json index 4c1941fc7..7af0796a4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,6 +21,7 @@ "@cossistant/jobs": "workspace:*", "@cossistant/location": "workspace:*", "@cossistant/memory": "workspace:*", + "@cossistant/protocol": "workspace:*", "@cossistant/redis": "workspace:*", "@cossistant/transactional": "workspace:*", "@cossistant/types": "workspace:*", @@ -28,8 +29,8 @@ "@hono/node-server": "^1.19.9", "@hono/swagger-ui": "^0.5.3", "@hono/trpc-server": "^0.4.2", - "@hono/zod-openapi": "^1.2.0", - "@hono/zod-validator": "^0.7.6", + "@hono/zod-openapi": "^1.5.1", + "@hono/zod-validator": "^0.9.0", "@openrouter/ai-sdk-provider": "^2.5.1", "@polar-sh/sdk": "^0.44.0", "@tinybirdco/sdk": "^0.0.33", @@ -39,7 +40,7 @@ "ai": "^6.0.154", "drizzle-orm": "^0.45.2", "email-reply-parser": "^2.3.5", - "hono": "^4.11.5", + "hono": "^4.12.32", "hono-rate-limiter": "^0.4.2", "ioredis": "^5.9.2", "jose": "^6.1.3", diff --git a/apps/api/src/rest/openapi-contract.test.ts b/apps/api/src/rest/openapi-contract.test.ts index 7dcbbf320..4a42bac43 100644 --- a/apps/api/src/rest/openapi-contract.test.ts +++ b/apps/api/src/rest/openapi-contract.test.ts @@ -21,7 +21,17 @@ mock.module("@api/realtime/emitter", () => ({ }, })); +// Mocked because importing the real module constructs an S3Client at load time. +// Every export must be listed: mock.module replaces the module wholesale, so an +// omitted symbol becomes "Export named 'x' not found" for any importer. mock.module("@api/services/upload", () => ({ + s3Client: {}, + deleteConversationFiles: mock(async () => 0), + deleteContactFiles: mock(async () => 0), + deleteOrganizationFiles: mock(async () => 0), + deleteVisitorFiles: mock(async () => 0), + deleteWebsiteFiles: mock(async () => 0), + getWebsiteUsageBytes: mock(async () => 0), generateUploadUrl: mock(async () => ({ uploadUrl: "https://example.com/upload", key: "uploads/example.txt", diff --git a/apps/api/src/rest/routers/ai-agent.ts b/apps/api/src/rest/routers/ai-agent.ts index a858081e4..d2521a599 100644 --- a/apps/api/src/rest/routers/ai-agent.ts +++ b/apps/api/src/rest/routers/ai-agent.ts @@ -14,6 +14,11 @@ import { safelyExtractRequestData, validateResponse, } from "@api/utils/validate"; +import { + getAiAgentRoute, + getAiAgentTrainingStatusRoute, + startAiAgentTrainingRoute, +} from "@cossistant/protocol/routes"; import { type AiAgentTrainingPublicStatus, aiAgentResponseSchema, @@ -242,315 +247,194 @@ async function buildTrainingStatusResponse(params: { ); } -aiAgentRouter.openapi( - { - method: "get", - path: "/{id}/training", - summary: "Get AI agent training status", - description: - "Returns the current public and internal knowledge base training status for a specific AI agent.", - operationId: "getAiAgentTrainingStatus", - responses: { - 200: { - description: "AI agent training status retrieved successfully", - content: { - "application/json": { - schema: aiAgentTrainingStatusResponseSchema, - }, - }, - }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("AI agent not found"), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["AI Agents"], - ...privateControlAuth({ - parameters: [aiAgentIdPathParameter], - }), - }, - async (c) => { - try { - const extracted = await safelyExtractRequestData(c); - const privateContext = requirePrivateControlContext(c, extracted); - const aiAgentId = c.req.param("id"); - - if (privateContext instanceof Response) { - return privateContext; - } +aiAgentRouter.openapi(getAiAgentTrainingStatusRoute, async (c) => { + try { + const extracted = await safelyExtractRequestData(c); + const privateContext = requirePrivateControlContext(c, extracted); + const aiAgentId = c.req.param("id"); - if (!aiAgentId) { - return c.json( - { error: "NOT_FOUND", message: "AI agent not found" }, - 404 - ); - } + if (privateContext instanceof Response) { + return privateContext; + } - const agent = await getAiAgentForWebsiteById(extracted.db, { - aiAgentId, - websiteId: privateContext.website.id, - organizationId: privateContext.organization.id, - }); + if (!aiAgentId) { + return c.json({ error: "NOT_FOUND", message: "AI agent not found" }, 404); + } - if (!agent) { - return c.json( - { error: "NOT_FOUND", message: "AI agent not found" }, - 404 - ); - } + const agent = await getAiAgentForWebsiteById(extracted.db, { + aiAgentId, + websiteId: privateContext.website.id, + organizationId: privateContext.organization.id, + }); - return c.json( - await buildTrainingStatusResponse({ - db: extracted.db, - website: privateContext.website, - agent, - }), - 200 - ); - } catch (error) { - return handleAiAgentRouterError( - c, - error, - "Failed to fetch AI agent training status" - ) as never; + if (!agent) { + return c.json({ error: "NOT_FOUND", message: "AI agent not found" }, 404); } + + return c.json( + await buildTrainingStatusResponse({ + db: extracted.db, + website: privateContext.website, + agent, + }), + 200 + ); + } catch (error) { + return handleAiAgentRouterError( + c, + error, + "Failed to fetch AI agent training status" + ) as never; } -); - -aiAgentRouter.openapi( - { - method: "post", - path: "/{id}/training", - summary: "Start AI agent training", - description: - "Queues a retraining job for the AI agent knowledge base. Requires a private API key. When using an unlinked private key, send `X-Actor-User-Id` with a valid website teammate ID.", - operationId: "startAiAgentTraining", - responses: { - 202: { - description: "AI agent training job queued successfully", - content: { - "application/json": { - schema: aiAgentStartTrainingResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Bad request - Missing required actor header for an unlinked private key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor is not allowed for this website" - ), - 404: errorJsonResponse("AI agent not found"), - 409: errorJsonResponse("Conflict - Training is already in progress"), - 429: errorJsonResponse( - "Too Many Requests - Training cooldown has not elapsed yet" - ), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["AI Agents"], - ...privateControlAuth({ - parameters: [aiAgentIdPathParameter], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - try { - const extracted = await safelyExtractRequestData(c); - const privateContext = requirePrivateControlContext(c, extracted); - const aiAgentId = c.req.param("id"); - - if (privateContext instanceof Response) { - return privateContext; - } +}); - if (!aiAgentId) { - return c.json( - { error: "NOT_FOUND", message: "AI agent not found" }, - 404 - ); - } +aiAgentRouter.openapi(startAiAgentTrainingRoute, async (c) => { + try { + const extracted = await safelyExtractRequestData(c); + const privateContext = requirePrivateControlContext(c, extracted); + const aiAgentId = c.req.param("id"); - const agent = await getAiAgentForWebsiteById(extracted.db, { - aiAgentId, - websiteId: privateContext.website.id, - organizationId: privateContext.organization.id, - }); + if (privateContext instanceof Response) { + return privateContext; + } - if (!agent) { - return c.json( - { error: "NOT_FOUND", message: "AI agent not found" }, - 404 - ); - } + if (!aiAgentId) { + return c.json({ error: "NOT_FOUND", message: "AI agent not found" }, 404); + } - const actor = await requirePrivateAiAgentActor({ - c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: true, + const agent = await getAiAgentForWebsiteById(extracted.db, { + aiAgentId, + websiteId: privateContext.website.id, + organizationId: privateContext.organization.id, + }); + + if (!agent) { + return c.json({ error: "NOT_FOUND", message: "AI agent not found" }, 404); + } + + const actor = await requirePrivateAiAgentActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: true, + }); + + if (!actor) { + throw new HTTPException(403, { + message: "Actor user is not allowed for this website", }); + } - if (!actor) { - throw new HTTPException(403, { - message: "Actor user is not allowed for this website", - }); - } + if ( + agent.trainingStatus === "pending" || + agent.trainingStatus === "training" + ) { + return c.json( + { + error: "CONFLICT", + message: "Training is already in progress", + }, + 409 + ); + } + + const planInfo = await getPlanForWebsite(privateContext.website); + const cooldownMs = getTrainingCooldownMs( + planInfo.features["ai-agent-training-interval"] + ); + if (cooldownMs > 0 && agent.lastTrainedAt) { + const cooldownEnd = new Date( + new Date(agent.lastTrainedAt).getTime() + cooldownMs + ); - if ( - agent.trainingStatus === "pending" || - agent.trainingStatus === "training" - ) { + if (cooldownEnd > new Date()) { + c.header( + "Retry-After", + String( + Math.max(1, Math.ceil((cooldownEnd.getTime() - Date.now()) / 1000)) + ) + ); return c.json( { - error: "CONFLICT", - message: "Training is already in progress", + error: "TOO_MANY_REQUESTS", + message: `Your plan allows training every ${Math.round(cooldownMs / 60_000)} minutes. Try again after ${cooldownEnd.toISOString()}`, }, - 409 + 429 ); } + } - const planInfo = await getPlanForWebsite(privateContext.website); - const cooldownMs = getTrainingCooldownMs( - planInfo.features["ai-agent-training-interval"] - ); - if (cooldownMs > 0 && agent.lastTrainedAt) { - const cooldownEnd = new Date( - new Date(agent.lastTrainedAt).getTime() + cooldownMs - ); - - if (cooldownEnd > new Date()) { - c.header( - "Retry-After", - String( - Math.max( - 1, - Math.ceil((cooldownEnd.getTime() - Date.now()) / 1000) - ) - ) - ); - return c.json( - { - error: "TOO_MANY_REQUESTS", - message: `Your plan allows training every ${Math.round(cooldownMs / 60_000)} minutes. Try again after ${cooldownEnd.toISOString()}`, - }, - 429 - ); - } - } - - await updateAiAgentTrainingStatus(extracted.db, { - aiAgentId: agent.id, - trainingStatus: "pending", - trainingProgress: 0, - trainingError: null, - }); + await updateAiAgentTrainingStatus(extracted.db, { + aiAgentId: agent.id, + trainingStatus: "pending", + trainingProgress: 0, + trainingError: null, + }); - const jobId = await triggerAiTraining({ - websiteId: privateContext.website.id, - organizationId: privateContext.organization.id, - aiAgentId: agent.id, - triggeredBy: actor.userId, - }); + const jobId = await triggerAiTraining({ + websiteId: privateContext.website.id, + organizationId: privateContext.organization.id, + aiAgentId: agent.id, + triggeredBy: actor.userId, + }); - return c.json( - validateResponse( - { - aiAgentId: agent.id, - jobId, - status: "training_ongoing" as const, - internalStatus: "pending" as const, - progress: 0, - }, - aiAgentStartTrainingResponseSchema - ), - 202 - ); - } catch (error) { - return handleAiAgentRouterError( - c, - error, - "Failed to start AI agent training" - ) as never; - } - } -); - -aiAgentRouter.openapi( - { - method: "get", - path: "/{id}", - summary: "Get an AI agent", - description: - "Retrieves a single AI agent by ID for the authenticated website.", - operationId: "getAiAgent", - responses: { - 200: { - description: "AI agent retrieved successfully", - content: { - "application/json": { - schema: aiAgentResponseSchema, - }, + return c.json( + validateResponse( + { + aiAgentId: agent.id, + jobId, + status: "training_ongoing" as const, + internalStatus: "pending" as const, + progress: 0, }, - }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" + aiAgentStartTrainingResponseSchema ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("AI agent not found"), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["AI Agents"], - ...privateControlAuth({ - parameters: [aiAgentIdPathParameter], - }), - }, - async (c) => { - try { - const extracted = await safelyExtractRequestData(c); - const privateContext = requirePrivateControlContext(c, extracted); - const aiAgentId = c.req.param("id"); - - if (privateContext instanceof Response) { - return privateContext; - } + 202 + ); + } catch (error) { + return handleAiAgentRouterError( + c, + error, + "Failed to start AI agent training" + ) as never; + } +}); - if (!aiAgentId) { - return c.json( - { error: "NOT_FOUND", message: "AI agent not found" }, - 404 - ); - } +aiAgentRouter.openapi(getAiAgentRoute, async (c) => { + try { + const extracted = await safelyExtractRequestData(c); + const privateContext = requirePrivateControlContext(c, extracted); + const aiAgentId = c.req.param("id"); - const agent = await getAiAgentForWebsiteById(extracted.db, { - aiAgentId, - websiteId: privateContext.website.id, - organizationId: privateContext.organization.id, - }); + if (privateContext instanceof Response) { + return privateContext; + } - if (!agent) { - return c.json( - { error: "NOT_FOUND", message: "AI agent not found" }, - 404 - ); - } + if (!aiAgentId) { + return c.json({ error: "NOT_FOUND", message: "AI agent not found" }, 404); + } - return c.json( - validateResponse(toAiAgentResponse(agent), aiAgentResponseSchema), - 200 - ); - } catch (error) { - return handleAiAgentRouterError( - c, - error, - "Failed to fetch AI agent" - ) as never; + const agent = await getAiAgentForWebsiteById(extracted.db, { + aiAgentId, + websiteId: privateContext.website.id, + organizationId: privateContext.organization.id, + }); + + if (!agent) { + return c.json({ error: "NOT_FOUND", message: "AI agent not found" }, 404); } + + return c.json( + validateResponse(toAiAgentResponse(agent), aiAgentResponseSchema), + 200 + ); + } catch (error) { + return handleAiAgentRouterError( + c, + error, + "Failed to fetch AI agent" + ) as never; } -); +}); diff --git a/apps/api/src/rest/routers/contact.ts b/apps/api/src/rest/routers/contact.ts index 9c661a1a0..cedacaf8f 100644 --- a/apps/api/src/rest/routers/contact.ts +++ b/apps/api/src/rest/routers/contact.ts @@ -28,6 +28,19 @@ import { safelyExtractRequestQuery, validateResponse, } from "@api/utils/validate"; +import { + createContactOrganizationRoute, + createContactRoute, + deleteContactOrganizationRoute, + deleteContactRoute, + getContactOrganizationRoute, + getContactRoute, + identifyContactRoute, + listContactsRoute, + updateContactMetadataRoute, + updateContactOrganizationRoute, + updateContactRoute, +} from "@cossistant/protocol/routes"; import { type ContactOrganizationResponse, contactOrganizationResponseSchema, @@ -116,970 +129,607 @@ export function normalizeIdentifyContactIdentifiers(params: { }; } -contactRuntimeRouter.openapi( - { - method: "post", - path: "/identify", - summary: "Identify a visitor", - description: - "Creates or updates a contact for a visitor. If a contact with the same externalId or email exists, it will be updated. The visitor will be linked to the contact. Public callers may pass the visitor ID in the request body or via X-Visitor-Id; when both are provided they must match.", - request: { - body: { - content: { - "application/json": { - schema: identifyContactRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: identifyContactResponseSchema, - }, - }, - description: "Contact identified successfully", - }, - 400: errorJsonResponse("Invalid request data"), - 401: errorJsonResponse("Unauthorized - Invalid API key"), - 404: errorJsonResponse("Visitor not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...runtimeDualAuth({ includeVisitorIdHeader: true }), - }, - async (c) => { - try { - const { db, website, body, visitorIdHeader } = - await safelyExtractRequestData(c, identifyContactRequestSchema); - - if (!(website?.id && website.organizationId)) { - return c.json( - { error: "UNAUTHORIZED", message: "Invalid API key" }, - 401 - ); - } - - const { externalId, email } = normalizeIdentifyContactIdentifiers({ - externalId: body.externalId, - email: body.email, - }); +contactRuntimeRouter.openapi(identifyContactRoute, async (c) => { + try { + const { db, website, body, visitorIdHeader } = + await safelyExtractRequestData(c, identifyContactRequestSchema); - if (!(externalId || email)) { - return c.json( - { - error: "BAD_REQUEST", - message: "Either externalId or email is required", - }, - 400 - ); - } - - const visitorIdentity = await resolveRuntimeVisitorIdentity({ - c, - db, - organizationId: website.organizationId, - websiteId: website.id, - headerVisitorId: visitorIdHeader, - requestVisitorId: body.visitorId, - }); - - if (visitorIdentity.error) { - return visitorIdentity.error; - } - - const visitor = visitorIdentity.visitor; - if (!visitor) { - return c.json( - { - error: "BAD_REQUEST", - message: "Visitor not found, please pass a valid visitorId", - }, - 400 - ); - } - - const contact = await identifyContact(db, { - websiteId: website.id, - organizationId: website.organizationId, - externalId, - email, - name: body.name, - image: body.image, - metadata: body.metadata, - contactOrganizationId: body.contactOrganizationId, - }); + if (!(website?.id && website.organizationId)) { + return c.json({ error: "UNAUTHORIZED", message: "Invalid API key" }, 401); + } - await linkVisitorToContact(db, { - visitorId: visitor.id, - contactId: contact.id, - websiteId: website.id, - }); + const { externalId, email } = normalizeIdentifyContactIdentifiers({ + externalId: body.externalId, + email: body.email, + }); - await copyVisitorOnboardingToContactIfEmpty(db, { - visitorId: visitor.id, - contactId: contact.id, - websiteId: website.id, - }); + if (!(externalId || email)) { + return c.json( + { + error: "BAD_REQUEST", + message: "Either externalId or email is required", + }, + 400 + ); + } - const visitorRecord = await getCompleteVisitorWithContact(db, { - visitorId: visitor.id, - }); + const visitorIdentity = await resolveRuntimeVisitorIdentity({ + c, + db, + organizationId: website.organizationId, + websiteId: website.id, + headerVisitorId: visitorIdHeader, + requestVisitorId: body.visitorId, + }); + + if (visitorIdentity.error) { + return visitorIdentity.error; + } - if (visitorRecord) { - try { - await realtime.emit("visitorIdentified", { - websiteId: website.id, - organizationId: website.organizationId, - visitorId: visitorRecord.id, - userId: null, - visitor: formatVisitorWithContactResponse( - visitorRecord as CompleteVisitorRecord - ), - }); - } catch (emitError) { - console.error("Failed to emit visitorIdentified event:", emitError); - } - } + const visitor = visitorIdentity.visitor; + if (!visitor) { + return c.json( + { + error: "BAD_REQUEST", + message: "Visitor not found, please pass a valid visitorId", + }, + 400 + ); + } + const contact = await identifyContact(db, { + websiteId: website.id, + organizationId: website.organizationId, + externalId, + email, + name: body.name, + image: body.image, + metadata: body.metadata, + contactOrganizationId: body.contactOrganizationId, + }); + + await linkVisitorToContact(db, { + visitorId: visitor.id, + contactId: contact.id, + websiteId: website.id, + }); + + await copyVisitorOnboardingToContactIfEmpty(db, { + visitorId: visitor.id, + contactId: contact.id, + websiteId: website.id, + }); + + const visitorRecord = await getCompleteVisitorWithContact(db, { + visitorId: visitor.id, + }); + + if (visitorRecord) { try { - await emitSupportStateUpdated({ - db, - visitorId: visitor.id, + await realtime.emit("visitorIdentified", { websiteId: website.id, organizationId: website.organizationId, + visitorId: visitorRecord.id, + userId: null, + visitor: formatVisitorWithContactResponse( + visitorRecord as CompleteVisitorRecord + ), }); } catch (emitError) { - console.error("Failed to emit supportStateUpdated event:", emitError); + console.error("Failed to emit visitorIdentified event:", emitError); } + } - const response: IdentifyContactResponse = { - contact: formatContactResponse(contact), + try { + await emitSupportStateUpdated({ + db, visitorId: visitor.id, - }; - - return c.json( - validateResponse(response, identifyContactResponseSchema), - 200 - ); - } catch (error) { - console.error("Error identifying contact:", error); - return c.json( - { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to identify contact", - }, - 500 - ); + websiteId: website.id, + organizationId: website.organizationId, + }); + } catch (emitError) { + console.error("Failed to emit supportStateUpdated event:", emitError); } + + const response: IdentifyContactResponse = { + contact: formatContactResponse(contact), + visitorId: visitor.id, + }; + + return c.json( + validateResponse(response, identifyContactResponseSchema), + 200 + ); + } catch (error) { + console.error("Error identifying contact:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to identify contact", + }, + 500 + ); } -); - -contactControlRouter.openapi( - { - method: "get", - path: "/", - summary: "List contacts", - description: - "Returns a paginated list of contacts for the authenticated website.", - request: { - query: listContactsRequestSchema, - }, - responses: { - 200: { - content: { - "application/json": { - schema: listContactsRestResponseSchema, - }, +}); + +contactControlRouter.openapi(listContactsRoute, async (c) => { + try { + const extracted = await safelyExtractRequestQuery( + c, + listContactsRequestSchema + ); + const privateContext = requirePrivateControlContext(c, extracted); + + if (privateContext instanceof Response) { + return privateContext; + } + + const result = await listContacts(extracted.db, { + websiteId: privateContext.website.id, + organizationId: privateContext.organization.id, + page: extracted.query.page, + limit: extracted.query.limit, + search: extracted.query.search, + sortBy: extracted.query.sortBy, + sortOrder: extracted.query.sortOrder, + visitorStatus: + extracted.query.visitorStatus === "all" + ? undefined + : extracted.query.visitorStatus, + }); + + return c.json( + validateResponse( + { + ...result, + items: result.items.map((item) => + formatContactListItem(item as RestContactListItem) + ), }, - description: "Contact list retrieved successfully", - }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" + listContactsRestResponseSchema ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth(), - }, - async (c) => { - try { - const extracted = await safelyExtractRequestQuery( - c, - listContactsRequestSchema - ); - const privateContext = requirePrivateControlContext(c, extracted); + 200 + ); + } catch (error) { + console.error("Error listing contacts:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to list contacts", + }, + 500 + ); + } +}); + +contactControlRouter.openapi(createContactRoute, async (c) => { + try { + const extracted = await safelyExtractRequestData( + c, + createContactRequestSchema + ); + const privateContext = requirePrivateControlContext(c, extracted); + + if (privateContext instanceof Response) { + return privateContext; + } - if (privateContext instanceof Response) { - return privateContext; - } + const { externalId } = normalizeIdentifyContactIdentifiers({ + externalId: extracted.body.externalId, + }); - const result = await listContacts(extracted.db, { + if (externalId) { + const upsertResult = await upsertContactByExternalId(extracted.db, { websiteId: privateContext.website.id, organizationId: privateContext.organization.id, - page: extracted.query.page, - limit: extracted.query.limit, - search: extracted.query.search, - sortBy: extracted.query.sortBy, - sortOrder: extracted.query.sortOrder, - visitorStatus: - extracted.query.visitorStatus === "all" - ? undefined - : extracted.query.visitorStatus, + externalId, + email: extracted.body.email, + name: extracted.body.name, + image: extracted.body.image, + metadata: extracted.body.metadata, + contactOrganizationId: extracted.body.contactOrganizationId, }); + const response = formatContactResponse(upsertResult.contact); + const statusCode = upsertResult.status === "created" ? 201 : 200; + return c.json( - validateResponse( - { - ...result, - items: result.items.map((item) => - formatContactListItem(item as RestContactListItem) - ), - }, - listContactsRestResponseSchema - ), - 200 - ); - } catch (error) { - console.error("Error listing contacts:", error); - return c.json( - { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to list contacts", - }, - 500 + validateResponse(response, contactResponseSchema), + statusCode ); } - } -); - -contactControlRouter.openapi( - { - method: "post", - path: "/", - summary: "Create a contact", - description: - "Creates a new contact for the website. If externalId is provided and already exists for the website, the contact is updated and returned.", - request: { - body: { - content: { - "application/json": { - schema: createContactRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: contactResponseSchema, - }, - }, - description: "Contact updated successfully via externalId upsert", - }, - 201: { - content: { - "application/json": { - schema: contactResponseSchema, - }, - }, - description: "Contact created successfully", + + const newContact = await createContact(extracted.db, { + websiteId: privateContext.website.id, + organizationId: privateContext.organization.id, + data: { + ...extracted.body, + externalId, }, - 400: errorJsonResponse("Invalid request data"), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" + }); + + return c.json( + validateResponse( + formatContactResponse(newContact), + contactResponseSchema ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth(), - }, - async (c) => { - try { - const extracted = await safelyExtractRequestData( - c, - createContactRequestSchema - ); - const privateContext = requirePrivateControlContext(c, extracted); + 201 + ); + } catch (error) { + console.error("Error creating contact:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to create contact", + }, + 500 + ); + } +}); - if (privateContext instanceof Response) { - return privateContext; - } +contactControlRouter.openapi(getContactRoute, async (c) => { + try { + const extracted = await safelyExtractRequestData(c); + const privateContext = requirePrivateControlContext(c, extracted); + const contactId = c.req.param("id"); - const { externalId } = normalizeIdentifyContactIdentifiers({ - externalId: extracted.body.externalId, - }); + if (privateContext instanceof Response) { + return privateContext; + } - if (externalId) { - const upsertResult = await upsertContactByExternalId(extracted.db, { - websiteId: privateContext.website.id, - organizationId: privateContext.organization.id, - externalId, - email: extracted.body.email, - name: extracted.body.name, - image: extracted.body.image, - metadata: extracted.body.metadata, - contactOrganizationId: extracted.body.contactOrganizationId, - }); + if (!contactId) { + return c.json({ error: "NOT_FOUND", message: "Contact not found" }, 404); + } - const response = formatContactResponse(upsertResult.contact); - const statusCode = upsertResult.status === "created" ? 201 : 200; + const contact = await findContactForWebsite(extracted.db, { + contactId, + websiteId: privateContext.website.id, + }); - return c.json( - validateResponse(response, contactResponseSchema), - statusCode - ); - } + if (!contact) { + return c.json({ error: "NOT_FOUND", message: "Contact not found" }, 404); + } - const newContact = await createContact(extracted.db, { - websiteId: privateContext.website.id, - organizationId: privateContext.organization.id, - data: { - ...extracted.body, - externalId, - }, - }); + return c.json( + validateResponse(formatContactResponse(contact), contactResponseSchema), + 200 + ); + } catch (error) { + console.error("Error fetching contact:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch contact", + }, + 500 + ); + } +}); + +contactControlRouter.openapi(updateContactRoute, async (c) => { + try { + const extracted = await safelyExtractRequestData( + c, + updateContactRequestSchema + ); + const privateContext = requirePrivateControlContext(c, extracted); + const contactId = c.req.param("id"); + + if (privateContext instanceof Response) { + return privateContext; + } - return c.json( - validateResponse( - formatContactResponse(newContact), - contactResponseSchema - ), - 201 - ); - } catch (error) { - console.error("Error creating contact:", error); - return c.json( - { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to create contact", - }, - 500 - ); + if (!contactId) { + return c.json({ error: "NOT_FOUND", message: "Contact not found" }, 404); } - } -); - -contactControlRouter.openapi( - { - method: "get", - path: "/{id}", - summary: "Get a contact", - description: "Retrieves a contact by ID.", - responses: { - 200: { - content: { - "application/json": { - schema: contactResponseSchema, - }, - }, - description: "Contact retrieved successfully", - }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("Contact not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ parameters: [contactIdPathParameter] }), - }, - async (c) => { - try { - const extracted = await safelyExtractRequestData(c); - const privateContext = requirePrivateControlContext(c, extracted); - const contactId = c.req.param("id"); - if (privateContext instanceof Response) { - return privateContext; - } + const updatedContact = await updateContact(extracted.db, { + contactId, + websiteId: privateContext.website.id, + data: extracted.body, + }); - if (!contactId) { - return c.json( - { error: "NOT_FOUND", message: "Contact not found" }, - 404 - ); - } + if (!updatedContact) { + return c.json({ error: "NOT_FOUND", message: "Contact not found" }, 404); + } - const contact = await findContactForWebsite(extracted.db, { - contactId, - websiteId: privateContext.website.id, - }); + return c.json( + validateResponse( + formatContactResponse(updatedContact), + contactResponseSchema + ), + 200 + ); + } catch (error) { + console.error("Error updating contact:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to update contact", + }, + 500 + ); + } +}); + +contactControlRouter.openapi(updateContactMetadataRoute, async (c) => { + try { + const extracted = await safelyExtractRequestData( + c, + updateContactMetadataRequestSchema + ); + const privateContext = requirePrivateControlContext(c, extracted); + const contactId = c.req.param("id"); + + if (privateContext instanceof Response) { + return privateContext; + } - if (!contact) { - return c.json( - { error: "NOT_FOUND", message: "Contact not found" }, - 404 - ); - } + if (!contactId) { + return c.json({ error: "NOT_FOUND", message: "Contact not found" }, 404); + } - return c.json( - validateResponse(formatContactResponse(contact), contactResponseSchema), - 200 - ); - } catch (error) { - console.error("Error fetching contact:", error); - return c.json( - { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch contact", - }, - 500 - ); + const updatedContact = await mergeContactMetadata(extracted.db, { + contactId, + websiteId: privateContext.website.id, + metadata: extracted.body.metadata, + }); + + if (!updatedContact) { + return c.json({ error: "NOT_FOUND", message: "Contact not found" }, 404); } - } -); - -contactControlRouter.openapi( - { - method: "patch", - path: "/{id}", - summary: "Update a contact", - description: "Updates an existing contact.", - request: { - body: { - content: { - "application/json": { - schema: updateContactRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: contactResponseSchema, - }, - }, - description: "Contact updated successfully", - }, - 400: errorJsonResponse("Invalid request data"), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" + + return c.json( + validateResponse( + formatContactResponse(updatedContact), + contactResponseSchema ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("Contact not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ parameters: [contactIdPathParameter] }), - }, - async (c) => { - try { - const extracted = await safelyExtractRequestData( - c, - updateContactRequestSchema - ); - const privateContext = requirePrivateControlContext(c, extracted); - const contactId = c.req.param("id"); + 200 + ); + } catch (error) { + console.error("Error updating contact metadata:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to update contact metadata", + }, + 500 + ); + } +}); - if (privateContext instanceof Response) { - return privateContext; - } +contactControlRouter.openapi(deleteContactRoute, async (c) => { + try { + const extracted = await safelyExtractRequestData(c); + const privateContext = requirePrivateControlContext(c, extracted); + const contactId = c.req.param("id"); - if (!contactId) { - return c.json( - { error: "NOT_FOUND", message: "Contact not found" }, - 404 - ); - } + if (privateContext instanceof Response) { + return privateContext; + } - const updatedContact = await updateContact(extracted.db, { - contactId, - websiteId: privateContext.website.id, - data: extracted.body, - }); + if (!contactId) { + return c.json({ error: "NOT_FOUND", message: "Contact not found" }, 404); + } - if (!updatedContact) { - return c.json( - { error: "NOT_FOUND", message: "Contact not found" }, - 404 - ); - } + const deleted = await deleteContact(extracted.db, { + contactId, + websiteId: privateContext.website.id, + }); - return c.json( - validateResponse( - formatContactResponse(updatedContact), - contactResponseSchema - ), - 200 - ); - } catch (error) { - console.error("Error updating contact:", error); - return c.json( - { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to update contact", - }, - 500 - ); + if (!deleted) { + return c.json({ error: "NOT_FOUND", message: "Contact not found" }, 404); } - } -); - -contactControlRouter.openapi( - { - method: "patch", - path: "/{id}/metadata", - summary: "Update contact metadata", - description: "Merges the provided metadata into the contact profile.", - request: { - body: { - content: { - "application/json": { - schema: updateContactMetadataRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: contactResponseSchema, - }, - }, - description: "Contact metadata updated successfully", + + return c.body(null, 204); + } catch (error) { + console.error("Error deleting contact:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to delete contact", }, - 400: errorJsonResponse("Invalid request data"), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("Contact not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ parameters: [contactIdPathParameter] }), - }, - async (c) => { - try { - const extracted = await safelyExtractRequestData( - c, - updateContactMetadataRequestSchema - ); - const privateContext = requirePrivateControlContext(c, extracted); - const contactId = c.req.param("id"); + 500 + ); + } +}); + +contactControlRouter.openapi(createContactOrganizationRoute, async (c) => { + try { + const extracted = await safelyExtractRequestData( + c, + createContactOrganizationRequestSchema + ); + const privateContext = requirePrivateControlContext(c, extracted); + + if (privateContext instanceof Response) { + return privateContext; + } - if (privateContext instanceof Response) { - return privateContext; - } + const created = await createContactOrganization(extracted.db, { + websiteId: privateContext.website.id, + organizationId: privateContext.organization.id, + data: extracted.body, + }); - if (!contactId) { - return c.json( - { error: "NOT_FOUND", message: "Contact not found" }, - 404 - ); - } + return c.json( + validateResponse( + formatContactOrganizationResponse(created), + contactOrganizationResponseSchema + ), + 201 + ); + } catch (error) { + console.error("Error creating contact organization:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to create contact organization", + }, + 500 + ); + } +}); - const updatedContact = await mergeContactMetadata(extracted.db, { - contactId, - websiteId: privateContext.website.id, - metadata: extracted.body.metadata, - }); +contactControlRouter.openapi(getContactOrganizationRoute, async (c) => { + try { + const extracted = await safelyExtractRequestData(c); + const privateContext = requirePrivateControlContext(c, extracted); + const contactOrganizationId = c.req.param("id"); - if (!updatedContact) { - return c.json( - { error: "NOT_FOUND", message: "Contact not found" }, - 404 - ); - } + if (privateContext instanceof Response) { + return privateContext; + } - return c.json( - validateResponse( - formatContactResponse(updatedContact), - contactResponseSchema - ), - 200 - ); - } catch (error) { - console.error("Error updating contact metadata:", error); + if (!contactOrganizationId) { return c.json( { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to update contact metadata", + error: "NOT_FOUND", + message: "Contact organization not found", }, - 500 + 404 ); } - } -); - -contactControlRouter.openapi( - { - method: "delete", - path: "/{id}", - summary: "Delete a contact", - description: "Soft deletes a contact.", - responses: { - 204: { - description: "Contact deleted successfully", - }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("Contact not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ parameters: [contactIdPathParameter] }), - }, - async (c) => { - try { - const extracted = await safelyExtractRequestData(c); - const privateContext = requirePrivateControlContext(c, extracted); - const contactId = c.req.param("id"); - - if (privateContext instanceof Response) { - return privateContext; - } - - if (!contactId) { - return c.json( - { error: "NOT_FOUND", message: "Contact not found" }, - 404 - ); - } - const deleted = await deleteContact(extracted.db, { - contactId, - websiteId: privateContext.website.id, - }); - - if (!deleted) { - return c.json( - { error: "NOT_FOUND", message: "Contact not found" }, - 404 - ); - } + const organization = await findContactOrganizationForWebsite(extracted.db, { + contactOrganizationId, + websiteId: privateContext.website.id, + }); - return c.body(null, 204); - } catch (error) { - console.error("Error deleting contact:", error); + if (!organization) { return c.json( { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to delete contact", + error: "NOT_FOUND", + message: "Contact organization not found", }, - 500 + 404 ); } - } -); - -contactControlRouter.openapi( - { - method: "post", - path: "/organizations", - summary: "Create a contact organization", - description: "Creates a new contact organization for the website.", - request: { - body: { - content: { - "application/json": { - schema: createContactOrganizationRequestSchema, - }, - }, - }, - }, - responses: { - 201: { - content: { - "application/json": { - schema: contactOrganizationResponseSchema, - }, - }, - description: "Contact organization created successfully", - }, - 400: errorJsonResponse("Invalid request data"), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth(), - }, - async (c) => { - try { - const extracted = await safelyExtractRequestData( - c, - createContactOrganizationRequestSchema - ); - const privateContext = requirePrivateControlContext(c, extracted); - if (privateContext instanceof Response) { - return privateContext; - } - - const created = await createContactOrganization(extracted.db, { - websiteId: privateContext.website.id, - organizationId: privateContext.organization.id, - data: extracted.body, - }); + return c.json( + validateResponse( + formatContactOrganizationResponse(organization), + contactOrganizationResponseSchema + ), + 200 + ); + } catch (error) { + console.error("Error fetching contact organization:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch contact organization", + }, + 500 + ); + } +}); + +contactControlRouter.openapi(updateContactOrganizationRoute, async (c) => { + try { + const extracted = await safelyExtractRequestData( + c, + updateContactOrganizationRequestSchema + ); + const privateContext = requirePrivateControlContext(c, extracted); + const contactOrganizationId = c.req.param("id"); + + if (privateContext instanceof Response) { + return privateContext; + } - return c.json( - validateResponse( - formatContactOrganizationResponse(created), - contactOrganizationResponseSchema - ), - 201 - ); - } catch (error) { - console.error("Error creating contact organization:", error); + if (!contactOrganizationId) { return c.json( { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to create contact organization", + error: "NOT_FOUND", + message: "Contact organization not found", }, - 500 + 404 ); } - } -); - -contactControlRouter.openapi( - { - method: "get", - path: "/organizations/{id}", - summary: "Get a contact organization", - description: "Retrieves a contact organization by ID.", - responses: { - 200: { - content: { - "application/json": { - schema: contactOrganizationResponseSchema, - }, - }, - description: "Contact organization retrieved successfully", - }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("Contact organization not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ parameters: [contactOrganizationIdPathParameter] }), - }, - async (c) => { - try { - const extracted = await safelyExtractRequestData(c); - const privateContext = requirePrivateControlContext(c, extracted); - const contactOrganizationId = c.req.param("id"); - - if (privateContext instanceof Response) { - return privateContext; - } - - if (!contactOrganizationId) { - return c.json( - { - error: "NOT_FOUND", - message: "Contact organization not found", - }, - 404 - ); - } - const organization = await findContactOrganizationForWebsite( - extracted.db, - { - contactOrganizationId, - websiteId: privateContext.website.id, - } - ); - - if (!organization) { - return c.json( - { - error: "NOT_FOUND", - message: "Contact organization not found", - }, - 404 - ); - } + const updated = await updateContactOrganization(extracted.db, { + contactOrganizationId, + websiteId: privateContext.website.id, + data: extracted.body, + }); - return c.json( - validateResponse( - formatContactOrganizationResponse(organization), - contactOrganizationResponseSchema - ), - 200 - ); - } catch (error) { - console.error("Error fetching contact organization:", error); + if (!updated) { return c.json( { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch contact organization", + error: "NOT_FOUND", + message: "Contact organization not found", }, - 500 + 404 ); } - } -); - -contactControlRouter.openapi( - { - method: "patch", - path: "/organizations/{id}", - summary: "Update a contact organization", - description: "Updates an existing contact organization.", - request: { - body: { - content: { - "application/json": { - schema: updateContactOrganizationRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: contactOrganizationResponseSchema, - }, - }, - description: "Contact organization updated successfully", - }, - 400: errorJsonResponse("Invalid request data"), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("Contact organization not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ parameters: [contactOrganizationIdPathParameter] }), - }, - async (c) => { - try { - const extracted = await safelyExtractRequestData( - c, - updateContactOrganizationRequestSchema - ); - const privateContext = requirePrivateControlContext(c, extracted); - const contactOrganizationId = c.req.param("id"); - - if (privateContext instanceof Response) { - return privateContext; - } - if (!contactOrganizationId) { - return c.json( - { - error: "NOT_FOUND", - message: "Contact organization not found", - }, - 404 - ); - } + return c.json( + validateResponse( + formatContactOrganizationResponse(updated), + contactOrganizationResponseSchema + ), + 200 + ); + } catch (error) { + console.error("Error updating contact organization:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to update contact organization", + }, + 500 + ); + } +}); - const updated = await updateContactOrganization(extracted.db, { - contactOrganizationId, - websiteId: privateContext.website.id, - data: extracted.body, - }); +contactControlRouter.openapi(deleteContactOrganizationRoute, async (c) => { + try { + const extracted = await safelyExtractRequestData(c); + const privateContext = requirePrivateControlContext(c, extracted); + const contactOrganizationId = c.req.param("id"); - if (!updated) { - return c.json( - { - error: "NOT_FOUND", - message: "Contact organization not found", - }, - 404 - ); - } + if (privateContext instanceof Response) { + return privateContext; + } - return c.json( - validateResponse( - formatContactOrganizationResponse(updated), - contactOrganizationResponseSchema - ), - 200 - ); - } catch (error) { - console.error("Error updating contact organization:", error); + if (!contactOrganizationId) { return c.json( { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to update contact organization", + error: "NOT_FOUND", + message: "Contact organization not found", }, - 500 + 404 ); } - } -); - -contactControlRouter.openapi( - { - method: "delete", - path: "/organizations/{id}", - summary: "Delete a contact organization", - description: "Soft deletes a contact organization.", - responses: { - 204: { - description: "Contact organization deleted successfully", - }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("Contact organization not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ parameters: [contactOrganizationIdPathParameter] }), - }, - async (c) => { - try { - const extracted = await safelyExtractRequestData(c); - const privateContext = requirePrivateControlContext(c, extracted); - const contactOrganizationId = c.req.param("id"); - - if (privateContext instanceof Response) { - return privateContext; - } - if (!contactOrganizationId) { - return c.json( - { - error: "NOT_FOUND", - message: "Contact organization not found", - }, - 404 - ); - } - - const deleted = await deleteContactOrganization(extracted.db, { - contactOrganizationId, - websiteId: privateContext.website.id, - }); + const deleted = await deleteContactOrganization(extracted.db, { + contactOrganizationId, + websiteId: privateContext.website.id, + }); - if (!deleted) { - return c.json( - { - error: "NOT_FOUND", - message: "Contact organization not found", - }, - 404 - ); - } - - return c.body(null, 204); - } catch (error) { - console.error("Error deleting contact organization:", error); + if (!deleted) { return c.json( { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to delete contact organization", + error: "NOT_FOUND", + message: "Contact organization not found", }, - 500 + 404 ); } + + return c.body(null, 204); + } catch (error) { + console.error("Error deleting contact organization:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to delete contact organization", + }, + 500 + ); } -); +}); // Mount shared runtime routes before private control routes so public requests // like POST /contacts/identify are not intercepted by private-only middleware. diff --git a/apps/api/src/rest/routers/conversation-auth.test.ts b/apps/api/src/rest/routers/conversation-auth.test.ts index 454e08d2a..a99594b21 100644 --- a/apps/api/src/rest/routers/conversation-auth.test.ts +++ b/apps/api/src/rest/routers/conversation-auth.test.ts @@ -115,6 +115,7 @@ mock.module("@api/db/queries/conversation", () => ({ getConversationByIdWithLastMessage: getConversationByIdWithLastMessageMock, getConversationHeader: getConversationHeaderMock, getConversationSeenData: getConversationSeenDataMock, + getConversationTimelineItemByIdForOrganization: mock(async () => null), getConversationTimelineItems: getConversationTimelineItemsMock, listConversations: listConversationsMock, listConversationsHeaders: listConversationsHeadersMock, diff --git a/apps/api/src/rest/routers/conversation.ts b/apps/api/src/rest/routers/conversation.ts index 3dab6dc7f..a4a9ed796 100644 --- a/apps/api/src/rest/routers/conversation.ts +++ b/apps/api/src/rest/routers/conversation.ts @@ -92,6 +92,34 @@ import { safelyExtractRequestQuery, validateResponse, } from "@api/utils/validate"; +import { + archiveConversationRoute, + createConversationRatingRoute, + createConversationRoute, + exportConversationRoute, + getConversationContextRoute, + getConversationRoute, + getConversationSeenRoute, + getConversationTimelineRoute, + joinConversationEscalationRoute, + listConversationsRoute, + listInboxConversationsRoute, + markConversationAsNotSpamRoute, + markConversationAsReadRoute, + markConversationAsSpamRoute, + markConversationAsUnreadRoute, + markConversationSeenRoute, + pauseConversationAiRoute, + reopenConversationRoute, + resolveConversationRoute, + resumeConversationAiRoute, + setConversationTypingRoute, + unarchiveConversationRoute, + updateConversationMetadataRoute, + updateConversationPriorityRoute, + updateConversationSentimentRoute, + updateConversationTitleRoute, +} from "@cossistant/protocol/routes"; import { APIKeyType, ConversationEventType, @@ -633,178 +661,148 @@ export const conversationRouter = new OpenAPIHono(); // Apply middleware to all routes in this router conversationRouter.use("/*", ...protectedPublicApiKeyMiddleware); -conversationRouter.openapi( - { - method: "post", - path: "/", - summary: "Create a conversation (optionally with initial timeline items)", - description: - "Create a conversation; optionally pass a conversationId, public metadata, and a set of default timeline items. When a default item's createdAt is omitted, the server assigns the timestamp. Historical timestamps are allowed. Timestamps more than 5 minutes in the future are rejected.", - tags: ["Conversations"], - request: { - body: { - required: true, - content: { - "application/json": { - schema: createConversationRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Conversation created", - content: { - "application/json": { - schema: createConversationResponseSchema, - }, - }, - }, - 409: { - description: - "Conversation ID conflict (already exists for a different visitor or tenant)", - content: { - "application/json": { - schema: createConversationConflictResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Invalid request or defaultTimelineItems createdAt more than 5 minutes in the future" - ), - 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), - 403: errorJsonResponse("Forbidden - Public key origin validation failed"), - }, - ...runtimeDualAuth({ includeVisitorIdHeader: true }), - }, - async (c) => { - const { db, website, organization, body, visitorIdHeader } = - await safelyExtractRequestData(c, createConversationRequestSchema); - - type NormalizedDefaultTimelineItemInput = Omit< - TimelineItemCreateInput, - "createdAt" - > & { - createdAt?: Date; - }; +conversationRouter.openapi(createConversationRoute, async (c) => { + const { db, website, organization, body, visitorIdHeader } = + await safelyExtractRequestData(c, createConversationRequestSchema); - let normalizedDefaults: NormalizedDefaultTimelineItemInput[]; - try { - normalizedDefaults = (body.defaultTimelineItems ?? []).map( - (item, index) => ({ - ...item, - createdAt: normalizeClientTimelineItemCreatedAt({ - createdAt: item.createdAt, - field: `defaultTimelineItems[${index}].createdAt`, - }), - }) - ); - } catch (error) { - if (error instanceof ClientTimelineItemCreatedAtValidationError) { - return restError(c, 400, "BAD_REQUEST", error.message); - } + type NormalizedDefaultTimelineItemInput = Omit< + TimelineItemCreateInput, + "createdAt" + > & { + createdAt?: Date; + }; - throw error; + let normalizedDefaults: NormalizedDefaultTimelineItemInput[]; + try { + normalizedDefaults = (body.defaultTimelineItems ?? []).map( + (item, index) => ({ + ...item, + createdAt: normalizeClientTimelineItemCreatedAt({ + createdAt: item.createdAt, + field: `defaultTimelineItems[${index}].createdAt`, + }), + }) + ); + } catch (error) { + if (error instanceof ClientTimelineItemCreatedAtValidationError) { + return restError(c, 400, "BAD_REQUEST", error.message); } - const visitorIdentity = await resolveRuntimeVisitorIdentity({ - c, - db, - organizationId: organization.id, - websiteId: website.id, - headerVisitorId: visitorIdHeader, - requestVisitorId: body.visitorId, - }); + throw error; + } - if (visitorIdentity.error) { - return visitorIdentity.error; - } + const visitorIdentity = await resolveRuntimeVisitorIdentity({ + c, + db, + organizationId: organization.id, + websiteId: website.id, + headerVisitorId: visitorIdHeader, + requestVisitorId: body.visitorId, + }); - const visitor = visitorIdentity.visitor; - if (!visitor) { - return restError( - c, - 400, - "BAD_REQUEST", - "Visitor not found, please pass a valid visitorId" - ); - } + if (visitorIdentity.error) { + return visitorIdentity.error; + } + + const visitor = visitorIdentity.visitor; + if (!visitor) { + return restError( + c, + 400, + "BAD_REQUEST", + "Visitor not found, please pass a valid visitorId" + ); + } + + const upsertResult = await upsertConversation(db, { + organizationId: organization.id, + websiteId: website.id, + visitorId: visitor.id, + conversationId: body.conversationId, + channel: body.channel, + metadata: body.metadata, + visitorLanguage: null, + }); + if (upsertResult.status === "conflict") { + return c.json( + createConversationConflictResponse({ + code: "CONVERSATION_ID_CONFLICT", + error: "Conversation ID already exists for another visitor", + }), + 409 + ); + } + const conversationRecord = upsertResult.conversation; - const upsertResult = await upsertConversation(db, { + // Add default participants if configured + const defaultParticipantIds = await getDefaultParticipants(db, website); + if (defaultParticipantIds.length > 0) { + await addConversationParticipants(db, { + conversationId: conversationRecord.id, + userIds: defaultParticipantIds, organizationId: organization.id, - websiteId: website.id, - visitorId: visitor.id, - conversationId: body.conversationId, - channel: body.channel, - metadata: body.metadata, - visitorLanguage: null, + reason: "Default participant", }); - if (upsertResult.status === "conflict") { - return c.json( - createConversationConflictResponse({ - code: "CONVERSATION_ID_CONFLICT", - error: "Conversation ID already exists for another visitor", - }), - 409 - ); - } - const conversationRecord = upsertResult.conversation; + } - // Add default participants if configured - const defaultParticipantIds = await getDefaultParticipants(db, website); - if (defaultParticipantIds.length > 0) { - await addConversationParticipants(db, { - conversationId: conversationRecord.id, - userIds: defaultParticipantIds, - organizationId: organization.id, - reason: "Default participant", - }); + const planInfo = await getPlanForWebsite(website); + const autoTranslateEnabled = isAutomaticTranslationEnabled({ + planAllowsAutoTranslate: planInfo.features["auto-translate"] === true, + websiteAutoTranslateEnabled: website.autoTranslateEnabled, + }); + const preparedDefaults = normalizedDefaults.map((item, index) => + mapDefaultTimelineItemForCreation({ + ...item, + id: + item.id ?? + buildDefaultTimelineItemId({ + conversationId: conversationRecord.id, + index, + item, + }), + }) + ); + let resolvedVisitorLanguage = resolveCreateConversationVisitorLanguage({ + defaultTimelineItems: preparedDefaults, + visitorLanguage: conversationRecord.visitorLanguage ?? null, + }); + let hasBootstrapTranslationPart = false; + let bootstrapBillingSource: OpenRouterBillingSource | undefined; + const translatedDefaults: ReturnType< + typeof mapDefaultTimelineItemForCreation + >[] = []; + + for (const preparedItem of preparedDefaults) { + if (preparedItem.kind !== "message") { + translatedDefaults.push(preparedItem); + continue; } - const planInfo = await getPlanForWebsite(website); - const autoTranslateEnabled = isAutomaticTranslationEnabled({ - planAllowsAutoTranslate: planInfo.features["auto-translate"] === true, - websiteAutoTranslateEnabled: website.autoTranslateEnabled, - }); - const preparedDefaults = normalizedDefaults.map((item, index) => - mapDefaultTimelineItemForCreation({ - ...item, - id: - item.id ?? - buildDefaultTimelineItemId({ - conversationId: conversationRecord.id, - index, - item, - }), - }) - ); - let resolvedVisitorLanguage = resolveCreateConversationVisitorLanguage({ - defaultTimelineItems: preparedDefaults, - visitorLanguage: conversationRecord.visitorLanguage ?? null, - }); - let hasBootstrapTranslationPart = false; - let bootstrapBillingSource: OpenRouterBillingSource | undefined; - const translatedDefaults: ReturnType< - typeof mapDefaultTimelineItemForCreation - >[] = []; - - for (const preparedItem of preparedDefaults) { - if (preparedItem.kind !== "message") { - translatedDefaults.push(preparedItem); - continue; - } - - const isVisitorMessage = - Boolean(preparedItem.input.visitorId) && - !preparedItem.input.userId && - !preparedItem.input.aiAgentId; - const inboundTranslation = isVisitorMessage - ? await prepareInboundVisitorTranslation({ + const isVisitorMessage = + Boolean(preparedItem.input.visitorId) && + !preparedItem.input.userId && + !preparedItem.input.aiAgentId; + const inboundTranslation = isVisitorMessage + ? await prepareInboundVisitorTranslation({ + text: preparedItem.input.text, + websiteDefaultLanguage: website.defaultLanguage, + visitorLanguageHint: resolvedVisitorLanguage, + mode: "auto", + autoTranslateEnabled, + aiContext: { + db, + organizationId: organization.id, + websiteId: website.id, + }, + }) + : null; + const outboundTranslation = + !isVisitorMessage && autoTranslateEnabled + ? await prepareOutboundVisitorTranslation({ text: preparedItem.input.text, - websiteDefaultLanguage: website.defaultLanguage, - visitorLanguageHint: resolvedVisitorLanguage, + sourceLanguage: website.defaultLanguage, + visitorLanguage: resolvedVisitorLanguage, mode: "auto", - autoTranslateEnabled, aiContext: { db, organizationId: organization.id, @@ -812,2675 +810,1744 @@ conversationRouter.openapi( }, }) : null; - const outboundTranslation = - !isVisitorMessage && autoTranslateEnabled - ? await prepareOutboundVisitorTranslation({ - text: preparedItem.input.text, - sourceLanguage: website.defaultLanguage, - visitorLanguage: resolvedVisitorLanguage, - mode: "auto", - aiContext: { - db, - organizationId: organization.id, - websiteId: website.id, - }, - }) - : null; - - if (inboundTranslation?.visitorLanguage) { - resolvedVisitorLanguage = inboundTranslation.visitorLanguage; - } - let extraParts = preparedItem.input.extraParts; - - if (inboundTranslation?.translationPart) { - hasBootstrapTranslationPart = true; - if (inboundTranslation.translationResult.status === "translated") { - bootstrapBillingSource = - inboundTranslation.translationResult.billingSource ?? - bootstrapBillingSource; - } - extraParts = replaceAudienceTranslationPart( - extraParts, - "team", - inboundTranslation.translationPart - ); - } + if (inboundTranslation?.visitorLanguage) { + resolvedVisitorLanguage = inboundTranslation.visitorLanguage; + } - if (outboundTranslation?.translationPart) { - hasBootstrapTranslationPart = true; - if (outboundTranslation.translationResult.status === "translated") { - bootstrapBillingSource = - outboundTranslation.translationResult.billingSource ?? - bootstrapBillingSource; - } - extraParts = replaceAudienceTranslationPart( - extraParts, - "visitor", - outboundTranslation.translationPart - ); + let extraParts = preparedItem.input.extraParts; + + if (inboundTranslation?.translationPart) { + hasBootstrapTranslationPart = true; + if (inboundTranslation.translationResult.status === "translated") { + bootstrapBillingSource = + inboundTranslation.translationResult.billingSource ?? + bootstrapBillingSource; } + extraParts = replaceAudienceTranslationPart( + extraParts, + "team", + inboundTranslation.translationPart + ); + } - translatedDefaults.push({ - ...preparedItem, - input: { - ...preparedItem.input, - extraParts, - }, - }); + if (outboundTranslation?.translationPart) { + hasBootstrapTranslationPart = true; + if (outboundTranslation.translationResult.status === "translated") { + bootstrapBillingSource = + outboundTranslation.translationResult.billingSource ?? + bootstrapBillingSource; + } + extraParts = replaceAudienceTranslationPart( + extraParts, + "visitor", + outboundTranslation.translationPart + ); } - const createdItemsWithActors: Array<{ - item: (ConversationTimelineItemRow & { parts: unknown }) | TimelineItem; - actor: MessageTimelineActor | null; - isNew: boolean; - }> = []; + translatedDefaults.push({ + ...preparedItem, + input: { + ...preparedItem.input, + extraParts, + }, + }); + } - for (const preparedItem of translatedDefaults) { - const timelineItemId = preparedItem.input.id; + const createdItemsWithActors: Array<{ + item: (ConversationTimelineItemRow & { parts: unknown }) | TimelineItem; + actor: MessageTimelineActor | null; + isNew: boolean; + }> = []; - if (!timelineItemId) { - throw new Error("Expected prepared default timeline item id"); - } + for (const preparedItem of translatedDefaults) { + const timelineItemId = preparedItem.input.id; - try { - if (preparedItem.kind === "message") { - const created = await createMessageTimelineItem({ - db, - organizationId: organization.id, - websiteId: website.id, - conversationId: conversationRecord.id, - conversationOwnerVisitorId: conversationRecord.visitorId, - id: preparedItem.input.id, - text: preparedItem.input.text, - extraParts: preparedItem.input.extraParts, - visibility: preparedItem.input.visibility, - userId: preparedItem.input.userId, - aiAgentId: preparedItem.input.aiAgentId, - visitorId: preparedItem.input.visitorId, - createdAt: preparedItem.input.createdAt, - tool: preparedItem.input.tool, - }); - createdItemsWithActors.push({ - item: created.item, - actor: created.actor, - isNew: true, - }); - continue; - } - - const createdItem = await createTimelineItem({ + if (!timelineItemId) { + throw new Error("Expected prepared default timeline item id"); + } + + try { + if (preparedItem.kind === "message") { + const created = await createMessageTimelineItem({ db, organizationId: organization.id, websiteId: website.id, conversationId: conversationRecord.id, conversationOwnerVisitorId: conversationRecord.visitorId, - item: preparedItem.input, + id: preparedItem.input.id, + text: preparedItem.input.text, + extraParts: preparedItem.input.extraParts, + visibility: preparedItem.input.visibility, + userId: preparedItem.input.userId, + aiAgentId: preparedItem.input.aiAgentId, + visitorId: preparedItem.input.visitorId, + createdAt: preparedItem.input.createdAt, + tool: preparedItem.input.tool, }); createdItemsWithActors.push({ - item: createdItem, - actor: null, + item: created.item, + actor: created.actor, isNew: true, }); - } catch (error) { - if (!isUniqueViolationError(error)) { - throw error; - } - - const existingTimelineItem = - await getConversationTimelineItemByIdForOrganization(db, { - timelineItemId, - organizationId: organization.id, - }); - - if (!existingTimelineItem) { - throw new Error( - `Unable to resolve timeline item conflict for id ${timelineItemId}` - ); - } - - if (existingTimelineItem.conversationId !== conversationRecord.id) { - return c.json( - createConversationConflictResponse({ - code: "TIMELINE_ITEM_ID_CONFLICT", - error: "Timeline item ID collision detected", - }), - 409 - ); - } - - const actor = resolveMessageTimelineActor( - existingTimelineItem, - conversationRecord.visitorId - ); - - createdItemsWithActors.push({ - item: existingTimelineItem, - actor: existingTimelineItem.type === "message" ? actor : null, - isNew: false, - }); + continue; } - } - - const createdItems = createdItemsWithActors.map(({ item }) => item); - const shouldFinalizeBootstrapTranslation = - hasBootstrapTranslationPart || - Boolean( - resolvedVisitorLanguage && - resolvedVisitorLanguage !== conversationRecord.visitorLanguage - ); - const responseConversation = shouldFinalizeBootstrapTranslation - ? applyTranslationFinalizeResult( - conversationRecord, - await finalizeConversationTranslation({ - db, - conversation: conversationRecord, - websiteDefaultLanguage: website.defaultLanguage, - visitorLanguage: resolvedVisitorLanguage, - hasTranslationPart: hasBootstrapTranslationPart, - chargeCredits: autoTranslateEnabled, - billingSource: bootstrapBillingSource, - emitRealtime: false, - }) - ) - : conversationRecord; - // Trigger notification workflow for initial message items explicitly. - for (const { item, actor, isNew } of createdItemsWithActors) { - if (!isNew || item.type !== "message" || !actor || !item.id) { - continue; + const createdItem = await createTimelineItem({ + db, + organizationId: organization.id, + websiteId: website.id, + conversationId: conversationRecord.id, + conversationOwnerVisitorId: conversationRecord.visitorId, + item: preparedItem.input, + }); + createdItemsWithActors.push({ + item: createdItem, + actor: null, + isNew: true, + }); + } catch (error) { + if (!isUniqueViolationError(error)) { + throw error; } - try { - await triggerMessageNotificationWorkflow({ - conversationId: conversationRecord.id, - messageId: item.id, - websiteId: website.id, - organizationId: organization.id, - actor, - }); - } catch (error) { - console.error("[conversation.create] Notification trigger failed", { - stage: "trigger_notification_workflow", - conversationId: conversationRecord.id, - messageId: item.id, + const existingTimelineItem = + await getConversationTimelineItemByIdForOrganization(db, { + timelineItemId, organizationId: organization.id, - websiteId: website.id, - error, }); + + if (!existingTimelineItem) { + throw new Error( + `Unable to resolve timeline item conflict for id ${timelineItemId}` + ); } - } - const header = await getConversationHeader(db, { - organizationId: organization.id, - websiteId: website.id, - conversationId: conversationRecord.id, - userId: null, - }); + if (existingTimelineItem.conversationId !== conversationRecord.id) { + return c.json( + createConversationConflictResponse({ + code: "TIMELINE_ITEM_ID_CONFLICT", + error: "Timeline item ID collision detected", + }), + 409 + ); + } - if (header && upsertResult.status === "created") { - const hardLimitPolicy = resolveDashboardHardLimitPolicy(planInfo); - const lockCutoff = await getDashboardConversationLockCutoff(db, { - websiteId: website.id, - organizationId: organization.id, - policy: hardLimitPolicy, - }); - const eventHeader = applyDashboardConversationHardLimit({ - conversation: header, - policy: hardLimitPolicy, - cutoff: lockCutoff, - }); + const actor = resolveMessageTimelineActor( + existingTimelineItem, + conversationRecord.visitorId + ); - await emitConversationCreatedEvent({ - conversation: responseConversation, - header: eventHeader, + createdItemsWithActors.push({ + item: existingTimelineItem, + actor: existingTimelineItem.type === "message" ? actor : null, + isNew: false, }); } - - const lastTimelineItem = - createdItems.at(-1) ?? header?.lastTimelineItem ?? undefined; - - const response = { - initialTimelineItems: createdItems.map(serializeTimelineItemForResponse), - conversation: serializeConversationForResponse({ - ...responseConversation, - lastTimelineItem, - }), - }; - - return c.json( - validateResponse(response, createConversationResponseSchema), - 200 - ); } -); - -conversationRouter.openapi( - { - method: "get", - path: "/", - summary: "List conversations for a visitor", - description: - "Fetch paginated list of conversations for a specific visitor with optional filters. Public conversation metadata is included when present.", - tags: ["Conversations"], - request: { - query: listConversationsRequestSchema, - }, - responses: { - 200: { - description: "List of conversations retrieved successfully", - content: { - "application/json": { - schema: listConversationsResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Invalid request"), - 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), - 403: errorJsonResponse("Forbidden - Public key origin validation failed"), - }, - ...runtimeDualAuth({ includeVisitorIdHeader: true }), - }, - async (c) => { - const { db, website, organization, query, visitorIdHeader, apiKey } = - await safelyExtractRequestQuery(c, listConversationsRequestSchema); - const visitorIdentity = await resolveRuntimeVisitorIdentity({ - c, - db, - apiKey, - organizationId: organization.id, - websiteId: website.id, - headerVisitorId: visitorIdHeader, - requestVisitorId: query.visitorId, - }); + const createdItems = createdItemsWithActors.map(({ item }) => item); + const shouldFinalizeBootstrapTranslation = + hasBootstrapTranslationPart || + Boolean( + resolvedVisitorLanguage && + resolvedVisitorLanguage !== conversationRecord.visitorLanguage + ); + const responseConversation = shouldFinalizeBootstrapTranslation + ? applyTranslationFinalizeResult( + conversationRecord, + await finalizeConversationTranslation({ + db, + conversation: conversationRecord, + websiteDefaultLanguage: website.defaultLanguage, + visitorLanguage: resolvedVisitorLanguage, + hasTranslationPart: hasBootstrapTranslationPart, + chargeCredits: autoTranslateEnabled, + billingSource: bootstrapBillingSource, + emitRealtime: false, + }) + ) + : conversationRecord; - if (visitorIdentity.error) { - return visitorIdentity.error; + // Trigger notification workflow for initial message items explicitly. + for (const { item, actor, isNew } of createdItemsWithActors) { + if (!isNew || item.type !== "message" || !actor || !item.id) { + continue; } - const visitor = visitorIdentity.visitor; - if (!visitor) { - return restError( - c, - 400, - "BAD_REQUEST", - "Visitor not found, please pass a valid visitorId" - ); + try { + await triggerMessageNotificationWorkflow({ + conversationId: conversationRecord.id, + messageId: item.id, + websiteId: website.id, + organizationId: organization.id, + actor, + }); + } catch (error) { + console.error("[conversation.create] Notification trigger failed", { + stage: "trigger_notification_workflow", + conversationId: conversationRecord.id, + messageId: item.id, + organizationId: organization.id, + websiteId: website.id, + error, + }); } + } - const result = await listConversations(db, { - organizationId: organization.id, + const header = await getConversationHeader(db, { + organizationId: organization.id, + websiteId: website.id, + conversationId: conversationRecord.id, + userId: null, + }); + + if (header && upsertResult.status === "created") { + const hardLimitPolicy = resolveDashboardHardLimitPolicy(planInfo); + const lockCutoff = await getDashboardConversationLockCutoff(db, { websiteId: website.id, - visitorId: visitor.id, - page: query.page, - limit: query.limit, - status: query.status, - orderBy: query.orderBy, - order: query.order, + organizationId: organization.id, + policy: hardLimitPolicy, + }); + const eventHeader = applyDashboardConversationHardLimit({ + conversation: header, + policy: hardLimitPolicy, + cutoff: lockCutoff, }); - const response = { - conversations: result.conversations.map((conv) => - serializeConversationForResponse(conv) - ), - pagination: result.pagination, - }; - - return c.json( - validateResponse(response, listConversationsResponseSchema), - 200 - ); + await emitConversationCreatedEvent({ + conversation: responseConversation, + header: eventHeader, + }); } -); - -conversationRouter.openapi( - { - method: "post", - path: "/{conversationId}/resolve", - summary: "Resolve a conversation", - description: - "Marks a conversation as resolved. Requires a private API key. When using an unlinked private key, send `X-Actor-User-Id` with a valid website teammate ID.", - operationId: "resolveConversation", - tags: ["Conversations"], - responses: { - 200: { - description: "Conversation resolved successfully", - content: { - "application/json": { - schema: privateConversationMutationResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Bad request - Missing actor for an unlinked private API key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor user not allowed for this website" - ), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - includeActorUserIdHeader: true, + + const lastTimelineItem = + createdItems.at(-1) ?? header?.lastTimelineItem ?? undefined; + + const response = { + initialTimelineItems: createdItems.map(serializeTimelineItemForResponse), + conversation: serializeConversationForResponse({ + ...responseConversation, + lastTimelineItem, }), - }, - async (c) => { - const extracted = await safelyExtractRequestData(c); - const privateContext = assertPrivateConversationControlContext(extracted); + }; - const { conversationId } = getConversationPathParams(c); - const conversationRecord = await loadPrivateConversationRecord({ - db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId, - }); + return c.json( + validateResponse(response, createConversationResponseSchema), + 200 + ); +}); + +conversationRouter.openapi(listConversationsRoute, async (c) => { + const { db, website, organization, query, visitorIdHeader, apiKey } = + await safelyExtractRequestQuery(c, listConversationsRequestSchema); + + const visitorIdentity = await resolveRuntimeVisitorIdentity({ + c, + db, + apiKey, + organizationId: organization.id, + websiteId: website.id, + headerVisitorId: visitorIdHeader, + requestVisitorId: query.visitorId, + }); - if (!conversationRecord) { - return restError(c, 404, "NOT_FOUND", "Conversation not found"); - } + if (visitorIdentity.error) { + return visitorIdentity.error; + } - const actor = await requirePrivateConversationActor({ + const visitor = visitorIdentity.visitor; + if (!visitor) { + return restError( c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: true, - }); + 400, + "BAD_REQUEST", + "Visitor not found, please pass a valid visitorId" + ); + } - const updatedConversation = await resolveConversation(extracted.db, { - conversation: conversationRecord, - actorUserId: actor.userId, - }); + const result = await listConversations(db, { + organizationId: organization.id, + websiteId: website.id, + visitorId: visitor.id, + page: query.page, + limit: query.limit, + status: query.status, + orderBy: query.orderBy, + order: query.order, + }); - if (!updatedConversation) { - return restError( - c, - 500, - "INTERNAL_SERVER_ERROR", - "Unable to resolve conversation" - ); - } + const response = { + conversations: result.conversations.map((conv) => + serializeConversationForResponse(conv) + ), + pagination: result.pagination, + }; - await emitPrivateConversationUpdate(updatedConversation, { - status: updatedConversation.status, - }); + return c.json( + validateResponse(response, listConversationsResponseSchema), + 200 + ); +}); - return c.json( - createPrivateConversationMutationResponse(updatedConversation), - 200 - ); +conversationRouter.openapi(resolveConversationRoute, async (c) => { + const extracted = await safelyExtractRequestData(c); + const privateContext = assertPrivateConversationControlContext(extracted); + + const { conversationId } = getConversationPathParams(c); + const conversationRecord = await loadPrivateConversationRecord({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId, + }); + + if (!conversationRecord) { + return restError(c, 404, "NOT_FOUND", "Conversation not found"); } -); - -conversationRouter.openapi( - { - method: "post", - path: "/{conversationId}/reopen", - summary: "Reopen a conversation", - description: - "Reopens a previously resolved or spam conversation. Requires a private API key and an acting teammate.", - operationId: "reopenConversation", - tags: ["Conversations"], - responses: { - 200: { - description: "Conversation reopened successfully", - content: { - "application/json": { - schema: privateConversationMutationResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Bad request - Missing actor for an unlinked private API key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor user not allowed for this website" - ), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - const extracted = await safelyExtractRequestData(c); - const privateContext = assertPrivateConversationControlContext(extracted); - const { conversationId } = getConversationPathParams(c); - const conversationRecord = await loadPrivateConversationRecord({ - db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId, - }); + const actor = await requirePrivateConversationActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: true, + }); - if (!conversationRecord) { - return restError(c, 404, "NOT_FOUND", "Conversation not found"); - } + const updatedConversation = await resolveConversation(extracted.db, { + conversation: conversationRecord, + actorUserId: actor.userId, + }); - const actor = await requirePrivateConversationActor({ + if (!updatedConversation) { + return restError( c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: true, - }); + 500, + "INTERNAL_SERVER_ERROR", + "Unable to resolve conversation" + ); + } - const updatedConversation = await reopenConversation(extracted.db, { - conversation: conversationRecord, - actorUserId: actor.userId, - }); + await emitPrivateConversationUpdate(updatedConversation, { + status: updatedConversation.status, + }); - if (!updatedConversation) { - return restError( - c, - 500, - "INTERNAL_SERVER_ERROR", - "Unable to reopen conversation" - ); - } + return c.json( + createPrivateConversationMutationResponse(updatedConversation), + 200 + ); +}); - await emitPrivateConversationUpdate(updatedConversation, { - status: updatedConversation.status, - }); +conversationRouter.openapi(reopenConversationRoute, async (c) => { + const extracted = await safelyExtractRequestData(c); + const privateContext = assertPrivateConversationControlContext(extracted); + + const { conversationId } = getConversationPathParams(c); + const conversationRecord = await loadPrivateConversationRecord({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId, + }); - return c.json( - createPrivateConversationMutationResponse(updatedConversation), - 200 - ); + if (!conversationRecord) { + return restError(c, 404, "NOT_FOUND", "Conversation not found"); } -); - -conversationRouter.openapi( - { - method: "post", - path: "/{conversationId}/spam", - summary: "Mark a conversation as spam", - description: - "Marks a conversation as spam. Requires a private API key and an acting teammate.", - operationId: "markConversationAsSpam", - tags: ["Conversations"], - responses: { - 200: { - description: "Conversation marked as spam successfully", - content: { - "application/json": { - schema: privateConversationMutationResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Bad request - Missing actor for an unlinked private API key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor user not allowed for this website" - ), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - const extracted = await safelyExtractRequestData(c); - const privateContext = assertPrivateConversationControlContext(extracted); - const { conversationId } = getConversationPathParams(c); - const conversationRecord = await loadPrivateConversationRecord({ - db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId, - }); + const actor = await requirePrivateConversationActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: true, + }); - if (!conversationRecord) { - return restError(c, 404, "NOT_FOUND", "Conversation not found"); - } + const updatedConversation = await reopenConversation(extracted.db, { + conversation: conversationRecord, + actorUserId: actor.userId, + }); - const actor = await requirePrivateConversationActor({ + if (!updatedConversation) { + return restError( c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: true, - }); + 500, + "INTERNAL_SERVER_ERROR", + "Unable to reopen conversation" + ); + } - const updatedConversation = await markConversationAsSpam(extracted.db, { - conversation: conversationRecord, - actorUserId: actor.userId, - }); + await emitPrivateConversationUpdate(updatedConversation, { + status: updatedConversation.status, + }); - if (!updatedConversation) { - return restError( - c, - 500, - "INTERNAL_SERVER_ERROR", - "Unable to mark conversation as spam" - ); - } + return c.json( + createPrivateConversationMutationResponse(updatedConversation), + 200 + ); +}); - await emitPrivateConversationUpdate(updatedConversation, { - status: updatedConversation.status, - }); +conversationRouter.openapi(markConversationAsSpamRoute, async (c) => { + const extracted = await safelyExtractRequestData(c); + const privateContext = assertPrivateConversationControlContext(extracted); + + const { conversationId } = getConversationPathParams(c); + const conversationRecord = await loadPrivateConversationRecord({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId, + }); - return c.json( - createPrivateConversationMutationResponse(updatedConversation), - 200 - ); + if (!conversationRecord) { + return restError(c, 404, "NOT_FOUND", "Conversation not found"); } -); - -conversationRouter.openapi( - { - method: "post", - path: "/{conversationId}/not-spam", - summary: "Mark a conversation as not spam", - description: - "Restores a spam conversation back to the open state. Requires a private API key and an acting teammate.", - operationId: "markConversationAsNotSpam", - tags: ["Conversations"], - responses: { - 200: { - description: "Conversation marked as not spam successfully", - content: { - "application/json": { - schema: privateConversationMutationResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Bad request - Missing actor for an unlinked private API key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor user not allowed for this website" - ), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - const extracted = await safelyExtractRequestData(c); - const privateContext = assertPrivateConversationControlContext(extracted); - const { conversationId } = getConversationPathParams(c); - const conversationRecord = await loadPrivateConversationRecord({ - db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId, - }); + const actor = await requirePrivateConversationActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: true, + }); - if (!conversationRecord) { - return restError(c, 404, "NOT_FOUND", "Conversation not found"); - } + const updatedConversation = await markConversationAsSpam(extracted.db, { + conversation: conversationRecord, + actorUserId: actor.userId, + }); - const actor = await requirePrivateConversationActor({ + if (!updatedConversation) { + return restError( c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: true, - }); + 500, + "INTERNAL_SERVER_ERROR", + "Unable to mark conversation as spam" + ); + } - const updatedConversation = await markConversationAsNotSpam(extracted.db, { - conversation: conversationRecord, - actorUserId: actor.userId, - }); + await emitPrivateConversationUpdate(updatedConversation, { + status: updatedConversation.status, + }); - if (!updatedConversation) { - return restError( - c, - 500, - "INTERNAL_SERVER_ERROR", - "Unable to mark conversation as not spam" - ); - } + return c.json( + createPrivateConversationMutationResponse(updatedConversation), + 200 + ); +}); - await emitPrivateConversationUpdate(updatedConversation, { - status: updatedConversation.status, - }); +conversationRouter.openapi(markConversationAsNotSpamRoute, async (c) => { + const extracted = await safelyExtractRequestData(c); + const privateContext = assertPrivateConversationControlContext(extracted); + + const { conversationId } = getConversationPathParams(c); + const conversationRecord = await loadPrivateConversationRecord({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId, + }); - return c.json( - createPrivateConversationMutationResponse(updatedConversation), - 200 - ); + if (!conversationRecord) { + return restError(c, 404, "NOT_FOUND", "Conversation not found"); } -); - -conversationRouter.openapi( - { - method: "post", - path: "/{conversationId}/archive", - summary: "Archive a conversation", - description: - "Archives a conversation from the inbox. This matches the dashboard delete behavior and requires a private API key plus an acting teammate.", - operationId: "archiveConversation", - tags: ["Conversations"], - responses: { - 200: { - description: "Conversation archived successfully", - content: { - "application/json": { - schema: privateConversationMutationResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Bad request - Missing actor for an unlinked private API key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor user not allowed for this website" - ), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - const extracted = await safelyExtractRequestData(c); - const privateContext = assertPrivateConversationControlContext(extracted); - const { conversationId } = getConversationPathParams(c); - const conversationRecord = await loadPrivateConversationRecord({ - db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId, - }); + const actor = await requirePrivateConversationActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: true, + }); - if (!conversationRecord) { - return restError(c, 404, "NOT_FOUND", "Conversation not found"); - } + const updatedConversation = await markConversationAsNotSpam(extracted.db, { + conversation: conversationRecord, + actorUserId: actor.userId, + }); - const actor = await requirePrivateConversationActor({ + if (!updatedConversation) { + return restError( c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: true, - }); + 500, + "INTERNAL_SERVER_ERROR", + "Unable to mark conversation as not spam" + ); + } - const updatedConversation = await archiveConversation(extracted.db, { - conversation: conversationRecord, - actorUserId: actor.userId, - }); + await emitPrivateConversationUpdate(updatedConversation, { + status: updatedConversation.status, + }); - if (!updatedConversation) { - return restError( - c, - 500, - "INTERNAL_SERVER_ERROR", - "Unable to archive conversation" - ); - } + return c.json( + createPrivateConversationMutationResponse(updatedConversation), + 200 + ); +}); - await emitPrivateConversationUpdate(updatedConversation, { - deletedAt: updatedConversation.deletedAt, - }); +conversationRouter.openapi(archiveConversationRoute, async (c) => { + const extracted = await safelyExtractRequestData(c); + const privateContext = assertPrivateConversationControlContext(extracted); + + const { conversationId } = getConversationPathParams(c); + const conversationRecord = await loadPrivateConversationRecord({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId, + }); - return c.json( - createPrivateConversationMutationResponse(updatedConversation), - 200 - ); + if (!conversationRecord) { + return restError(c, 404, "NOT_FOUND", "Conversation not found"); } -); - -conversationRouter.openapi( - { - method: "post", - path: "/{conversationId}/unarchive", - summary: "Unarchive a conversation", - description: - "Restores an archived conversation. Requires a private API key and an acting teammate.", - operationId: "unarchiveConversation", - tags: ["Conversations"], - responses: { - 200: { - description: "Conversation unarchived successfully", - content: { - "application/json": { - schema: privateConversationMutationResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Bad request - Missing actor for an unlinked private API key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor user not allowed for this website" - ), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - const extracted = await safelyExtractRequestData(c); - const privateContext = assertPrivateConversationControlContext(extracted); - const { conversationId } = getConversationPathParams(c); - const conversationRecord = await loadPrivateConversationRecord({ - db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId, - }); + const actor = await requirePrivateConversationActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: true, + }); - if (!conversationRecord) { - return restError(c, 404, "NOT_FOUND", "Conversation not found"); - } + const updatedConversation = await archiveConversation(extracted.db, { + conversation: conversationRecord, + actorUserId: actor.userId, + }); - const actor = await requirePrivateConversationActor({ + if (!updatedConversation) { + return restError( c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: true, - }); + 500, + "INTERNAL_SERVER_ERROR", + "Unable to archive conversation" + ); + } - const updatedConversation = await unarchiveConversation(extracted.db, { - conversation: conversationRecord, - actorUserId: actor.userId, - }); + await emitPrivateConversationUpdate(updatedConversation, { + deletedAt: updatedConversation.deletedAt, + }); - if (!updatedConversation) { - return restError( - c, - 500, - "INTERNAL_SERVER_ERROR", - "Unable to unarchive conversation" - ); - } + return c.json( + createPrivateConversationMutationResponse(updatedConversation), + 200 + ); +}); - await emitPrivateConversationUpdate(updatedConversation, { - deletedAt: updatedConversation.deletedAt, - }); +conversationRouter.openapi(unarchiveConversationRoute, async (c) => { + const extracted = await safelyExtractRequestData(c); + const privateContext = assertPrivateConversationControlContext(extracted); + + const { conversationId } = getConversationPathParams(c); + const conversationRecord = await loadPrivateConversationRecord({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId, + }); - return c.json( - createPrivateConversationMutationResponse(updatedConversation), - 200 - ); + if (!conversationRecord) { + return restError(c, 404, "NOT_FOUND", "Conversation not found"); } -); - -conversationRouter.openapi( - { - method: "post", - path: "/{conversationId}/read", - summary: "Mark a conversation as read", - description: - "Marks a conversation as read for the acting teammate. Requires a private API key and an acting teammate.", - operationId: "markConversationAsRead", - tags: ["Conversations"], - responses: { - 200: { - description: "Conversation marked as read successfully", - content: { - "application/json": { - schema: privateConversationMutationResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Bad request - Missing actor for an unlinked private API key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor user not allowed for this website" - ), - 404: errorJsonResponse("Conversation not found"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - const extracted = await safelyExtractRequestData(c); - const privateContext = assertPrivateConversationControlContext(extracted); - const { conversationId } = getConversationPathParams(c); - const conversationRecord = await loadPrivateConversationRecord({ - db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId, - }); + const actor = await requirePrivateConversationActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: true, + }); - if (!conversationRecord) { - return restError(c, 404, "NOT_FOUND", "Conversation not found"); - } + const updatedConversation = await unarchiveConversation(extracted.db, { + conversation: conversationRecord, + actorUserId: actor.userId, + }); - const actor = await requirePrivateConversationActor({ + if (!updatedConversation) { + return restError( c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: true, - }); - - const { conversation: updatedConversation, lastSeenAt } = - await markConversationAsRead(extracted.db, { - conversation: conversationRecord, - actorUserId: actor.userId, - }); - - await emitConversationSeenEvent({ - conversation: updatedConversation, - actor: { type: "user", userId: actor.userId }, - lastSeenAt, - }); - - return c.json( - createPrivateConversationMutationResponse(updatedConversation), - 200 + 500, + "INTERNAL_SERVER_ERROR", + "Unable to unarchive conversation" ); } -); - -conversationRouter.openapi( - { - method: "post", - path: "/{conversationId}/unread", - summary: "Mark a conversation as unread", - description: - "Clears the acting teammate's read marker for a conversation. Requires a private API key and an acting teammate.", - operationId: "markConversationAsUnread", - tags: ["Conversations"], - responses: { - 200: { - description: "Conversation marked as unread successfully", - content: { - "application/json": { - schema: privateConversationMutationResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Bad request - Missing actor for an unlinked private API key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor user not allowed for this website" - ), - 404: errorJsonResponse("Conversation not found"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - const extracted = await safelyExtractRequestData(c); - const privateContext = assertPrivateConversationControlContext(extracted); - const { conversationId } = getConversationPathParams(c); - const conversationRecord = await loadPrivateConversationRecord({ - db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId, - }); + await emitPrivateConversationUpdate(updatedConversation, { + deletedAt: updatedConversation.deletedAt, + }); - if (!conversationRecord) { - return restError(c, 404, "NOT_FOUND", "Conversation not found"); - } + return c.json( + createPrivateConversationMutationResponse(updatedConversation), + 200 + ); +}); - const actor = await requirePrivateConversationActor({ - c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: true, - }); +conversationRouter.openapi(markConversationAsReadRoute, async (c) => { + const extracted = await safelyExtractRequestData(c); + const privateContext = assertPrivateConversationControlContext(extracted); + + const { conversationId } = getConversationPathParams(c); + const conversationRecord = await loadPrivateConversationRecord({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId, + }); + + if (!conversationRecord) { + return restError(c, 404, "NOT_FOUND", "Conversation not found"); + } + + const actor = await requirePrivateConversationActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: true, + }); - const updatedConversation = await markConversationAsUnread(extracted.db, { + const { conversation: updatedConversation, lastSeenAt } = + await markConversationAsRead(extracted.db, { conversation: conversationRecord, actorUserId: actor.userId, }); - return c.json( - createPrivateConversationMutationResponse(updatedConversation), - 200 - ); + await emitConversationSeenEvent({ + conversation: updatedConversation, + actor: { type: "user", userId: actor.userId }, + lastSeenAt, + }); + + return c.json( + createPrivateConversationMutationResponse(updatedConversation), + 200 + ); +}); + +conversationRouter.openapi(markConversationAsUnreadRoute, async (c) => { + const extracted = await safelyExtractRequestData(c); + const privateContext = assertPrivateConversationControlContext(extracted); + + const { conversationId } = getConversationPathParams(c); + const conversationRecord = await loadPrivateConversationRecord({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId, + }); + + if (!conversationRecord) { + return restError(c, 404, "NOT_FOUND", "Conversation not found"); } -); - -conversationRouter.openapi( - { - method: "patch", - path: "/{conversationId}/metadata", - summary: "Update conversation metadata", - description: - "Merges metadata into a conversation. Conversation metadata are public and retrievable on public conversation endpoints, but this post-creation update route requires a private API key.", - operationId: "updateConversationMetadata", - tags: ["Conversations"], - request: { - body: { - required: true, - content: { - "application/json": { - schema: updateConversationMetadataRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Conversation metadata updated successfully", - content: { - "application/json": { - schema: privateConversationMutationResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Bad request - Invalid request payload"), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - }), - }, - async (c) => { - const extracted = await safelyExtractRequestData( - c, - updateConversationMetadataRequestSchema - ); - const privateContext = assertPrivateConversationControlContext(extracted); - const { conversationId } = getConversationPathParams(c); - const conversationRecord = await loadPrivateConversationRecord({ - db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId, - }); + const actor = await requirePrivateConversationActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: true, + }); - if (!conversationRecord) { - return restError(c, 404, "NOT_FOUND", "Conversation not found"); - } + const updatedConversation = await markConversationAsUnread(extracted.db, { + conversation: conversationRecord, + actorUserId: actor.userId, + }); - const updatedConversation = await mergeConversationMetadata(extracted.db, { - conversation: conversationRecord, - metadata: extracted.body.metadata, - }); + return c.json( + createPrivateConversationMutationResponse(updatedConversation), + 200 + ); +}); - if (!updatedConversation) { - return restError( - c, - 500, - "INTERNAL_SERVER_ERROR", - "Unable to update conversation metadata" - ); - } +conversationRouter.openapi(updateConversationMetadataRoute, async (c) => { + const extracted = await safelyExtractRequestData( + c, + updateConversationMetadataRequestSchema + ); + const privateContext = assertPrivateConversationControlContext(extracted); + + const { conversationId } = getConversationPathParams(c); + const conversationRecord = await loadPrivateConversationRecord({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId, + }); - return c.json( - createPrivateConversationMutationResponse(updatedConversation), - 200 - ); + if (!conversationRecord) { + return restError(c, 404, "NOT_FOUND", "Conversation not found"); } -); - -conversationRouter.openapi( - { - method: "patch", - path: "/{conversationId}/priority", - summary: "Update a conversation priority", - description: - "Updates the conversation priority and marks it as human-owned. Requires a private API key and an acting teammate.", - operationId: "updateConversationPriority", - tags: ["Conversations"], - request: { - body: { - required: true, - content: { - "application/json": { - schema: updateConversationPriorityRestRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Conversation priority updated successfully", - content: { - "application/json": { - schema: privateConversationMutationResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Bad request - Invalid request payload or missing actor for an unlinked private API key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor user not allowed for this website" - ), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - const extracted = await safelyExtractRequestData( + + const updatedConversation = await mergeConversationMetadata(extracted.db, { + conversation: conversationRecord, + metadata: extracted.body.metadata, + }); + + if (!updatedConversation) { + return restError( c, - updateConversationPriorityRestRequestSchema + 500, + "INTERNAL_SERVER_ERROR", + "Unable to update conversation metadata" ); - const privateContext = assertPrivateConversationControlContext(extracted); + } - const { conversationId } = getConversationPathParams(c); - const conversationRecord = await loadPrivateConversationRecord({ - db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId, - }); + return c.json( + createPrivateConversationMutationResponse(updatedConversation), + 200 + ); +}); - if (!conversationRecord) { - return restError(c, 404, "NOT_FOUND", "Conversation not found"); - } +conversationRouter.openapi(updateConversationPriorityRoute, async (c) => { + const extracted = await safelyExtractRequestData( + c, + updateConversationPriorityRestRequestSchema + ); + const privateContext = assertPrivateConversationControlContext(extracted); + + const { conversationId } = getConversationPathParams(c); + const conversationRecord = await loadPrivateConversationRecord({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId, + }); - const actor = await requirePrivateConversationActor({ - c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: true, - }); + if (!conversationRecord) { + return restError(c, 404, "NOT_FOUND", "Conversation not found"); + } - const updatedConversation = await updateConversationPriority(extracted.db, { - conversation: conversationRecord, - priority: extracted.body.priority, - actorUserId: actor.userId, - }); + const actor = await requirePrivateConversationActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: true, + }); - if (!updatedConversation) { - return restError( - c, - 500, - "INTERNAL_SERVER_ERROR", - "Unable to update conversation priority" - ); - } - - await emitPrivateConversationUpdate(updatedConversation, { - priority: updatedConversation.priority, - prioritySource: updatedConversation.prioritySource, - }); + const updatedConversation = await updateConversationPriority(extracted.db, { + conversation: conversationRecord, + priority: extracted.body.priority, + actorUserId: actor.userId, + }); - return c.json( - createPrivateConversationMutationResponse(updatedConversation), - 200 - ); - } -); - -conversationRouter.openapi( - { - method: "patch", - path: "/{conversationId}/sentiment", - summary: "Update a conversation sentiment", - description: - "Updates the conversation sentiment and marks it as human-owned. Pass null to mark sentiment as unknown. Requires a private API key and an acting teammate.", - operationId: "updateConversationSentiment", - tags: ["Conversations"], - request: { - body: { - required: true, - content: { - "application/json": { - schema: updateConversationSentimentRestRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Conversation sentiment updated successfully", - content: { - "application/json": { - schema: privateConversationMutationResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Bad request - Invalid request payload or missing actor for an unlinked private API key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor user not allowed for this website" - ), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - const extracted = await safelyExtractRequestData( + if (!updatedConversation) { + return restError( c, - updateConversationSentimentRestRequestSchema + 500, + "INTERNAL_SERVER_ERROR", + "Unable to update conversation priority" ); - const privateContext = assertPrivateConversationControlContext(extracted); + } - const { conversationId } = getConversationPathParams(c); - const conversationRecord = await loadPrivateConversationRecord({ - db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId, - }); + await emitPrivateConversationUpdate(updatedConversation, { + priority: updatedConversation.priority, + prioritySource: updatedConversation.prioritySource, + }); - if (!conversationRecord) { - return restError(c, 404, "NOT_FOUND", "Conversation not found"); - } + return c.json( + createPrivateConversationMutationResponse(updatedConversation), + 200 + ); +}); - const actor = await requirePrivateConversationActor({ - c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: true, - }); +conversationRouter.openapi(updateConversationSentimentRoute, async (c) => { + const extracted = await safelyExtractRequestData( + c, + updateConversationSentimentRestRequestSchema + ); + const privateContext = assertPrivateConversationControlContext(extracted); + + const { conversationId } = getConversationPathParams(c); + const conversationRecord = await loadPrivateConversationRecord({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId, + }); - const updatedConversation = await updateConversationSentiment( - extracted.db, - { - conversation: conversationRecord, - sentiment: extracted.body.sentiment, - actorUserId: actor.userId, - } - ); + if (!conversationRecord) { + return restError(c, 404, "NOT_FOUND", "Conversation not found"); + } - if (!updatedConversation) { - return restError( - c, - 500, - "INTERNAL_SERVER_ERROR", - "Unable to update conversation sentiment" - ); - } + const actor = await requirePrivateConversationActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: true, + }); - await emitPrivateConversationUpdate(updatedConversation, { - sentiment: updatedConversation.sentiment, - sentimentConfidence: updatedConversation.sentimentConfidence, - sentimentSource: updatedConversation.sentimentSource, - }); + const updatedConversation = await updateConversationSentiment(extracted.db, { + conversation: conversationRecord, + sentiment: extracted.body.sentiment, + actorUserId: actor.userId, + }); - return c.json( - createPrivateConversationMutationResponse(updatedConversation), - 200 - ); - } -); - -conversationRouter.openapi( - { - method: "patch", - path: "/{conversationId}", - summary: "Update a conversation title", - description: - "Updates the conversation title. This private control route does not require an acting teammate in v1.", - operationId: "updateConversationTitle", - tags: ["Conversations"], - request: { - body: { - required: true, - content: { - "application/json": { - schema: updateConversationTitleRestRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Conversation title updated successfully", - content: { - "application/json": { - schema: privateConversationMutationResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Bad request - Invalid request payload"), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - }), - }, - async (c) => { - const extracted = await safelyExtractRequestData( + if (!updatedConversation) { + return restError( c, - updateConversationTitleRestRequestSchema + 500, + "INTERNAL_SERVER_ERROR", + "Unable to update conversation sentiment" ); - const privateContext = assertPrivateConversationControlContext(extracted); - - const { conversationId } = getConversationPathParams(c); - const conversationRecord = await loadPrivateConversationRecord({ - db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId, - }); + } - if (!conversationRecord) { - return restError(c, 404, "NOT_FOUND", "Conversation not found"); - } + await emitPrivateConversationUpdate(updatedConversation, { + sentiment: updatedConversation.sentiment, + sentimentConfidence: updatedConversation.sentimentConfidence, + sentimentSource: updatedConversation.sentimentSource, + }); - const normalizedTitle = extracted.body.title?.trim() || null; - const updatedConversation = await updateConversationTitle(extracted.db, { - conversation: conversationRecord, - title: normalizedTitle, - titleSource: "user", - }); + return c.json( + createPrivateConversationMutationResponse(updatedConversation), + 200 + ); +}); - if (!updatedConversation) { - return restError( - c, - 500, - "INTERNAL_SERVER_ERROR", - "Unable to update conversation title" - ); - } +conversationRouter.openapi(updateConversationTitleRoute, async (c) => { + const extracted = await safelyExtractRequestData( + c, + updateConversationTitleRestRequestSchema + ); + const privateContext = assertPrivateConversationControlContext(extracted); + + const { conversationId } = getConversationPathParams(c); + const conversationRecord = await loadPrivateConversationRecord({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId, + }); - const planInfo = await getPlanForWebsite(privateContext.website); - const titleTranslation = await syncConversationVisitorTitle({ - db: extracted.db, - conversationId: updatedConversation.id, - organizationId: updatedConversation.organizationId, - websiteId: updatedConversation.websiteId, - title: updatedConversation.title, - websiteDefaultLanguage: privateContext.website.defaultLanguage, - visitorLanguage: updatedConversation.visitorLanguage, - autoTranslateEnabled: isAutomaticTranslationEnabled({ - planAllowsAutoTranslate: planInfo.features["auto-translate"] === true, - websiteAutoTranslateEnabled: - privateContext.website.autoTranslateEnabled, - }), - aiContext: { - db: extracted.db, - organizationId: updatedConversation.organizationId, - websiteId: updatedConversation.websiteId, - }, - }); - const responseConversation = { - ...updatedConversation, - visitorTitle: titleTranslation.visitorTitle, - visitorTitleLanguage: titleTranslation.visitorTitleLanguage, - }; + if (!conversationRecord) { + return restError(c, 404, "NOT_FOUND", "Conversation not found"); + } - await emitPrivateConversationUpdate(responseConversation, { - title: responseConversation.title, - titleSource: responseConversation.titleSource, - visitorTitle: responseConversation.visitorTitle, - visitorTitleLanguage: responseConversation.visitorTitleLanguage, - }); + const normalizedTitle = extracted.body.title?.trim() || null; + const updatedConversation = await updateConversationTitle(extracted.db, { + conversation: conversationRecord, + title: normalizedTitle, + titleSource: "user", + }); - return c.json( - createPrivateConversationMutationResponse(responseConversation), - 200 - ); - } -); - -conversationRouter.openapi( - { - method: "post", - path: "/{conversationId}/ai/pause", - summary: "Pause AI replies for a conversation", - description: - "Pauses AI replies for a conversation for the provided duration. Requires a private API key and an acting teammate.", - operationId: "pauseConversationAi", - tags: ["Conversations"], - request: { - body: { - required: false, - content: { - "application/json": { - schema: pauseConversationAiRestRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Conversation AI paused successfully", - content: { - "application/json": { - schema: privateConversationMutationResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Bad request - Invalid request payload or missing actor for an unlinked private API key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor user not allowed for this website" - ), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - const extracted = await safelyExtractOptionalRequestData( + if (!updatedConversation) { + return restError( c, - pauseConversationAiRestRequestSchema + 500, + "INTERNAL_SERVER_ERROR", + "Unable to update conversation title" ); - const privateContext = assertPrivateConversationControlContext(extracted); + } - const { conversationId } = getConversationPathParams(c); - const conversationRecord = await loadPrivateConversationRecord({ + const planInfo = await getPlanForWebsite(privateContext.website); + const titleTranslation = await syncConversationVisitorTitle({ + db: extracted.db, + conversationId: updatedConversation.id, + organizationId: updatedConversation.organizationId, + websiteId: updatedConversation.websiteId, + title: updatedConversation.title, + websiteDefaultLanguage: privateContext.website.defaultLanguage, + visitorLanguage: updatedConversation.visitorLanguage, + autoTranslateEnabled: isAutomaticTranslationEnabled({ + planAllowsAutoTranslate: planInfo.features["auto-translate"] === true, + websiteAutoTranslateEnabled: privateContext.website.autoTranslateEnabled, + }), + aiContext: { db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId, - }); - - if (!conversationRecord) { - return restError(c, 404, "NOT_FOUND", "Conversation not found"); - } + organizationId: updatedConversation.organizationId, + websiteId: updatedConversation.websiteId, + }, + }); + const responseConversation = { + ...updatedConversation, + visitorTitle: titleTranslation.visitorTitle, + visitorTitleLanguage: titleTranslation.visitorTitleLanguage, + }; - const actor = await requirePrivateConversationActor({ - c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: true, - }); + await emitPrivateConversationUpdate(responseConversation, { + title: responseConversation.title, + titleSource: responseConversation.titleSource, + visitorTitle: responseConversation.visitorTitle, + visitorTitleLanguage: responseConversation.visitorTitleLanguage, + }); - const durationMinutes = - extracted.body.durationMinutes ?? env.AI_AGENT_ROGUE_PAUSE_MINUTES; + return c.json( + createPrivateConversationMutationResponse(responseConversation), + 200 + ); +}); - if (durationMinutes > AI_PAUSE_DURATION_MAX_MINUTES) { - return restError( - c, - 400, - "BAD_REQUEST", - "durationMinutes exceeds the supported maximum" - ); - } +conversationRouter.openapi(pauseConversationAiRoute, async (c) => { + const extracted = await safelyExtractOptionalRequestData( + c, + pauseConversationAiRestRequestSchema + ); + const privateContext = assertPrivateConversationControlContext(extracted); + + const { conversationId } = getConversationPathParams(c); + const conversationRecord = await loadPrivateConversationRecord({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId, + }); - const updatedConversation = await pauseAiForConversation({ - db: extracted.db, - redis: getRedis(), - conversationId: conversationRecord.id, - organizationId: conversationRecord.organizationId, - durationMinutes, - reason: `manual:${actor.userId}`, - mode: "replace", - }); + if (!conversationRecord) { + return restError(c, 404, "NOT_FOUND", "Conversation not found"); + } - if (!updatedConversation) { - return restError( - c, - 500, - "INTERNAL_SERVER_ERROR", - "Unable to pause AI for conversation" - ); - } + const actor = await requirePrivateConversationActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: true, + }); - await Promise.all([ - emitPrivateConversationUpdate(updatedConversation, { - aiPausedUntil: updatedConversation.aiPausedUntil, - }), - createConversationEvent({ - db: extracted.db, - context: { - conversationId: updatedConversation.id, - organizationId: updatedConversation.organizationId, - websiteId: updatedConversation.websiteId, - visitorId: updatedConversation.visitorId, - }, - event: { - type: ConversationEventType.AI_PAUSED, - actorUserId: actor.userId, - message: buildAiPauseEventMessage(durationMinutes), - visibility: TimelineItemVisibility.PRIVATE, - }, - }), - ]); + const durationMinutes = + extracted.body.durationMinutes ?? env.AI_AGENT_ROGUE_PAUSE_MINUTES; - return c.json( - createPrivateConversationMutationResponse(updatedConversation), - 200 + if (durationMinutes > AI_PAUSE_DURATION_MAX_MINUTES) { + return restError( + c, + 400, + "BAD_REQUEST", + "durationMinutes exceeds the supported maximum" ); } -); - -conversationRouter.openapi( - { - method: "post", - path: "/{conversationId}/ai/resume", - summary: "Resume AI replies for a conversation", - description: - "Resumes AI replies for a conversation. Requires a private API key and an acting teammate.", - operationId: "resumeConversationAi", - tags: ["Conversations"], - responses: { - 200: { - description: "Conversation AI resumed successfully", - content: { - "application/json": { - schema: privateConversationMutationResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Bad request - Missing actor for an unlinked private API key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor user not allowed for this website" - ), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - const extracted = await safelyExtractRequestData(c); - const privateContext = assertPrivateConversationControlContext(extracted); - - const { conversationId } = getConversationPathParams(c); - const conversationRecord = await loadPrivateConversationRecord({ - db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId, - }); - if (!conversationRecord) { - return restError(c, 404, "NOT_FOUND", "Conversation not found"); - } + const updatedConversation = await pauseAiForConversation({ + db: extracted.db, + redis: getRedis(), + conversationId: conversationRecord.id, + organizationId: conversationRecord.organizationId, + durationMinutes, + reason: `manual:${actor.userId}`, + mode: "replace", + }); - const actor = await requirePrivateConversationActor({ + if (!updatedConversation) { + return restError( c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: true, - }); - - const updatedConversation = await resumeAiForConversation({ - db: extracted.db, - redis: getRedis(), - conversationId: conversationRecord.id, - organizationId: conversationRecord.organizationId, - }); - - if (!updatedConversation) { - return restError( - c, - 500, - "INTERNAL_SERVER_ERROR", - "Unable to resume AI for conversation" - ); - } - - await Promise.all([ - emitPrivateConversationUpdate(updatedConversation, { - aiPausedUntil: null, - }), - createConversationEvent({ - db: extracted.db, - context: { - conversationId: updatedConversation.id, - organizationId: updatedConversation.organizationId, - websiteId: updatedConversation.websiteId, - visitorId: updatedConversation.visitorId, - }, - event: { - type: ConversationEventType.AI_RESUMED, - actorUserId: actor.userId, - message: "resumed AI answers", - visibility: TimelineItemVisibility.PRIVATE, - }, - }), - ]); - - return c.json( - createPrivateConversationMutationResponse(updatedConversation), - 200 + 500, + "INTERNAL_SERVER_ERROR", + "Unable to pause AI for conversation" ); } -); - -conversationRouter.openapi( - { - method: "post", - path: "/{conversationId}/join-escalation", - summary: "Join an escalated conversation", - description: - "Marks an escalation as handled and adds the acting teammate as a participant if needed. Requires a private API key and an acting teammate.", - operationId: "joinConversationEscalation", - tags: ["Conversations"], - responses: { - 200: { - description: "Escalation joined successfully", - content: { - "application/json": { - schema: privateConversationMutationResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Bad request - Missing actor for an unlinked private API key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor user not allowed for this website" - ), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - const extracted = await safelyExtractRequestData(c); - const privateContext = assertPrivateConversationControlContext(extracted); - const { conversationId } = getConversationPathParams(c); - const conversationRecord = await loadPrivateConversationRecord({ + await Promise.all([ + emitPrivateConversationUpdate(updatedConversation, { + aiPausedUntil: updatedConversation.aiPausedUntil, + }), + createConversationEvent({ db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId, - }); - - if (!conversationRecord) { - return restError(c, 404, "NOT_FOUND", "Conversation not found"); - } + context: { + conversationId: updatedConversation.id, + organizationId: updatedConversation.organizationId, + websiteId: updatedConversation.websiteId, + visitorId: updatedConversation.visitorId, + }, + event: { + type: ConversationEventType.AI_PAUSED, + actorUserId: actor.userId, + message: buildAiPauseEventMessage(durationMinutes), + visibility: TimelineItemVisibility.PRIVATE, + }, + }), + ]); - const actor = await requirePrivateConversationActor({ - c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: true, - }); + return c.json( + createPrivateConversationMutationResponse(updatedConversation), + 200 + ); +}); - const isParticipant = await isUserParticipant(extracted.db, { - conversationId, - userId: actor.userId, - }); +conversationRouter.openapi(resumeConversationAiRoute, async (c) => { + const extracted = await safelyExtractRequestData(c); + const privateContext = assertPrivateConversationControlContext(extracted); + + const { conversationId } = getConversationPathParams(c); + const conversationRecord = await loadPrivateConversationRecord({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId, + }); - if (!isParticipant) { - await addConversationParticipant(extracted.db, { - conversationId, - userId: actor.userId, - organizationId: privateContext.organization.id, - requestedByUserId: actor.userId, - reason: "Joined escalation", - }); - } + if (!conversationRecord) { + return restError(c, 404, "NOT_FOUND", "Conversation not found"); + } - await createParticipantJoinedEvent(extracted.db, { - conversationId, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - visitorId: conversationRecord.visitorId, - targetUserId: actor.userId, - actorUserId: actor.userId, - isAutoAdded: false, - customMessage: "joined to help", - }); + const actor = await requirePrivateConversationActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: true, + }); - const updatedConversation = await joinEscalation(extracted.db, { - conversation: conversationRecord, - actorUserId: actor.userId, - }); + const updatedConversation = await resumeAiForConversation({ + db: extracted.db, + redis: getRedis(), + conversationId: conversationRecord.id, + organizationId: conversationRecord.organizationId, + }); - return c.json( - createPrivateConversationMutationResponse(updatedConversation), - 200 + if (!updatedConversation) { + return restError( + c, + 500, + "INTERNAL_SERVER_ERROR", + "Unable to resume AI for conversation" ); } -); - -conversationRouter.openapi( - { - method: "get", - path: "/inbox", - summary: "List inbox conversations", - description: - "Returns a cursor-paginated inbox view for the authenticated website. This control-plane endpoint requires a private API key.", - tags: ["Conversations"], - request: { - query: listInboxConversationsRequestSchema, - }, - responses: { - 200: { - description: "Inbox conversations retrieved successfully", - content: { - "application/json": { - schema: listInboxConversationsResponseSchema, - }, - }, + + await Promise.all([ + emitPrivateConversationUpdate(updatedConversation, { + aiPausedUntil: null, + }), + createConversationEvent({ + db: extracted.db, + context: { + conversationId: updatedConversation.id, + organizationId: updatedConversation.organizationId, + websiteId: updatedConversation.websiteId, + visitorId: updatedConversation.visitorId, + }, + event: { + type: ConversationEventType.AI_RESUMED, + actorUserId: actor.userId, + message: "resumed AI answers", + visibility: TimelineItemVisibility.PRIVATE, }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ - includeActorUserIdHeader: true, }), - }, - async (c) => { - const extracted = await safelyExtractRequestQuery( - c, - listInboxConversationsRequestSchema - ); - const privateContext = requirePrivateControlContext(c, extracted); + ]); - if (privateContext instanceof Response) { - return privateContext; - } - - const actor = await requirePrivateConversationActor({ - c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: false, - }); + return c.json( + createPrivateConversationMutationResponse(updatedConversation), + 200 + ); +}); - const response = await listSupportConversations(extracted.db, { - website: privateContext.website, - actorUserId: actor?.userId ?? null, - limit: extracted.query.limit, - cursor: extracted.query.cursor ?? null, - status: extracted.query.status, - priority: extracted.query.priority, - sentiment: extracted.query.sentiment, - visitorId: extracted.query.visitorId, - contactId: extracted.query.contactId, - assignedToUserId: extracted.query.assignedToUserId, - viewId: extracted.query.viewId, - createdAtFrom: extracted.query.createdAtFrom, - createdAtTo: extracted.query.createdAtTo, - updatedAtFrom: extracted.query.updatedAtFrom, - updatedAtTo: extracted.query.updatedAtTo, - q: extracted.query.q, - orderBy: extracted.query.orderBy, - order: extracted.query.order, - }); +conversationRouter.openapi(joinConversationEscalationRoute, async (c) => { + const extracted = await safelyExtractRequestData(c); + const privateContext = assertPrivateConversationControlContext(extracted); + + const { conversationId } = getConversationPathParams(c); + const conversationRecord = await loadPrivateConversationRecord({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId, + }); - return c.json( - validateResponse(response, listInboxConversationsResponseSchema), - 200 - ); + if (!conversationRecord) { + return restError(c, 404, "NOT_FOUND", "Conversation not found"); } -); - -conversationRouter.openapi( - { - method: "get", - path: "/{conversationId}/context", - summary: "Get conversation context", - description: - "Returns agent-ready private context for a conversation, including rich conversation state, visitor/contact profile, a private timeline page, and linked feedback.", - tags: ["Conversations"], - request: { - params: getConversationRequestSchema, - query: conversationContextRequestSchema, - }, - responses: { - 200: { - description: "Conversation context retrieved successfully", - content: { - "application/json": { - schema: conversationContextResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Invalid request"), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - const extracted = await safelyExtractRequestQuery( - c, - conversationContextRequestSchema - ); - const privateContext = requirePrivateControlContext(c, extracted); - if (privateContext instanceof Response) { - return privateContext; - } + const actor = await requirePrivateConversationActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: true, + }); - const { conversationId } = getConversationPathParams(c); - const actor = await requirePrivateConversationActor({ - c, - db: extracted.db, - apiKey: privateContext.apiKey, + const isParticipant = await isUserParticipant(extracted.db, { + conversationId, + userId: actor.userId, + }); + + if (!isParticipant) { + await addConversationParticipant(extracted.db, { + conversationId, + userId: actor.userId, organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - required: false, + requestedByUserId: actor.userId, + reason: "Joined escalation", }); - - let response: Awaited>; - try { - response = await getSupportConversation(extracted.db, { - website: privateContext.website, - actorUserId: actor?.userId ?? null, - conversationId, - timelineLimit: extracted.query.timelineLimit, - timelineCursor: extracted.query.timelineCursor ?? null, - feedbackLimit: extracted.query.feedbackLimit, - }); - } catch (error) { - if (error instanceof SupportCapabilityError) { - const status = error.status === 409 ? 400 : error.status; - return restError(c, status, error.code, error.message); - } - throw error; - } - - return c.json( - validateResponse(response, conversationContextResponseSchema), - 200 - ); } -); - -conversationRouter.openapi( - { - method: "get", - path: "/{conversationId}", - summary: "Get a single conversation by ID", - description: - "Fetch a specific conversation by its ID, including any public conversation metadata.", - tags: ["Conversations"], - request: { - params: getConversationRequestSchema, - }, - responses: { - 200: { - description: "Conversation retrieved successfully", - content: { - "application/json": { - schema: getConversationResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Invalid request"), - 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), - 403: errorJsonResponse("Forbidden - Public key origin validation failed"), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...runtimeDualAuth({ - parameters: [conversationIdPathParameter], - includeVisitorIdHeader: true, - }), - }, - async (c) => { - const { db, website, organization, apiKey, visitorIdHeader } = - await safelyExtractRequestData(c); - const params = getConversationPathParams(c); - const conversationRecord = await getConversationByIdWithLastMessage(db, { - organizationId: organization.id, - websiteId: website.id, - conversationId: params.conversationId, - }); + await createParticipantJoinedEvent(extracted.db, { + conversationId, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + visitorId: conversationRecord.visitorId, + targetUserId: actor.userId, + actorUserId: actor.userId, + isAutoAdded: false, + customMessage: "joined to help", + }); - if (!conversationRecord) { - return c.json( - { - error: "Conversation not found", - }, - 404 - ); - } + const updatedConversation = await joinEscalation(extracted.db, { + conversation: conversationRecord, + actorUserId: actor.userId, + }); - const publicVisitor = await resolvePublicConversationVisitor({ - c, - db, - organizationId: organization.id, - websiteId: website.id, - apiKey, - headerVisitorId: visitorIdHeader, - }); + return c.json( + createPrivateConversationMutationResponse(updatedConversation), + 200 + ); +}); - if (publicVisitor.error) { - return publicVisitor.error; - } +conversationRouter.openapi(listInboxConversationsRoute, async (c) => { + const extracted = await safelyExtractRequestQuery( + c, + listInboxConversationsRequestSchema + ); + const privateContext = requirePrivateControlContext(c, extracted); - const ownershipError = await ensurePublicConversationAccess({ - c, - db, - organizationId: organization.id, - websiteId: website.id, - conversationVisitorId: conversationRecord.visitorId, - viewerVisitorId: publicVisitor.visitor?.id ?? null, - }); + if (privateContext instanceof Response) { + return privateContext; + } - if (ownershipError) { - return ownershipError; - } + const actor = await requirePrivateConversationActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: false, + }); - try { - const response = { - conversation: serializeConversationForResponse(conversationRecord), - }; + const response = await listSupportConversations(extracted.db, { + website: privateContext.website, + actorUserId: actor?.userId ?? null, + limit: extracted.query.limit, + cursor: extracted.query.cursor ?? null, + status: extracted.query.status, + priority: extracted.query.priority, + sentiment: extracted.query.sentiment, + visitorId: extracted.query.visitorId, + contactId: extracted.query.contactId, + assignedToUserId: extracted.query.assignedToUserId, + viewId: extracted.query.viewId, + createdAtFrom: extracted.query.createdAtFrom, + createdAtTo: extracted.query.createdAtTo, + updatedAtFrom: extracted.query.updatedAtFrom, + updatedAtTo: extracted.query.updatedAtTo, + q: extracted.query.q, + orderBy: extracted.query.orderBy, + order: extracted.query.order, + }); - return c.json( - validateResponse(response, getConversationResponseSchema), - 200 - ); - } catch (error) { - console.error( - "[GET_CONVERSATION] Failed to serialize conversation response", - { - error, - conversationId: params.conversationId, - organizationId: organization.id, - websiteId: website.id, - } - ); + return c.json( + validateResponse(response, listInboxConversationsResponseSchema), + 200 + ); +}); - return c.json( - { error: "Failed to serialize conversation response" }, - 500 - ); - } +conversationRouter.openapi(getConversationContextRoute, async (c) => { + const extracted = await safelyExtractRequestQuery( + c, + conversationContextRequestSchema + ); + const privateContext = requirePrivateControlContext(c, extracted); + + if (privateContext instanceof Response) { + return privateContext; } -); - -conversationRouter.openapi( - { - method: "post", - path: "/{conversationId}/seen", - summary: "Mark a conversation as seen by the visitor", - description: - "Record a visitor's last seen timestamp for a specific conversation.", - tags: ["Conversations"], - request: { - body: { - required: false, - content: { - "application/json": { - schema: markConversationSeenRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Conversation seen timestamp recorded", - content: { - "application/json": { - schema: markConversationSeenResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Invalid request"), - 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), - 403: errorJsonResponse("Forbidden - Public key origin validation failed"), - 404: errorJsonResponse("Conversation not found"), - }, - ...runtimeDualAuth({ - parameters: [conversationIdPathParameter], - includeVisitorIdHeader: true, - }), - }, - async (c) => { - const { db, website, organization, body, visitorIdHeader } = - await safelyExtractRequestData(c, markConversationSeenRequestSchema); - const params = getConversationRequestSchema.parse({ - conversationId: c.req.param("conversationId"), + const { conversationId } = getConversationPathParams(c); + const actor = await requirePrivateConversationActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + required: false, + }); + + let response: Awaited>; + try { + response = await getSupportConversation(extracted.db, { + website: privateContext.website, + actorUserId: actor?.userId ?? null, + conversationId, + timelineLimit: extracted.query.timelineLimit, + timelineCursor: extracted.query.timelineCursor ?? null, + feedbackLimit: extracted.query.feedbackLimit, }); + } catch (error) { + if (error instanceof SupportCapabilityError) { + const status = error.status === 409 ? 400 : error.status; + return restError(c, status, error.code, error.message); + } + throw error; + } - const [visitorIdentity, conversationRecord] = await Promise.all([ - resolveRuntimeVisitorIdentity({ - c, - db, - organizationId: organization.id, - websiteId: website.id, - headerVisitorId: visitorIdHeader, - requestVisitorId: body.visitorId, - }), - getConversationByIdWithLastMessage(db, { - organizationId: organization.id, - websiteId: website.id, - conversationId: params.conversationId, - }), - ]); + return c.json( + validateResponse(response, conversationContextResponseSchema), + 200 + ); +}); - if (visitorIdentity.error) { - return visitorIdentity.error; - } +conversationRouter.openapi(getConversationRoute, async (c) => { + const { db, website, organization, apiKey, visitorIdHeader } = + await safelyExtractRequestData(c); + const params = getConversationPathParams(c); - const visitor = visitorIdentity.visitor; - if (!visitor) { - return restError( - c, - 400, - "BAD_REQUEST", - "Visitor not found, please pass a valid visitorId" - ); - } + const conversationRecord = await getConversationByIdWithLastMessage(db, { + organizationId: organization.id, + websiteId: website.id, + conversationId: params.conversationId, + }); - if (!conversationRecord) { - return c.json( - { - error: "Conversation not found", - }, - 404 - ); - } + if (!conversationRecord) { + return c.json( + { + error: "Conversation not found", + }, + 404 + ); + } - const canAccessConversation = await canVisitorAccessConversation(db, { - organizationId: organization.id, - websiteId: website.id, - viewerVisitorId: visitor.id, - conversationVisitorId: conversationRecord.visitorId, - }); + const publicVisitor = await resolvePublicConversationVisitor({ + c, + db, + organizationId: organization.id, + websiteId: website.id, + apiKey, + headerVisitorId: visitorIdHeader, + }); - if (!canAccessConversation) { - return c.json( - { - error: "Conversation not found", - }, - 404 - ); - } + if (publicVisitor.error) { + return publicVisitor.error; + } - const lastSeenAt = await markConversationAsSeenByVisitor(db, { - conversation: conversationRecord, - visitorId: visitor.id, - }); + const ownershipError = await ensurePublicConversationAccess({ + c, + db, + organizationId: organization.id, + websiteId: website.id, + conversationVisitorId: conversationRecord.visitorId, + viewerVisitorId: publicVisitor.visitor?.id ?? null, + }); - await emitConversationSeenEvent({ - conversation: conversationRecord, - actor: { type: "visitor", visitorId: visitor.id }, - lastSeenAt, - }); + if (ownershipError) { + return ownershipError; + } + try { const response = { - conversationId: conversationRecord.id, - lastSeenAt, + conversation: serializeConversationForResponse(conversationRecord), }; return c.json( - validateResponse(response, markConversationSeenResponseSchema), + validateResponse(response, getConversationResponseSchema), 200 ); - } -); - -conversationRouter.openapi( - { - method: "post", - path: "/{conversationId}/typing", - summary: "Report a visitor typing state", - description: - "Emit a typing indicator event for the visitor. Either visitorId must be provided via body or headers.", - tags: ["Conversations"], - request: { - body: { - required: true, - content: { - "application/json": { - schema: setConversationTypingRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Typing state recorded", - content: { - "application/json": { - schema: setConversationTypingResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Invalid request"), - 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), - 403: errorJsonResponse("Forbidden - Public key origin validation failed"), - 404: errorJsonResponse("Conversation not found"), - }, - ...runtimeDualAuth({ - parameters: [conversationIdPathParameter], - includeVisitorIdHeader: true, - }), - }, - async (c) => { - const { db, website, organization, body, visitorIdHeader } = - await safelyExtractRequestData(c, setConversationTypingRequestSchema); - - const params = getConversationRequestSchema.parse({ - conversationId: c.req.param("conversationId"), - }); - - const [visitorIdentity, conversationRecord] = await Promise.all([ - resolveRuntimeVisitorIdentity({ - c, - db, - organizationId: organization.id, - websiteId: website.id, - headerVisitorId: visitorIdHeader, - requestVisitorId: body.visitorId, - }), - getConversationByIdWithLastMessage(db, { + } catch (error) { + console.error( + "[GET_CONVERSATION] Failed to serialize conversation response", + { + error, + conversationId: params.conversationId, organizationId: organization.id, websiteId: website.id, - conversationId: params.conversationId, - }), - ]); + } + ); - if (visitorIdentity.error) { - return visitorIdentity.error; - } + return c.json({ error: "Failed to serialize conversation response" }, 500); + } +}); - const visitor = visitorIdentity.visitor; - if (!visitor) { - return restError( - c, - 400, - "BAD_REQUEST", - "Visitor not found, please pass a valid visitorId" - ); - } +conversationRouter.openapi(markConversationSeenRoute, async (c) => { + const { db, website, organization, body, visitorIdHeader } = + await safelyExtractRequestData(c, markConversationSeenRequestSchema); - if (!conversationRecord) { - return c.json( - { - error: "Conversation not found", - }, - 404 - ); - } + const params = getConversationRequestSchema.parse({ + conversationId: c.req.param("conversationId"), + }); - const canAccessConversation = await canVisitorAccessConversation(db, { + const [visitorIdentity, conversationRecord] = await Promise.all([ + resolveRuntimeVisitorIdentity({ + c, + db, organizationId: organization.id, websiteId: website.id, - viewerVisitorId: visitor.id, - conversationVisitorId: conversationRecord.visitorId, - }); - - if (!canAccessConversation) { - return c.json( - { - error: "Conversation not found", - }, - 404 - ); - } - - const trimmedPreview = body.visitorPreview?.trim() ?? ""; - let effectivePreview = - body.isTyping && trimmedPreview.length > 0 - ? trimmedPreview.slice(0, 2000) - : null; - - if (effectivePreview) { - const stickyVisitorLanguage = - conversationRecord.visitorLanguage ?? visitor.language ?? null; - const autoTranslateEnabled = - website.autoTranslateEnabled === true - ? isAutomaticTranslationEnabled({ - planAllowsAutoTranslate: - (await getPlanForWebsite(website)).features[ - "auto-translate" - ] === true, - websiteAutoTranslateEnabled: website.autoTranslateEnabled, - }) - : false; - if ( - autoTranslateEnabled && - shouldMaskTypingPreview({ - preview: effectivePreview, - websiteDefaultLanguage: website.defaultLanguage, - visitorLanguageHint: stickyVisitorLanguage, - }) - ) { - effectivePreview = "Typing in another language"; - } - } - - await emitConversationTypingEvent({ - conversation: conversationRecord, - actor: { type: "visitor", visitorId: visitor.id }, - isTyping: body.isTyping, - visitorPreview: effectivePreview ?? undefined, - }); - - const sentAt = new Date(); - - await markVisitorPresence({ + headerVisitorId: visitorIdHeader, + requestVisitorId: body.visitorId, + }), + getConversationByIdWithLastMessage(db, { + organizationId: organization.id, websiteId: website.id, - visitorId: visitor.id, - lastSeenAt: sentAt, - geo: extractGeoFromVisitor(visitor), - }); + conversationId: params.conversationId, + }), + ]); - const response = { - conversationId: conversationRecord.id, - isTyping: body.isTyping, - visitorPreview: effectivePreview, - sentAt: sentAt.toISOString(), - }; + if (visitorIdentity.error) { + return visitorIdentity.error; + } - return c.json( - validateResponse(response, setConversationTypingResponseSchema), - 200 + const visitor = visitorIdentity.visitor; + if (!visitor) { + return restError( + c, + 400, + "BAD_REQUEST", + "Visitor not found, please pass a valid visitorId" ); } -); - -conversationRouter.openapi( - { - method: "post", - path: "/{conversationId}/rating", - summary: "Submit a visitor rating for a conversation", - description: - "Record a visitor rating (1-5) for a resolved conversation. Requires visitor ownership.", - tags: ["Conversations"], - request: { - body: { - required: true, - content: { - "application/json": { - schema: submitConversationRatingRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Conversation rating recorded", - content: { - "application/json": { - schema: submitConversationRatingResponseSchema, - }, - }, + + if (!conversationRecord) { + return c.json( + { + error: "Conversation not found", }, - 400: errorJsonResponse("Invalid request"), - 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), - 403: errorJsonResponse("Forbidden"), - 404: errorJsonResponse("Conversation not found"), - }, - ...runtimeDualAuth({ - parameters: [conversationIdPathParameter], - includeVisitorIdHeader: true, - }), - }, - async (c) => { - const { db, website, organization, body, visitorIdHeader } = - await safelyExtractRequestData(c, submitConversationRatingRequestSchema); + 404 + ); + } - const params = getConversationRequestSchema.parse({ - conversationId: c.req.param("conversationId"), - }); + const canAccessConversation = await canVisitorAccessConversation(db, { + organizationId: organization.id, + websiteId: website.id, + viewerVisitorId: visitor.id, + conversationVisitorId: conversationRecord.visitorId, + }); - const [visitorIdentity, conversationRecord] = await Promise.all([ - resolveRuntimeVisitorIdentity({ - c, - db, - organizationId: organization.id, - websiteId: website.id, - headerVisitorId: visitorIdHeader, - requestVisitorId: body.visitorId, - }), - getConversationByIdWithLastMessage(db, { - organizationId: organization.id, - websiteId: website.id, - conversationId: params.conversationId, - }), - ]); + if (!canAccessConversation) { + return c.json( + { + error: "Conversation not found", + }, + 404 + ); + } - if (visitorIdentity.error) { - return visitorIdentity.error; - } + const lastSeenAt = await markConversationAsSeenByVisitor(db, { + conversation: conversationRecord, + visitorId: visitor.id, + }); - const visitor = visitorIdentity.visitor; - if (!visitor) { - return restError( - c, - 400, - "BAD_REQUEST", - "Visitor not found, please pass a valid visitorId" - ); - } + await emitConversationSeenEvent({ + conversation: conversationRecord, + actor: { type: "visitor", visitorId: visitor.id }, + lastSeenAt, + }); - if (!conversationRecord) { - return c.json( - { - error: "Conversation not found", - }, - 404 - ); - } + const response = { + conversationId: conversationRecord.id, + lastSeenAt, + }; - const canAccessConversation = await canVisitorAccessConversation(db, { - organizationId: organization.id, - websiteId: website.id, - viewerVisitorId: visitor.id, - conversationVisitorId: conversationRecord.visitorId, - }); + return c.json( + validateResponse(response, markConversationSeenResponseSchema), + 200 + ); +}); - if (!canAccessConversation) { - return c.json( - { - error: "Conversation not found", - }, - 404 - ); - } +conversationRouter.openapi(setConversationTypingRoute, async (c) => { + const { db, website, organization, body, visitorIdHeader } = + await safelyExtractRequestData(c, setConversationTypingRequestSchema); - if (conversationRecord.status !== "resolved") { - return c.json( - { - error: "Conversation must be resolved before submitting a rating", - }, - 403 - ); - } + const params = getConversationRequestSchema.parse({ + conversationId: c.req.param("conversationId"), + }); - const { ratedAt } = await persistFeedbackSubmission({ + const [visitorIdentity, conversationRecord] = await Promise.all([ + resolveRuntimeVisitorIdentity({ + c, db, organizationId: organization.id, websiteId: website.id, - website, - conversationId: conversationRecord.id, - visitorId: visitor.id, - contactId: visitor.contactId, - rating: body.rating, - topic: undefined, - comment: body.comment, - trigger: "conversation_resolved", - source: "widget", - syncConversationRating: true, - }); + headerVisitorId: visitorIdHeader, + requestVisitorId: body.visitorId, + }), + getConversationByIdWithLastMessage(db, { + organizationId: organization.id, + websiteId: website.id, + conversationId: params.conversationId, + }), + ]); - const response = { - conversationId: conversationRecord.id, - rating: body.rating, - ratedAt, - }; + if (visitorIdentity.error) { + return visitorIdentity.error; + } - return c.json( - validateResponse(response, submitConversationRatingResponseSchema), - 200 + const visitor = visitorIdentity.visitor; + if (!visitor) { + return restError( + c, + 400, + "BAD_REQUEST", + "Visitor not found, please pass a valid visitorId" ); } -); -// GET /conversations/:conversationId/seen - Fetch seen data for a conversation -conversationRouter.openapi( - { - method: "get", - path: "/{conversationId}/seen", - summary: "Get conversation seen data", - description: - "Fetch the seen data (read receipts) for a conversation, showing who has seen messages and when.", - tags: ["Conversations"], - responses: { - 200: { - description: "Seen data retrieved successfully", - content: { - "application/json": { - schema: getConversationSeenDataResponseSchema, - }, - }, + if (!conversationRecord) { + return c.json( + { + error: "Conversation not found", }, - 400: errorJsonResponse("Invalid request"), - 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), - 403: errorJsonResponse("Forbidden - Public key origin validation failed"), - 404: errorJsonResponse("Conversation not found"), - }, - ...runtimeDualAuth({ - parameters: [conversationIdPathParameter], - includeVisitorIdHeader: true, - }), - }, - async (c) => { - const { db, website, organization, apiKey, visitorIdHeader } = - await safelyExtractRequestQuery(c, z.object({})); + 404 + ); + } - const params = getConversationPathParams(c); + const canAccessConversation = await canVisitorAccessConversation(db, { + organizationId: organization.id, + websiteId: website.id, + viewerVisitorId: visitor.id, + conversationVisitorId: conversationRecord.visitorId, + }); - const conversationRecord = await getConversationByIdWithLastMessage(db, { - organizationId: organization.id, - websiteId: website.id, - conversationId: params.conversationId, - }); + if (!canAccessConversation) { + return c.json( + { + error: "Conversation not found", + }, + 404 + ); + } - if (!conversationRecord) { - return c.json( - { - error: "Conversation not found", - }, - 404 - ); + const trimmedPreview = body.visitorPreview?.trim() ?? ""; + let effectivePreview = + body.isTyping && trimmedPreview.length > 0 + ? trimmedPreview.slice(0, 2000) + : null; + + if (effectivePreview) { + const stickyVisitorLanguage = + conversationRecord.visitorLanguage ?? visitor.language ?? null; + const autoTranslateEnabled = + website.autoTranslateEnabled === true + ? isAutomaticTranslationEnabled({ + planAllowsAutoTranslate: + (await getPlanForWebsite(website)).features["auto-translate"] === + true, + websiteAutoTranslateEnabled: website.autoTranslateEnabled, + }) + : false; + if ( + autoTranslateEnabled && + shouldMaskTypingPreview({ + preview: effectivePreview, + websiteDefaultLanguage: website.defaultLanguage, + visitorLanguageHint: stickyVisitorLanguage, + }) + ) { + effectivePreview = "Typing in another language"; } + } + + await emitConversationTypingEvent({ + conversation: conversationRecord, + actor: { type: "visitor", visitorId: visitor.id }, + isTyping: body.isTyping, + visitorPreview: effectivePreview ?? undefined, + }); + + const sentAt = new Date(); + + await markVisitorPresence({ + websiteId: website.id, + visitorId: visitor.id, + lastSeenAt: sentAt, + geo: extractGeoFromVisitor(visitor), + }); + + const response = { + conversationId: conversationRecord.id, + isTyping: body.isTyping, + visitorPreview: effectivePreview, + sentAt: sentAt.toISOString(), + }; + + return c.json( + validateResponse(response, setConversationTypingResponseSchema), + 200 + ); +}); + +conversationRouter.openapi(createConversationRatingRoute, async (c) => { + const { db, website, organization, body, visitorIdHeader } = + await safelyExtractRequestData(c, submitConversationRatingRequestSchema); + + const params = getConversationRequestSchema.parse({ + conversationId: c.req.param("conversationId"), + }); - const publicVisitor = await resolvePublicConversationVisitor({ + const [visitorIdentity, conversationRecord] = await Promise.all([ + resolveRuntimeVisitorIdentity({ c, db, organizationId: organization.id, websiteId: website.id, - apiKey, headerVisitorId: visitorIdHeader, - }); + requestVisitorId: body.visitorId, + }), + getConversationByIdWithLastMessage(db, { + organizationId: organization.id, + websiteId: website.id, + conversationId: params.conversationId, + }), + ]); - if (publicVisitor.error) { - return publicVisitor.error; - } + if (visitorIdentity.error) { + return visitorIdentity.error; + } - const ownershipError = await ensurePublicConversationAccess({ + const visitor = visitorIdentity.visitor; + if (!visitor) { + return restError( c, - db, - organizationId: organization.id, - websiteId: website.id, - conversationVisitorId: conversationRecord.visitorId, - viewerVisitorId: publicVisitor.visitor?.id ?? null, - }); + 400, + "BAD_REQUEST", + "Visitor not found, please pass a valid visitorId" + ); + } - if (ownershipError) { - return ownershipError; - } + if (!conversationRecord) { + return c.json( + { + error: "Conversation not found", + }, + 404 + ); + } - const seenData = await getConversationSeenData(db, { - conversationId: params.conversationId, - organizationId: organization.id, - }); + const canAccessConversation = await canVisitorAccessConversation(db, { + organizationId: organization.id, + websiteId: website.id, + viewerVisitorId: visitor.id, + conversationVisitorId: conversationRecord.visitorId, + }); + if (!canAccessConversation) { return c.json( - validateResponse({ seenData }, getConversationSeenDataResponseSchema), - 200 + { + error: "Conversation not found", + }, + 404 ); } -); - -conversationRouter.openapi( - { - method: "get", - path: "/{conversationId}/export", - summary: "Download a full conversation export", - description: - "Returns the full internal conversation transcript as plain text. This control-plane endpoint requires a private API key.", - tags: ["Conversations"], - request: { - query: emptyQuerySchema, - params: getConversationRequestSchema, - }, - responses: { - 200: { - description: "Conversation export generated successfully", - content: { - "text/plain": { - schema: z.string(), - }, - }, + + if (conversationRecord.status !== "resolved") { + return c.json( + { + error: "Conversation must be resolved before submitting a rating", }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("Conversation not found"), - }, - ...privateControlAuth({ - parameters: [conversationIdPathParameter], - }), - }, - async (c) => { - const extracted = await safelyExtractRequestQuery(c, emptyQuerySchema); - const privateContext = requirePrivateControlContext(c, extracted); + 403 + ); + } - if (privateContext instanceof Response) { - return privateContext; - } + const { ratedAt } = await persistFeedbackSubmission({ + db, + organizationId: organization.id, + websiteId: website.id, + website, + conversationId: conversationRecord.id, + visitorId: visitor.id, + contactId: visitor.contactId, + rating: body.rating, + topic: undefined, + comment: body.comment, + trigger: "conversation_resolved", + source: "widget", + syncConversationRating: true, + }); + + const response = { + conversationId: conversationRecord.id, + rating: body.rating, + ratedAt, + }; + + return c.json( + validateResponse(response, submitConversationRatingResponseSchema), + 200 + ); +}); + +// GET /conversations/:conversationId/seen - Fetch seen data for a conversation +conversationRouter.openapi(getConversationSeenRoute, async (c) => { + const { db, website, organization, apiKey, visitorIdHeader } = + await safelyExtractRequestQuery(c, z.object({})); + + const params = getConversationPathParams(c); - const params = getConversationPathParams(c); - const conversationRecord = await getConversationByIdWithLastMessage( - extracted.db, + const conversationRecord = await getConversationByIdWithLastMessage(db, { + organizationId: organization.id, + websiteId: website.id, + conversationId: params.conversationId, + }); + + if (!conversationRecord) { + return c.json( { - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - conversationId: params.conversationId, - } + error: "Conversation not found", + }, + 404 ); + } - if (!conversationRecord) { - return c.json( - { - error: "Conversation not found", - }, - 404 - ); - } + const publicVisitor = await resolvePublicConversationVisitor({ + c, + db, + organizationId: organization.id, + websiteId: website.id, + apiKey, + headerVisitorId: visitorIdHeader, + }); - const exportResult = await buildConversationExport({ - db: extracted.db, - website: { - id: privateContext.website.id, - slug: privateContext.website.slug, - organizationId: privateContext.website.organizationId, - teamId: privateContext.website.teamId, - }, - conversation: { - id: conversationRecord.id, - title: conversationRecord.title, - createdAt: conversationRecord.createdAt, - visitorId: conversationRecord.visitorId, - }, - }); + if (publicVisitor.error) { + return publicVisitor.error; + } - return c.text(exportResult.content, 200, { - "Content-Disposition": `attachment; filename="${exportResult.filename}"`, - "Content-Type": exportResult.mimeType, - }); + const ownershipError = await ensurePublicConversationAccess({ + c, + db, + organizationId: organization.id, + websiteId: website.id, + conversationVisitorId: conversationRecord.visitorId, + viewerVisitorId: publicVisitor.visitor?.id ?? null, + }); + + if (ownershipError) { + return ownershipError; } -); -// GET /conversations/:conversationId/timeline - Fetch timeline items for a conversation -conversationRouter.openapi( - { - method: "get", - path: "/{conversationId}/timeline", - summary: "Get conversation timeline items", - description: - "Fetch paginated timeline items (messages and events) for a conversation in chronological order.", - tags: ["Conversations"], - request: { - query: getConversationTimelineItemsRequestSchema, - }, - responses: { - 200: { - description: "Timeline items retrieved successfully", - content: { - "application/json": { - schema: getConversationTimelineItemsResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Invalid request"), - 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), - 403: errorJsonResponse("Forbidden - Public key origin validation failed"), - 404: errorJsonResponse("Conversation not found"), - }, - ...runtimeDualAuth({ - parameters: [conversationIdPathParameter], - includeVisitorIdHeader: true, - }), - }, - async (c) => { - const { db, website, organization, query, apiKey, visitorIdHeader } = - await safelyExtractRequestQuery( - c, - getConversationTimelineItemsRequestSchema - ); + const seenData = await getConversationSeenData(db, { + conversationId: params.conversationId, + organizationId: organization.id, + }); - const params = getConversationPathParams(c); + return c.json( + validateResponse({ seenData }, getConversationSeenDataResponseSchema), + 200 + ); +}); - const conversationRecord = await getConversationByIdWithLastMessage(db, { - organizationId: organization.id, - websiteId: website.id, - conversationId: params.conversationId, - }); +conversationRouter.openapi(exportConversationRoute, async (c) => { + const extracted = await safelyExtractRequestQuery(c, emptyQuerySchema); + const privateContext = requirePrivateControlContext(c, extracted); - if (!conversationRecord) { - return c.json( - { - error: "Conversation not found", - }, - 404 - ); + if (privateContext instanceof Response) { + return privateContext; + } + + const params = getConversationPathParams(c); + const conversationRecord = await getConversationByIdWithLastMessage( + extracted.db, + { + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + conversationId: params.conversationId, } + ); - const publicVisitor = await resolvePublicConversationVisitor({ - c, - db, - organizationId: organization.id, - websiteId: website.id, - apiKey, - headerVisitorId: visitorIdHeader, - }); + if (!conversationRecord) { + return c.json( + { + error: "Conversation not found", + }, + 404 + ); + } - if (publicVisitor.error) { - return publicVisitor.error; - } + const exportResult = await buildConversationExport({ + db: extracted.db, + website: { + id: privateContext.website.id, + slug: privateContext.website.slug, + organizationId: privateContext.website.organizationId, + teamId: privateContext.website.teamId, + }, + conversation: { + id: conversationRecord.id, + title: conversationRecord.title, + createdAt: conversationRecord.createdAt, + visitorId: conversationRecord.visitorId, + }, + }); - const ownershipError = await ensurePublicConversationAccess({ - c, - db, - organizationId: organization.id, - websiteId: website.id, - conversationVisitorId: conversationRecord.visitorId, - viewerVisitorId: publicVisitor.visitor?.id ?? null, - }); + return c.text(exportResult.content, 200, { + "Content-Disposition": `attachment; filename="${exportResult.filename}"`, + "Content-Type": exportResult.mimeType, + }); +}); - if (ownershipError) { - return ownershipError; - } +// GET /conversations/:conversationId/timeline - Fetch timeline items for a conversation +conversationRouter.openapi(getConversationTimelineRoute, async (c) => { + const { db, website, organization, query, apiKey, visitorIdHeader } = + await safelyExtractRequestQuery( + c, + getConversationTimelineItemsRequestSchema + ); - const visibilityFilter = - apiKey?.keyType === APIKeyType.PUBLIC - ? [TimelineItemVisibility.PUBLIC] - : undefined; + const params = getConversationPathParams(c); - const result = await getConversationTimelineItems(db, { - organizationId: organization.id, - conversationId: params.conversationId, - websiteId: website.id, - limit: query.limit, - cursor: query.cursor, - visibility: visibilityFilter, - }); + const conversationRecord = await getConversationByIdWithLastMessage(db, { + organizationId: organization.id, + websiteId: website.id, + conversationId: params.conversationId, + }); + if (!conversationRecord) { return c.json( { - items: result.items as TimelineItem[], - nextCursor: result.nextCursor ?? null, - hasNextPage: result.hasNextPage, + error: "Conversation not found", }, - 200 + 404 ); } -); + + const publicVisitor = await resolvePublicConversationVisitor({ + c, + db, + organizationId: organization.id, + websiteId: website.id, + apiKey, + headerVisitorId: visitorIdHeader, + }); + + if (publicVisitor.error) { + return publicVisitor.error; + } + + const ownershipError = await ensurePublicConversationAccess({ + c, + db, + organizationId: organization.id, + websiteId: website.id, + conversationVisitorId: conversationRecord.visitorId, + viewerVisitorId: publicVisitor.visitor?.id ?? null, + }); + + if (ownershipError) { + return ownershipError; + } + + const visibilityFilter = + apiKey?.keyType === APIKeyType.PUBLIC + ? [TimelineItemVisibility.PUBLIC] + : undefined; + + const result = await getConversationTimelineItems(db, { + organizationId: organization.id, + conversationId: params.conversationId, + websiteId: website.id, + limit: query.limit, + cursor: query.cursor, + visibility: visibilityFilter, + }); + + return c.json( + { + items: result.items as TimelineItem[], + nextCursor: result.nextCursor ?? null, + hasNextPage: result.hasNextPage, + }, + 200 + ); +}); diff --git a/apps/api/src/rest/routers/feedback.ts b/apps/api/src/rest/routers/feedback.ts index b20d2b7ad..ba4cbdd64 100644 --- a/apps/api/src/rest/routers/feedback.ts +++ b/apps/api/src/rest/routers/feedback.ts @@ -11,6 +11,12 @@ import { safelyExtractRequestQuery, validateResponse, } from "@api/utils/validate"; +import { + createFeedbackRoute, + getFeedbackRoute, + getFeedbackSummaryRoute, + listFeedbackRoute, +} from "@cossistant/protocol/routes"; import { APIKeyType } from "@cossistant/types"; import { feedbackSummaryRequestSchema, @@ -45,176 +51,48 @@ const feedbackReadRouter = new OpenAPIHono(); feedbackCreateRouter.use("/*", ...protectedPublicApiKeyMiddleware); feedbackReadRouter.use("/*", ...protectedPrivateApiKeyMiddleware); -feedbackCreateRouter.openapi( - { - method: "post", - path: "/", - summary: "Submit feedback", - description: - "Submit feedback with a rating, optional topic, and optional comment. Can be tied to a conversation or standalone.", - request: { - body: { - content: { - "application/json": { - schema: submitFeedbackRequestSchema, - }, - }, - }, - }, - responses: { - 201: { - description: "Feedback submitted successfully", - content: { - "application/json": { - schema: submitFeedbackResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Invalid request data"), - 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), - 403: errorJsonResponse("Forbidden - API key required"), - 404: errorJsonResponse("Conversation not found"), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["Feedback"], - ...runtimeDualAuth({ includeVisitorIdHeader: true }), - }, - async (c) => { - try { - const { apiKey, db, organization, website, body, visitorIdHeader } = - await safelyExtractRequestData(c, submitFeedbackRequestSchema); - - if (!(website?.id && website.organizationId && organization?.id)) { - return c.json( - { error: "UNAUTHORIZED", message: "Invalid API key" }, - 401 - ); - } - - if (apiKey?.keyType === APIKeyType.PUBLIC) { - const visitorIdentity = await resolveRuntimeVisitorIdentity({ - c, - db, - apiKey, - organizationId: organization.id, - websiteId: website.id, - headerVisitorId: visitorIdHeader, - requestVisitorId: body.visitorId, - publicOnly: true, - }); - - if (visitorIdentity.error) { - return visitorIdentity.error; - } - - const visitor = visitorIdentity.visitor; - if (!visitor) { - return c.json( - { - error: "BAD_REQUEST", - message: "Visitor not found, please pass a valid visitorId", - }, - 400 - ); - } +feedbackCreateRouter.openapi(createFeedbackRoute, async (c) => { + try { + const { apiKey, db, organization, website, body, visitorIdHeader } = + await safelyExtractRequestData(c, submitFeedbackRequestSchema); - let conversationOwnerVisitorId: string | null = null; - if (body.conversationId) { - const conversationRecord = await getConversationByIdWithLastMessage( - db, - { - organizationId: organization.id, - websiteId: website.id, - conversationId: body.conversationId, - } - ); - - if (!conversationRecord) { - return c.json( - { - error: "NOT_FOUND", - message: "Conversation not found", - }, - 404 - ); - } - - const canAccessConversation = await canVisitorAccessConversation(db, { - organizationId: organization.id, - websiteId: website.id, - viewerVisitorId: visitor.id, - conversationVisitorId: conversationRecord.visitorId, - }); - - if (!canAccessConversation) { - return c.json( - { - error: "NOT_FOUND", - message: "Conversation not found", - }, - 404 - ); - } + if (!(website?.id && website.organizationId && organization?.id)) { + return c.json({ error: "UNAUTHORIZED", message: "Invalid API key" }, 401); + } - conversationOwnerVisitorId = conversationRecord.visitorId; - } + if (apiKey?.keyType === APIKeyType.PUBLIC) { + const visitorIdentity = await resolveRuntimeVisitorIdentity({ + c, + db, + apiKey, + organizationId: organization.id, + websiteId: website.id, + headerVisitorId: visitorIdHeader, + requestVisitorId: body.visitorId, + publicOnly: true, + }); - const { entry: authenticatedEntry } = await persistFeedbackSubmission({ - db, - organizationId: organization.id, - websiteId: website.id, - website, - conversationId: body.conversationId, - visitorId: visitor.id, - conversationOwnerVisitorId, - contactId: visitor.contactId, - rating: body.rating, - topic: body.topic, - comment: body.comment, - trigger: body.trigger, - source: body.source ?? "widget", - }); + if (visitorIdentity.error) { + return visitorIdentity.error; + } + const visitor = visitorIdentity.visitor; + if (!visitor) { return c.json( - validateResponse( - { feedback: formatFeedbackResponse(authenticatedEntry) }, - submitFeedbackResponseSchema - ), - 201 + { + error: "BAD_REQUEST", + message: "Visitor not found, please pass a valid visitorId", + }, + 400 ); } - let privateVisitor: - | Awaited> - | null - | undefined; - let privateContactId = body.contactId; - let privateConversationOwnerVisitorId: string | null = null; - - if (body.visitorId) { - privateVisitor = await getVisitor(db, { - visitorId: body.visitorId, - }); - - if (!privateVisitor || privateVisitor.websiteId !== website.id) { - return c.json( - { - error: "BAD_REQUEST", - message: "Visitor not found, please pass a valid visitorId", - }, - 400 - ); - } - - privateContactId = - privateContactId ?? privateVisitor.contactId ?? undefined; - } - + let conversationOwnerVisitorId: string | null = null; if (body.conversationId) { const conversationRecord = await getConversationByIdWithLastMessage( db, { - organizationId: website.organizationId, + organizationId: organization.id, websiteId: website.id, conversationId: body.conversationId, } @@ -229,324 +107,300 @@ feedbackCreateRouter.openapi( 404 ); } - privateConversationOwnerVisitorId = conversationRecord.visitorId; - if (privateVisitor) { - const canAccessConversation = await canVisitorAccessConversation(db, { - organizationId: website.organizationId, - websiteId: website.id, - viewerVisitorId: privateVisitor.id, - conversationVisitorId: conversationRecord.visitorId, - }); - - if (!canAccessConversation) { - return c.json( - { - error: "BAD_REQUEST", - message: "Visitor does not match conversation", - }, - 400 - ); - } - } + const canAccessConversation = await canVisitorAccessConversation(db, { + organizationId: organization.id, + websiteId: website.id, + viewerVisitorId: visitor.id, + conversationVisitorId: conversationRecord.visitorId, + }); - if (!privateVisitor) { - privateVisitor = await getVisitor(db, { - visitorId: conversationRecord.visitorId, - }); - - if (!privateVisitor || privateVisitor.websiteId !== website.id) { - return c.json( - { - error: "NOT_FOUND", - message: "Conversation not found", - }, - 404 - ); - } + if (!canAccessConversation) { + return c.json( + { + error: "NOT_FOUND", + message: "Conversation not found", + }, + 404 + ); } - privateContactId = privateVisitor.contactId ?? undefined; + conversationOwnerVisitorId = conversationRecord.visitorId; } - const { entry } = await persistFeedbackSubmission({ + const { entry: authenticatedEntry } = await persistFeedbackSubmission({ db, - organizationId: website.organizationId, + organizationId: organization.id, websiteId: website.id, website, + conversationId: body.conversationId, + visitorId: visitor.id, + conversationOwnerVisitorId, + contactId: visitor.contactId, rating: body.rating, topic: body.topic, comment: body.comment, trigger: body.trigger, source: body.source ?? "widget", - conversationId: body.conversationId, - conversationOwnerVisitorId: privateConversationOwnerVisitorId, - visitorId: privateVisitor?.id, - contactId: privateContactId, }); return c.json( validateResponse( - { feedback: formatFeedbackResponse(entry) }, + { feedback: formatFeedbackResponse(authenticatedEntry) }, submitFeedbackResponseSchema ), 201 ); - } catch (error) { - console.error("Error submitting feedback:", error); - return c.json( - { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to submit feedback", - }, - 500 - ); } - } -); - -feedbackReadRouter.openapi( - { - method: "get", - path: "/", - summary: "List feedback", - description: - "Returns a paginated list of feedback for the website. Supports filtering by trigger, source, conversation, visitor, contact, topic, rating, and creation time.", - request: { - query: listFeedbackRequestSchema, - }, - responses: { - 200: { - description: "Feedback list retrieved successfully", - content: { - "application/json": { - schema: listFeedbackResponseSchema, - }, - }, - }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["Feedback"], - ...privateControlAuth(), - }, - async (c) => { - try { - const { db, website, query } = await safelyExtractRequestQuery( - c, - listFeedbackRequestSchema - ); - if (!(website?.id && website.organizationId)) { - return c.json( - { error: "UNAUTHORIZED", message: "Invalid API key" }, - 401 - ); - } + let privateVisitor: + | Awaited> + | null + | undefined; + let privateContactId = body.contactId; + let privateConversationOwnerVisitorId: string | null = null; - const result = await listFeedback(db, { - organizationId: website.organizationId, - websiteId: website.id, - trigger: query.trigger, - source: query.source, - conversationId: query.conversationId, - visitorId: query.visitorId, - contactId: query.contactId, - topic: query.topic, - rating: query.rating, - createdAtFrom: query.createdAtFrom, - createdAtTo: query.createdAtTo, - order: query.order, - page: query.page, - limit: query.limit, + if (body.visitorId) { + privateVisitor = await getVisitor(db, { + visitorId: body.visitorId, }); - return c.json( - validateResponse( + if (!privateVisitor || privateVisitor.websiteId !== website.id) { + return c.json( { - feedback: result.items.map(formatFeedbackResponse), - pagination: result.pagination, + error: "BAD_REQUEST", + message: "Visitor not found, please pass a valid visitorId", }, - listFeedbackResponseSchema - ), - 200 - ); - } catch (error) { - console.error("Error listing feedback:", error); - return c.json( - { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to list feedback", - }, - 500 - ); - } - } -); - -feedbackReadRouter.openapi( - { - method: "get", - path: "/summary", - summary: "Summarize feedback", - description: - "Returns aggregate feedback metrics for the website using the same filters as the feedback list endpoint.", - request: { - query: feedbackSummaryRequestSchema, - }, - responses: { - 200: { - description: "Feedback summary retrieved successfully", - content: { - "application/json": { - schema: feedbackSummaryResponseSchema, - }, - }, - }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["Feedback"], - ...privateControlAuth(), - }, - async (c) => { - try { - const { db, website, query } = await safelyExtractRequestQuery( - c, - feedbackSummaryRequestSchema - ); - - if (!(website?.id && website.organizationId)) { - return c.json( - { error: "UNAUTHORIZED", message: "Invalid API key" }, - 401 + 400 ); } - const summary = await getFeedbackSummary(db, { + privateContactId = + privateContactId ?? privateVisitor.contactId ?? undefined; + } + + if (body.conversationId) { + const conversationRecord = await getConversationByIdWithLastMessage(db, { organizationId: website.organizationId, websiteId: website.id, - trigger: query.trigger, - source: query.source, - conversationId: query.conversationId, - visitorId: query.visitorId, - contactId: query.contactId, - topic: query.topic, - rating: query.rating, - createdAtFrom: query.createdAtFrom, - createdAtTo: query.createdAtTo, + conversationId: body.conversationId, }); - return c.json( - validateResponse(summary, feedbackSummaryResponseSchema), - 200 - ); - } catch (error) { - console.error("Error summarizing feedback:", error); - return c.json( - { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to summarize feedback", - }, - 500 - ); - } - } -); - -feedbackReadRouter.openapi( - { - method: "get", - path: "/{id}", - summary: "Get feedback by ID", - description: "Retrieves a single feedback entry by ID", - responses: { - 200: { - description: "Feedback retrieved successfully", - content: { - "application/json": { - schema: getFeedbackResponseSchema, - }, - }, - }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("Feedback not found"), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["Feedback"], - ...privateControlAuth({ - parameters: [ - { - name: "id", - in: "path", - required: true, - description: "The feedback ID", - schema: { - type: "string", - }, - }, - ], - }), - }, - async (c) => { - try { - const { db, website } = await safelyExtractRequestData(c); - const id = c.req.param("id"); - - if (!id) { + if (!conversationRecord) { return c.json( - { error: "NOT_FOUND", message: "Feedback not found" }, + { + error: "NOT_FOUND", + message: "Conversation not found", + }, 404 ); } + privateConversationOwnerVisitorId = conversationRecord.visitorId; - if (!website?.id) { - return c.json( - { error: "UNAUTHORIZED", message: "Invalid API key" }, - 401 - ); + if (privateVisitor) { + const canAccessConversation = await canVisitorAccessConversation(db, { + organizationId: website.organizationId, + websiteId: website.id, + viewerVisitorId: privateVisitor.id, + conversationVisitorId: conversationRecord.visitorId, + }); + + if (!canAccessConversation) { + return c.json( + { + error: "BAD_REQUEST", + message: "Visitor does not match conversation", + }, + 400 + ); + } } - const entry = await getFeedbackById(db, { - id, - websiteId: website.id, - }); + if (!privateVisitor) { + privateVisitor = await getVisitor(db, { + visitorId: conversationRecord.visitorId, + }); - if (!entry) { - return c.json( - { error: "NOT_FOUND", message: "Feedback not found" }, - 404 - ); + if (!privateVisitor || privateVisitor.websiteId !== website.id) { + return c.json( + { + error: "NOT_FOUND", + message: "Conversation not found", + }, + 404 + ); + } } - return c.json( - validateResponse( - { feedback: formatFeedbackResponse(entry) }, - getFeedbackResponseSchema - ), - 200 - ); - } catch (error) { - console.error("Error fetching feedback:", error); - return c.json( + privateContactId = privateVisitor.contactId ?? undefined; + } + + const { entry } = await persistFeedbackSubmission({ + db, + organizationId: website.organizationId, + websiteId: website.id, + website, + rating: body.rating, + topic: body.topic, + comment: body.comment, + trigger: body.trigger, + source: body.source ?? "widget", + conversationId: body.conversationId, + conversationOwnerVisitorId: privateConversationOwnerVisitorId, + visitorId: privateVisitor?.id, + contactId: privateContactId, + }); + + return c.json( + validateResponse( + { feedback: formatFeedbackResponse(entry) }, + submitFeedbackResponseSchema + ), + 201 + ); + } catch (error) { + console.error("Error submitting feedback:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to submit feedback", + }, + 500 + ); + } +}); + +feedbackReadRouter.openapi(listFeedbackRoute, async (c) => { + try { + const { db, website, query } = await safelyExtractRequestQuery( + c, + listFeedbackRequestSchema + ); + + if (!(website?.id && website.organizationId)) { + return c.json({ error: "UNAUTHORIZED", message: "Invalid API key" }, 401); + } + + const result = await listFeedback(db, { + organizationId: website.organizationId, + websiteId: website.id, + trigger: query.trigger, + source: query.source, + conversationId: query.conversationId, + visitorId: query.visitorId, + contactId: query.contactId, + topic: query.topic, + rating: query.rating, + createdAtFrom: query.createdAtFrom, + createdAtTo: query.createdAtTo, + order: query.order, + page: query.page, + limit: query.limit, + }); + + return c.json( + validateResponse( { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch feedback", + feedback: result.items.map(formatFeedbackResponse), + pagination: result.pagination, }, - 500 - ); + listFeedbackResponseSchema + ), + 200 + ); + } catch (error) { + console.error("Error listing feedback:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to list feedback", + }, + 500 + ); + } +}); + +feedbackReadRouter.openapi(getFeedbackSummaryRoute, async (c) => { + try { + const { db, website, query } = await safelyExtractRequestQuery( + c, + feedbackSummaryRequestSchema + ); + + if (!(website?.id && website.organizationId)) { + return c.json({ error: "UNAUTHORIZED", message: "Invalid API key" }, 401); + } + + const summary = await getFeedbackSummary(db, { + organizationId: website.organizationId, + websiteId: website.id, + trigger: query.trigger, + source: query.source, + conversationId: query.conversationId, + visitorId: query.visitorId, + contactId: query.contactId, + topic: query.topic, + rating: query.rating, + createdAtFrom: query.createdAtFrom, + createdAtTo: query.createdAtTo, + }); + + return c.json( + validateResponse(summary, feedbackSummaryResponseSchema), + 200 + ); + } catch (error) { + console.error("Error summarizing feedback:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to summarize feedback", + }, + 500 + ); + } +}); + +feedbackReadRouter.openapi(getFeedbackRoute, async (c) => { + try { + const { db, website } = await safelyExtractRequestData(c); + const id = c.req.param("id"); + + if (!id) { + return c.json({ error: "NOT_FOUND", message: "Feedback not found" }, 404); + } + + if (!website?.id) { + return c.json({ error: "UNAUTHORIZED", message: "Invalid API key" }, 401); } + + const entry = await getFeedbackById(db, { + id, + websiteId: website.id, + }); + + if (!entry) { + return c.json({ error: "NOT_FOUND", message: "Feedback not found" }, 404); + } + + return c.json( + validateResponse( + { feedback: formatFeedbackResponse(entry) }, + getFeedbackResponseSchema + ), + 200 + ); + } catch (error) { + console.error("Error fetching feedback:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch feedback", + }, + 500 + ); } -); +}); feedbackRouter.route("/", feedbackCreateRouter); feedbackRouter.route("/", feedbackReadRouter); diff --git a/apps/api/src/rest/routers/knowledge.ts b/apps/api/src/rest/routers/knowledge.ts index bf005d75a..4d92b8eb9 100644 --- a/apps/api/src/rest/routers/knowledge.ts +++ b/apps/api/src/rest/routers/knowledge.ts @@ -15,6 +15,14 @@ import { safelyExtractRequestQuery, validateResponse, } from "@api/utils/validate"; +import { + createKnowledgeRoute, + deleteKnowledgeRoute, + getKnowledgeRoute, + listKnowledgeRoute, + searchKnowledgeRoute, + updateKnowledgeRoute, +} from "@cossistant/protocol/routes"; import { type CreateKnowledgeRestRequest, createKnowledgeRestRequestSchema, @@ -274,536 +282,291 @@ function formatKnowledgeResponse(entry: { } // GET /knowledge - List knowledge entries with filters and pagination -knowledgeRouter.openapi( - { - method: "get", - path: "/", - summary: "List knowledge entries", - description: - "Returns a paginated list of knowledge entries for the website. Supports filtering by type, AI agent, training inclusion, and link source.", - operationId: "listKnowledge", - request: { - query: listKnowledgeRestRequestSchema, - }, - responses: { - 200: { - description: "Knowledge entries retrieved successfully", - content: { - "application/json": { - schema: listKnowledgeResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Bad request - Invalid query parameters"), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["Knowledge"], - ...privateControlAuth(), - }, - async (c) => { - try { - const { db, website, query } = await safelyExtractRequestQuery( - c, - listKnowledgeRestRequestSchema - ); - - if (!(website?.id && website.organizationId)) { - return c.json( - { error: "UNAUTHORIZED", message: "Invalid API key" }, - 401 - ); - } +knowledgeRouter.openapi(listKnowledgeRoute, async (c) => { + try { + const { db, website, query } = await safelyExtractRequestQuery( + c, + listKnowledgeRestRequestSchema + ); - const aiAgentId = normalizeAiAgentId(query.aiAgentId); - const isIncluded = normalizeIsIncluded(query.isIncluded); + if (!(website?.id && website.organizationId)) { + return c.json({ error: "UNAUTHORIZED", message: "Invalid API key" }, 401); + } - const result = await listKnowledge(db, { - organizationId: website.organizationId, - websiteId: website.id, - type: query.type, - aiAgentId, - isIncluded, - linkSourceId: query.linkSourceId, - page: query.page, - limit: query.limit, - }); + const aiAgentId = normalizeAiAgentId(query.aiAgentId); + const isIncluded = normalizeIsIncluded(query.isIncluded); + + const result = await listKnowledge(db, { + organizationId: website.organizationId, + websiteId: website.id, + type: query.type, + aiAgentId, + isIncluded, + linkSourceId: query.linkSourceId, + page: query.page, + limit: query.limit, + }); - return c.json( - validateResponse( - { - items: result.items.map(formatKnowledgeResponse), - pagination: result.pagination, - }, - listKnowledgeResponseSchema - ), - 200 - ); - } catch (error) { - return handleKnowledgeRouterError( - c, - error, - "Failed to list knowledge entries" - ) as never; - } - } -); - -knowledgeRouter.openapi( - { - method: "get", - path: "/search", - summary: "Search knowledge", - description: - "Runs private semantic retrieval against indexed knowledge chunks for the authenticated website. Designed for CLI, MCP, and support-agent tools that need source-backed context.", - operationId: "searchKnowledge", - request: { - query: knowledgeSearchRequestSchema, - }, - responses: { - 200: { - description: "Knowledge search completed successfully", - content: { - "application/json": { - schema: knowledgeSearchResponseSchema, - }, + return c.json( + validateResponse( + { + items: result.items.map(formatKnowledgeResponse), + pagination: result.pagination, }, - }, - 400: errorJsonResponse("Bad request - Invalid query parameters"), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" + listKnowledgeResponseSchema ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["Knowledge"], - ...privateControlAuth(), - }, - async (c) => { - try { - const { db, website, query } = await safelyExtractRequestQuery( - c, - knowledgeSearchRequestSchema - ); - - if (!(website?.id && website.organizationId)) { - return c.json( - { error: "UNAUTHORIZED", message: "Invalid API key" }, - 401 - ); - } + 200 + ); + } catch (error) { + return handleKnowledgeRouterError( + c, + error, + "Failed to list knowledge entries" + ) as never; + } +}); - const response = await searchSupportKnowledge(db, { - website, - query: query.query, - knowledgeId: query.knowledgeId, - limit: query.limit, - minSimilarity: query.minSimilarity, - }); +knowledgeRouter.openapi(searchKnowledgeRoute, async (c) => { + try { + const { db, website, query } = await safelyExtractRequestQuery( + c, + knowledgeSearchRequestSchema + ); - return c.json( - validateResponse(response, knowledgeSearchResponseSchema), - 200 - ); - } catch (error) { - return handleKnowledgeRouterError( - c, - error, - "Failed to search knowledge" - ) as never; + if (!(website?.id && website.organizationId)) { + return c.json({ error: "UNAUTHORIZED", message: "Invalid API key" }, 401); } + + const response = await searchSupportKnowledge(db, { + website, + query: query.query, + knowledgeId: query.knowledgeId, + limit: query.limit, + minSimilarity: query.minSimilarity, + }); + + return c.json( + validateResponse(response, knowledgeSearchResponseSchema), + 200 + ); + } catch (error) { + return handleKnowledgeRouterError( + c, + error, + "Failed to search knowledge" + ) as never; } -); +}); // GET /knowledge/:id - Get a single knowledge entry -knowledgeRouter.openapi( - { - method: "get", - path: "/{id}", - summary: "Get a knowledge entry", - description: "Retrieves a single knowledge entry by ID", - operationId: "getKnowledge", - responses: { - 200: { - description: "Knowledge entry retrieved successfully", - content: { - "application/json": { - schema: knowledgeResponseSchema, - }, - }, - }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 404: errorJsonResponse("Knowledge entry not found"), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["Knowledge"], - ...privateControlAuth({ - parameters: [ - { - name: "id", - in: "path", - required: true, - description: "The knowledge entry ID", - schema: { - type: "string", - }, - }, - ], - }), - }, - async (c) => { - try { - const { db, website } = await safelyExtractRequestData(c); - const id = c.req.param("id"); - - if (!id) { - return c.json( - { error: "NOT_FOUND", message: "Knowledge entry not found" }, - 404 - ); - } +knowledgeRouter.openapi(getKnowledgeRoute, async (c) => { + try { + const { db, website } = await safelyExtractRequestData(c); + const id = c.req.param("id"); - if (!website?.id) { - return c.json( - { error: "UNAUTHORIZED", message: "Invalid API key" }, - 401 - ); - } + if (!id) { + return c.json( + { error: "NOT_FOUND", message: "Knowledge entry not found" }, + 404 + ); + } - const entry = await getKnowledgeById(db, { - id, - websiteId: website.id, - }); + if (!website?.id) { + return c.json({ error: "UNAUTHORIZED", message: "Invalid API key" }, 401); + } - if (!entry) { - return c.json( - { error: "NOT_FOUND", message: "Knowledge entry not found" }, - 404 - ); - } + const entry = await getKnowledgeById(db, { + id, + websiteId: website.id, + }); + if (!entry) { return c.json( - validateResponse( - formatKnowledgeResponse(entry), - knowledgeResponseSchema - ), - 200 + { error: "NOT_FOUND", message: "Knowledge entry not found" }, + 404 ); - } catch (error) { - return handleKnowledgeRouterError( - c, - error, - "Failed to fetch knowledge entry" - ) as never; } + + return c.json( + validateResponse(formatKnowledgeResponse(entry), knowledgeResponseSchema), + 200 + ); + } catch (error) { + return handleKnowledgeRouterError( + c, + error, + "Failed to fetch knowledge entry" + ) as never; } -); +}); // POST /knowledge - Create a new knowledge entry -knowledgeRouter.openapi( - { - method: "post", - path: "/", - summary: "Create a knowledge entry", - description: "Creates a new knowledge entry for the website", - request: { - body: { - content: { - "application/json": { - schema: createKnowledgeRestRequestSchema, - }, - }, - }, - }, - responses: { - 201: { - description: "Knowledge entry created successfully", - content: { - "application/json": { - schema: knowledgeResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Invalid request data"), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Plan knowledge limits prevent creating this entry" - ), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["Knowledge"], - ...privateControlAuth(), - }, - async (c) => { - try { - const { db, website, apiKey, body } = await safelyExtractRequestData( - c, - createKnowledgeRestRequestSchema - ); +knowledgeRouter.openapi(createKnowledgeRoute, async (c) => { + try { + const { db, website, apiKey, body } = await safelyExtractRequestData( + c, + createKnowledgeRestRequestSchema + ); - if (!(website?.id && website.organizationId)) { - return c.json( - { error: "UNAUTHORIZED", message: "Invalid API key" }, - 401 - ); - } + if (!(website?.id && website.organizationId)) { + return c.json({ error: "UNAUTHORIZED", message: "Invalid API key" }, 401); + } - await enforceKnowledgeCreateLimits({ - db, - website, - body, - }); + await enforceKnowledgeCreateLimits({ + db, + website, + body, + }); - const entry = await createKnowledge(db, { - organizationId: website.organizationId, - websiteId: website.id, - aiAgentId: body.aiAgentId ?? null, - type: body.type, - sourceUrl: body.sourceUrl ?? null, - sourceTitle: body.sourceTitle ?? null, - origin: body.origin, - createdBy: `api_key_${apiKey.id}`, - payload: body.payload, - metadata: body.metadata ?? null, - }); + const entry = await createKnowledge(db, { + organizationId: website.organizationId, + websiteId: website.id, + aiAgentId: body.aiAgentId ?? null, + type: body.type, + sourceUrl: body.sourceUrl ?? null, + sourceTitle: body.sourceTitle ?? null, + origin: body.origin, + createdBy: `api_key_${apiKey.id}`, + payload: body.payload, + metadata: body.metadata ?? null, + }); - return c.json( - validateResponse( - formatKnowledgeResponse(entry), - knowledgeResponseSchema - ), - 201 - ); - } catch (error) { - return handleKnowledgeRouterError( - c, - error, - "Failed to create knowledge entry" - ) as never; - } + return c.json( + validateResponse(formatKnowledgeResponse(entry), knowledgeResponseSchema), + 201 + ); + } catch (error) { + return handleKnowledgeRouterError( + c, + error, + "Failed to create knowledge entry" + ) as never; } -); +}); // PATCH /knowledge/:id - Update a knowledge entry -knowledgeRouter.openapi( - { - method: "patch", - path: "/{id}", - summary: "Update a knowledge entry", - description: "Updates an existing knowledge entry", - request: { - body: { - content: { - "application/json": { - schema: updateKnowledgeRestRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Knowledge entry updated successfully", - content: { - "application/json": { - schema: knowledgeResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Invalid request data"), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Plan knowledge limits prevent updating this entry" - ), - 404: errorJsonResponse("Knowledge entry not found"), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["Knowledge"], - ...privateControlAuth({ - parameters: [ - { - name: "id", - in: "path", - required: true, - description: "The knowledge entry ID", - schema: { - type: "string", - }, - }, - ], - }), - }, - async (c) => { - try { - const { db, website, body } = await safelyExtractRequestData( - c, - updateKnowledgeRestRequestSchema - ); - const id = c.req.param("id"); - - if (!id) { - return c.json( - { error: "NOT_FOUND", message: "Knowledge entry not found" }, - 404 - ); - } +knowledgeRouter.openapi(updateKnowledgeRoute, async (c) => { + try { + const { db, website, body } = await safelyExtractRequestData( + c, + updateKnowledgeRestRequestSchema + ); + const id = c.req.param("id"); - if (!website?.id) { - return c.json( - { error: "UNAUTHORIZED", message: "Invalid API key" }, - 401 - ); - } + if (!id) { + return c.json( + { error: "NOT_FOUND", message: "Knowledge entry not found" }, + 404 + ); + } - const existingEntry = await getKnowledgeById(db, { - id, - websiteId: website.id, - }); + if (!website?.id) { + return c.json({ error: "UNAUTHORIZED", message: "Invalid API key" }, 401); + } - if (!existingEntry) { - return c.json( - { error: "NOT_FOUND", message: "Knowledge entry not found" }, - 404 - ); - } + const existingEntry = await getKnowledgeById(db, { + id, + websiteId: website.id, + }); - await enforceKnowledgeUpdateSizeLimit({ - db, - website, - existingEntry, - body, - }); + if (!existingEntry) { + return c.json( + { error: "NOT_FOUND", message: "Knowledge entry not found" }, + 404 + ); + } - const entry = await updateKnowledge(db, { - id, - websiteId: website.id, - aiAgentId: body.aiAgentId, - sourceUrl: body.sourceUrl, - sourceTitle: body.sourceTitle, - payload: body.payload, - metadata: body.metadata, - }); + await enforceKnowledgeUpdateSizeLimit({ + db, + website, + existingEntry, + body, + }); - if (!entry) { - return c.json( - { error: "NOT_FOUND", message: "Knowledge entry not found" }, - 404 - ); - } + const entry = await updateKnowledge(db, { + id, + websiteId: website.id, + aiAgentId: body.aiAgentId, + sourceUrl: body.sourceUrl, + sourceTitle: body.sourceTitle, + payload: body.payload, + metadata: body.metadata, + }); + if (!entry) { return c.json( - validateResponse( - formatKnowledgeResponse(entry), - knowledgeResponseSchema - ), - 200 + { error: "NOT_FOUND", message: "Knowledge entry not found" }, + 404 ); - } catch (error) { - return handleKnowledgeRouterError( - c, - error, - "Failed to update knowledge entry" - ) as never; } + + return c.json( + validateResponse(formatKnowledgeResponse(entry), knowledgeResponseSchema), + 200 + ); + } catch (error) { + return handleKnowledgeRouterError( + c, + error, + "Failed to update knowledge entry" + ) as never; } -); +}); // DELETE /knowledge/:id - Delete a knowledge entry -knowledgeRouter.openapi( - { - method: "delete", - path: "/{id}", - summary: "Delete a knowledge entry", - description: "Permanently deletes a knowledge entry", - responses: { - 204: { - description: "Knowledge entry deleted successfully", - }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 404: errorJsonResponse("Knowledge entry not found"), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["Knowledge"], - ...privateControlAuth({ - parameters: [ - { - name: "id", - in: "path", - required: true, - description: "The knowledge entry ID", - schema: { - type: "string", - }, - }, - ], - }), - }, - async (c) => { - try { - const { db, website } = await safelyExtractRequestData(c); - const id = c.req.param("id"); - - if (!id) { - return c.json( - { error: "NOT_FOUND", message: "Knowledge entry not found" }, - 404 - ); - } +knowledgeRouter.openapi(deleteKnowledgeRoute, async (c) => { + try { + const { db, website } = await safelyExtractRequestData(c); + const id = c.req.param("id"); - if (!website?.id) { - return c.json( - { error: "UNAUTHORIZED", message: "Invalid API key" }, - 401 - ); - } + if (!id) { + return c.json( + { error: "NOT_FOUND", message: "Knowledge entry not found" }, + 404 + ); + } - const knowledgeEntry = await getKnowledgeById(db, { - id, - websiteId: website.id, - }); + if (!website?.id) { + return c.json({ error: "UNAUTHORIZED", message: "Invalid API key" }, 401); + } - if (!knowledgeEntry) { - return c.json( - { error: "NOT_FOUND", message: "Knowledge entry not found" }, - 404 - ); - } + const knowledgeEntry = await getKnowledgeById(db, { + id, + websiteId: website.id, + }); - const deleted = await deleteKnowledge(db, { - id, - websiteId: website.id, - }); + if (!knowledgeEntry) { + return c.json( + { error: "NOT_FOUND", message: "Knowledge entry not found" }, + 404 + ); + } - if (!deleted) { - return c.json( - { error: "NOT_FOUND", message: "Knowledge entry not found" }, - 404 - ); - } + const deleted = await deleteKnowledge(db, { + id, + websiteId: website.id, + }); - if (knowledgeEntry.linkSourceId && knowledgeEntry.type === "url") { - await syncLinkSourceStatsFromKnowledge(db, { - id: knowledgeEntry.linkSourceId, - websiteId: website.id, - }); - } + if (!deleted) { + return c.json( + { error: "NOT_FOUND", message: "Knowledge entry not found" }, + 404 + ); + } - return c.body(null, 204); - } catch (error) { - return handleKnowledgeRouterError( - c, - error, - "Failed to delete knowledge entry" - ) as never; + if (knowledgeEntry.linkSourceId && knowledgeEntry.type === "url") { + await syncLinkSourceStatsFromKnowledge(db, { + id: knowledgeEntry.linkSourceId, + websiteId: website.id, + }); } + + return c.body(null, 204); + } catch (error) { + return handleKnowledgeRouterError( + c, + error, + "Failed to delete knowledge entry" + ) as never; } -); +}); diff --git a/apps/api/src/rest/routers/messages.ts b/apps/api/src/rest/routers/messages.ts index 395e597bf..29bded688 100644 --- a/apps/api/src/rest/routers/messages.ts +++ b/apps/api/src/rest/routers/messages.ts @@ -29,6 +29,7 @@ import { safelyExtractRequestData, validateResponse, } from "@api/utils/validate"; +import { createMessageRoute } from "@cossistant/protocol/routes"; import { sendTimelineItemRequestSchema, sendTimelineItemResponseSchema, @@ -87,442 +88,398 @@ messagesRouter.use("/*", ...protectedPublicApiKeyMiddleware); // GET /messages endpoint removed - use /conversations/:id/timeline instead -messagesRouter.openapi( - { - method: "post", - path: "/", - summary: "Send a timeline item to a conversation", - description: - "Send a new timeline item to an existing conversation. When item.createdAt is omitted, the server assigns the timestamp. Historical timestamps are allowed. Timestamps more than 5 minutes in the future are rejected.", - tags: ["Messages", "Timeline item"], - request: { - body: { - required: true, - content: { - "application/json": { - schema: sendTimelineItemRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Timeline item sent successfully", - content: { - "application/json": { - schema: sendTimelineItemResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Invalid request or item.createdAt more than 5 minutes in the future" - ), - 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), - 403: errorJsonResponse( - "Forbidden - Public key origin validation failed or actor constraints failed" - ), - 404: errorJsonResponse("Conversation not found"), - }, - ...runtimeDualAuth({ - includeActorUserIdHeader: true, - includeVisitorIdHeader: true, - }), - }, - async (c) => { - const { db, website, organization, body, visitorIdHeader, apiKey } = - await safelyExtractRequestData(c, sendTimelineItemRequestSchema); - - let visitorId = body.item.visitorId || visitorIdHeader || null; - const isPublic = apiKey?.keyType === APIKeyType.PUBLIC; - const publicVisitorIdentity = await resolveRuntimeVisitorIdentity({ - c, - db, - apiKey, - organizationId: organization.id, - websiteId: website.id, - headerVisitorId: visitorIdHeader, - requestVisitorId: body.item.visitorId, - publicOnly: true, - }); - - if (publicVisitorIdentity.error) { - return publicVisitorIdentity.error; - } - - if (isPublic) { - visitorId = publicVisitorIdentity.visitor?.id ?? null; - } - - let itemCreatedAt: Date | undefined; - try { - itemCreatedAt = normalizeClientTimelineItemCreatedAt({ - createdAt: body.item.createdAt, - field: "item.createdAt", - }); - } catch (error) { - if (error instanceof ClientTimelineItemCreatedAtValidationError) { - return restError(c, 400, "BAD_REQUEST", error.message); - } +messagesRouter.openapi(createMessageRoute, async (c) => { + const { db, website, organization, body, visitorIdHeader, apiKey } = + await safelyExtractRequestData(c, sendTimelineItemRequestSchema); + + let visitorId = body.item.visitorId || visitorIdHeader || null; + const isPublic = apiKey?.keyType === APIKeyType.PUBLIC; + const publicVisitorIdentity = await resolveRuntimeVisitorIdentity({ + c, + db, + apiKey, + organizationId: organization.id, + websiteId: website.id, + headerVisitorId: visitorIdHeader, + requestVisitorId: body.item.visitorId, + publicOnly: true, + }); + + if (publicVisitorIdentity.error) { + return publicVisitorIdentity.error; + } - throw error; - } + if (isPublic) { + visitorId = publicVisitorIdentity.visitor?.id ?? null; + } - const conversation = await getConversationById(db, { - conversationId: body.conversationId, + let itemCreatedAt: Date | undefined; + try { + itemCreatedAt = normalizeClientTimelineItemCreatedAt({ + createdAt: body.item.createdAt, + field: "item.createdAt", }); - - if (!conversation || conversation.websiteId !== website.id) { - return c.json( - validateResponse( - { error: "Conversation for website not found" }, - z.object({ error: z.string() }) - ), - 404 - ); + } catch (error) { + if (error instanceof ClientTimelineItemCreatedAtValidationError) { + return restError(c, 400, "BAD_REQUEST", error.message); } - if (isPublic) { - if (!visitorId) { - return c.json( - validateResponse( - { error: "Forbidden: visitor does not own the conversation" }, - z.object({ error: z.string() }) - ), - 403 - ); - } - - const canAccessConversation = await canVisitorAccessConversation(db, { - organizationId: organization.id, - websiteId: website.id, - viewerVisitorId: visitorId, - conversationVisitorId: conversation.visitorId, - }); + throw error; + } - if (!canAccessConversation) { - return c.json( - validateResponse( - { error: "Forbidden: visitor does not own the conversation" }, - z.object({ error: z.string() }) - ), - 403 - ); - } - } + const conversation = await getConversationById(db, { + conversationId: body.conversationId, + }); - const conversationIsClosed = - conversation.status === ConversationStatus.RESOLVED || - conversation.status === ConversationStatus.SPAM || - Boolean(conversation.deletedAt); + if (!conversation || conversation.websiteId !== website.id) { + return c.json( + validateResponse( + { error: "Conversation for website not found" }, + z.object({ error: z.string() }) + ), + 404 + ); + } - if (isPublic && conversationIsClosed) { + if (isPublic) { + if (!visitorId) { return c.json( validateResponse( - { - error: - "Forbidden: this conversation is closed and cannot receive new messages", - }, + { error: "Forbidden: visitor does not own the conversation" }, z.object({ error: z.string() }) ), 403 ); } - // Disallow setting user/ai actor via public key - if (isPublic && (body.item.userId || body.item.aiAgentId)) { + const canAccessConversation = await canVisitorAccessConversation(db, { + organizationId: organization.id, + websiteId: website.id, + viewerVisitorId: visitorId, + conversationVisitorId: conversation.visitorId, + }); + + if (!canAccessConversation) { return c.json( validateResponse( - { - error: - "Forbidden: cannot set userId/aiAgentId with a public API key", - }, + { error: "Forbidden: visitor does not own the conversation" }, z.object({ error: z.string() }) ), 403 ); } + } - const timelineItemType = body.item.type ?? ConversationTimelineType.MESSAGE; - const shouldResolvePrivateUserActor = - !isPublic && - (body.item.userId || - (timelineItemType === ConversationTimelineType.MESSAGE && - !body.item.aiAgentId && - !visitorId)); - let privateActorUserId: string | null = null; - - if (shouldResolvePrivateUserActor) { - if (!apiKey) { - return restError(c, 401, "UNAUTHORIZED", "Invalid API key"); - } + const conversationIsClosed = + conversation.status === ConversationStatus.RESOLVED || + conversation.status === ConversationStatus.SPAM || + Boolean(conversation.deletedAt); - try { - const privateActor = await resolvePrivateApiKeyActorUser({ - db, - apiKey, - organizationId: organization.id, - websiteTeamId: website.teamId, - explicitActorUserId: c.req.header("X-Actor-User-Id") ?? null, - required: true, - }); - privateActorUserId = privateActor?.userId ?? null; - } catch (error) { - if (error instanceof AuthValidationError) { - const status = error.statusCode === 400 ? 400 : 403; - return restError( - c, - status, - status === 400 ? "BAD_REQUEST" : "FORBIDDEN", - error.message - ); - } - throw error; - } + if (isPublic && conversationIsClosed) { + return c.json( + validateResponse( + { + error: + "Forbidden: this conversation is closed and cannot receive new messages", + }, + z.object({ error: z.string() }) + ), + 403 + ); + } + + // Disallow setting user/ai actor via public key + if (isPublic && (body.item.userId || body.item.aiAgentId)) { + return c.json( + validateResponse( + { + error: "Forbidden: cannot set userId/aiAgentId with a public API key", + }, + z.object({ error: z.string() }) + ), + 403 + ); + } + + const timelineItemType = body.item.type ?? ConversationTimelineType.MESSAGE; + const shouldResolvePrivateUserActor = + !isPublic && + (body.item.userId || + (timelineItemType === ConversationTimelineType.MESSAGE && + !body.item.aiAgentId && + !visitorId)); + let privateActorUserId: string | null = null; + + if (shouldResolvePrivateUserActor) { + if (!apiKey) { + return restError(c, 401, "UNAUTHORIZED", "Invalid API key"); + } - if (body.item.userId && body.item.userId !== privateActorUserId) { + try { + const privateActor = await resolvePrivateApiKeyActorUser({ + db, + apiKey, + organizationId: organization.id, + websiteTeamId: website.teamId, + explicitActorUserId: c.req.header("X-Actor-User-Id") ?? null, + required: true, + }); + privateActorUserId = privateActor?.userId ?? null; + } catch (error) { + if (error instanceof AuthValidationError) { + const status = error.statusCode === 400 ? 400 : 403; return restError( c, - 403, - "FORBIDDEN", - "Body item.userId must match the resolved private API actor" + status, + status === 400 ? "BAD_REQUEST" : "FORBIDDEN", + error.message ); } + throw error; } - const resolvedUserId = isPublic - ? null - : (privateActorUserId ?? body.item.userId ?? null); - const resolvedAiAgentId = isPublic ? null : (body.item.aiAgentId ?? null); + if (body.item.userId && body.item.userId !== privateActorUserId) { + return restError( + c, + 403, + "FORBIDDEN", + "Body item.userId must match the resolved private API actor" + ); + } + } + + const resolvedUserId = isPublic + ? null + : (privateActorUserId ?? body.item.userId ?? null); + const resolvedAiAgentId = isPublic ? null : (body.item.aiAgentId ?? null); + + // Check if user needs to be added as participant + if (resolvedUserId && !isPublic) { + const isParticipant = await isUserParticipant(db, { + conversationId: body.conversationId, + userId: resolvedUserId, + }); - // Check if user needs to be added as participant - if (resolvedUserId && !isPublic) { - const isParticipant = await isUserParticipant(db, { + if (!isParticipant) { + // Add user as participant + await addConversationParticipant(db, { conversationId: body.conversationId, userId: resolvedUserId, + organizationId: organization.id, + reason: "Sent message", }); - if (!isParticipant) { - // Add user as participant - await addConversationParticipant(db, { - conversationId: body.conversationId, - userId: resolvedUserId, - organizationId: organization.id, - reason: "Sent message", - }); - - // Create participant joined event (PUBLIC so visitor sees it) - await createParticipantJoinedEvent(db, { - conversationId: body.conversationId, - organizationId: organization.id, - websiteId: website.id, - visitorId: conversation.visitorId, - targetUserId: resolvedUserId, - isAutoAdded: true, - }); - } + // Create participant joined event (PUBLIC so visitor sees it) + await createParticipantJoinedEvent(db, { + conversationId: body.conversationId, + organizationId: organization.id, + websiteId: website.id, + visitorId: conversation.visitorId, + targetUserId: resolvedUserId, + isAutoAdded: true, + }); } + } - const planInfo = await getPlanForWebsite(website); - const autoTranslateEnabled = isAutomaticTranslationEnabled({ - planAllowsAutoTranslate: planInfo.features["auto-translate"] === true, - websiteAutoTranslateEnabled: website.autoTranslateEnabled, - }); - - const aiContext = { - db, - organizationId: organization.id, - websiteId: website.id, - }; - const isVisitorMessage = - timelineItemType === ConversationTimelineType.MESSAGE && - Boolean(visitorId) && - !resolvedUserId && - !resolvedAiAgentId; - - const inboundTranslation = isVisitorMessage - ? await prepareInboundVisitorTranslation({ + const planInfo = await getPlanForWebsite(website); + const autoTranslateEnabled = isAutomaticTranslationEnabled({ + planAllowsAutoTranslate: planInfo.features["auto-translate"] === true, + websiteAutoTranslateEnabled: website.autoTranslateEnabled, + }); + + const aiContext = { + db, + organizationId: organization.id, + websiteId: website.id, + }; + const isVisitorMessage = + timelineItemType === ConversationTimelineType.MESSAGE && + Boolean(visitorId) && + !resolvedUserId && + !resolvedAiAgentId; + + const inboundTranslation = isVisitorMessage + ? await prepareInboundVisitorTranslation({ + text: body.item.text ?? "", + websiteDefaultLanguage: website.defaultLanguage, + visitorLanguageHint: conversation.visitorLanguage, + mode: "auto", + autoTranslateEnabled, + aiContext, + }) + : null; + + const outboundTranslation = + timelineItemType === ConversationTimelineType.MESSAGE && + !isVisitorMessage && + autoTranslateEnabled + ? await prepareOutboundVisitorTranslation({ text: body.item.text ?? "", - websiteDefaultLanguage: website.defaultLanguage, - visitorLanguageHint: conversation.visitorLanguage, + sourceLanguage: website.defaultLanguage, + visitorLanguage: conversation.visitorLanguage, mode: "auto", - autoTranslateEnabled, aiContext, }) : null; - const outboundTranslation = - timelineItemType === ConversationTimelineType.MESSAGE && - !isVisitorMessage && - autoTranslateEnabled - ? await prepareOutboundVisitorTranslation({ - text: body.item.text ?? "", - sourceLanguage: website.defaultLanguage, - visitorLanguage: conversation.visitorLanguage, - mode: "auto", - aiContext, - }) - : null; - - const { item: createdTimelineItem, actor } = - timelineItemType === ConversationTimelineType.MESSAGE - ? await createMessageTimelineItem({ + const { item: createdTimelineItem, actor } = + timelineItemType === ConversationTimelineType.MESSAGE + ? await createMessageTimelineItem({ + db, + organizationId: organization.id, + websiteId: website.id, + conversationId: body.conversationId, + conversationOwnerVisitorId: conversation.visitorId, + id: body.item.id, + text: body.item.text ?? "", + extraParts: [ + ...(body.item.parts?.filter((part) => part.type !== "text") ?? []), + ...(inboundTranslation?.translationPart + ? [inboundTranslation.translationPart] + : []), + ...(outboundTranslation?.translationPart + ? [outboundTranslation.translationPart] + : []), + ], + visibility: body.item.visibility, + userId: resolvedUserId, + aiAgentId: resolvedAiAgentId, + visitorId: visitorId ?? null, + createdAt: itemCreatedAt, + tool: body.item.tool ?? null, + }) + : { + item: await createTimelineItem({ db, organizationId: organization.id, websiteId: website.id, conversationId: body.conversationId, conversationOwnerVisitorId: conversation.visitorId, - id: body.item.id, - text: body.item.text ?? "", - extraParts: [ - ...(body.item.parts?.filter((part) => part.type !== "text") ?? - []), - ...(inboundTranslation?.translationPart - ? [inboundTranslation.translationPart] - : []), - ...(outboundTranslation?.translationPart - ? [outboundTranslation.translationPart] - : []), - ], - visibility: body.item.visibility, - userId: resolvedUserId, - aiAgentId: resolvedAiAgentId, - visitorId: visitorId ?? null, - createdAt: itemCreatedAt, - tool: body.item.tool ?? null, - }) - : { - item: await createTimelineItem({ - db, - organizationId: organization.id, - websiteId: website.id, - conversationId: body.conversationId, - conversationOwnerVisitorId: conversation.visitorId, - item: { - id: body.item.id, - type: timelineItemType, - text: body.item.text ?? null, - parts: body.item.parts ?? [], - userId: resolvedUserId, - aiAgentId: resolvedAiAgentId, - visitorId: visitorId ?? null, - visibility: body.item.visibility, - createdAt: itemCreatedAt, - tool: body.item.tool ?? null, - }, - }), - actor: null, - }; - - if (isVisitorMessage && inboundTranslation?.visitorLanguage) { - await finalizeConversationTranslation({ - db, - conversation, - websiteDefaultLanguage: website.defaultLanguage, - visitorLanguage: inboundTranslation.visitorLanguage, - hasTranslationPart: Boolean(inboundTranslation.translationPart), - chargeCredits: autoTranslateEnabled, - billingSource: - inboundTranslation.translationResult.status === "translated" - ? inboundTranslation.translationResult.billingSource - : undefined, - }); - } - - if (!isVisitorMessage && outboundTranslation?.translationPart) { - await finalizeConversationTranslation({ - db, - conversation, - websiteDefaultLanguage: website.defaultLanguage, - visitorLanguage: conversation.visitorLanguage, - hasTranslationPart: true, - chargeCredits: autoTranslateEnabled, - billingSource: - outboundTranslation.translationResult.status === "translated" - ? outboundTranslation.translationResult.billingSource - : undefined, - }); - } + item: { + id: body.item.id, + type: timelineItemType, + text: body.item.text ?? null, + parts: body.item.parts ?? [], + userId: resolvedUserId, + aiAgentId: resolvedAiAgentId, + visitorId: visitorId ?? null, + visibility: body.item.visibility, + createdAt: itemCreatedAt, + tool: body.item.tool ?? null, + }, + }), + actor: null, + }; + + if (isVisitorMessage && inboundTranslation?.visitorLanguage) { + await finalizeConversationTranslation({ + db, + conversation, + websiteDefaultLanguage: website.defaultLanguage, + visitorLanguage: inboundTranslation.visitorLanguage, + hasTranslationPart: Boolean(inboundTranslation.translationPart), + chargeCredits: autoTranslateEnabled, + billingSource: + inboundTranslation.translationResult.status === "translated" + ? inboundTranslation.translationResult.billingSource + : undefined, + }); + } - const resolvedActor: ConversationActor | null = resolveTimelineActor({ - actor, - userId: resolvedUserId, - aiAgentId: resolvedAiAgentId, - visitorId: visitorId ?? null, + if (!isVisitorMessage && outboundTranslation?.translationPart) { + await finalizeConversationTranslation({ + db, + conversation, + websiteDefaultLanguage: website.defaultLanguage, + visitorLanguage: conversation.visitorLanguage, + hasTranslationPart: true, + chargeCredits: autoTranslateEnabled, + billingSource: + outboundTranslation.translationResult.status === "translated" + ? outboundTranslation.translationResult.billingSource + : undefined, }); + } - if ( - timelineItemType === ConversationTimelineType.MESSAGE && - resolvedActor !== null - ) { - try { - await triggerMessageNotificationWorkflow({ - conversationId: body.conversationId, - messageId: createdTimelineItem.id, - websiteId: website.id, - organizationId: organization.id, - actor: resolvedActor, - }); - } catch (error) { - console.error( - "[notification] Failed to trigger workflow for rest message route", - error - ); - } + const resolvedActor: ConversationActor | null = resolveTimelineActor({ + actor, + userId: resolvedUserId, + aiAgentId: resolvedAiAgentId, + visitorId: visitorId ?? null, + }); + + if ( + timelineItemType === ConversationTimelineType.MESSAGE && + resolvedActor !== null + ) { + try { + await triggerMessageNotificationWorkflow({ + conversationId: body.conversationId, + messageId: createdTimelineItem.id, + websiteId: website.id, + organizationId: organization.id, + actor: resolvedActor, + }); + } catch (error) { + console.error( + "[notification] Failed to trigger workflow for rest message route", + error + ); } + } - // Build promises for realtime events and presence tracking - const promises: Promise[] = []; + // Build promises for realtime events and presence tracking + const promises: Promise[] = []; + + // Mark conversations as seen for users and AI agents when sending timeline items. + // For visitors, we rely on the frontend auto-seen mechanism which checks widget visibility. + // This prevents conversations from being marked as seen when the widget is closed. + let lastSeenAt: string | undefined; + if (resolvedActor && resolvedActor.type !== "visitor") { + lastSeenAt = await markConversationAsSeen(db, { + conversation, + actor: resolvedActor, + }); - // Mark conversations as seen for users and AI agents when sending timeline items. - // For visitors, we rely on the frontend auto-seen mechanism which checks widget visibility. - // This prevents conversations from being marked as seen when the widget is closed. - let lastSeenAt: string | undefined; - if (resolvedActor && resolvedActor.type !== "visitor") { - lastSeenAt = await markConversationAsSeen(db, { + // Emit conversation seen event for users and AI agents + promises.push( + emitConversationSeenEvent({ conversation, actor: resolvedActor, - }); - - // Emit conversation seen event for users and AI agents - promises.push( - emitConversationSeenEvent({ - conversation, - actor: resolvedActor, - lastSeenAt, - }) - ); - - if (resolvedActor.type === "user") { - promises.push( - markUserPresence({ - websiteId: website.id, - userId: resolvedActor.userId, - lastSeenAt, - }) - ); - } - } else if (resolvedActor?.type === "visitor" && resolvedActor.visitorId) { - // For visitors, just create a timestamp for presence tracking - // without marking the conversation as seen - lastSeenAt = new Date().toISOString(); + lastSeenAt, + }) + ); + if (resolvedActor.type === "user") { promises.push( - markVisitorPresence({ + markUserPresence({ websiteId: website.id, - visitorId: resolvedActor.visitorId, + userId: resolvedActor.userId, lastSeenAt, }) ); } + } else if (resolvedActor?.type === "visitor" && resolvedActor.visitorId) { + // For visitors, just create a timestamp for presence tracking + // without marking the conversation as seen + lastSeenAt = new Date().toISOString(); - await Promise.allSettled(promises); - - return c.json( - validateResponse( - { item: createdTimelineItem as TimelineItem }, - sendTimelineItemResponseSchema - ), - 200 + promises.push( + markVisitorPresence({ + websiteId: website.id, + visitorId: resolvedActor.visitorId, + lastSeenAt, + }) ); } -); + + await Promise.allSettled(promises); + + return c.json( + validateResponse( + { item: createdTimelineItem as TimelineItem }, + sendTimelineItemResponseSchema + ), + 200 + ); +}); diff --git a/apps/api/src/rest/routers/organization.ts b/apps/api/src/rest/routers/organization.ts index ea97eb2c8..1de4f7713 100644 --- a/apps/api/src/rest/routers/organization.ts +++ b/apps/api/src/rest/routers/organization.ts @@ -1,4 +1,5 @@ import { validateResponse } from "@api/utils/validate"; +import { getOrganizationRoute } from "@cossistant/protocol/routes"; import { organizationResponseSchema } from "@cossistant/types"; import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; import { protectedPrivateApiKeyMiddleware } from "../middleware"; @@ -14,80 +15,42 @@ const app = new OpenAPIHono(); app.use("/*", ...protectedPrivateApiKeyMiddleware); -app.openapi( - createRoute({ - method: "get", - path: "/{id}", - summary: "Retrieve an organization", - description: - "Retrieve an organization by its ID for the authenticated organization.", - tags: ["Organizations"], - responses: { - 200: { - description: "Organization details", - content: { - "application/json": { - schema: organizationResponseSchema, - }, - }, - }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("Organization not found"), - }, - ...privateControlAuth({ - parameters: [ - { - name: "id", - in: "path", - description: "The organization ID to retrieve.", - required: true, - schema: { - type: "string", - }, - }, - ], - }), - }), - async (c) => { - const privateContext = requirePrivateControlContext(c, { - apiKey: c.get("apiKey"), - organization: c.get("organization"), - website: c.get("website"), - }); - if (privateContext instanceof Response) { - return privateContext; - } - - const organizationId = c.req.param("id"); - if (!organizationId) { - return c.json( - { error: "NOT_FOUND", message: "Organization not found" }, - 404 - ); - } - const mismatchResponse = ensureAuthenticatedOrganizationMatch( - c, - organizationId, - privateContext.organization.id - ); - if (mismatchResponse) { - return mismatchResponse; - } +app.openapi(getOrganizationRoute, async (c) => { + const privateContext = requirePrivateControlContext(c, { + apiKey: c.get("apiKey"), + organization: c.get("organization"), + website: c.get("website"), + }); + if (privateContext instanceof Response) { + return privateContext; + } + const organizationId = c.req.param("id"); + if (!organizationId) { return c.json( - validateResponse( - { - id: privateContext.organization.id, - name: privateContext.organization.name, - }, - organizationResponseSchema - ), - 200 + { error: "NOT_FOUND", message: "Organization not found" }, + 404 ); } -); + const mismatchResponse = ensureAuthenticatedOrganizationMatch( + c, + organizationId, + privateContext.organization.id + ); + if (mismatchResponse) { + return mismatchResponse; + } + + return c.json( + validateResponse( + { + id: privateContext.organization.id, + name: privateContext.organization.name, + }, + organizationResponseSchema + ), + 200 + ); +}); export const organizationRouter = app; diff --git a/apps/api/src/rest/routers/support.ts b/apps/api/src/rest/routers/support.ts index 7b244b958..d1ee6f69c 100644 --- a/apps/api/src/rest/routers/support.ts +++ b/apps/api/src/rest/routers/support.ts @@ -9,6 +9,11 @@ import { safelyExtractRequestData, validateResponse, } from "@api/utils/validate"; +import { + getSupportStateRoute, + updateSupportFeatureFlagsRoute, + updateSupportOnboardingRoute, +} from "@cossistant/protocol/routes"; import { supportFeatureFlagMutationRequestSchema, supportFeatureFlagMutationResponseSchema, @@ -34,204 +39,112 @@ const supportControlRouter = new OpenAPIHono(); supportRuntimeRouter.use("/*", ...protectedPublicApiKeyMiddleware); supportControlRouter.use("/*", ...protectedPrivateApiKeyMiddleware); -supportRuntimeRouter.openapi( - { - method: "get", - path: "/state", - summary: "Get support state", - description: - "Returns resolved feature flags and onboarding progress for the current visitor.", - tags: ["Support"], - responses: { - 200: { - description: "Support state retrieved successfully", - content: { - "application/json": { - schema: supportStateResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Visitor ID is required"), - 401: errorJsonResponse("Unauthorized - Invalid API key"), - 404: errorJsonResponse("Visitor not found"), - }, - ...runtimeDualAuth({ includeVisitorIdHeader: true }), - }, - async (c) => { - const { db, website, visitorIdHeader } = await safelyExtractRequestData(c); - - if (!visitorIdHeader) { - return c.json( - { error: "BAD_REQUEST", message: "Visitor ID is required" }, - 400 - ); - } +supportRuntimeRouter.openapi(getSupportStateRoute, async (c) => { + const { db, website, visitorIdHeader } = await safelyExtractRequestData(c); - const state = await getSupportStateForVisitor(db, { - websiteId: website.id, - visitorId: visitorIdHeader, - }); + if (!visitorIdHeader) { + return c.json( + { error: "BAD_REQUEST", message: "Visitor ID is required" }, + 400 + ); + } - if (!state) { - return c.json({ error: "NOT_FOUND", message: "Visitor not found" }, 404); - } + const state = await getSupportStateForVisitor(db, { + websiteId: website.id, + visitorId: visitorIdHeader, + }); - return c.json(validateResponse(state, supportStateResponseSchema), 200); + if (!state) { + return c.json({ error: "NOT_FOUND", message: "Visitor not found" }, 404); } -); - -supportRuntimeRouter.openapi( - { - method: "patch", - path: "/onboarding", - summary: "Update onboarding state", - description: - "Updates onboarding progress or metadata for the current visitor/contact.", - tags: ["Support"], - request: { - body: { - content: { - "application/json": { - schema: supportOnboardingUpdateRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Onboarding state updated successfully", - content: { - "application/json": { - schema: supportStateResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Invalid request data"), - 401: errorJsonResponse("Unauthorized - Invalid API key"), - 404: errorJsonResponse("Visitor not found"), - }, - ...runtimeDualAuth({ includeVisitorIdHeader: true }), - }, - async (c) => { - const { db, website, organization, body, visitorIdHeader } = - await safelyExtractRequestData(c, supportOnboardingUpdateRequestSchema); - - if (!visitorIdHeader) { - return c.json( - { error: "BAD_REQUEST", message: "Visitor ID is required" }, - 400 - ); - } - const state = await updateOnboardingForVisitor(db, { - websiteId: website.id, - visitorId: visitorIdHeader, - update: body, - }); + return c.json(validateResponse(state, supportStateResponseSchema), 200); +}); - if (!state) { - return c.json({ error: "NOT_FOUND", message: "Visitor not found" }, 404); - } +supportRuntimeRouter.openapi(updateSupportOnboardingRoute, async (c) => { + const { db, website, organization, body, visitorIdHeader } = + await safelyExtractRequestData(c, supportOnboardingUpdateRequestSchema); - await emitSupportStateUpdated({ - db, - websiteId: website.id, - organizationId: organization.id, - visitorId: visitorIdHeader, - state, - }); + if (!visitorIdHeader) { + return c.json( + { error: "BAD_REQUEST", message: "Visitor ID is required" }, + 400 + ); + } - return c.json(validateResponse(state, supportStateResponseSchema), 200); + const state = await updateOnboardingForVisitor(db, { + websiteId: website.id, + visitorId: visitorIdHeader, + update: body, + }); + + if (!state) { + return c.json({ error: "NOT_FOUND", message: "Visitor not found" }, 404); } -); - -supportControlRouter.openapi( - { - method: "patch", - path: "/feature-flags", - summary: "Mutate feature flags", - description: - "Adds, removes, or replaces feature flags on a visitor, contact, or contact organization.", - tags: ["Support"], - request: { - body: { - content: { - "application/json": { - schema: supportFeatureFlagMutationRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Feature flags updated successfully", - content: { - "application/json": { - schema: supportFeatureFlagMutationResponseSchema, - }, - }, - }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 404: errorJsonResponse("Target not found"), - }, - ...privateControlAuth(), - }, - async (c) => { - const context = await safelyExtractRequestData( - c, - supportFeatureFlagMutationRequestSchema - ); - const privateContext = requirePrivateControlContext(c, context); - if (privateContext instanceof Response) { - return privateContext; - } + await emitSupportStateUpdated({ + db, + websiteId: website.id, + organizationId: organization.id, + visitorId: visitorIdHeader, + state, + }); + + return c.json(validateResponse(state, supportStateResponseSchema), 200); +}); + +supportControlRouter.openapi(updateSupportFeatureFlagsRoute, async (c) => { + const context = await safelyExtractRequestData( + c, + supportFeatureFlagMutationRequestSchema + ); + const privateContext = requirePrivateControlContext(c, context); + + if (privateContext instanceof Response) { + return privateContext; + } + + const flags = await mutateFeatureFlagsForTarget(context.db, { + websiteId: privateContext.website.id, + organizationId: privateContext.organization.id, + request: context.body, + }); + + if (!flags) { + return c.json({ error: "NOT_FOUND", message: "Target not found" }, 404); + } - const flags = await mutateFeatureFlagsForTarget(context.db, { + const affectedVisitorIds = await listAffectedVisitorIdsForFeatureFlagTarget( + context.db, + { websiteId: privateContext.website.id, organizationId: privateContext.organization.id, - request: context.body, - }); - - if (!flags) { - return c.json({ error: "NOT_FOUND", message: "Target not found" }, 404); + target: context.body.target, } + ); - const affectedVisitorIds = await listAffectedVisitorIdsForFeatureFlagTarget( - context.db, - { + await Promise.all( + affectedVisitorIds.map((visitorId) => + emitSupportStateUpdated({ + db: context.db, websiteId: privateContext.website.id, organizationId: privateContext.organization.id, - target: context.body.target, - } - ); + visitorId, + }) + ) + ); - await Promise.all( - affectedVisitorIds.map((visitorId) => - emitSupportStateUpdated({ - db: context.db, - websiteId: privateContext.website.id, - organizationId: privateContext.organization.id, - visitorId, - }) - ) - ); - - return c.json( - validateResponse( - { - target: context.body.target, - flags, - }, - supportFeatureFlagMutationResponseSchema - ), - 200 - ); - } -); + return c.json( + validateResponse( + { + target: context.body.target, + flags, + }, + supportFeatureFlagMutationResponseSchema + ), + 200 + ); +}); export const supportRouter = new OpenAPIHono() .route("/", supportRuntimeRouter) diff --git a/apps/api/src/rest/routers/upload.ts b/apps/api/src/rest/routers/upload.ts index 157eb93f0..e604b3e90 100644 --- a/apps/api/src/rest/routers/upload.ts +++ b/apps/api/src/rest/routers/upload.ts @@ -3,6 +3,7 @@ import { safelyExtractRequestData, validateResponse, } from "@api/utils/validate"; +import { createUploadSignUrlRoute } from "@cossistant/protocol/routes"; import { generateUploadUrlRequestSchema, generateUploadUrlResponseSchema, @@ -16,93 +17,56 @@ export const uploadRouter = new OpenAPIHono(); uploadRouter.use("/*", ...protectedPublicApiKeyMiddleware); -uploadRouter.openapi( - { - method: "post", - path: "/sign-url", - summary: "Generate a signed S3 upload URL", - description: - "Creates a temporary signed URL that can be used to upload a file directly to the configured S3 bucket.", - tags: ["Uploads"], - request: { - body: { - required: true, - content: { - "application/json": { - schema: generateUploadUrlRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Signed URL generated successfully", - content: { - "application/json": { - schema: generateUploadUrlResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Invalid request"), - 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), - 403: errorJsonResponse("Forbidden - Public key origin validation failed"), - }, - ...runtimeDualAuth(), - }, - async (c) => { - const { body, organization, website } = await safelyExtractRequestData( - c, - generateUploadUrlRequestSchema - ); - - if (!organization) { - return c.json( - validateResponse( - { error: "Organization context not found for API key" }, - z.object({ error: z.string() }) - ), - 400 - ); - } +uploadRouter.openapi(createUploadSignUrlRoute, async (c) => { + const { body, organization, website } = await safelyExtractRequestData( + c, + generateUploadUrlRequestSchema + ); - if (body.scope.organizationId !== organization.id) { - return c.json( - validateResponse( - { - error: - "Scope organization does not match the API key organization context", - }, - z.object({ error: z.string() }) - ), - 400 - ); - } - - if (website && body.scope.websiteId !== website.id) { - return c.json( - validateResponse( - { - error: "Scope website does not match the API key website context", - }, - z.object({ error: z.string() }) - ), - 400 - ); - } + if (!organization) { + return c.json( + validateResponse( + { error: "Organization context not found for API key" }, + z.object({ error: z.string() }) + ), + 400 + ); + } - const result = await generateUploadUrl({ - contentType: body.contentType, - fileName: body.fileName, - fileExtension: body.fileExtension, - path: body.path, - scope: body.scope, - useCdn: body.useCdn, - expiresInSeconds: body.expiresInSeconds, - }); + if (body.scope.organizationId !== organization.id) { + return c.json( + validateResponse( + { + error: + "Scope organization does not match the API key organization context", + }, + z.object({ error: z.string() }) + ), + 400 + ); + } + if (website && body.scope.websiteId !== website.id) { return c.json( - validateResponse(result, generateUploadUrlResponseSchema), - 200 + validateResponse( + { + error: "Scope website does not match the API key website context", + }, + z.object({ error: z.string() }) + ), + 400 ); } -); + + const result = await generateUploadUrl({ + contentType: body.contentType, + fileName: body.fileName, + fileExtension: body.fileExtension, + path: body.path, + scope: body.scope, + useCdn: body.useCdn, + expiresInSeconds: body.expiresInSeconds, + }); + + return c.json(validateResponse(result, generateUploadUrlResponseSchema), 200); +}); diff --git a/apps/api/src/rest/routers/visitor.ts b/apps/api/src/rest/routers/visitor.ts index b6ae70d4b..994746381 100644 --- a/apps/api/src/rest/routers/visitor.ts +++ b/apps/api/src/rest/routers/visitor.ts @@ -29,6 +29,14 @@ import { safelyExtractRequestData, validateResponse, } from "@api/utils/validate"; +import { + blockVisitorRoute, + getVisitorActivityRoute, + getVisitorRoute, + unblockVisitorRoute, + updateVisitorMetadataRoute, + updateVisitorRoute, +} from "@cossistant/protocol/routes"; import { ConversationEventType, TimelineItemVisibility, @@ -523,808 +531,509 @@ function toOptionalString( visitorRouter.use("/*", ...protectedPublicApiKeyMiddleware); // POST /visitors/:id/activity - Track live visitor activity -visitorRouter.openapi( - { - method: "post", - path: "/{id}/activity", - summary: "Track live visitor activity", - description: - "Records live visitor activity for realtime dashboards. This endpoint is the canonical ingestion path for live visitor presence and page activity.", - request: { - body: { - content: { - "application/json": { - schema: visitorActivityRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Live activity accepted", - content: { - "application/json": { - schema: visitorActivityResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Invalid request data"), - 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), - 403: errorJsonResponse("Forbidden - Public key origin validation failed"), - 404: errorJsonResponse("Visitor not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...runtimeDualAuth({ - parameters: [ +visitorRouter.openapi(getVisitorActivityRoute, async (c) => { + try { + const { body, db, website } = await safelyExtractRequestData( + c, + visitorActivityRequestSchema + ); + const visitorId = c.req.param("id"); + + if (!visitorId) { + return c.json( { - name: "id", - in: "path", - required: true, - description: "The visitor ID", - schema: { - type: "string", - }, + error: "BAD_REQUEST", + message: "Visitor ID is required", }, - ], - }), - }, - async (c) => { - try { - const { body, db, website } = await safelyExtractRequestData( - c, - visitorActivityRequestSchema + 400 ); - const visitorId = c.req.param("id"); - - if (!visitorId) { - return c.json( - { - error: "BAD_REQUEST", - message: "Visitor ID is required", - }, - 400 - ); - } + } - const visitor = await findVisitorForWebsite(db, { - visitorId, - websiteId: website.id, - }); + const visitor = await findVisitorForWebsite(db, { + visitorId, + websiteId: website.id, + }); - if (!visitor) { - return c.json( - { error: "NOT_FOUND", message: "Visitor not found" }, - 404 - ); - } + if (!visitor) { + return c.json({ error: "NOT_FOUND", message: "Visitor not found" }, 404); + } - const acceptedAt = new Date().toISOString(); - const updatedVisitor = await updateVisitorForWebsite(db, { - visitorId, - websiteId: website.id, - data: { - lastSeenAt: acceptedAt, - updatedAt: acceptedAt, - }, - }); - const trackingContext = flattenVisitorTrackingContext({ - attribution: body.attribution, - currentPage: body.currentPage, - }); - void updatedVisitor; + const acceptedAt = new Date().toISOString(); + const updatedVisitor = await updateVisitorForWebsite(db, { + visitorId, + websiteId: website.id, + data: { + lastSeenAt: acceptedAt, + updatedAt: acceptedAt, + }, + }); + const trackingContext = flattenVisitorTrackingContext({ + attribution: body.attribution, + currentPage: body.currentPage, + }); + void updatedVisitor; + + trackVisitorActivity({ + website_id: website.id, + visitor_id: visitorId, + session_id: body.sessionId, + event_type: body.activityType, + city: toOptionalString(visitor.city), + country_code: toOptionalString(visitor.countryCode), + latitude: toOptionalNumber(visitor.latitude), + longitude: toOptionalNumber(visitor.longitude), + ...trackingContext, + }); - trackVisitorActivity({ - website_id: website.id, - visitor_id: visitorId, - session_id: body.sessionId, - event_type: body.activityType, + await markVisitorPresence({ + websiteId: website.id, + visitorId, + lastSeenAt: acceptedAt, + geo: { city: toOptionalString(visitor.city), - country_code: toOptionalString(visitor.countryCode), + countryCode: toOptionalString(visitor.countryCode), latitude: toOptionalNumber(visitor.latitude), longitude: toOptionalNumber(visitor.longitude), - ...trackingContext, - }); + }, + }); - await markVisitorPresence({ - websiteId: website.id, + void realtime + .emit("visitorPresenceUpdate", { + activityType: body.activityType, + attribution: body.attribution, + currentPage: body.currentPage, + organizationId: visitor.organizationId, + sessionId: body.sessionId, + userId: null, visitorId, - lastSeenAt: acceptedAt, - geo: { - city: toOptionalString(visitor.city), - countryCode: toOptionalString(visitor.countryCode), - latitude: toOptionalNumber(visitor.latitude), - longitude: toOptionalNumber(visitor.longitude), - }, - }); - - void realtime - .emit("visitorPresenceUpdate", { - activityType: body.activityType, - attribution: body.attribution, - currentPage: body.currentPage, - organizationId: visitor.organizationId, - sessionId: body.sessionId, - userId: null, - visitorId, + websiteId: website.id, + }) + .catch((error) => { + console.error("[VisitorActivity] Failed to publish realtime event", { websiteId: website.id, - }) - .catch((error) => { - console.error("[VisitorActivity] Failed to publish realtime event", { - websiteId: website.id, - visitorId, - error, - }); + visitorId, + error, }); + }); - const response: VisitorActivityResponse = { - ok: true, - acceptedAt, - }; + const response: VisitorActivityResponse = { + ok: true, + acceptedAt, + }; - return c.json( - validateResponse(response, visitorActivityResponseSchema), - 200 - ); - } catch (error) { - console.error("Error tracking visitor activity:", error); - return c.json( - { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to track visitor activity", - }, - 500 - ); - } + return c.json( + validateResponse(response, visitorActivityResponseSchema), + 200 + ); + } catch (error) { + console.error("Error tracking visitor activity:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to track visitor activity", + }, + 500 + ); } -); +}); // PATCH /visitors/:id - Update existing visitor information -visitorRouter.openapi( - { - method: "patch", - path: "/{id}", - summary: "Update existing visitor information", - description: - "Updates an existing visitor's browser, device, and location data. The visitor must already exist in the system.", - request: { - body: { - content: { - "application/json": { - schema: updateVisitorRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: visitorResponseSchema, - }, - }, - description: "Visitor information successfully created or updated", - }, - 400: errorJsonResponse("Invalid request data"), - 401: errorJsonResponse("Unauthorized - Invalid API key"), - 403: errorJsonResponse("Forbidden - Public key origin validation failed"), - 404: errorJsonResponse("Visitor not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...runtimeDualAuth({ - parameters: [ +visitorRouter.openapi(updateVisitorRoute, async (c) => { + try { + const { db, website, body } = await safelyExtractRequestData( + c, + updateVisitorRequestSchema + ); + const visitorId = c.req.param("id"); + + if (!visitorId) { + return c.json({ error: "NOT_FOUND", message: "Visitor not found" }, 404); + } + + if (!website?.id) { + return c.json({ error: "UNAUTHORIZED", message: "Invalid API key" }, 401); + } + + const existingVisitor = await findVisitorForWebsite(db, { + visitorId, + websiteId: website.id, + }); + + if (!existingVisitor) { + return c.json({ error: "NOT_FOUND", message: "Visitor not found" }, 404); + } + + const normalizedBodyCountryCode = normalizeCountryCode(body.countryCode); + if ( + typeof body.countryCode === "string" && + normalizedBodyCountryCode === null + ) { + return c.json( { - name: "id", - in: "path", - required: true, - description: "The visitor ID to update", - schema: { - type: "string", - }, + error: "BAD_REQUEST", + message: "countryCode must be a valid ISO 3166-1 alpha-2 code", }, - ], - }), - }, - async (c) => { - try { - const { db, website, body } = await safelyExtractRequestData( - c, - updateVisitorRequestSchema + 400 ); - const visitorId = c.req.param("id"); + } - if (!visitorId) { - return c.json( - { error: "NOT_FOUND", message: "Visitor not found" }, - 404 - ); - } + const normalizedBody: UpdateVisitorRequest = { + ...body, + ...(normalizedBodyCountryCode + ? { countryCode: normalizedBodyCountryCode } + : {}), + attribution: body.attribution, + currentPage: body.currentPage, + }; - if (!website?.id) { - return c.json( - { error: "UNAUTHORIZED", message: "Invalid API key" }, - 401 - ); - } + const now = new Date(); + const nowIso = now.toISOString(); + const requestContext = extractRequestContext(c.req); + const { geoUpdate, timezoneFallback } = await resolveServerGeoUpdate({ + existingVisitor, + canonicalIp: requestContext.canonicalIp, + publicIp: requestContext.publicIp, + edgeGeoUpdate: requestContext.edgeGeoUpdate, + edgeTimezone: requestContext.edgeTimezone, + resolvedAt: nowIso, + }); - const existingVisitor = await findVisitorForWebsite(db, { - visitorId, - websiteId: website.id, - }); + const baseVisitorUpdate = stripServerOwnedGeoFields(normalizedBody); + if ( + baseVisitorUpdate.language === undefined && + requestContext.preferredLanguage + ) { + baseVisitorUpdate.language = requestContext.preferredLanguage; + } + if (baseVisitorUpdate.timezone === undefined && timezoneFallback) { + baseVisitorUpdate.timezone = timezoneFallback; + } - if (!existingVisitor) { - return c.json( - { error: "NOT_FOUND", message: "Visitor not found" }, - 404 - ); - } + const manualGeoFallback = + requestContext.canonicalIp === null + ? extractManualGeoFallback( + normalizedBody, + requestContext.preferredLocale + ) + : {}; + + const updatedVisitor = await updateVisitorForWebsite(db, { + visitorId, + websiteId: website.id, + data: { + ...baseVisitorUpdate, + ...manualGeoFallback, + ...geoUpdate, + attribution: resolveFirstTouchAttribution({ + existingAttribution: existingVisitor.attribution, + incomingAttribution: normalizedBody.attribution, + }), + currentPage: normalizedBody.currentPage ?? existingVisitor.currentPage, + lastSeenAt: nowIso, + updatedAt: nowIso, + }, + }); - const normalizedBodyCountryCode = normalizeCountryCode(body.countryCode); - if ( - typeof body.countryCode === "string" && - normalizedBodyCountryCode === null - ) { - return c.json( - { - error: "BAD_REQUEST", - message: "countryCode must be a valid ISO 3166-1 alpha-2 code", - }, - 400 - ); - } + if (!updatedVisitor) { + return c.json({ error: "NOT_FOUND", message: "Visitor not found" }, 404); + } - const normalizedBody: UpdateVisitorRequest = { - ...body, - ...(normalizedBodyCountryCode - ? { countryCode: normalizedBodyCountryCode } - : {}), - attribution: body.attribution, - currentPage: body.currentPage, - }; - - const now = new Date(); - const nowIso = now.toISOString(); - const requestContext = extractRequestContext(c.req); - const { geoUpdate, timezoneFallback } = await resolveServerGeoUpdate({ - existingVisitor, - canonicalIp: requestContext.canonicalIp, - publicIp: requestContext.publicIp, - edgeGeoUpdate: requestContext.edgeGeoUpdate, - edgeTimezone: requestContext.edgeTimezone, - resolvedAt: nowIso, + if (normalizedBody.currentPage) { + const trackingContext = flattenVisitorTrackingContext({ + attribution: updatedVisitor.attribution, + currentPage: updatedVisitor.currentPage, }); - const baseVisitorUpdate = stripServerOwnedGeoFields(normalizedBody); - if ( - baseVisitorUpdate.language === undefined && - requestContext.preferredLanguage - ) { - baseVisitorUpdate.language = requestContext.preferredLanguage; - } - if (baseVisitorUpdate.timezone === undefined && timezoneFallback) { - baseVisitorUpdate.timezone = timezoneFallback; - } - - const manualGeoFallback = - requestContext.canonicalIp === null - ? extractManualGeoFallback( - normalizedBody, - requestContext.preferredLocale - ) - : {}; + trackVisitorEvent({ + website_id: website.id, + visitor_id: visitorId, + event_type: "page_view", + ...trackingContext, + }); - const updatedVisitor = await updateVisitorForWebsite(db, { - visitorId, - websiteId: website.id, - data: { - ...baseVisitorUpdate, - ...manualGeoFallback, - ...geoUpdate, - attribution: resolveFirstTouchAttribution({ - existingAttribution: existingVisitor.attribution, - incomingAttribution: normalizedBody.attribution, - }), - currentPage: - normalizedBody.currentPage ?? existingVisitor.currentPage, - lastSeenAt: nowIso, - updatedAt: nowIso, - }, + trackVisitorActivity({ + website_id: website.id, + visitor_id: visitorId, + event_type: "page_sync", + session_id: visitorId, + city: updatedVisitor.city ?? undefined, + country_code: updatedVisitor.countryCode ?? undefined, + latitude: updatedVisitor.latitude ?? undefined, + longitude: updatedVisitor.longitude ?? undefined, + ...trackingContext, }); + } - if (!updatedVisitor) { - return c.json( - { error: "NOT_FOUND", message: "Visitor not found" }, - 404 - ); - } + const response = formatVisitorResponse(updatedVisitor); - if (normalizedBody.currentPage) { - const trackingContext = flattenVisitorTrackingContext({ - attribution: updatedVisitor.attribution, - currentPage: updatedVisitor.currentPage, - }); + return c.json(validateResponse(response, visitorResponseSchema), 200); + } catch (error) { + console.error("Error updating visitor:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to update visitor information", + }, + 500 + ); + } +}); - trackVisitorEvent({ - website_id: website.id, - visitor_id: visitorId, - event_type: "page_view", - ...trackingContext, - }); +// PATCH /visitors/:id/metadata - Update contact metadata for a visitor +visitorRouter.openapi(updateVisitorMetadataRoute, async (c) => { + try { + const { db, website, body } = await safelyExtractRequestData( + c, + updateVisitorMetadataRequestSchema + ); + const visitorId = c.req.param("id"); + + if (!visitorId) { + return c.json({ error: "NOT_FOUND", message: "Visitor not found" }, 404); + } - trackVisitorActivity({ - website_id: website.id, - visitor_id: visitorId, - event_type: "page_sync", - session_id: visitorId, - city: updatedVisitor.city ?? undefined, - country_code: updatedVisitor.countryCode ?? undefined, - latitude: updatedVisitor.latitude ?? undefined, - longitude: updatedVisitor.longitude ?? undefined, - ...trackingContext, - }); - } + if (!website?.id) { + return c.json({ error: "UNAUTHORIZED", message: "Invalid API key" }, 401); + } - const response = formatVisitorResponse(updatedVisitor); + const contact = await getContactForVisitor(db, { + visitorId, + websiteId: website.id, + }); - return c.json(validateResponse(response, visitorResponseSchema), 200); - } catch (error) { - console.error("Error updating visitor:", error); + if (!contact) { return c.json( { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to update visitor information", + error: "BAD_REQUEST", + message: + "Visitor is not identified. Please use the /contacts/identify endpoint first to create a contact for this visitor.", }, - 500 + 400 ); } - } -); -// PATCH /visitors/:id/metadata - Update contact metadata for a visitor -visitorRouter.openapi( - { - method: "patch", - path: "/{id}/metadata", - summary: "Update contact metadata for a visitor", - description: - "Merges the provided metadata into the contact profile associated with the visitor. The visitor must be identified first (linked to a contact) via the /contacts/identify endpoint.", - request: { - body: { - content: { - "application/json": { - schema: updateVisitorMetadataRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Contact metadata updated successfully", - content: { - "application/json": { - schema: visitorResponseSchema, - }, - }, - }, - 400: errorJsonResponse("Invalid request data or visitor not identified"), - 401: errorJsonResponse("Unauthorized - Invalid API key"), - 403: errorJsonResponse("Forbidden - Public key origin validation failed"), - 404: errorJsonResponse("Visitor not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...runtimeDualAuth({ - parameters: [ - { - name: "id", - in: "path", - required: true, - description: "The visitor ID", - schema: { - type: "string", - }, - }, - ], - }), - }, - async (c) => { - try { - const { db, website, body } = await safelyExtractRequestData( - c, - updateVisitorMetadataRequestSchema - ); - const visitorId = c.req.param("id"); - - if (!visitorId) { - return c.json( - { error: "NOT_FOUND", message: "Visitor not found" }, - 404 - ); - } - - if (!website?.id) { - return c.json( - { error: "UNAUTHORIZED", message: "Invalid API key" }, - 401 - ); - } - - const contact = await getContactForVisitor(db, { - visitorId, - websiteId: website.id, - }); - - if (!contact) { - return c.json( - { - error: "BAD_REQUEST", - message: - "Visitor is not identified. Please use the /contacts/identify endpoint first to create a contact for this visitor.", - }, - 400 - ); - } - - await mergeContactMetadata(db, { - contactId: contact.id, - websiteId: website.id, - metadata: body.metadata, - }); + await mergeContactMetadata(db, { + contactId: contact.id, + websiteId: website.id, + metadata: body.metadata, + }); - const visitor = await findVisitorForWebsite(db, { - visitorId, - websiteId: website.id, - }); + const visitor = await findVisitorForWebsite(db, { + visitorId, + websiteId: website.id, + }); - if (!visitor) { - return c.json( - { error: "NOT_FOUND", message: "Visitor not found" }, - 404 - ); - } + if (!visitor) { + return c.json({ error: "NOT_FOUND", message: "Visitor not found" }, 404); + } - const response = formatVisitorResponse(visitor); + const response = formatVisitorResponse(visitor); - return c.json(validateResponse(response, visitorResponseSchema), 200); - } catch (error) { - console.error("Error updating contact metadata:", error); - return c.json( - { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to update contact metadata", - }, - 500 - ); - } - } -); - -visitorRouter.openapi( - { - method: "post", - path: "/{id}/block", - summary: "Block a visitor", - description: - "Blocks a visitor from sending new messages. Requires a private API key and an acting teammate.", - operationId: "blockVisitor", - responses: { - 200: { - description: "Visitor blocked successfully", - content: { - "application/json": { - schema: visitorResponseSchema, - }, - }, + return c.json(validateResponse(response, visitorResponseSchema), 200); + } catch (error) { + console.error("Error updating contact metadata:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to update contact metadata", }, - 400: errorJsonResponse( - "Bad request - Missing actor for an unlinked private API key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor user not allowed for this website" - ), - 404: errorJsonResponse("Visitor not found"), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["Visitors"], - ...privateControlAuth({ - parameters: [ - { - name: "id", - in: "path", - required: true, - description: "The visitor ID", - schema: { - type: "string", - }, - }, - ], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - try { - const extracted = await safelyExtractRequestData(c); - const privateContext = requirePrivateControlContext(c, extracted); - - if (privateContext instanceof Response) { - return privateContext; - } + 500 + ); + } +}); - const visitorId = c.req.param("id"); - if (!visitorId) { - return restError(c, 404, "NOT_FOUND", "Visitor not found"); - } +visitorRouter.openapi(blockVisitorRoute, async (c) => { + try { + const extracted = await safelyExtractRequestData(c); + const privateContext = requirePrivateControlContext(c, extracted); - const actor = await resolvePrivateVisitorActor({ - c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - }); + if (privateContext instanceof Response) { + return privateContext; + } - if (actor instanceof Response) { - return actor; - } - if (!actor) { - return restError(c, 400, "BAD_REQUEST", "Actor user is required"); - } + const visitorId = c.req.param("id"); + if (!visitorId) { + return restError(c, 404, "NOT_FOUND", "Visitor not found"); + } - const visitorRecord = await findVisitorForWebsite(extracted.db, { - visitorId, - websiteId: privateContext.website.id, - }); + const actor = await resolvePrivateVisitorActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + }); - if (!visitorRecord) { - return restError(c, 404, "NOT_FOUND", "Visitor not found"); - } + if (actor instanceof Response) { + return actor; + } + if (!actor) { + return restError(c, 400, "BAD_REQUEST", "Actor user is required"); + } - const updatedVisitor = await blockVisitor(extracted.db, { - visitor: visitorRecord, - actorUserId: actor.userId, - }); + const visitorRecord = await findVisitorForWebsite(extracted.db, { + visitorId, + websiteId: privateContext.website.id, + }); - if (!updatedVisitor) { - return restError( - c, - 500, - "INTERNAL_SERVER_ERROR", - "Unable to block visitor" - ); - } + if (!visitorRecord) { + return restError(c, 404, "NOT_FOUND", "Visitor not found"); + } - await createLatestVisitorModerationEvent({ - db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - visitorId, - actorUserId: actor.userId, - type: ConversationEventType.VISITOR_BLOCKED, - }); + const updatedVisitor = await blockVisitor(extracted.db, { + visitor: visitorRecord, + actorUserId: actor.userId, + }); - return c.json( - validateResponse( - formatVisitorResponse(updatedVisitor), - visitorResponseSchema - ), - 200 - ); - } catch (error) { - console.error("Error blocking visitor:", error); + if (!updatedVisitor) { return restError( c, 500, "INTERNAL_SERVER_ERROR", - "Failed to block visitor" + "Unable to block visitor" ); } - } -); - -visitorRouter.openapi( - { - method: "post", - path: "/{id}/unblock", - summary: "Unblock a visitor", - description: - "Unblocks a visitor so they can send messages again. Requires a private API key and an acting teammate.", - operationId: "unblockVisitor", - responses: { - 200: { - description: "Visitor unblocked successfully", - content: { - "application/json": { - schema: visitorResponseSchema, - }, - }, - }, - 400: errorJsonResponse( - "Bad request - Missing actor for an unlinked private API key" - ), - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse( - "Forbidden - Private API key required or actor user not allowed for this website" + + await createLatestVisitorModerationEvent({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + visitorId, + actorUserId: actor.userId, + type: ConversationEventType.VISITOR_BLOCKED, + }); + + return c.json( + validateResponse( + formatVisitorResponse(updatedVisitor), + visitorResponseSchema ), - 404: errorJsonResponse("Visitor not found"), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["Visitors"], - ...privateControlAuth({ - parameters: [ - { - name: "id", - in: "path", - required: true, - description: "The visitor ID", - schema: { - type: "string", - }, - }, - ], - includeActorUserIdHeader: true, - }), - }, - async (c) => { - try { - const extracted = await safelyExtractRequestData(c); - const privateContext = requirePrivateControlContext(c, extracted); - - if (privateContext instanceof Response) { - return privateContext; - } + 200 + ); + } catch (error) { + console.error("Error blocking visitor:", error); + return restError( + c, + 500, + "INTERNAL_SERVER_ERROR", + "Failed to block visitor" + ); + } +}); - const visitorId = c.req.param("id"); - if (!visitorId) { - return restError(c, 404, "NOT_FOUND", "Visitor not found"); - } +visitorRouter.openapi(unblockVisitorRoute, async (c) => { + try { + const extracted = await safelyExtractRequestData(c); + const privateContext = requirePrivateControlContext(c, extracted); - const actor = await resolvePrivateVisitorActor({ - c, - db: extracted.db, - apiKey: privateContext.apiKey, - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - }); + if (privateContext instanceof Response) { + return privateContext; + } - if (actor instanceof Response) { - return actor; - } - if (!actor) { - return restError(c, 400, "BAD_REQUEST", "Actor user is required"); - } + const visitorId = c.req.param("id"); + if (!visitorId) { + return restError(c, 404, "NOT_FOUND", "Visitor not found"); + } - const visitorRecord = await findVisitorForWebsite(extracted.db, { - visitorId, - websiteId: privateContext.website.id, - }); + const actor = await resolvePrivateVisitorActor({ + c, + db: extracted.db, + apiKey: privateContext.apiKey, + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + }); - if (!visitorRecord) { - return restError(c, 404, "NOT_FOUND", "Visitor not found"); - } + if (actor instanceof Response) { + return actor; + } + if (!actor) { + return restError(c, 400, "BAD_REQUEST", "Actor user is required"); + } - const updatedVisitor = await unblockVisitor(extracted.db, { - visitor: visitorRecord, - actorUserId: actor.userId, - }); + const visitorRecord = await findVisitorForWebsite(extracted.db, { + visitorId, + websiteId: privateContext.website.id, + }); - if (!updatedVisitor) { - return restError( - c, - 500, - "INTERNAL_SERVER_ERROR", - "Unable to unblock visitor" - ); - } + if (!visitorRecord) { + return restError(c, 404, "NOT_FOUND", "Visitor not found"); + } - await createLatestVisitorModerationEvent({ - db: extracted.db, - organizationId: privateContext.organization.id, - websiteId: privateContext.website.id, - visitorId, - actorUserId: actor.userId, - type: ConversationEventType.VISITOR_UNBLOCKED, - }); + const updatedVisitor = await unblockVisitor(extracted.db, { + visitor: visitorRecord, + actorUserId: actor.userId, + }); - return c.json( - validateResponse( - formatVisitorResponse(updatedVisitor), - visitorResponseSchema - ), - 200 - ); - } catch (error) { - console.error("Error unblocking visitor:", error); + if (!updatedVisitor) { return restError( c, 500, "INTERNAL_SERVER_ERROR", - "Failed to unblock visitor" + "Unable to unblock visitor" ); } + + await createLatestVisitorModerationEvent({ + db: extracted.db, + organizationId: privateContext.organization.id, + websiteId: privateContext.website.id, + visitorId, + actorUserId: actor.userId, + type: ConversationEventType.VISITOR_UNBLOCKED, + }); + + return c.json( + validateResponse( + formatVisitorResponse(updatedVisitor), + visitorResponseSchema + ), + 200 + ); + } catch (error) { + console.error("Error unblocking visitor:", error); + return restError( + c, + 500, + "INTERNAL_SERVER_ERROR", + "Failed to unblock visitor" + ); } -); +}); // GET /visitors/:id - Get visitor information by ID -visitorRouter.openapi( - { - method: "get", - path: "/{id}", - summary: "Get visitor information", - description: "Retrieves visitor information by visitor ID", - responses: { - 200: { - content: { - "application/json": { - schema: visitorResponseSchema, - }, - }, - description: "Visitor information retrieved successfully", - }, - 401: errorJsonResponse("Unauthorized - Invalid API key"), - 403: errorJsonResponse("Forbidden - Public key origin validation failed"), - 404: errorJsonResponse("Visitor not found"), - 500: errorJsonResponse("Internal server error"), - }, - ...runtimeDualAuth({ - parameters: [ - { - name: "id", - in: "path", - required: true, - description: "The visitor ID", - schema: { - type: "string", - }, - }, - ], - }), - }, - async (c) => { - try { - const { db, website } = await safelyExtractRequestData(c); - const visitorId = c.req.param("id"); - - if (!visitorId) { - return c.json( - { error: "NOT_FOUND", message: "Visitor not found" }, - 404 - ); - } - - if (!website?.id) { - return c.json( - { error: "UNAUTHORIZED", message: "Invalid API key" }, - 401 - ); - } +visitorRouter.openapi(getVisitorRoute, async (c) => { + try { + const { db, website } = await safelyExtractRequestData(c); + const visitorId = c.req.param("id"); - const visitorRecord = await findVisitorForWebsite(db, { - visitorId, - websiteId: website.id, - }); + if (!visitorId) { + return c.json({ error: "NOT_FOUND", message: "Visitor not found" }, 404); + } - if (!visitorRecord) { - return c.json( - { error: "NOT_FOUND", message: "Visitor not found" }, - 404 - ); - } + if (!website?.id) { + return c.json({ error: "UNAUTHORIZED", message: "Invalid API key" }, 401); + } - const response = formatVisitorResponse(visitorRecord); + const visitorRecord = await findVisitorForWebsite(db, { + visitorId, + websiteId: website.id, + }); - return c.json(validateResponse(response, visitorResponseSchema), 200); - } catch (error) { - console.error("Error fetching visitor:", error); - return c.json( - { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch visitor information", - }, - 500 - ); + if (!visitorRecord) { + return c.json({ error: "NOT_FOUND", message: "Visitor not found" }, 404); } + + const response = formatVisitorResponse(visitorRecord); + + return c.json(validateResponse(response, visitorResponseSchema), 200); + } catch (error) { + console.error("Error fetching visitor:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch visitor information", + }, + 500 + ); } -); +}); diff --git a/apps/api/src/rest/routers/website.ts b/apps/api/src/rest/routers/website.ts index 698652a82..e24ef980e 100644 --- a/apps/api/src/rest/routers/website.ts +++ b/apps/api/src/rest/routers/website.ts @@ -12,6 +12,10 @@ import { } from "@api/utils/validate"; import { getMostRecentLastOnlineAt } from "@api/utils/website"; import { normalizeHumanAgentName } from "@cossistant/core"; +import { + getWebsiteRoute, + listWebsiteTeamMembersRoute, +} from "@cossistant/protocol/routes"; import { publicWebsiteResponseSchema, websiteTeamMembersResponseSchema, @@ -31,179 +35,126 @@ export const websiteRouter = new OpenAPIHono(); websiteRouter.use("/*", ...protectedPublicApiKeyMiddleware); // GET /website - Get website information linked to the API key -websiteRouter.openapi( - { - method: "get", - path: "/", - summary: "Get website information", - description: - "Returns the website information associated with the provided API key. This endpoint supports both public and private API keys with different authentication methods.", - responses: { - 200: { - description: "Website information successfully retrieved", - content: { - "application/json": { - schema: publicWebsiteResponseSchema, - }, - }, - }, - 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), - 403: errorJsonResponse( - "Forbidden - Origin validation failed for public key or domain not whitelisted" - ), - 404: errorJsonResponse("Website not found for this API key"), - }, - tags: ["Website"], - ...runtimeDualAuth({ includeVisitorIdHeader: true }), - }, - async (c) => { - const { db, website, apiKey, visitorIdHeader } = - await safelyExtractRequestData(c); +websiteRouter.openapi(getWebsiteRoute, async (c) => { + const { db, website, apiKey, visitorIdHeader } = + await safelyExtractRequestData(c); - // if visitorIdHeader is not provided, generate a new one - const visitorId = visitorIdHeader ?? generateULID(); + // if visitorIdHeader is not provided, generate a new one + const visitorId = visitorIdHeader ?? generateULID(); - const [visitor, websiteAccessUsers, contact, websiteAiAgents] = - await Promise.all([ - upsertVisitor(db, { - websiteId: website.id, - organizationId: website.organizationId, - visitorId, - isTest: apiKey.isTest, - }), - website.teamId - ? listWebsiteAccessUsers(db, { - organizationId: website.organizationId, - teamId: website.teamId, - }) - : Promise.resolve([]), - getContactForVisitor(db, { - visitorId, - websiteId: website.id, - }), - listActiveAiAgentSummariesForWebsite(db, { - websiteId: website.id, - }), - ]); - - const availableHumanAgents = websiteAccessUsers - .slice(0, 3) - .map((humanAgent) => ({ - id: humanAgent.userId, - name: normalizeHumanAgentName(humanAgent.name), - email: humanAgent.email ?? null, - image: humanAgent.image, - lastSeenAt: humanAgent.lastSeenAt?.toISOString() ?? null, - })); + const [visitor, websiteAccessUsers, contact, websiteAiAgents] = + await Promise.all([ + upsertVisitor(db, { + websiteId: website.id, + organizationId: website.organizationId, + visitorId, + isTest: apiKey.isTest, + }), + website.teamId + ? listWebsiteAccessUsers(db, { + organizationId: website.organizationId, + teamId: website.teamId, + }) + : Promise.resolve([]), + getContactForVisitor(db, { + visitorId, + websiteId: website.id, + }), + listActiveAiAgentSummariesForWebsite(db, { + websiteId: website.id, + }), + ]); - // Map AI agents to the AvailableAIAgent format - const availableAIAgents = websiteAiAgents.map((agent) => ({ - id: agent.id, - name: agent.name, - image: agent.image ?? null, + const availableHumanAgents = websiteAccessUsers + .slice(0, 3) + .map((humanAgent) => ({ + id: humanAgent.userId, + name: normalizeHumanAgentName(humanAgent.name), + email: humanAgent.email ?? null, + image: humanAgent.image, + lastSeenAt: humanAgent.lastSeenAt?.toISOString() ?? null, })); - // iso string indicating support activity - uses most recent lastSeenAt from available human agents - const lastOnlineAt = getMostRecentLastOnlineAt(availableHumanAgents); + // Map AI agents to the AvailableAIAgent format + const availableAIAgents = websiteAiAgents.map((agent) => ({ + id: agent.id, + name: agent.name, + image: agent.image ?? null, + })); - return c.json( - validateResponse( - { - id: website.id, - name: website.name, - domain: website.domain, - defaultLanguage: website.defaultLanguage, - description: website.description, - logoUrl: website.logoUrl, - organizationId: website.organizationId, - status: website.status, - lastOnlineAt, - availableHumanAgents, - availableAIAgents, - visitor: { - id: visitor.id, - isBlocked: Boolean(visitor.blockedAt), - language: visitor.language, - contact: contact - ? { - id: contact.id, - name: contact.name, - email: contact.email, - image: contact.image, - metadataHash: computeMetadataHash( - contact.metadata as Record | null - ), - } - : null, - }, - }, - publicWebsiteResponseSchema - ), - 200 - ); - } -); + // iso string indicating support activity - uses most recent lastSeenAt from available human agents + const lastOnlineAt = getMostRecentLastOnlineAt(availableHumanAgents); -websiteRouter.openapi( - { - method: "get", - path: "/team-members", - summary: "List website team members", - description: - "Returns the website-access teammates that can be linked to private API keys or used as actor IDs on actor-aware private API routes.", - operationId: "listWebsiteTeamMembers", - responses: { - 200: { - description: "Website team members retrieved successfully", - content: { - "application/json": { - schema: websiteTeamMembersResponseSchema, - }, + return c.json( + validateResponse( + { + id: website.id, + name: website.name, + domain: website.domain, + defaultLanguage: website.defaultLanguage, + description: website.description, + logoUrl: website.logoUrl, + organizationId: website.organizationId, + status: website.status, + lastOnlineAt, + availableHumanAgents, + availableAIAgents, + visitor: { + id: visitor.id, + isBlocked: Boolean(visitor.blockedAt), + language: visitor.language, + contact: contact + ? { + id: contact.id, + name: contact.name, + email: contact.email, + image: contact.image, + metadataHash: computeMetadataHash( + contact.metadata as Record | null + ), + } + : null, }, }, - 401: errorJsonResponse( - "Unauthorized - Invalid or missing private API key" - ), - 403: errorJsonResponse("Forbidden - Private API key required"), - 500: errorJsonResponse("Internal server error"), - }, - tags: ["Website"], - ...privateControlAuth(), - }, - async (c) => { - try { - const context = await safelyExtractRequestData(c); - const privateContext = requirePrivateControlContext(c, context); + publicWebsiteResponseSchema + ), + 200 + ); +}); - if (privateContext instanceof Response) { - return privateContext; - } +websiteRouter.openapi(listWebsiteTeamMembersRoute, async (c) => { + try { + const context = await safelyExtractRequestData(c); + const privateContext = requirePrivateControlContext(c, context); - const members = privateContext.website.teamId - ? await getWebsiteMembersForApi(context.db, { - organizationId: privateContext.organization.id, - websiteTeamId: privateContext.website.teamId, - }) - : []; + if (privateContext instanceof Response) { + return privateContext; + } + + const members = privateContext.website.teamId + ? await getWebsiteMembersForApi(context.db, { + organizationId: privateContext.organization.id, + websiteTeamId: privateContext.website.teamId, + }) + : []; - return c.json( - validateResponse( - { - members, - }, - websiteTeamMembersResponseSchema - ), - 200 - ); - } catch (error) { - console.error("Error listing website team members:", error); - return c.json( + return c.json( + validateResponse( { - error: "INTERNAL_SERVER_ERROR", - message: "Failed to list website team members", + members, }, - 500 - ); - } + websiteTeamMembersResponseSchema + ), + 200 + ); + } catch (error) { + console.error("Error listing website team members:", error); + return c.json( + { + error: "INTERNAL_SERVER_ERROR", + message: "Failed to list website team members", + }, + 500 + ); } -); +}); diff --git a/apps/api/src/ws/socket.test.ts b/apps/api/src/ws/socket.test.ts index f55355a6c..f4d617a27 100644 --- a/apps/api/src/ws/socket.test.ts +++ b/apps/api/src/ws/socket.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; +import * as schema from "@api/db/schema"; import type { AnyRealtimeEvent } from "@cossistant/types/realtime-events"; import type { EventContext } from "./router"; import type { RawSocket } from "./socket"; @@ -22,18 +23,12 @@ mock.module("@api/db/queries/session", () => ({ mock.module("@api/db/queries/website", () => ({ getWebsiteByIdWithAccess: async () => null, })); +// Spread the real schema barrel rather than enumerating tables: Drizzle table +// definitions are side-effect free, and an omitted export would surface as +// "Export named 'x' not found" for any importer, since mock.module replaces the +// module wholesale. mock.module("@api/db/schema", () => ({ - website: {}, - organization: {}, - user: {}, - member: {}, - visitor: {}, - view: {}, - conversation: {}, - conversationView: {}, - conversationSeen: {}, - conversationTimelineItem: {}, - aiAgent: {}, + ...schema, })); mock.module("@api/lib/auth", () => ({ auth: { diff --git a/apps/web/package.json b/apps/web/package.json index ba42e2415..0265f81d7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -68,7 +68,7 @@ "fumadocs-openapi": "^10.5.0", "fumadocs-typescript": "^5.2.0", "fumadocs-ui": "^16.7.6", - "hono": "^4.11.5", + "hono": "^4.12.32", "lucide-react": "^0.562.0", "motion": "^12.29.0", "nanoid": "^5.1.6", diff --git a/apps/workers/package.json b/apps/workers/package.json index 0ec0c1965..612c6d923 100644 --- a/apps/workers/package.json +++ b/apps/workers/package.json @@ -18,7 +18,7 @@ "@polar-sh/sdk": "^0.44.0", "bullmq": "^5.34.8", "drizzle-orm": "^0.45.2", - "hono": "^4.10.1", + "hono": "^4.12.32", "ioredis": "^5.8.1", "pg": "^8.16.3", "react": "^19.2.0" diff --git a/bun.lock b/bun.lock index 5c4dd8ad6..ba9bf254d 100644 --- a/bun.lock +++ b/bun.lock @@ -46,6 +46,7 @@ "@cossistant/jobs": "workspace:*", "@cossistant/location": "workspace:*", "@cossistant/memory": "workspace:*", + "@cossistant/protocol": "workspace:*", "@cossistant/redis": "workspace:*", "@cossistant/transactional": "workspace:*", "@cossistant/types": "workspace:*", @@ -53,8 +54,8 @@ "@hono/node-server": "^1.19.9", "@hono/swagger-ui": "^0.5.3", "@hono/trpc-server": "^0.4.2", - "@hono/zod-openapi": "^1.2.0", - "@hono/zod-validator": "^0.7.6", + "@hono/zod-openapi": "^1.5.1", + "@hono/zod-validator": "^0.9.0", "@openrouter/ai-sdk-provider": "^2.5.1", "@polar-sh/sdk": "^0.44.0", "@tinybirdco/sdk": "^0.0.33", @@ -64,7 +65,7 @@ "ai": "^6.0.154", "drizzle-orm": "^0.45.2", "email-reply-parser": "^2.3.5", - "hono": "^4.11.5", + "hono": "^4.12.32", "hono-rate-limiter": "^0.4.2", "ioredis": "^5.9.2", "jose": "^6.1.3", @@ -186,7 +187,7 @@ "fumadocs-openapi": "^10.5.0", "fumadocs-typescript": "^5.2.0", "fumadocs-ui": "^16.7.6", - "hono": "^4.11.5", + "hono": "^4.12.32", "lucide-react": "^0.562.0", "motion": "^12.29.0", "nanoid": "^5.1.6", @@ -250,7 +251,7 @@ "@polar-sh/sdk": "^0.44.0", "bullmq": "^5.34.8", "drizzle-orm": "^0.45.2", - "hono": "^4.10.1", + "hono": "^4.12.32", "ioredis": "^5.8.1", "pg": "^8.16.3", "react": "^19.2.0", @@ -321,10 +322,10 @@ "name": "@cossistant/core", "version": "0.2.0", "dependencies": { - "@hono/zod-openapi": "^1.1.0", + "@hono/zod-openapi": "^1.5.1", "nanoid": "^5.1.5", "ulid": "^3.0.1", - "zod": "^4.1.8", + "zod": "^4.3.6", }, "devDependencies": { "@cossistant/types": "workspace:*", @@ -398,6 +399,24 @@ "react-dom": ">=18 <20", }, }, + "packages/protocol": { + "name": "@cossistant/protocol", + "version": "0.2.0", + "dependencies": { + "@cossistant/types": "workspace:*", + "@hono/zod-openapi": "^1.5.1", + "openapi3-ts": "^4.5.0", + "zod": "^4.3.6", + }, + "devDependencies": { + "@cossistant/typescript-config": "workspace:*", + "@types/bun": "^1.3.6", + "hono": "^4.12.32", + }, + "peerDependencies": { + "hono": ">=4.10.0", + }, + }, "packages/react": { "name": "@cossistant/react", "version": "0.2.0", @@ -513,8 +532,8 @@ "name": "@cossistant/types", "version": "0.2.0", "dependencies": { - "@hono/zod-openapi": "^1.1.0", - "zod": "^4.1.8", + "@hono/zod-openapi": "^1.5.1", + "zod": "^4.3.6", }, "devDependencies": { "@cossistant/typescript-config": "workspace:*", @@ -531,7 +550,9 @@ }, "overrides": { "@ai-sdk/provider": "3.0.8", + "@asteasolutions/zod-to-openapi": "8.5.0", "@polar-sh/sdk": "^0.44.0", + "zod": "4.3.6", }, "packages": { "@ai-sdk/devtools": ["@ai-sdk/devtools@0.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@hono/node-server": "^1.13.7", "hono": "^4.6.14" }, "bin": { "devtools": "bin/cli.js" } }, "sha512-zRF+ClRh0fcmvoKclOcmy2hmTDN48ZfHD3y1fC3Lx0vIYaX55uywssiyaA18WlV2mD+N9H4fgPxq+9JeGfMGlQ=="], @@ -552,7 +573,7 @@ "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="], - "@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@8.4.0", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-Ckp971tmTw4pnv+o7iK85ldBHBKk6gxMaoNyLn3c2Th/fKoTG8G3jdYuOanpdGqwlDB0z01FOjry2d32lfTqrA=="], + "@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@8.5.0", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q=="], "@authenio/xml-encryption": ["@authenio/xml-encryption@2.0.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "escape-html": "^1.0.3", "xpath": "0.0.32" } }, "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg=="], @@ -846,6 +867,8 @@ "@cossistant/next": ["@cossistant/next@workspace:packages/next"], + "@cossistant/protocol": ["@cossistant/protocol@workspace:packages/protocol"], + "@cossistant/react": ["@cossistant/react@workspace:packages/react"], "@cossistant/redis": ["@cossistant/redis@workspace:packages/redis"], @@ -990,9 +1013,9 @@ "@hono/trpc-server": ["@hono/trpc-server@0.4.2", "", { "peerDependencies": { "@trpc/server": "^10.10.0 || >11.0.0-rc", "hono": ">=4.0.0" } }, "sha512-3TDrc42CZLgcTFkXQba+y7JlRWRiyw1AqhLqztWyNS2IFT+3bHld0lxKdGBttCtGKHYx0505dM67RMazjhdZqw=="], - "@hono/zod-openapi": ["@hono/zod-openapi@1.2.0", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^8.1.0", "@hono/zod-validator": "^0.7.6", "openapi3-ts": "^4.5.0" }, "peerDependencies": { "hono": ">=4.3.6", "zod": "^4.0.0" } }, "sha512-KDfHqv/Wy4elVseZXgokbHxeWuqBL+AZfqsOMpEihBMUsk/fJ+bLIi3Sf70JFVpt1Ihui8RasYrInozt7ZBDIA=="], + "@hono/zod-openapi": ["@hono/zod-openapi@1.5.1", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", "@hono/zod-validator": "^0.9.0", "openapi3-ts": "^4.5.0" }, "peerDependencies": { "hono": ">=4.10.0", "zod": "^4.0.0" } }, "sha512-ZaDdEIkn6PEGjIYHXJeyByg6yhvLzI+UXn968pWtahpwcQ6HMjQYXI3zNffTl0Wl9QZ79nUnGqiN1erEIu23fA=="], - "@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="], + "@hono/zod-validator": ["@hono/zod-validator@0.9.0", "", { "peerDependencies": { "hono": ">=4.11.2", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-n0ZSXmCiHVIp4Y5wlOOyZCeTd/rsawA/qW1cipB8QOYKZ9N8Tk0nZUZCXho9cu374AN4JpDNKioNKBJ/W+LBug=="], "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], @@ -2368,7 +2391,7 @@ "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], - "hono": ["hono@4.11.5", "", {}, "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g=="], + "hono": ["hono@4.12.32", "", {}, "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg=="], "hono-rate-limiter": ["hono-rate-limiter@0.4.2", "", { "peerDependencies": { "hono": "^4.1.1" } }, "sha512-AAtFqgADyrmbDijcRTT/HJfwqfvhalya2Zo+MgfdrMPas3zSMD8SU03cv+ZsYwRU1swv7zgVt0shwN059yzhjw=="], @@ -3524,9 +3547,9 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@ai-sdk/ui-utils/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], + "@ai-sdk/devtools/hono": ["hono@4.11.5", "", {}, "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g=="], - "@ai-sdk/ui-utils/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@ai-sdk/ui-utils/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], "@antfu/ni/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], @@ -3692,8 +3715,6 @@ "@cossistant/location/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "@cossistant/release/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@cossistant/transactional/react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], "@cossistant/transactional/react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], @@ -3738,6 +3759,8 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "@modelcontextprotocol/sdk/hono": ["hono@4.11.5", "", {}, "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g=="], + "@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "@openstatus/react/react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], @@ -3826,8 +3849,6 @@ "@tinybirdco/sdk/esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], - "@tinybirdco/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@ts-morph/common/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], "@types/conventional-commits-parser/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], @@ -3856,8 +3877,6 @@ "@upstash/workflow/ai": ["ai@4.3.19", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q=="], - "@upstash/workflow/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.9.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ=="], @@ -3996,8 +4015,6 @@ "shadcn/ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], - "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], @@ -4506,8 +4523,6 @@ "shadcn/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], - "shadcn/@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "shadcn/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "shadcn/ts-morph/@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], diff --git a/bunfig.toml b/bunfig.toml index 9ab87b7c8..799b6587e 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -13,6 +13,7 @@ minimumReleaseAgeExcludes = [ "@cossistant/location", "@cossistant/memory", "@cossistant/next", + "@cossistant/protocol", "@cossistant/react", "@cossistant/redis", "@cossistant/release", diff --git a/package.json b/package.json index 9483fd38e..2ff8fa429 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "changeset:publish": "turbo run build --filter=@cossistant/browser... --filter=@cossistant/next... --filter=facehash && changeset publish", "release": "bun run packages/release/src/index.ts create", "billing:backfill-free-subscriptions": "bun scripts/billing/backfill-free-subscriptions.ts", - "prepare": "husky" + "prepare": "husky", + "test": "turbo run test", + "check:openapi": "turbo run check:openapi" }, "devDependencies": { "@biomejs/biome": "2.2.6", @@ -66,6 +68,8 @@ }, "overrides": { "@ai-sdk/provider": "3.0.8", - "@polar-sh/sdk": "^0.44.0" + "@polar-sh/sdk": "^0.44.0", + "@asteasolutions/zod-to-openapi": "8.5.0", + "zod": "4.3.6" } } diff --git a/packages/core/package.json b/packages/core/package.json index df1364d13..ef7f1af86 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -52,10 +52,10 @@ "@cossistant/types": "workspace:*" }, "dependencies": { - "@hono/zod-openapi": "^1.1.0", + "@hono/zod-openapi": "^1.5.1", "nanoid": "^5.1.5", "ulid": "^3.0.1", - "zod": "^4.1.8" + "zod": "^4.3.6" }, "scripts": { "build": "tsdown && bun run prepare-package", diff --git a/packages/protocol/README.md b/packages/protocol/README.md new file mode 100644 index 000000000..75d56c3e6 --- /dev/null +++ b/packages/protocol/README.md @@ -0,0 +1,141 @@ +# @cossistant/protocol + +The canonical HTTP and WebSocket wire contract for the Cossistant API. + +This package is the single source of truth for Cossistant's public API surface: +65 operations (64 REST routes plus the `/ws` handshake), their request and +response schemas, shared error responses, security schemes, and protocol +headers. + +It is **protocol only**. It contains no request handlers, no authentication +enforcement, no database or Redis clients, no billing, and no environment +parsing. Importing it starts nothing. + +## Install + +```bash +bun add @cossistant/protocol +``` + +## Mount a shared route with your own handler + +Route descriptors are `createRoute()` results from `@hono/zod-openapi`, so a +Hono app can register one directly. Your handler is typed against the shared +contract: `c.req.valid(...)` and the allowed `c.json(...)` status codes come +from the descriptor. + +```ts +import { getOrganizationRoute } from "@cossistant/protocol/routes"; +import { OpenAPIHono } from "@hono/zod-openapi"; + +const app = new OpenAPIHono(); + +app.openapi(getOrganizationRoute, async (c) => { + const id = c.req.param("id"); + // ... your implementation + return c.json({ id, name: "Acme" }, 200); +}); +``` + +Paths are **router-relative**, matching how Cossistant mounts them. Use +`REST_MOUNT_TABLE` to reproduce the full path layout: + +```ts +import { REST_MOUNT_TABLE } from "@cossistant/protocol/routes"; + +for (const group of REST_MOUNT_TABLE) { + // group.prefix -> "/conversations" + // group.routes -> descriptors registered directly on the group + // group.groups -> nested sub-routers (an auth split, mounted at "/") +} +``` + +> **Descriptors declare security, they do not enforce it.** Mounting a route +> installs no authentication middleware. Every server must independently +> enforce the API keys the descriptor declares. + +The nested `groups` are meaningful: `contacts`, `support` and `feedback` each +split into a public-key runtime sub-router and a private-key control +sub-router. Keep the split — collapsing it makes it easy to register a control +route on the runtime side, which exposes a private endpoint to public keys +without changing the OpenAPI document at all. + +## Generate the specification + +```ts +import { buildCossistantOpenApiDocument } from "@cossistant/protocol/openapi"; + +const document = buildCossistantOpenApiDocument({ + restServerUrl: "https://api.example.com/v1", // optional + websocketServerUrl: "wss://api.example.com", // optional +}); +``` + +This is pure: it runs in a process with no `DATABASE_URL`, Redis, S3, billing +credentials, or auth secrets, makes no network connections, and produces +deterministic output. + +A pre-generated artifact ships with the package: + +```ts +import document from "@cossistant/protocol/openapi.json" with { type: "json" }; +``` + +## Prove your implementation still conforms + +```ts +import { assertCossistantProtocolCompatibility } from "@cossistant/protocol/testkit"; + +// throws, listing every violation +assertCossistantProtocolCompatibility(myApp.getOpenAPI31Document(config)); +``` + +It checks that every method/path pair exists, operation IDs match and are +unique, parameter names/locations/required flags match, request and response +media types and schemas match, security declarations match, and the `/ws` +handshake stays documented. + +Use `checkCossistantProtocolCompatibility` for the non-throwing variant, which +returns a `CompatibilityViolation[]`. + +Intentional differences go through an explicit allowlist. Each entry names the +exact operation and field, and says why: + +```ts +assertCossistantProtocolCompatibility(document, { + allow: [ + { + operation: "POST /conversations/{conversationId}/resolve", + field: "*", + reason: "Not implemented by this service yet.", + }, + ], +}); +``` + +## Exports + +| Entry point | Contents | +| --- | --- | +| `@cossistant/protocol` | Everything below, re-exported | +| `@cossistant/protocol/routes` | Route descriptors and `REST_MOUNT_TABLE` | +| `@cossistant/protocol/openapi` | `buildCossistantOpenApiDocument`, `buildRestApp`, `getCossistantRouteDefinitions` | +| `@cossistant/protocol/testkit` | Compatibility checking | +| `@cossistant/protocol/openapi.json` | Generated specification | + +## Known contract quirks + +These are preserved deliberately. They predate this package and are part of the +already-published contract, so changing them would be a breaking change: + +- `GET /conversations/{conversationId}`, `.../context` and `.../export` each + declare the `conversationId` path parameter twice — once explicitly and once + derived from `request.params`. OpenAPI forbids duplicate `name`+`in` pairs. +- The two `/websites` routes are tagged `Website` (singular) while the tag + fallback for that prefix is `Websites`. +- Several operation-ID overrides no longer match any live path. + +## Schemas + +Request and response schemas live in `@cossistant/types` and are re-used here +rather than duplicated. diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json new file mode 100644 index 000000000..6b28ba4fc --- /dev/null +++ b/packages/protocol/openapi.json @@ -0,0 +1,37585 @@ +{ + "info": { + "description": "Public REST and realtime API for Cossistant. Use public keys for browser/widget visitor flows and private keys for trusted server, CLI, MCP, and support-agent integrations.", + "title": "Cossistant API", + "version": "1.0.0" + }, + "openapi": "3.1.0", + "servers": [ + { + "url": "https://api.cossistant.com/v1" + } + ], + "components": { + "schemas": { + "RealtimeConnectionAccepted": { + "additionalProperties": false, + "properties": { + "connectionId": { + "description": "Server-assigned connection identifier.", + "type": "string" + }, + "type": { + "enum": [ + "connection.accepted" + ], + "type": "string" + } + }, + "required": [ + "type", + "connectionId" + ], + "type": "object" + }, + "RealtimeErrorMessage": { + "additionalProperties": false, + "properties": { + "code": { + "description": "Machine-readable error code.", + "type": "string" + }, + "message": { + "description": "Human-readable error detail.", + "type": "string" + }, + "type": { + "enum": [ + "error" + ], + "type": "string" + } + }, + "required": [ + "type", + "code", + "message" + ], + "type": "object" + }, + "RealtimeOutboundEvent": { + "additionalProperties": true, + "description": "Realtime event payloads. Event names mirror conversation and visitor updates emitted by the REST API.", + "properties": { + "type": { + "description": "Realtime event type.", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "RestErrorResponse": { + "additionalProperties": false, + "properties": { + "error": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "code", + "message" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + }, + "parameters": {}, + "securitySchemes": { + "PrivateApiKey": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "API key", + "description": "Private API key in Bearer token format. Example: `Authorization: Bearer sk_live_...`." + }, + "PublicApiKey": { + "type": "apiKey", + "in": "header", + "name": "X-Public-Key", + "description": "Public API key for browser-based authentication. Requests must include an `Origin` header that matches the website allowlist." + } + } + }, + "paths": { + "/ai-agents/{id}/training": { + "get": { + "summary": "Get AI agent training status", + "description": "Returns the current public and internal knowledge base training status for a specific AI agent.", + "operationId": "getAiAgentTrainingStatus", + "tags": [ + "AI Agents" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The AI agent ID", + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "AI agent training status retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "aiAgentId": { + "type": "string", + "format": "ulid", + "description": "The AI agent's unique identifier.", + "example": "01JG000000000000000000000" + }, + "status": { + "type": "string", + "enum": [ + "out_of_date", + "trained", + "training_ongoing" + ], + "description": "Public-facing knowledge base training status.", + "example": "out_of_date" + }, + "internalStatus": { + "type": "string", + "enum": [ + "idle", + "pending", + "training", + "completed", + "failed" + ], + "description": "Internal training state used by the training worker.", + "example": "completed" + }, + "progress": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Training progress percentage.", + "example": 100 + }, + "updatedSourcesCount": { + "type": "integer", + "minimum": 0, + "description": "Number of included knowledge entries updated since the last successful training run.", + "example": 3 + }, + "canTrainAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the next training run is allowed under the current plan cooldown. Null means training may be started immediately.", + "example": "2024-01-01T00:30:00.000Z" + }, + "lastTrainedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the knowledge base was last trained successfully.", + "example": "2024-01-01T00:00:00.000Z" + }, + "trainingStartedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the current or last training run started.", + "example": "2024-01-01T00:05:00.000Z" + }, + "trainedItemsCount": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "How many knowledge items were included in the last successful training run.", + "example": 42 + }, + "lastError": { + "type": [ + "string", + "null" + ], + "description": "Last training error message, if any.", + "example": "Embedding provider timeout" + } + }, + "required": [ + "aiAgentId", + "status", + "internalStatus", + "progress", + "updatedSourcesCount", + "canTrainAt", + "lastTrainedAt", + "trainingStartedAt", + "trainedItemsCount", + "lastError" + ], + "description": "Current knowledge base training status for an AI agent, including both public and internal state." + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "AI agent not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + }, + "post": { + "summary": "Start AI agent training", + "description": "Queues a retraining job for the AI agent knowledge base. Requires a private API key. When using an unlinked private key, send `X-Actor-User-Id` with a valid website teammate ID.", + "operationId": "startAiAgentTraining", + "tags": [ + "AI Agents" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The AI agent ID", + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "202": { + "description": "AI agent training job queued successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "aiAgentId": { + "type": "string", + "format": "ulid", + "description": "The AI agent's unique identifier.", + "example": "01JG000000000000000000000" + }, + "jobId": { + "type": "string", + "description": "Queued training job identifier.", + "example": "ai-training-01JG000000000000000000000" + }, + "status": { + "type": "string", + "enum": [ + "training_ongoing" + ], + "description": "Public-facing status immediately after a retraining job has been queued.", + "example": "training_ongoing" + }, + "internalStatus": { + "type": "string", + "enum": [ + "pending", + "training" + ], + "description": "Initial internal worker status immediately after the job is queued.", + "example": "pending" + }, + "progress": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Initial training progress percentage.", + "example": 0 + } + }, + "required": [ + "aiAgentId", + "jobId", + "status", + "internalStatus", + "progress" + ], + "description": "Response returned after a knowledge base retraining job has been queued." + } + } + } + }, + "400": { + "description": "Bad request - Missing required actor header for an unlinked private key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor is not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "AI agent not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "409": { + "description": "Conflict - Training is already in progress", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests - Training cooldown has not elapsed yet", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/ai-agents/{id}": { + "get": { + "summary": "Get an AI agent", + "description": "Retrieves a single AI agent by ID for the authenticated website.", + "operationId": "getAiAgent", + "tags": [ + "AI Agents" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The AI agent ID", + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "AI agent retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The AI agent's unique identifier.", + "example": "01JG000000000000000000000" + }, + "name": { + "type": "string", + "description": "The AI agent's display name.", + "example": "Support Assistant" + }, + "image": { + "type": [ + "string", + "null" + ], + "description": "The AI agent's profile image URL.", + "example": "https://cdn.example.com/agent-avatar.png" + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "A brief description of the AI agent's purpose.", + "example": "Helps users with common support questions." + }, + "basePrompt": { + "type": "string", + "description": "The system prompt that defines the AI agent's behavior.", + "example": "You are a helpful support assistant..." + }, + "model": { + "type": "string", + "description": "The OpenRouter model identifier.", + "example": "moonshotai/kimi-k2-0905" + }, + "temperature": { + "type": [ + "number", + "null" + ], + "description": "The temperature setting for response generation (0-2).", + "example": 0.7 + }, + "maxOutputTokens": { + "type": [ + "number", + "null" + ], + "description": "Maximum tokens for response generation.", + "example": 1024 + }, + "isActive": { + "type": "boolean", + "description": "Whether the AI agent is currently active.", + "example": true + }, + "lastUsedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the AI agent was last used.", + "example": "2024-01-01T00:00:00.000Z" + }, + "usageCount": { + "type": "number", + "description": "Total number of times the AI agent has been used.", + "example": 42 + }, + "goals": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "The goals/intents for this AI agent.", + "example": [ + "support", + "product_qa" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the AI agent was created.", + "example": "2024-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the AI agent was last updated.", + "example": "2024-01-01T00:00:00.000Z" + }, + "onboardingCompletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When onboarding was completed. Null if still in onboarding flow.", + "example": "2024-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "name", + "image", + "description", + "basePrompt", + "model", + "temperature", + "maxOutputTokens", + "isActive", + "lastUsedAt", + "usageCount", + "goals", + "createdAt", + "updatedAt", + "onboardingCompletedAt" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "AI agent not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/organizations/{id}": { + "get": { + "summary": "Retrieve an organization", + "description": "Retrieve an organization by its ID for the authenticated organization.", + "tags": [ + "Organizations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The organization ID to retrieve.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Organization details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier.", + "example": "01JG000000000000000000000" + }, + "name": { + "type": "string", + "description": "The organization's name.", + "example": "Acme Corp" + } + }, + "required": [ + "id", + "name" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Organization not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "getOrganization" + } + }, + "/websites": { + "get": { + "summary": "Get website information", + "description": "Returns the website information associated with the provided API key. This endpoint supports both public and private API keys with different authentication methods.", + "tags": [ + "Website" + ], + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + }, + { + "name": "X-Visitor-Id", + "in": "header", + "description": "Visitor ID from localStorage.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "Website information successfully retrieved", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier.", + "example": "01JG000000000000000000000" + }, + "name": { + "type": "string", + "description": "The website's name.", + "example": "Dub" + }, + "domain": { + "type": "string", + "description": "The website's domain.", + "example": "dub.co" + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "The website's description.", + "example": "Link management for modern marketing teams." + }, + "logoUrl": { + "type": [ + "string", + "null" + ], + "description": "The website's logo URL.", + "example": "https://dub.co/logo.png" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier.", + "example": "01JG000000000000000000000" + }, + "defaultLanguage": { + "type": "string", + "default": "en", + "description": "The website's default working language.", + "example": "en" + }, + "status": { + "type": "string", + "description": "The website's status.", + "example": "active" + }, + "lastOnlineAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "The website's support last online date.", + "example": "2021-01-01T00:00:00.000Z" + }, + "availableHumanAgents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The human agent's unique identifier.", + "example": "01JG000000000000000000000" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The agent's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email", + "description": "The agent's email address.", + "example": "john@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "description": "The agent's avatar URL.", + "example": "https://cossistant.com/avatar.png" + }, + "lastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "The agent's last online timestamp, used to determine if the agent is online. If the agent is offline, this will be null or more than 5 minutes ago.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "name", + "email", + "image", + "lastSeenAt" + ] + } + }, + "availableAIAgents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The AI agent's unique identifier.", + "example": "01JG000000000000000000000" + }, + "name": { + "type": "string", + "description": "The AI agent's name.", + "example": "John Doe" + }, + "image": { + "type": [ + "string", + "null" + ], + "description": "The AI agent's avatar URL.", + "example": "https://cossistant.com/avatar.png" + } + }, + "required": [ + "id", + "name", + "image" + ] + } + }, + "visitor": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The visitor's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "isBlocked": { + "type": "boolean", + "description": "Whether the visitor is currently blocked.", + "example": false + }, + "language": { + "type": [ + "string", + "null" + ], + "description": "The visitor's preferred language.", + "example": "en-US" + }, + "contact": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier.", + "example": "01JG000000000000000000000" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "description": "The contact's avatar/profile image URL.", + "example": "https://example.com/avatar.png" + }, + "metadataHash": { + "type": "string", + "description": "Hash of the contact's metadata. Used to detect if metadata has changed without comparing full objects.", + "example": "a1b2c3d4" + } + }, + "required": [ + "id", + "name", + "email", + "image" + ], + "description": "Contact information if the visitor has been identified via .identify().", + "example": { + "id": "01JG000000000000000000000", + "name": "John Doe", + "email": "john.doe@example.com", + "image": "https://example.com/avatar.png" + } + } + }, + "required": [ + "id", + "isBlocked", + "language", + "contact" + ], + "description": "The visitor information. Either existing visitor data or newly created visitor." + } + }, + "required": [ + "id", + "name", + "domain", + "description", + "logoUrl", + "organizationId", + "status", + "lastOnlineAt", + "availableHumanAgents", + "availableAIAgents", + "visitor" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Origin validation failed for public key or domain not whitelisted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Website not found for this API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "listWebsites" + } + }, + "/websites/team-members": { + "get": { + "summary": "List website team members", + "description": "Returns the website-access teammates that can be linked to private API keys or used as actor IDs on actor-aware private API routes.", + "operationId": "listWebsiteTeamMembers", + "tags": [ + "Website" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [], + "responses": { + "200": { + "description": "Website team members retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "members": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The user's unique identifier.", + "example": "01JG000000000000000000000" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The user's name.", + "example": "John Doe" + }, + "email": { + "type": "string", + "format": "email", + "description": "The user's email address.", + "example": "john.doe@example.com" + }, + "role": { + "type": [ + "string", + "null" + ], + "description": "The user's role.", + "example": "admin" + }, + "image": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "The user's image URL.", + "example": "https://example.com/image.png" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "The user's creation date.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "The user's last update date.", + "example": "2021-01-01T00:00:00.000Z" + }, + "lastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "The user's last seen date.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "name", + "email", + "role", + "image", + "createdAt", + "updatedAt", + "lastSeenAt" + ] + }, + "description": "Website teammates that can be linked to private API keys or used as actor IDs." + } + }, + "required": [ + "members" + ], + "description": "List of website-access team members." + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/messages": { + "post": { + "summary": "Send a timeline item to a conversation", + "description": "Send a new timeline item to an existing conversation. When item.createdAt is omitted, the server assigns the timestamp. Historical timestamps are allowed. Timestamps more than 5 minutes in the future are rejected.", + "tags": [ + "Messages", + "Timeline item" + ], + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + }, + { + "name": "X-Visitor-Id", + "in": "header", + "description": "Visitor ID from localStorage.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversationId": { + "type": "string", + "description": "ID of the conversation to send the timeline item to" + }, + "item": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Optional client-generated ID for the timeline item" + }, + "type": { + "type": "string", + "enum": [ + "message", + "event", + "identification", + "tool" + ], + "default": "message", + "description": "Type of timeline item - defaults to MESSAGE" + }, + "text": { + "type": [ + "string", + "null" + ], + "description": "Main text content of the timeline item. Use null for non-text items when no human-readable text is needed." + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "Text content part - matches AI SDK TextUIPart" + }, + "text": { + "type": "string", + "description": "The text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "description": "AI reasoning/chain-of-thought - matches AI SDK ReasoningUIPart" + }, + "text": { + "type": "string", + "description": "The reasoning text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "pattern": "^tool-.+$", + "description": "Tool type following AI SDK pattern: tool-{toolName}" + }, + "toolCallId": { + "type": "string", + "description": "Unique identifier for this tool invocation" + }, + "toolName": { + "type": "string", + "description": "Name of the tool being invoked" + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input parameters passed to the tool" + }, + "output": { + "description": "Output returned by the tool (when state is 'result')" + }, + "state": { + "type": "string", + "enum": [ + "partial", + "result", + "error" + ], + "description": "AI SDK tool state: 'partial' = executing, 'result' = success, 'error' = failed" + }, + "errorText": { + "type": "string", + "description": "Error message when state is 'error'" + }, + "callProviderMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "toolCallId", + "toolName", + "input", + "state" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-url" + ], + "description": "URL source citation - matches AI SDK SourceUrlUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the source" + }, + "title": { + "type": "string", + "description": "Title of the source" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "url" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-document" + ], + "description": "Document source citation - matches AI SDK SourceDocumentUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the document" + }, + "title": { + "type": "string", + "description": "Title of the document" + }, + "filename": { + "type": "string", + "description": "Filename of the document" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "mediaType", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "step-start" + ], + "description": "Step boundary marker - matches AI SDK StepStartUIPart" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ], + "description": "File attachment - matches AI SDK FileUIPart" + }, + "url": { + "type": "string", + "description": "URL of the file (can be hosted URL or Data URL)" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the file" + }, + "filename": { + "type": "string", + "description": "Original filename" + }, + "size": { + "type": "number", + "description": "Size of the file in bytes" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ], + "description": "Image attachment with dimensions" + }, + "url": { + "type": "string", + "description": "URL of the image" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the image" + }, + "filename": { + "type": "string", + "description": "Original filename of the image" + }, + "size": { + "type": "number", + "description": "Size of the image in bytes" + }, + "width": { + "type": "number", + "description": "Width of the image in pixels" + }, + "height": { + "type": "number", + "description": "Height of the image in pixels" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "translation" + ], + "description": "Stored translation for a timeline item message, scoped to an audience" + }, + "text": { + "type": "string", + "description": "Translated text content" + }, + "sourceLanguage": { + "type": "string", + "description": "Detected/source language of the original message" + }, + "targetLanguage": { + "type": "string", + "description": "Language of the translated text" + }, + "audience": { + "type": "string", + "enum": [ + "team", + "visitor" + ], + "description": "Audience this translated text is meant for" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "description": "Whether this translation was created automatically or manually" + }, + "modelId": { + "type": [ + "string", + "null" + ], + "description": "Model identifier used to generate the translation, if any" + } + }, + "required": [ + "type", + "text", + "sourceLanguage", + "targetLanguage", + "audience", + "mode" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "event" + ], + "description": "Type of timeline part - always 'event' for event parts" + }, + "eventType": { + "type": "string", + "enum": [ + "assigned", + "unassigned", + "participant_requested", + "participant_joined", + "participant_left", + "status_changed", + "priority_changed", + "tag_added", + "tag_removed", + "resolved", + "reopened", + "visitor_blocked", + "visitor_unblocked", + "visitor_identified", + "ai_paused", + "ai_resumed" + ], + "description": "Type of event that occurred" + }, + "actorUserId": { + "type": [ + "string", + "null" + ], + "description": "User that triggered the event, if applicable" + }, + "actorAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that triggered the event, if applicable" + }, + "targetUserId": { + "type": [ + "string", + "null" + ], + "description": "User targeted by the event, if applicable" + }, + "targetAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent targeted by the event, if applicable" + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "Optional human readable message attached to the event" + } + }, + "required": [ + "type", + "eventType", + "actorUserId", + "actorAiAgentId", + "targetUserId", + "targetAiAgentId" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "metadata" + ], + "description": "Type of timeline part - always 'metadata' for metadata parts" + }, + "source": { + "type": "string", + "enum": [ + "email", + "widget", + "api" + ], + "description": "Source channel through which the message was created" + } + }, + "required": [ + "type", + "source" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "feedback" + ], + "description": "Type of timeline part - always 'feedback' for feedback parts" + }, + "feedbackId": { + "type": "string", + "description": "Feedback record associated with this timeline item" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Visitor rating from 1 to 5" + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Structured feedback topic selected by the visitor" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback" + }, + "source": { + "type": "string", + "description": "Source channel where the feedback was collected" + } + }, + "required": [ + "type", + "feedbackId", + "rating", + "source" + ] + } + ] + }, + "description": "Array of timeline parts that make up the timeline item content. Includes AI SDK compatible parts (text, reasoning, tool-*, source-url, source-document, step-start, file, image) and Cossistant-specific parts (event, metadata, feedback)." + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "default": "public", + "description": "Visibility level of the timeline item" + }, + "tool": { + "type": [ + "string", + "null" + ], + "description": "Optional tool identifier when sending non-message timeline items" + }, + "userId": { + "type": [ + "string", + "null" + ], + "description": "ID of the user creating this timeline item" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "description": "ID of the AI agent creating this timeline item" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "ID of the visitor creating this timeline item" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Optional client-authored timestamp. When omitted, the server assigns the timestamp. Historical timestamps are allowed. Timestamps more than 5 minutes in the future are rejected.", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "text" + ], + "description": "Timeline item to create. When createdAt is omitted, the server assigns the timestamp. Historical timestamps are allowed. Timestamps more than 5 minutes in the future are rejected." + } + }, + "required": [ + "conversationId", + "item" + ], + "description": "Request body for sending a timeline item to a conversation" + } + } + } + }, + "responses": { + "200": { + "description": "Timeline item sent successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the timeline item" + }, + "conversationId": { + "type": "string", + "description": "ID of the conversation this timeline item belongs to" + }, + "organizationId": { + "type": "string", + "description": "ID of the organization this timeline item belongs to" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility level of the timeline item" + }, + "type": { + "type": "string", + "enum": [ + "message", + "event", + "identification", + "tool" + ], + "description": "Type of timeline item - message, event, identification, or tool call" + }, + "text": { + "type": [ + "string", + "null" + ], + "description": "Main text content of the timeline item" + }, + "tool": { + "type": [ + "string", + "null" + ], + "description": "Optional tool identifier associated with this timeline item" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "Text content part - matches AI SDK TextUIPart" + }, + "text": { + "type": "string", + "description": "The text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "description": "AI reasoning/chain-of-thought - matches AI SDK ReasoningUIPart" + }, + "text": { + "type": "string", + "description": "The reasoning text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "pattern": "^tool-.+$", + "description": "Tool type following AI SDK pattern: tool-{toolName}" + }, + "toolCallId": { + "type": "string", + "description": "Unique identifier for this tool invocation" + }, + "toolName": { + "type": "string", + "description": "Name of the tool being invoked" + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input parameters passed to the tool" + }, + "output": { + "description": "Output returned by the tool (when state is 'result')" + }, + "state": { + "type": "string", + "enum": [ + "partial", + "result", + "error" + ], + "description": "AI SDK tool state: 'partial' = executing, 'result' = success, 'error' = failed" + }, + "errorText": { + "type": "string", + "description": "Error message when state is 'error'" + }, + "callProviderMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "toolCallId", + "toolName", + "input", + "state" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-url" + ], + "description": "URL source citation - matches AI SDK SourceUrlUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the source" + }, + "title": { + "type": "string", + "description": "Title of the source" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "url" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-document" + ], + "description": "Document source citation - matches AI SDK SourceDocumentUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the document" + }, + "title": { + "type": "string", + "description": "Title of the document" + }, + "filename": { + "type": "string", + "description": "Filename of the document" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "mediaType", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "step-start" + ], + "description": "Step boundary marker - matches AI SDK StepStartUIPart" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ], + "description": "File attachment - matches AI SDK FileUIPart" + }, + "url": { + "type": "string", + "description": "URL of the file (can be hosted URL or Data URL)" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the file" + }, + "filename": { + "type": "string", + "description": "Original filename" + }, + "size": { + "type": "number", + "description": "Size of the file in bytes" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ], + "description": "Image attachment with dimensions" + }, + "url": { + "type": "string", + "description": "URL of the image" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the image" + }, + "filename": { + "type": "string", + "description": "Original filename of the image" + }, + "size": { + "type": "number", + "description": "Size of the image in bytes" + }, + "width": { + "type": "number", + "description": "Width of the image in pixels" + }, + "height": { + "type": "number", + "description": "Height of the image in pixels" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "translation" + ], + "description": "Stored translation for a timeline item message, scoped to an audience" + }, + "text": { + "type": "string", + "description": "Translated text content" + }, + "sourceLanguage": { + "type": "string", + "description": "Detected/source language of the original message" + }, + "targetLanguage": { + "type": "string", + "description": "Language of the translated text" + }, + "audience": { + "type": "string", + "enum": [ + "team", + "visitor" + ], + "description": "Audience this translated text is meant for" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "description": "Whether this translation was created automatically or manually" + }, + "modelId": { + "type": [ + "string", + "null" + ], + "description": "Model identifier used to generate the translation, if any" + } + }, + "required": [ + "type", + "text", + "sourceLanguage", + "targetLanguage", + "audience", + "mode" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "event" + ], + "description": "Type of timeline part - always 'event' for event parts" + }, + "eventType": { + "type": "string", + "enum": [ + "assigned", + "unassigned", + "participant_requested", + "participant_joined", + "participant_left", + "status_changed", + "priority_changed", + "tag_added", + "tag_removed", + "resolved", + "reopened", + "visitor_blocked", + "visitor_unblocked", + "visitor_identified", + "ai_paused", + "ai_resumed" + ], + "description": "Type of event that occurred" + }, + "actorUserId": { + "type": [ + "string", + "null" + ], + "description": "User that triggered the event, if applicable" + }, + "actorAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that triggered the event, if applicable" + }, + "targetUserId": { + "type": [ + "string", + "null" + ], + "description": "User targeted by the event, if applicable" + }, + "targetAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent targeted by the event, if applicable" + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "Optional human readable message attached to the event" + } + }, + "required": [ + "type", + "eventType", + "actorUserId", + "actorAiAgentId", + "targetUserId", + "targetAiAgentId" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "metadata" + ], + "description": "Type of timeline part - always 'metadata' for metadata parts" + }, + "source": { + "type": "string", + "enum": [ + "email", + "widget", + "api" + ], + "description": "Source channel through which the message was created" + } + }, + "required": [ + "type", + "source" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "feedback" + ], + "description": "Type of timeline part - always 'feedback' for feedback parts" + }, + "feedbackId": { + "type": "string", + "description": "Feedback record associated with this timeline item" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Visitor rating from 1 to 5" + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Structured feedback topic selected by the visitor" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback" + }, + "source": { + "type": "string", + "description": "Source channel where the feedback was collected" + } + }, + "required": [ + "type", + "feedbackId", + "rating", + "source" + ] + } + ] + }, + "description": "Array of timeline parts that make up the timeline item content. Includes AI SDK compatible parts (text, reasoning, tool-*, source-url, source-document, step-start, file, image) and Cossistant-specific parts (event, metadata, feedback)." + }, + "userId": { + "type": [ + "string", + "null" + ], + "description": "ID of the user who created this timeline item, if applicable" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "description": "ID of the AI agent that created this timeline item, if applicable" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "ID of the visitor who created this timeline item, if applicable" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was created", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was deleted, if applicable", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "conversationId", + "organizationId", + "visibility", + "type", + "text", + "parts", + "userId", + "aiAgentId", + "visitorId", + "createdAt" + ], + "description": "The created timeline item" + } + }, + "required": [ + "item" + ], + "description": "Response containing the created timeline item" + } + } + } + }, + "400": { + "description": "Invalid request or item.createdAt more than 5 minutes in the future", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Public key origin validation failed or actor constraints failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "createMessage" + } + }, + "/conversations": { + "post": { + "summary": "Create a conversation (optionally with initial timeline items)", + "description": "Create a conversation; optionally pass a conversationId, public metadata, and a set of default timeline items. When a default item's createdAt is omitted, the server assigns the timestamp. Historical timestamps are allowed. Timestamps more than 5 minutes in the future are rejected.", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + }, + { + "name": "X-Visitor-Id", + "in": "header", + "description": "Visitor ID from localStorage.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "visitorId": { + "type": "string", + "description": "Visitor ID, if not provided you must provide a visitorId in the headers." + }, + "conversationId": { + "type": "string", + "description": "Optional idempotency key for conversation creation. If provided, it is scoped to one organization+website+visitor owner tuple; retries with the same tuple reuse the existing conversation." + }, + "defaultTimelineItems": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Optional client-generated ID for the timeline item" + }, + "type": { + "type": "string", + "enum": [ + "message", + "event", + "identification", + "tool" + ], + "default": "message", + "description": "Type of timeline item - defaults to MESSAGE" + }, + "text": { + "type": [ + "string", + "null" + ], + "description": "Main text content of the timeline item. Use null for non-text items when no human-readable text is needed." + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "Text content part - matches AI SDK TextUIPart" + }, + "text": { + "type": "string", + "description": "The text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "description": "AI reasoning/chain-of-thought - matches AI SDK ReasoningUIPart" + }, + "text": { + "type": "string", + "description": "The reasoning text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "pattern": "^tool-.+$", + "description": "Tool type following AI SDK pattern: tool-{toolName}" + }, + "toolCallId": { + "type": "string", + "description": "Unique identifier for this tool invocation" + }, + "toolName": { + "type": "string", + "description": "Name of the tool being invoked" + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input parameters passed to the tool" + }, + "output": { + "description": "Output returned by the tool (when state is 'result')" + }, + "state": { + "type": "string", + "enum": [ + "partial", + "result", + "error" + ], + "description": "AI SDK tool state: 'partial' = executing, 'result' = success, 'error' = failed" + }, + "errorText": { + "type": "string", + "description": "Error message when state is 'error'" + }, + "callProviderMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "toolCallId", + "toolName", + "input", + "state" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-url" + ], + "description": "URL source citation - matches AI SDK SourceUrlUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the source" + }, + "title": { + "type": "string", + "description": "Title of the source" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "url" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-document" + ], + "description": "Document source citation - matches AI SDK SourceDocumentUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the document" + }, + "title": { + "type": "string", + "description": "Title of the document" + }, + "filename": { + "type": "string", + "description": "Filename of the document" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "mediaType", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "step-start" + ], + "description": "Step boundary marker - matches AI SDK StepStartUIPart" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ], + "description": "File attachment - matches AI SDK FileUIPart" + }, + "url": { + "type": "string", + "description": "URL of the file (can be hosted URL or Data URL)" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the file" + }, + "filename": { + "type": "string", + "description": "Original filename" + }, + "size": { + "type": "number", + "description": "Size of the file in bytes" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ], + "description": "Image attachment with dimensions" + }, + "url": { + "type": "string", + "description": "URL of the image" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the image" + }, + "filename": { + "type": "string", + "description": "Original filename of the image" + }, + "size": { + "type": "number", + "description": "Size of the image in bytes" + }, + "width": { + "type": "number", + "description": "Width of the image in pixels" + }, + "height": { + "type": "number", + "description": "Height of the image in pixels" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "translation" + ], + "description": "Stored translation for a timeline item message, scoped to an audience" + }, + "text": { + "type": "string", + "description": "Translated text content" + }, + "sourceLanguage": { + "type": "string", + "description": "Detected/source language of the original message" + }, + "targetLanguage": { + "type": "string", + "description": "Language of the translated text" + }, + "audience": { + "type": "string", + "enum": [ + "team", + "visitor" + ], + "description": "Audience this translated text is meant for" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "description": "Whether this translation was created automatically or manually" + }, + "modelId": { + "type": [ + "string", + "null" + ], + "description": "Model identifier used to generate the translation, if any" + } + }, + "required": [ + "type", + "text", + "sourceLanguage", + "targetLanguage", + "audience", + "mode" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "event" + ], + "description": "Type of timeline part - always 'event' for event parts" + }, + "eventType": { + "type": "string", + "enum": [ + "assigned", + "unassigned", + "participant_requested", + "participant_joined", + "participant_left", + "status_changed", + "priority_changed", + "tag_added", + "tag_removed", + "resolved", + "reopened", + "visitor_blocked", + "visitor_unblocked", + "visitor_identified", + "ai_paused", + "ai_resumed" + ], + "description": "Type of event that occurred" + }, + "actorUserId": { + "type": [ + "string", + "null" + ], + "description": "User that triggered the event, if applicable" + }, + "actorAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that triggered the event, if applicable" + }, + "targetUserId": { + "type": [ + "string", + "null" + ], + "description": "User targeted by the event, if applicable" + }, + "targetAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent targeted by the event, if applicable" + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "Optional human readable message attached to the event" + } + }, + "required": [ + "type", + "eventType", + "actorUserId", + "actorAiAgentId", + "targetUserId", + "targetAiAgentId" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "metadata" + ], + "description": "Type of timeline part - always 'metadata' for metadata parts" + }, + "source": { + "type": "string", + "enum": [ + "email", + "widget", + "api" + ], + "description": "Source channel through which the message was created" + } + }, + "required": [ + "type", + "source" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "feedback" + ], + "description": "Type of timeline part - always 'feedback' for feedback parts" + }, + "feedbackId": { + "type": "string", + "description": "Feedback record associated with this timeline item" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Visitor rating from 1 to 5" + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Structured feedback topic selected by the visitor" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback" + }, + "source": { + "type": "string", + "description": "Source channel where the feedback was collected" + } + }, + "required": [ + "type", + "feedbackId", + "rating", + "source" + ] + } + ] + }, + "description": "Array of timeline parts that make up the timeline item content. Includes AI SDK compatible parts (text, reasoning, tool-*, source-url, source-document, step-start, file, image) and Cossistant-specific parts (event, metadata, feedback)." + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "default": "public", + "description": "Visibility level of the timeline item" + }, + "tool": { + "type": [ + "string", + "null" + ], + "description": "Optional tool identifier when sending non-message timeline items" + }, + "userId": { + "type": [ + "string", + "null" + ], + "description": "ID of the user creating this timeline item" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "description": "ID of the AI agent creating this timeline item" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "ID of the visitor creating this timeline item" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Optional client-authored timestamp. When omitted, the server assigns the timestamp. Historical timestamps are allowed. Timestamps more than 5 minutes in the future are rejected.", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "text" + ], + "description": "Client-settable timeline item fields for conversation bootstrap and timeline item creation." + }, + "description": "Default timeline items to initiate the conversation with. When an item's createdAt is omitted, the server assigns the timestamp. Historical timestamps are allowed. Timestamps more than 5 minutes in the future are rejected." + }, + "channel": { + "type": "string", + "default": "widget", + "description": "Which channel the conversation is from" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + } + }, + "required": [ + "defaultTimelineItems" + ], + "description": "Body for creating a conversation." + } + } + } + }, + "responses": { + "200": { + "description": "Conversation created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "initialTimelineItems": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the timeline item" + }, + "conversationId": { + "type": "string", + "description": "ID of the conversation this timeline item belongs to" + }, + "organizationId": { + "type": "string", + "description": "ID of the organization this timeline item belongs to" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility level of the timeline item" + }, + "type": { + "type": "string", + "enum": [ + "message", + "event", + "identification", + "tool" + ], + "description": "Type of timeline item - message, event, identification, or tool call" + }, + "text": { + "type": [ + "string", + "null" + ], + "description": "Main text content of the timeline item" + }, + "tool": { + "type": [ + "string", + "null" + ], + "description": "Optional tool identifier associated with this timeline item" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "Text content part - matches AI SDK TextUIPart" + }, + "text": { + "type": "string", + "description": "The text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "description": "AI reasoning/chain-of-thought - matches AI SDK ReasoningUIPart" + }, + "text": { + "type": "string", + "description": "The reasoning text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "pattern": "^tool-.+$", + "description": "Tool type following AI SDK pattern: tool-{toolName}" + }, + "toolCallId": { + "type": "string", + "description": "Unique identifier for this tool invocation" + }, + "toolName": { + "type": "string", + "description": "Name of the tool being invoked" + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input parameters passed to the tool" + }, + "output": { + "description": "Output returned by the tool (when state is 'result')" + }, + "state": { + "type": "string", + "enum": [ + "partial", + "result", + "error" + ], + "description": "AI SDK tool state: 'partial' = executing, 'result' = success, 'error' = failed" + }, + "errorText": { + "type": "string", + "description": "Error message when state is 'error'" + }, + "callProviderMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "toolCallId", + "toolName", + "input", + "state" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-url" + ], + "description": "URL source citation - matches AI SDK SourceUrlUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the source" + }, + "title": { + "type": "string", + "description": "Title of the source" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "url" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-document" + ], + "description": "Document source citation - matches AI SDK SourceDocumentUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the document" + }, + "title": { + "type": "string", + "description": "Title of the document" + }, + "filename": { + "type": "string", + "description": "Filename of the document" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "mediaType", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "step-start" + ], + "description": "Step boundary marker - matches AI SDK StepStartUIPart" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ], + "description": "File attachment - matches AI SDK FileUIPart" + }, + "url": { + "type": "string", + "description": "URL of the file (can be hosted URL or Data URL)" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the file" + }, + "filename": { + "type": "string", + "description": "Original filename" + }, + "size": { + "type": "number", + "description": "Size of the file in bytes" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ], + "description": "Image attachment with dimensions" + }, + "url": { + "type": "string", + "description": "URL of the image" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the image" + }, + "filename": { + "type": "string", + "description": "Original filename of the image" + }, + "size": { + "type": "number", + "description": "Size of the image in bytes" + }, + "width": { + "type": "number", + "description": "Width of the image in pixels" + }, + "height": { + "type": "number", + "description": "Height of the image in pixels" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "translation" + ], + "description": "Stored translation for a timeline item message, scoped to an audience" + }, + "text": { + "type": "string", + "description": "Translated text content" + }, + "sourceLanguage": { + "type": "string", + "description": "Detected/source language of the original message" + }, + "targetLanguage": { + "type": "string", + "description": "Language of the translated text" + }, + "audience": { + "type": "string", + "enum": [ + "team", + "visitor" + ], + "description": "Audience this translated text is meant for" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "description": "Whether this translation was created automatically or manually" + }, + "modelId": { + "type": [ + "string", + "null" + ], + "description": "Model identifier used to generate the translation, if any" + } + }, + "required": [ + "type", + "text", + "sourceLanguage", + "targetLanguage", + "audience", + "mode" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "event" + ], + "description": "Type of timeline part - always 'event' for event parts" + }, + "eventType": { + "type": "string", + "enum": [ + "assigned", + "unassigned", + "participant_requested", + "participant_joined", + "participant_left", + "status_changed", + "priority_changed", + "tag_added", + "tag_removed", + "resolved", + "reopened", + "visitor_blocked", + "visitor_unblocked", + "visitor_identified", + "ai_paused", + "ai_resumed" + ], + "description": "Type of event that occurred" + }, + "actorUserId": { + "type": [ + "string", + "null" + ], + "description": "User that triggered the event, if applicable" + }, + "actorAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that triggered the event, if applicable" + }, + "targetUserId": { + "type": [ + "string", + "null" + ], + "description": "User targeted by the event, if applicable" + }, + "targetAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent targeted by the event, if applicable" + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "Optional human readable message attached to the event" + } + }, + "required": [ + "type", + "eventType", + "actorUserId", + "actorAiAgentId", + "targetUserId", + "targetAiAgentId" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "metadata" + ], + "description": "Type of timeline part - always 'metadata' for metadata parts" + }, + "source": { + "type": "string", + "enum": [ + "email", + "widget", + "api" + ], + "description": "Source channel through which the message was created" + } + }, + "required": [ + "type", + "source" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "feedback" + ], + "description": "Type of timeline part - always 'feedback' for feedback parts" + }, + "feedbackId": { + "type": "string", + "description": "Feedback record associated with this timeline item" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Visitor rating from 1 to 5" + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Structured feedback topic selected by the visitor" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback" + }, + "source": { + "type": "string", + "description": "Source channel where the feedback was collected" + } + }, + "required": [ + "type", + "feedbackId", + "rating", + "source" + ] + } + ] + }, + "description": "Array of timeline parts that make up the timeline item content. Includes AI SDK compatible parts (text, reasoning, tool-*, source-url, source-document, step-start, file, image) and Cossistant-specific parts (event, metadata, feedback)." + }, + "userId": { + "type": [ + "string", + "null" + ], + "description": "ID of the user who created this timeline item, if applicable" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "description": "ID of the AI agent that created this timeline item, if applicable" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "ID of the visitor who created this timeline item, if applicable" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was created", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was deleted, if applicable", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "conversationId", + "organizationId", + "visibility", + "type", + "text", + "parts", + "userId", + "aiAgentId", + "visitorId", + "createdAt" + ] + } + }, + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "channel": { + "type": "string", + "default": "widget" + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ], + "default": "open" + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "default": null, + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "visitorLastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "lastTimelineItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the timeline item" + }, + "conversationId": { + "type": "string", + "description": "ID of the conversation this timeline item belongs to" + }, + "organizationId": { + "type": "string", + "description": "ID of the organization this timeline item belongs to" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility level of the timeline item" + }, + "type": { + "type": "string", + "enum": [ + "message", + "event", + "identification", + "tool" + ], + "description": "Type of timeline item - message, event, identification, or tool call" + }, + "text": { + "type": [ + "string", + "null" + ], + "description": "Main text content of the timeline item" + }, + "tool": { + "type": [ + "string", + "null" + ], + "description": "Optional tool identifier associated with this timeline item" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "Text content part - matches AI SDK TextUIPart" + }, + "text": { + "type": "string", + "description": "The text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "description": "AI reasoning/chain-of-thought - matches AI SDK ReasoningUIPart" + }, + "text": { + "type": "string", + "description": "The reasoning text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "pattern": "^tool-.+$", + "description": "Tool type following AI SDK pattern: tool-{toolName}" + }, + "toolCallId": { + "type": "string", + "description": "Unique identifier for this tool invocation" + }, + "toolName": { + "type": "string", + "description": "Name of the tool being invoked" + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input parameters passed to the tool" + }, + "output": { + "description": "Output returned by the tool (when state is 'result')" + }, + "state": { + "type": "string", + "enum": [ + "partial", + "result", + "error" + ], + "description": "AI SDK tool state: 'partial' = executing, 'result' = success, 'error' = failed" + }, + "errorText": { + "type": "string", + "description": "Error message when state is 'error'" + }, + "callProviderMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "toolCallId", + "toolName", + "input", + "state" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-url" + ], + "description": "URL source citation - matches AI SDK SourceUrlUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the source" + }, + "title": { + "type": "string", + "description": "Title of the source" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "url" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-document" + ], + "description": "Document source citation - matches AI SDK SourceDocumentUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the document" + }, + "title": { + "type": "string", + "description": "Title of the document" + }, + "filename": { + "type": "string", + "description": "Filename of the document" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "mediaType", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "step-start" + ], + "description": "Step boundary marker - matches AI SDK StepStartUIPart" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ], + "description": "File attachment - matches AI SDK FileUIPart" + }, + "url": { + "type": "string", + "description": "URL of the file (can be hosted URL or Data URL)" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the file" + }, + "filename": { + "type": "string", + "description": "Original filename" + }, + "size": { + "type": "number", + "description": "Size of the file in bytes" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ], + "description": "Image attachment with dimensions" + }, + "url": { + "type": "string", + "description": "URL of the image" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the image" + }, + "filename": { + "type": "string", + "description": "Original filename of the image" + }, + "size": { + "type": "number", + "description": "Size of the image in bytes" + }, + "width": { + "type": "number", + "description": "Width of the image in pixels" + }, + "height": { + "type": "number", + "description": "Height of the image in pixels" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "translation" + ], + "description": "Stored translation for a timeline item message, scoped to an audience" + }, + "text": { + "type": "string", + "description": "Translated text content" + }, + "sourceLanguage": { + "type": "string", + "description": "Detected/source language of the original message" + }, + "targetLanguage": { + "type": "string", + "description": "Language of the translated text" + }, + "audience": { + "type": "string", + "enum": [ + "team", + "visitor" + ], + "description": "Audience this translated text is meant for" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "description": "Whether this translation was created automatically or manually" + }, + "modelId": { + "type": [ + "string", + "null" + ], + "description": "Model identifier used to generate the translation, if any" + } + }, + "required": [ + "type", + "text", + "sourceLanguage", + "targetLanguage", + "audience", + "mode" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "event" + ], + "description": "Type of timeline part - always 'event' for event parts" + }, + "eventType": { + "type": "string", + "enum": [ + "assigned", + "unassigned", + "participant_requested", + "participant_joined", + "participant_left", + "status_changed", + "priority_changed", + "tag_added", + "tag_removed", + "resolved", + "reopened", + "visitor_blocked", + "visitor_unblocked", + "visitor_identified", + "ai_paused", + "ai_resumed" + ], + "description": "Type of event that occurred" + }, + "actorUserId": { + "type": [ + "string", + "null" + ], + "description": "User that triggered the event, if applicable" + }, + "actorAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that triggered the event, if applicable" + }, + "targetUserId": { + "type": [ + "string", + "null" + ], + "description": "User targeted by the event, if applicable" + }, + "targetAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent targeted by the event, if applicable" + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "Optional human readable message attached to the event" + } + }, + "required": [ + "type", + "eventType", + "actorUserId", + "actorAiAgentId", + "targetUserId", + "targetAiAgentId" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "metadata" + ], + "description": "Type of timeline part - always 'metadata' for metadata parts" + }, + "source": { + "type": "string", + "enum": [ + "email", + "widget", + "api" + ], + "description": "Source channel through which the message was created" + } + }, + "required": [ + "type", + "source" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "feedback" + ], + "description": "Type of timeline part - always 'feedback' for feedback parts" + }, + "feedbackId": { + "type": "string", + "description": "Feedback record associated with this timeline item" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Visitor rating from 1 to 5" + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Structured feedback topic selected by the visitor" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback" + }, + "source": { + "type": "string", + "description": "Source channel where the feedback was collected" + } + }, + "required": [ + "type", + "feedbackId", + "rating", + "source" + ] + } + ] + }, + "description": "Array of timeline parts that make up the timeline item content. Includes AI SDK compatible parts (text, reasoning, tool-*, source-url, source-document, step-start, file, image) and Cossistant-specific parts (event, metadata, feedback)." + }, + "userId": { + "type": [ + "string", + "null" + ], + "description": "ID of the user who created this timeline item, if applicable" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "description": "ID of the AI agent that created this timeline item, if applicable" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "ID of the visitor who created this timeline item, if applicable" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was created", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was deleted, if applicable", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "conversationId", + "organizationId", + "visibility", + "type", + "text", + "parts", + "userId", + "aiAgentId", + "visitorId", + "createdAt" + ] + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "visitorId", + "websiteId" + ] + } + }, + "required": [ + "initialTimelineItems", + "conversation" + ], + "description": "Body including created conversation and default messages" + } + } + } + }, + "400": { + "description": "Invalid request or defaultTimelineItems createdAt more than 5 minutes in the future", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Public key origin validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "409": { + "description": "Conversation ID conflict (already exists for a different visitor or tenant)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "CONVERSATION_ID_CONFLICT", + "TIMELINE_ITEM_ID_CONFLICT" + ] + }, + "error": { + "type": "string" + } + }, + "required": [ + "code", + "error" + ], + "description": "Conflict response when conversationId is already claimed by a different visitor or tenant." + } + } + } + } + }, + "operationId": "createConversation" + }, + "get": { + "summary": "List conversations for a visitor", + "description": "Fetch paginated list of conversations for a specific visitor with optional filters. Public conversation metadata is included when present.", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + }, + { + "name": "X-Visitor-Id", + "in": "header", + "description": "Visitor ID from localStorage.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + }, + { + "schema": { + "type": "string", + "description": "Visitor ID to fetch conversations for." + }, + "required": false, + "description": "Visitor ID to fetch conversations for.", + "name": "visitorId", + "in": "query" + }, + { + "schema": { + "type": "number", + "minimum": 1, + "default": 1, + "description": "Page number for pagination" + }, + "required": false, + "description": "Page number for pagination", + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 6, + "description": "Number of conversations per page" + }, + "required": false, + "description": "Number of conversations per page", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "open", + "closed" + ], + "description": "Filter by conversation status" + }, + "required": false, + "description": "Filter by conversation status", + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "createdAt", + "updatedAt" + ], + "default": "updatedAt", + "description": "Field to order conversations by" + }, + "required": false, + "description": "Field to order conversations by", + "name": "orderBy", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc", + "description": "Order direction" + }, + "required": false, + "description": "Order direction", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of conversations retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "channel": { + "type": "string", + "default": "widget" + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ], + "default": "open" + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "default": null, + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "visitorLastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "lastTimelineItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the timeline item" + }, + "conversationId": { + "type": "string", + "description": "ID of the conversation this timeline item belongs to" + }, + "organizationId": { + "type": "string", + "description": "ID of the organization this timeline item belongs to" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility level of the timeline item" + }, + "type": { + "type": "string", + "enum": [ + "message", + "event", + "identification", + "tool" + ], + "description": "Type of timeline item - message, event, identification, or tool call" + }, + "text": { + "type": [ + "string", + "null" + ], + "description": "Main text content of the timeline item" + }, + "tool": { + "type": [ + "string", + "null" + ], + "description": "Optional tool identifier associated with this timeline item" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "Text content part - matches AI SDK TextUIPart" + }, + "text": { + "type": "string", + "description": "The text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "description": "AI reasoning/chain-of-thought - matches AI SDK ReasoningUIPart" + }, + "text": { + "type": "string", + "description": "The reasoning text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "pattern": "^tool-.+$", + "description": "Tool type following AI SDK pattern: tool-{toolName}" + }, + "toolCallId": { + "type": "string", + "description": "Unique identifier for this tool invocation" + }, + "toolName": { + "type": "string", + "description": "Name of the tool being invoked" + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input parameters passed to the tool" + }, + "output": { + "description": "Output returned by the tool (when state is 'result')" + }, + "state": { + "type": "string", + "enum": [ + "partial", + "result", + "error" + ], + "description": "AI SDK tool state: 'partial' = executing, 'result' = success, 'error' = failed" + }, + "errorText": { + "type": "string", + "description": "Error message when state is 'error'" + }, + "callProviderMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "toolCallId", + "toolName", + "input", + "state" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-url" + ], + "description": "URL source citation - matches AI SDK SourceUrlUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the source" + }, + "title": { + "type": "string", + "description": "Title of the source" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "url" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-document" + ], + "description": "Document source citation - matches AI SDK SourceDocumentUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the document" + }, + "title": { + "type": "string", + "description": "Title of the document" + }, + "filename": { + "type": "string", + "description": "Filename of the document" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "mediaType", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "step-start" + ], + "description": "Step boundary marker - matches AI SDK StepStartUIPart" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ], + "description": "File attachment - matches AI SDK FileUIPart" + }, + "url": { + "type": "string", + "description": "URL of the file (can be hosted URL or Data URL)" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the file" + }, + "filename": { + "type": "string", + "description": "Original filename" + }, + "size": { + "type": "number", + "description": "Size of the file in bytes" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ], + "description": "Image attachment with dimensions" + }, + "url": { + "type": "string", + "description": "URL of the image" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the image" + }, + "filename": { + "type": "string", + "description": "Original filename of the image" + }, + "size": { + "type": "number", + "description": "Size of the image in bytes" + }, + "width": { + "type": "number", + "description": "Width of the image in pixels" + }, + "height": { + "type": "number", + "description": "Height of the image in pixels" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "translation" + ], + "description": "Stored translation for a timeline item message, scoped to an audience" + }, + "text": { + "type": "string", + "description": "Translated text content" + }, + "sourceLanguage": { + "type": "string", + "description": "Detected/source language of the original message" + }, + "targetLanguage": { + "type": "string", + "description": "Language of the translated text" + }, + "audience": { + "type": "string", + "enum": [ + "team", + "visitor" + ], + "description": "Audience this translated text is meant for" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "description": "Whether this translation was created automatically or manually" + }, + "modelId": { + "type": [ + "string", + "null" + ], + "description": "Model identifier used to generate the translation, if any" + } + }, + "required": [ + "type", + "text", + "sourceLanguage", + "targetLanguage", + "audience", + "mode" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "event" + ], + "description": "Type of timeline part - always 'event' for event parts" + }, + "eventType": { + "type": "string", + "enum": [ + "assigned", + "unassigned", + "participant_requested", + "participant_joined", + "participant_left", + "status_changed", + "priority_changed", + "tag_added", + "tag_removed", + "resolved", + "reopened", + "visitor_blocked", + "visitor_unblocked", + "visitor_identified", + "ai_paused", + "ai_resumed" + ], + "description": "Type of event that occurred" + }, + "actorUserId": { + "type": [ + "string", + "null" + ], + "description": "User that triggered the event, if applicable" + }, + "actorAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that triggered the event, if applicable" + }, + "targetUserId": { + "type": [ + "string", + "null" + ], + "description": "User targeted by the event, if applicable" + }, + "targetAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent targeted by the event, if applicable" + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "Optional human readable message attached to the event" + } + }, + "required": [ + "type", + "eventType", + "actorUserId", + "actorAiAgentId", + "targetUserId", + "targetAiAgentId" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "metadata" + ], + "description": "Type of timeline part - always 'metadata' for metadata parts" + }, + "source": { + "type": "string", + "enum": [ + "email", + "widget", + "api" + ], + "description": "Source channel through which the message was created" + } + }, + "required": [ + "type", + "source" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "feedback" + ], + "description": "Type of timeline part - always 'feedback' for feedback parts" + }, + "feedbackId": { + "type": "string", + "description": "Feedback record associated with this timeline item" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Visitor rating from 1 to 5" + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Structured feedback topic selected by the visitor" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback" + }, + "source": { + "type": "string", + "description": "Source channel where the feedback was collected" + } + }, + "required": [ + "type", + "feedbackId", + "rating", + "source" + ] + } + ] + }, + "description": "Array of timeline parts that make up the timeline item content. Includes AI SDK compatible parts (text, reasoning, tool-*, source-url, source-document, step-start, file, image) and Cossistant-specific parts (event, metadata, feedback)." + }, + "userId": { + "type": [ + "string", + "null" + ], + "description": "ID of the user who created this timeline item, if applicable" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "description": "ID of the AI agent that created this timeline item, if applicable" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "ID of the visitor who created this timeline item, if applicable" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was created", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was deleted, if applicable", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "conversationId", + "organizationId", + "visibility", + "type", + "text", + "parts", + "userId", + "aiAgentId", + "visitorId", + "createdAt" + ] + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "visitorId", + "websiteId" + ] + } + }, + "pagination": { + "type": "object", + "properties": { + "page": { + "type": "number" + }, + "limit": { + "type": "number" + }, + "total": { + "type": "number" + }, + "totalPages": { + "type": "number" + }, + "hasMore": { + "type": "boolean" + } + }, + "required": [ + "page", + "limit", + "total", + "totalPages", + "hasMore" + ] + } + }, + "required": [ + "conversations", + "pagination" + ], + "description": "Paginated list of conversations" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Public key origin validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "listConversations" + } + }, + "/conversations/{conversationId}/resolve": { + "post": { + "summary": "Resolve a conversation", + "description": "Marks a conversation as resolved. Requires a private API key. When using an unlinked private key, send `X-Actor-User-Id` with a valid website teammate ID.", + "operationId": "resolveConversation", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "Conversation resolved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ] + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ] + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ] + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "channel": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ] + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ] + }, + "resolutionTime": { + "type": [ + "number", + "null" + ] + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageBy": { + "type": [ + "string", + "null" + ] + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ] + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalatedAt": { + "type": [ + "string", + "null" + ] + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalationReason": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ] + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "organizationId", + "visitorId", + "websiteId", + "status", + "priority", + "prioritySource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "channel", + "title", + "titleSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "lastMessageAt", + "lastMessageBy", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing the updated conversation after a private control action." + } + } + } + }, + "400": { + "description": "Bad request - Missing actor for an unlinked private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor user not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/conversations/{conversationId}/reopen": { + "post": { + "summary": "Reopen a conversation", + "description": "Reopens a previously resolved or spam conversation. Requires a private API key and an acting teammate.", + "operationId": "reopenConversation", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "Conversation reopened successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ] + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ] + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ] + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "channel": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ] + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ] + }, + "resolutionTime": { + "type": [ + "number", + "null" + ] + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageBy": { + "type": [ + "string", + "null" + ] + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ] + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalatedAt": { + "type": [ + "string", + "null" + ] + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalationReason": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ] + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "organizationId", + "visitorId", + "websiteId", + "status", + "priority", + "prioritySource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "channel", + "title", + "titleSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "lastMessageAt", + "lastMessageBy", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing the updated conversation after a private control action." + } + } + } + }, + "400": { + "description": "Bad request - Missing actor for an unlinked private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor user not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/conversations/{conversationId}/spam": { + "post": { + "summary": "Mark a conversation as spam", + "description": "Marks a conversation as spam. Requires a private API key and an acting teammate.", + "operationId": "markConversationAsSpam", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "Conversation marked as spam successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ] + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ] + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ] + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "channel": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ] + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ] + }, + "resolutionTime": { + "type": [ + "number", + "null" + ] + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageBy": { + "type": [ + "string", + "null" + ] + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ] + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalatedAt": { + "type": [ + "string", + "null" + ] + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalationReason": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ] + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "organizationId", + "visitorId", + "websiteId", + "status", + "priority", + "prioritySource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "channel", + "title", + "titleSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "lastMessageAt", + "lastMessageBy", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing the updated conversation after a private control action." + } + } + } + }, + "400": { + "description": "Bad request - Missing actor for an unlinked private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor user not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/conversations/{conversationId}/not-spam": { + "post": { + "summary": "Mark a conversation as not spam", + "description": "Restores a spam conversation back to the open state. Requires a private API key and an acting teammate.", + "operationId": "markConversationAsNotSpam", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "Conversation marked as not spam successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ] + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ] + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ] + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "channel": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ] + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ] + }, + "resolutionTime": { + "type": [ + "number", + "null" + ] + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageBy": { + "type": [ + "string", + "null" + ] + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ] + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalatedAt": { + "type": [ + "string", + "null" + ] + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalationReason": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ] + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "organizationId", + "visitorId", + "websiteId", + "status", + "priority", + "prioritySource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "channel", + "title", + "titleSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "lastMessageAt", + "lastMessageBy", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing the updated conversation after a private control action." + } + } + } + }, + "400": { + "description": "Bad request - Missing actor for an unlinked private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor user not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/conversations/{conversationId}/archive": { + "post": { + "summary": "Archive a conversation", + "description": "Archives a conversation from the inbox. This matches the dashboard delete behavior and requires a private API key plus an acting teammate.", + "operationId": "archiveConversation", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "Conversation archived successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ] + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ] + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ] + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "channel": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ] + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ] + }, + "resolutionTime": { + "type": [ + "number", + "null" + ] + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageBy": { + "type": [ + "string", + "null" + ] + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ] + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalatedAt": { + "type": [ + "string", + "null" + ] + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalationReason": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ] + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "organizationId", + "visitorId", + "websiteId", + "status", + "priority", + "prioritySource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "channel", + "title", + "titleSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "lastMessageAt", + "lastMessageBy", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing the updated conversation after a private control action." + } + } + } + }, + "400": { + "description": "Bad request - Missing actor for an unlinked private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor user not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/conversations/{conversationId}/unarchive": { + "post": { + "summary": "Unarchive a conversation", + "description": "Restores an archived conversation. Requires a private API key and an acting teammate.", + "operationId": "unarchiveConversation", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "Conversation unarchived successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ] + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ] + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ] + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "channel": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ] + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ] + }, + "resolutionTime": { + "type": [ + "number", + "null" + ] + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageBy": { + "type": [ + "string", + "null" + ] + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ] + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalatedAt": { + "type": [ + "string", + "null" + ] + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalationReason": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ] + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "organizationId", + "visitorId", + "websiteId", + "status", + "priority", + "prioritySource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "channel", + "title", + "titleSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "lastMessageAt", + "lastMessageBy", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing the updated conversation after a private control action." + } + } + } + }, + "400": { + "description": "Bad request - Missing actor for an unlinked private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor user not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/conversations/{conversationId}/read": { + "post": { + "summary": "Mark a conversation as read", + "description": "Marks a conversation as read for the acting teammate. Requires a private API key and an acting teammate.", + "operationId": "markConversationAsRead", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "Conversation marked as read successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ] + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ] + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ] + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "channel": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ] + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ] + }, + "resolutionTime": { + "type": [ + "number", + "null" + ] + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageBy": { + "type": [ + "string", + "null" + ] + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ] + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalatedAt": { + "type": [ + "string", + "null" + ] + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalationReason": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ] + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "organizationId", + "visitorId", + "websiteId", + "status", + "priority", + "prioritySource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "channel", + "title", + "titleSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "lastMessageAt", + "lastMessageBy", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing the updated conversation after a private control action." + } + } + } + }, + "400": { + "description": "Bad request - Missing actor for an unlinked private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor user not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/conversations/{conversationId}/unread": { + "post": { + "summary": "Mark a conversation as unread", + "description": "Clears the acting teammate's read marker for a conversation. Requires a private API key and an acting teammate.", + "operationId": "markConversationAsUnread", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "Conversation marked as unread successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ] + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ] + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ] + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "channel": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ] + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ] + }, + "resolutionTime": { + "type": [ + "number", + "null" + ] + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageBy": { + "type": [ + "string", + "null" + ] + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ] + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalatedAt": { + "type": [ + "string", + "null" + ] + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalationReason": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ] + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "organizationId", + "visitorId", + "websiteId", + "status", + "priority", + "prioritySource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "channel", + "title", + "titleSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "lastMessageAt", + "lastMessageBy", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing the updated conversation after a private control action." + } + } + } + }, + "400": { + "description": "Bad request - Missing actor for an unlinked private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor user not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/conversations/{conversationId}/metadata": { + "patch": { + "summary": "Update conversation metadata", + "description": "Merges metadata into a conversation. Conversation metadata are public and retrievable on public conversation endpoints, but this post-creation update route requires a private API key.", + "operationId": "updateConversationMetadata", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "metadata": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Metadata payload to merge into the conversation. Conversation metadata are public and retrievable on public conversation endpoints.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + } + }, + "required": [ + "metadata" + ], + "description": "Request payload for merging metadata into a conversation." + } + } + } + }, + "responses": { + "200": { + "description": "Conversation metadata updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ] + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ] + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ] + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "channel": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ] + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ] + }, + "resolutionTime": { + "type": [ + "number", + "null" + ] + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageBy": { + "type": [ + "string", + "null" + ] + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ] + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalatedAt": { + "type": [ + "string", + "null" + ] + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalationReason": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ] + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "organizationId", + "visitorId", + "websiteId", + "status", + "priority", + "prioritySource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "channel", + "title", + "titleSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "lastMessageAt", + "lastMessageBy", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing the updated conversation after a private control action." + } + } + } + }, + "400": { + "description": "Bad request - Invalid request payload", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/conversations/{conversationId}/priority": { + "patch": { + "summary": "Update a conversation priority", + "description": "Updates the conversation priority and marks it as human-owned. Requires a private API key and an acting teammate.", + "operationId": "updateConversationPriority", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "description": "New conversation priority.", + "example": "high" + } + }, + "required": [ + "priority" + ], + "description": "Request payload for changing a conversation priority." + } + } + } + }, + "responses": { + "200": { + "description": "Conversation priority updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ] + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ] + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ] + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "channel": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ] + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ] + }, + "resolutionTime": { + "type": [ + "number", + "null" + ] + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageBy": { + "type": [ + "string", + "null" + ] + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ] + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalatedAt": { + "type": [ + "string", + "null" + ] + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalationReason": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ] + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "organizationId", + "visitorId", + "websiteId", + "status", + "priority", + "prioritySource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "channel", + "title", + "titleSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "lastMessageAt", + "lastMessageBy", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing the updated conversation after a private control action." + } + } + } + }, + "400": { + "description": "Bad request - Invalid request payload or missing actor for an unlinked private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor user not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/conversations/{conversationId}/sentiment": { + "patch": { + "summary": "Update a conversation sentiment", + "description": "Updates the conversation sentiment and marks it as human-owned. Pass null to mark sentiment as unknown. Requires a private API key and an acting teammate.", + "operationId": "updateConversationSentiment", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ], + "description": "New conversation sentiment. Pass null to mark sentiment as unknown.", + "example": "neutral" + } + }, + "required": [ + "sentiment" + ], + "description": "Request payload for changing a conversation sentiment." + } + } + } + }, + "responses": { + "200": { + "description": "Conversation sentiment updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ] + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ] + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ] + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "channel": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ] + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ] + }, + "resolutionTime": { + "type": [ + "number", + "null" + ] + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageBy": { + "type": [ + "string", + "null" + ] + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ] + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalatedAt": { + "type": [ + "string", + "null" + ] + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalationReason": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ] + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "organizationId", + "visitorId", + "websiteId", + "status", + "priority", + "prioritySource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "channel", + "title", + "titleSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "lastMessageAt", + "lastMessageBy", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing the updated conversation after a private control action." + } + } + } + }, + "400": { + "description": "Bad request - Invalid request payload or missing actor for an unlinked private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor user not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/conversations/{conversationId}": { + "patch": { + "summary": "Update a conversation title", + "description": "Updates the conversation title. This private control route does not require an acting teammate in v1.", + "operationId": "updateConversationTitle", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": [ + "string", + "null" + ], + "maxLength": 255, + "description": "New conversation title. Pass null to clear the current title.", + "example": "Billing issue from enterprise customer" + } + }, + "required": [ + "title" + ], + "description": "Request payload for changing a conversation title." + } + } + } + }, + "responses": { + "200": { + "description": "Conversation title updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ] + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ] + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ] + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "channel": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ] + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ] + }, + "resolutionTime": { + "type": [ + "number", + "null" + ] + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageBy": { + "type": [ + "string", + "null" + ] + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ] + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalatedAt": { + "type": [ + "string", + "null" + ] + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalationReason": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ] + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "organizationId", + "visitorId", + "websiteId", + "status", + "priority", + "prioritySource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "channel", + "title", + "titleSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "lastMessageAt", + "lastMessageBy", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing the updated conversation after a private control action." + } + } + } + }, + "400": { + "description": "Bad request - Invalid request payload", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + }, + "get": { + "summary": "Get a single conversation by ID", + "description": "Fetch a specific conversation by its ID, including any public conversation metadata.", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + }, + { + "name": "X-Visitor-Id", + "in": "header", + "description": "Visitor ID from localStorage.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + }, + { + "schema": { + "type": "string", + "description": "The ID of the conversation to retrieve" + }, + "required": true, + "description": "The ID of the conversation to retrieve", + "name": "conversationId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Conversation retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "channel": { + "type": "string", + "default": "widget" + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ], + "default": "open" + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "default": null, + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "visitorLastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "lastTimelineItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the timeline item" + }, + "conversationId": { + "type": "string", + "description": "ID of the conversation this timeline item belongs to" + }, + "organizationId": { + "type": "string", + "description": "ID of the organization this timeline item belongs to" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility level of the timeline item" + }, + "type": { + "type": "string", + "enum": [ + "message", + "event", + "identification", + "tool" + ], + "description": "Type of timeline item - message, event, identification, or tool call" + }, + "text": { + "type": [ + "string", + "null" + ], + "description": "Main text content of the timeline item" + }, + "tool": { + "type": [ + "string", + "null" + ], + "description": "Optional tool identifier associated with this timeline item" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "Text content part - matches AI SDK TextUIPart" + }, + "text": { + "type": "string", + "description": "The text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "description": "AI reasoning/chain-of-thought - matches AI SDK ReasoningUIPart" + }, + "text": { + "type": "string", + "description": "The reasoning text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "pattern": "^tool-.+$", + "description": "Tool type following AI SDK pattern: tool-{toolName}" + }, + "toolCallId": { + "type": "string", + "description": "Unique identifier for this tool invocation" + }, + "toolName": { + "type": "string", + "description": "Name of the tool being invoked" + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input parameters passed to the tool" + }, + "output": { + "description": "Output returned by the tool (when state is 'result')" + }, + "state": { + "type": "string", + "enum": [ + "partial", + "result", + "error" + ], + "description": "AI SDK tool state: 'partial' = executing, 'result' = success, 'error' = failed" + }, + "errorText": { + "type": "string", + "description": "Error message when state is 'error'" + }, + "callProviderMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "toolCallId", + "toolName", + "input", + "state" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-url" + ], + "description": "URL source citation - matches AI SDK SourceUrlUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the source" + }, + "title": { + "type": "string", + "description": "Title of the source" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "url" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-document" + ], + "description": "Document source citation - matches AI SDK SourceDocumentUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the document" + }, + "title": { + "type": "string", + "description": "Title of the document" + }, + "filename": { + "type": "string", + "description": "Filename of the document" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "mediaType", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "step-start" + ], + "description": "Step boundary marker - matches AI SDK StepStartUIPart" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ], + "description": "File attachment - matches AI SDK FileUIPart" + }, + "url": { + "type": "string", + "description": "URL of the file (can be hosted URL or Data URL)" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the file" + }, + "filename": { + "type": "string", + "description": "Original filename" + }, + "size": { + "type": "number", + "description": "Size of the file in bytes" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ], + "description": "Image attachment with dimensions" + }, + "url": { + "type": "string", + "description": "URL of the image" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the image" + }, + "filename": { + "type": "string", + "description": "Original filename of the image" + }, + "size": { + "type": "number", + "description": "Size of the image in bytes" + }, + "width": { + "type": "number", + "description": "Width of the image in pixels" + }, + "height": { + "type": "number", + "description": "Height of the image in pixels" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "translation" + ], + "description": "Stored translation for a timeline item message, scoped to an audience" + }, + "text": { + "type": "string", + "description": "Translated text content" + }, + "sourceLanguage": { + "type": "string", + "description": "Detected/source language of the original message" + }, + "targetLanguage": { + "type": "string", + "description": "Language of the translated text" + }, + "audience": { + "type": "string", + "enum": [ + "team", + "visitor" + ], + "description": "Audience this translated text is meant for" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "description": "Whether this translation was created automatically or manually" + }, + "modelId": { + "type": [ + "string", + "null" + ], + "description": "Model identifier used to generate the translation, if any" + } + }, + "required": [ + "type", + "text", + "sourceLanguage", + "targetLanguage", + "audience", + "mode" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "event" + ], + "description": "Type of timeline part - always 'event' for event parts" + }, + "eventType": { + "type": "string", + "enum": [ + "assigned", + "unassigned", + "participant_requested", + "participant_joined", + "participant_left", + "status_changed", + "priority_changed", + "tag_added", + "tag_removed", + "resolved", + "reopened", + "visitor_blocked", + "visitor_unblocked", + "visitor_identified", + "ai_paused", + "ai_resumed" + ], + "description": "Type of event that occurred" + }, + "actorUserId": { + "type": [ + "string", + "null" + ], + "description": "User that triggered the event, if applicable" + }, + "actorAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that triggered the event, if applicable" + }, + "targetUserId": { + "type": [ + "string", + "null" + ], + "description": "User targeted by the event, if applicable" + }, + "targetAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent targeted by the event, if applicable" + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "Optional human readable message attached to the event" + } + }, + "required": [ + "type", + "eventType", + "actorUserId", + "actorAiAgentId", + "targetUserId", + "targetAiAgentId" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "metadata" + ], + "description": "Type of timeline part - always 'metadata' for metadata parts" + }, + "source": { + "type": "string", + "enum": [ + "email", + "widget", + "api" + ], + "description": "Source channel through which the message was created" + } + }, + "required": [ + "type", + "source" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "feedback" + ], + "description": "Type of timeline part - always 'feedback' for feedback parts" + }, + "feedbackId": { + "type": "string", + "description": "Feedback record associated with this timeline item" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Visitor rating from 1 to 5" + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Structured feedback topic selected by the visitor" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback" + }, + "source": { + "type": "string", + "description": "Source channel where the feedback was collected" + } + }, + "required": [ + "type", + "feedbackId", + "rating", + "source" + ] + } + ] + }, + "description": "Array of timeline parts that make up the timeline item content. Includes AI SDK compatible parts (text, reasoning, tool-*, source-url, source-document, step-start, file, image) and Cossistant-specific parts (event, metadata, feedback)." + }, + "userId": { + "type": [ + "string", + "null" + ], + "description": "ID of the user who created this timeline item, if applicable" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "description": "ID of the AI agent that created this timeline item, if applicable" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "ID of the visitor who created this timeline item, if applicable" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was created", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was deleted, if applicable", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "conversationId", + "organizationId", + "visibility", + "type", + "text", + "parts", + "userId", + "aiAgentId", + "visitorId", + "createdAt" + ] + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "visitorId", + "websiteId" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing a single conversation" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Public key origin validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "getConversation" + } + }, + "/conversations/{conversationId}/ai/pause": { + "post": { + "summary": "Pause AI replies for a conversation", + "description": "Pauses AI replies for a conversation for the provided duration. Requires a private API key and an acting teammate.", + "operationId": "pauseConversationAi", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "durationMinutes": { + "type": "integer", + "minimum": 1, + "maximum": 52560000, + "description": "How long to pause AI replies for this conversation, in minutes.", + "example": 60 + } + }, + "description": "Request payload for pausing AI replies on a conversation." + } + } + } + }, + "responses": { + "200": { + "description": "Conversation AI paused successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ] + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ] + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ] + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "channel": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ] + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ] + }, + "resolutionTime": { + "type": [ + "number", + "null" + ] + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageBy": { + "type": [ + "string", + "null" + ] + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ] + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalatedAt": { + "type": [ + "string", + "null" + ] + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalationReason": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ] + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "organizationId", + "visitorId", + "websiteId", + "status", + "priority", + "prioritySource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "channel", + "title", + "titleSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "lastMessageAt", + "lastMessageBy", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing the updated conversation after a private control action." + } + } + } + }, + "400": { + "description": "Bad request - Invalid request payload or missing actor for an unlinked private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor user not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/conversations/{conversationId}/ai/resume": { + "post": { + "summary": "Resume AI replies for a conversation", + "description": "Resumes AI replies for a conversation. Requires a private API key and an acting teammate.", + "operationId": "resumeConversationAi", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "Conversation AI resumed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ] + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ] + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ] + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "channel": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ] + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ] + }, + "resolutionTime": { + "type": [ + "number", + "null" + ] + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageBy": { + "type": [ + "string", + "null" + ] + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ] + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalatedAt": { + "type": [ + "string", + "null" + ] + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalationReason": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ] + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "organizationId", + "visitorId", + "websiteId", + "status", + "priority", + "prioritySource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "channel", + "title", + "titleSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "lastMessageAt", + "lastMessageBy", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing the updated conversation after a private control action." + } + } + } + }, + "400": { + "description": "Bad request - Missing actor for an unlinked private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor user not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/conversations/{conversationId}/join-escalation": { + "post": { + "summary": "Join an escalated conversation", + "description": "Marks an escalation as handled and adds the acting teammate as a participant if needed. Requires a private API key and an acting teammate.", + "operationId": "joinConversationEscalation", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "Escalation joined successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "visitorId": { + "type": "string" + }, + "websiteId": { + "type": "string" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ] + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ] + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ] + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "channel": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "visitorTitle": { + "type": [ + "string", + "null" + ] + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ] + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ] + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ] + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ] + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ] + }, + "resolutionTime": { + "type": [ + "number", + "null" + ] + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5 + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": [ + "string", + "null" + ] + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ] + }, + "lastMessageBy": { + "type": [ + "string", + "null" + ] + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ] + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalatedAt": { + "type": [ + "string", + "null" + ] + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ] + }, + "escalationReason": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ] + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ] + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "organizationId", + "visitorId", + "websiteId", + "status", + "priority", + "prioritySource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "channel", + "title", + "titleSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "lastMessageAt", + "lastMessageBy", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + }, + "required": [ + "conversation" + ], + "description": "Response containing the updated conversation after a private control action." + } + } + } + }, + "400": { + "description": "Bad request - Missing actor for an unlinked private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor user not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/conversations/inbox": { + "get": { + "summary": "List inbox conversations", + "description": "Returns a cursor-paginated inbox view for the authenticated website. This control-plane endpoint requires a private API key.", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50, + "description": "Maximum number of conversations to return per page." + }, + "required": false, + "description": "Maximum number of conversations to return per page.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": [ + "string", + "null" + ], + "description": "Opaque cursor returned by the previous inbox response, or null for the first page." + }, + "required": false, + "description": "Opaque cursor returned by the previous inbox response, or null for the first page.", + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ], + "description": "Filter by conversation status." + }, + "required": false, + "description": "Filter by conversation status.", + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "description": "Filter by conversation priority." + }, + "required": false, + "description": "Filter by conversation priority.", + "name": "priority", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "positive", + "negative", + "neutral" + ], + "description": "Filter by inferred or user-set sentiment." + }, + "required": false, + "description": "Filter by inferred or user-set sentiment.", + "name": "sentiment", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Filter by visitor ID." + }, + "required": false, + "description": "Filter by visitor ID.", + "name": "visitorId", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Filter by linked contact ID." + }, + "required": false, + "description": "Filter by linked contact ID.", + "name": "contactId", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Filter to conversations actively assigned to this user." + }, + "required": false, + "description": "Filter to conversations actively assigned to this user.", + "name": "assignedToUserId", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Filter to conversations associated with this view." + }, + "required": false, + "description": "Filter to conversations associated with this view.", + "name": "viewId", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only include conversations created at or after this time." + }, + "required": false, + "description": "Only include conversations created at or after this time.", + "name": "createdAtFrom", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only include conversations created before this time." + }, + "required": false, + "description": "Only include conversations created before this time.", + "name": "createdAtTo", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only include conversations updated at or after this time." + }, + "required": false, + "description": "Only include conversations updated at or after this time.", + "name": "updatedAtFrom", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only include conversations updated before this time." + }, + "required": false, + "description": "Only include conversations updated before this time.", + "name": "updatedAtTo", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Case-insensitive text search across title, visitor title, contact name, and contact email." + }, + "required": false, + "description": "Case-insensitive text search across title, visitor title, contact name, and contact email.", + "name": "q", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "createdAt", + "updatedAt" + ], + "default": "updatedAt", + "description": "Field to order conversations by." + }, + "required": false, + "description": "Field to order conversations by.", + "name": "orderBy", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc", + "description": "Order direction." + }, + "required": false, + "description": "Order direction.", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Inbox conversations retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the conversation." + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ], + "description": "Current status of the conversation." + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "description": "Current priority level of the conversation." + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ], + "description": "Source currently owning the conversation priority value. null means legacy AI-managed." + }, + "organizationId": { + "type": "string", + "description": "Organization that owns the conversation." + }, + "visitorId": { + "type": "string", + "description": "Visitor who owns the conversation." + }, + "visitor": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The visitor's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "lastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was last seen.", + "example": "2021-01-01T00:00:00.000Z" + }, + "blockedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was blocked, if applicable.", + "example": "2024-01-01T12:00:00.000Z" + }, + "blockedByUserId": { + "type": [ + "string", + "null" + ], + "description": "Identifier of the team member who blocked the visitor.", + "example": "01JG000000000000000000001" + }, + "isBlocked": { + "type": "boolean", + "description": "Whether the visitor is currently blocked.", + "example": true + }, + "contact": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier.", + "example": "01JG000000000000000000000" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "description": "The contact's avatar/profile image URL.", + "example": "https://example.com/avatar.png" + }, + "metadataHash": { + "type": "string", + "description": "Hash of the contact's metadata. Used to detect if metadata has changed without comparing full objects.", + "example": "a1b2c3d4" + } + }, + "required": [ + "id", + "name", + "email", + "image" + ] + } + }, + "required": [ + "id", + "lastSeenAt", + "blockedAt", + "blockedByUserId", + "isBlocked", + "contact" + ], + "description": "Visitor profile summary for the conversation." + }, + "websiteId": { + "type": "string", + "description": "Website that owns the conversation." + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "channel": { + "type": "string", + "description": "Channel where the conversation started.", + "example": "widget" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Conversation title if one has been generated or set." + }, + "visitorTitle": { + "type": [ + "string", + "null" + ], + "description": "Conversation title localized for the visitor, when available." + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ], + "description": "Language code used for the visitor-facing conversation title." + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ], + "description": "Detected visitor language for this conversation." + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ], + "description": "Whether the title was set by AI or a user." + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Timestamp when automatic translation was first activated for this conversation.", + "example": "2026-04-06T14:37:05.820Z" + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Timestamp when the one-time translation credit was charged for this conversation.", + "example": "2026-04-06T14:37:05.820Z" + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ], + "description": "Most recent inferred conversation sentiment." + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ], + "description": "Confidence score for the inferred sentiment, if available." + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ], + "description": "Source currently owning the conversation sentiment value. null means legacy AI-managed." + }, + "resolutionTime": { + "type": [ + "number", + "null" + ], + "description": "Resolution time in seconds once the conversation has been resolved." + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5, + "description": "Visitor satisfaction rating, if one has been submitted." + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor rating was submitted.", + "example": "2026-04-06T14:37:05.820Z" + }, + "startedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the conversation was considered started.", + "example": "2026-04-06T14:37:05.820Z" + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the first response was sent, if available.", + "example": "2026-04-06T14:37:05.820Z" + }, + "resolvedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the conversation was resolved, if applicable.", + "example": "2026-04-06T14:37:05.820Z" + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ], + "description": "User who resolved the conversation, if applicable." + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that resolved the conversation, if applicable." + }, + "escalatedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the conversation was escalated, if applicable.", + "example": "2026-04-06T14:37:05.820Z" + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that escalated the conversation, if applicable." + }, + "escalationReason": { + "type": [ + "string", + "null" + ], + "description": "Reason provided for the escalation, if applicable." + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the escalation was handled, if applicable.", + "example": "2026-04-06T14:37:05.820Z" + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ], + "description": "User who handled the escalation, if applicable." + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "If AI replies are paused, when the pause expires.", + "example": "2026-04-06T14:37:05.820Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the conversation record was created.", + "example": "2026-04-06T14:37:05.820Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the conversation record was last updated.", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the conversation was archived, if applicable.", + "example": "2026-04-06T14:37:05.820Z" + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Timestamp of the latest message-like activity in the thread.", + "example": "2026-04-06T14:37:05.820Z" + }, + "lastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "User-specific last-seen timestamp when available, otherwise null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "lastMessageTimelineItem": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the timeline item" + }, + "conversationId": { + "type": "string", + "description": "ID of the conversation this timeline item belongs to" + }, + "organizationId": { + "type": "string", + "description": "ID of the organization this timeline item belongs to" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility level of the timeline item" + }, + "type": { + "type": "string", + "enum": [ + "message", + "event", + "identification", + "tool" + ], + "description": "Type of timeline item - message, event, identification, or tool call" + }, + "text": { + "type": [ + "string", + "null" + ], + "description": "Main text content of the timeline item" + }, + "tool": { + "type": [ + "string", + "null" + ], + "description": "Optional tool identifier associated with this timeline item" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "Text content part - matches AI SDK TextUIPart" + }, + "text": { + "type": "string", + "description": "The text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "description": "AI reasoning/chain-of-thought - matches AI SDK ReasoningUIPart" + }, + "text": { + "type": "string", + "description": "The reasoning text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "pattern": "^tool-.+$", + "description": "Tool type following AI SDK pattern: tool-{toolName}" + }, + "toolCallId": { + "type": "string", + "description": "Unique identifier for this tool invocation" + }, + "toolName": { + "type": "string", + "description": "Name of the tool being invoked" + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input parameters passed to the tool" + }, + "output": { + "description": "Output returned by the tool (when state is 'result')" + }, + "state": { + "type": "string", + "enum": [ + "partial", + "result", + "error" + ], + "description": "AI SDK tool state: 'partial' = executing, 'result' = success, 'error' = failed" + }, + "errorText": { + "type": "string", + "description": "Error message when state is 'error'" + }, + "callProviderMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "toolCallId", + "toolName", + "input", + "state" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-url" + ], + "description": "URL source citation - matches AI SDK SourceUrlUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the source" + }, + "title": { + "type": "string", + "description": "Title of the source" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "url" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-document" + ], + "description": "Document source citation - matches AI SDK SourceDocumentUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the document" + }, + "title": { + "type": "string", + "description": "Title of the document" + }, + "filename": { + "type": "string", + "description": "Filename of the document" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "mediaType", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "step-start" + ], + "description": "Step boundary marker - matches AI SDK StepStartUIPart" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ], + "description": "File attachment - matches AI SDK FileUIPart" + }, + "url": { + "type": "string", + "description": "URL of the file (can be hosted URL or Data URL)" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the file" + }, + "filename": { + "type": "string", + "description": "Original filename" + }, + "size": { + "type": "number", + "description": "Size of the file in bytes" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ], + "description": "Image attachment with dimensions" + }, + "url": { + "type": "string", + "description": "URL of the image" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the image" + }, + "filename": { + "type": "string", + "description": "Original filename of the image" + }, + "size": { + "type": "number", + "description": "Size of the image in bytes" + }, + "width": { + "type": "number", + "description": "Width of the image in pixels" + }, + "height": { + "type": "number", + "description": "Height of the image in pixels" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "translation" + ], + "description": "Stored translation for a timeline item message, scoped to an audience" + }, + "text": { + "type": "string", + "description": "Translated text content" + }, + "sourceLanguage": { + "type": "string", + "description": "Detected/source language of the original message" + }, + "targetLanguage": { + "type": "string", + "description": "Language of the translated text" + }, + "audience": { + "type": "string", + "enum": [ + "team", + "visitor" + ], + "description": "Audience this translated text is meant for" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "description": "Whether this translation was created automatically or manually" + }, + "modelId": { + "type": [ + "string", + "null" + ], + "description": "Model identifier used to generate the translation, if any" + } + }, + "required": [ + "type", + "text", + "sourceLanguage", + "targetLanguage", + "audience", + "mode" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "event" + ], + "description": "Type of timeline part - always 'event' for event parts" + }, + "eventType": { + "type": "string", + "enum": [ + "assigned", + "unassigned", + "participant_requested", + "participant_joined", + "participant_left", + "status_changed", + "priority_changed", + "tag_added", + "tag_removed", + "resolved", + "reopened", + "visitor_blocked", + "visitor_unblocked", + "visitor_identified", + "ai_paused", + "ai_resumed" + ], + "description": "Type of event that occurred" + }, + "actorUserId": { + "type": [ + "string", + "null" + ], + "description": "User that triggered the event, if applicable" + }, + "actorAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that triggered the event, if applicable" + }, + "targetUserId": { + "type": [ + "string", + "null" + ], + "description": "User targeted by the event, if applicable" + }, + "targetAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent targeted by the event, if applicable" + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "Optional human readable message attached to the event" + } + }, + "required": [ + "type", + "eventType", + "actorUserId", + "actorAiAgentId", + "targetUserId", + "targetAiAgentId" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "metadata" + ], + "description": "Type of timeline part - always 'metadata' for metadata parts" + }, + "source": { + "type": "string", + "enum": [ + "email", + "widget", + "api" + ], + "description": "Source channel through which the message was created" + } + }, + "required": [ + "type", + "source" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "feedback" + ], + "description": "Type of timeline part - always 'feedback' for feedback parts" + }, + "feedbackId": { + "type": "string", + "description": "Feedback record associated with this timeline item" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Visitor rating from 1 to 5" + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Structured feedback topic selected by the visitor" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback" + }, + "source": { + "type": "string", + "description": "Source channel where the feedback was collected" + } + }, + "required": [ + "type", + "feedbackId", + "rating", + "source" + ] + } + ] + }, + "description": "Array of timeline parts that make up the timeline item content. Includes AI SDK compatible parts (text, reasoning, tool-*, source-url, source-document, step-start, file, image) and Cossistant-specific parts (event, metadata, feedback)." + }, + "userId": { + "type": [ + "string", + "null" + ], + "description": "ID of the user who created this timeline item, if applicable" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "description": "ID of the AI agent that created this timeline item, if applicable" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "ID of the visitor who created this timeline item, if applicable" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was created", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was deleted, if applicable", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "conversationId", + "organizationId", + "visibility", + "type", + "text", + "parts", + "userId", + "aiAgentId", + "visitorId", + "createdAt" + ], + "description": "Latest message timeline item for the conversation, if any." + }, + "lastTimelineItem": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the timeline item" + }, + "conversationId": { + "type": "string", + "description": "ID of the conversation this timeline item belongs to" + }, + "organizationId": { + "type": "string", + "description": "ID of the organization this timeline item belongs to" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility level of the timeline item" + }, + "type": { + "type": "string", + "enum": [ + "message", + "event", + "identification", + "tool" + ], + "description": "Type of timeline item - message, event, identification, or tool call" + }, + "text": { + "type": [ + "string", + "null" + ], + "description": "Main text content of the timeline item" + }, + "tool": { + "type": [ + "string", + "null" + ], + "description": "Optional tool identifier associated with this timeline item" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "Text content part - matches AI SDK TextUIPart" + }, + "text": { + "type": "string", + "description": "The text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "description": "AI reasoning/chain-of-thought - matches AI SDK ReasoningUIPart" + }, + "text": { + "type": "string", + "description": "The reasoning text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "pattern": "^tool-.+$", + "description": "Tool type following AI SDK pattern: tool-{toolName}" + }, + "toolCallId": { + "type": "string", + "description": "Unique identifier for this tool invocation" + }, + "toolName": { + "type": "string", + "description": "Name of the tool being invoked" + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input parameters passed to the tool" + }, + "output": { + "description": "Output returned by the tool (when state is 'result')" + }, + "state": { + "type": "string", + "enum": [ + "partial", + "result", + "error" + ], + "description": "AI SDK tool state: 'partial' = executing, 'result' = success, 'error' = failed" + }, + "errorText": { + "type": "string", + "description": "Error message when state is 'error'" + }, + "callProviderMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "toolCallId", + "toolName", + "input", + "state" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-url" + ], + "description": "URL source citation - matches AI SDK SourceUrlUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the source" + }, + "title": { + "type": "string", + "description": "Title of the source" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "url" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-document" + ], + "description": "Document source citation - matches AI SDK SourceDocumentUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the document" + }, + "title": { + "type": "string", + "description": "Title of the document" + }, + "filename": { + "type": "string", + "description": "Filename of the document" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "mediaType", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "step-start" + ], + "description": "Step boundary marker - matches AI SDK StepStartUIPart" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ], + "description": "File attachment - matches AI SDK FileUIPart" + }, + "url": { + "type": "string", + "description": "URL of the file (can be hosted URL or Data URL)" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the file" + }, + "filename": { + "type": "string", + "description": "Original filename" + }, + "size": { + "type": "number", + "description": "Size of the file in bytes" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ], + "description": "Image attachment with dimensions" + }, + "url": { + "type": "string", + "description": "URL of the image" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the image" + }, + "filename": { + "type": "string", + "description": "Original filename of the image" + }, + "size": { + "type": "number", + "description": "Size of the image in bytes" + }, + "width": { + "type": "number", + "description": "Width of the image in pixels" + }, + "height": { + "type": "number", + "description": "Height of the image in pixels" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "translation" + ], + "description": "Stored translation for a timeline item message, scoped to an audience" + }, + "text": { + "type": "string", + "description": "Translated text content" + }, + "sourceLanguage": { + "type": "string", + "description": "Detected/source language of the original message" + }, + "targetLanguage": { + "type": "string", + "description": "Language of the translated text" + }, + "audience": { + "type": "string", + "enum": [ + "team", + "visitor" + ], + "description": "Audience this translated text is meant for" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "description": "Whether this translation was created automatically or manually" + }, + "modelId": { + "type": [ + "string", + "null" + ], + "description": "Model identifier used to generate the translation, if any" + } + }, + "required": [ + "type", + "text", + "sourceLanguage", + "targetLanguage", + "audience", + "mode" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "event" + ], + "description": "Type of timeline part - always 'event' for event parts" + }, + "eventType": { + "type": "string", + "enum": [ + "assigned", + "unassigned", + "participant_requested", + "participant_joined", + "participant_left", + "status_changed", + "priority_changed", + "tag_added", + "tag_removed", + "resolved", + "reopened", + "visitor_blocked", + "visitor_unblocked", + "visitor_identified", + "ai_paused", + "ai_resumed" + ], + "description": "Type of event that occurred" + }, + "actorUserId": { + "type": [ + "string", + "null" + ], + "description": "User that triggered the event, if applicable" + }, + "actorAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that triggered the event, if applicable" + }, + "targetUserId": { + "type": [ + "string", + "null" + ], + "description": "User targeted by the event, if applicable" + }, + "targetAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent targeted by the event, if applicable" + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "Optional human readable message attached to the event" + } + }, + "required": [ + "type", + "eventType", + "actorUserId", + "actorAiAgentId", + "targetUserId", + "targetAiAgentId" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "metadata" + ], + "description": "Type of timeline part - always 'metadata' for metadata parts" + }, + "source": { + "type": "string", + "enum": [ + "email", + "widget", + "api" + ], + "description": "Source channel through which the message was created" + } + }, + "required": [ + "type", + "source" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "feedback" + ], + "description": "Type of timeline part - always 'feedback' for feedback parts" + }, + "feedbackId": { + "type": "string", + "description": "Feedback record associated with this timeline item" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Visitor rating from 1 to 5" + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Structured feedback topic selected by the visitor" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback" + }, + "source": { + "type": "string", + "description": "Source channel where the feedback was collected" + } + }, + "required": [ + "type", + "feedbackId", + "rating", + "source" + ] + } + ] + }, + "description": "Array of timeline parts that make up the timeline item content. Includes AI SDK compatible parts (text, reasoning, tool-*, source-url, source-document, step-start, file, image) and Cossistant-specific parts (event, metadata, feedback)." + }, + "userId": { + "type": [ + "string", + "null" + ], + "description": "ID of the user who created this timeline item, if applicable" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "description": "ID of the AI agent that created this timeline item, if applicable" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "ID of the visitor who created this timeline item, if applicable" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was created", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was deleted, if applicable", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "conversationId", + "organizationId", + "visibility", + "type", + "text", + "parts", + "userId", + "aiAgentId", + "visitorId", + "createdAt" + ], + "description": "Latest timeline item for the conversation, including private events." + }, + "activeClarification": { + "type": [ + "object", + "null" + ], + "properties": { + "requestId": { + "type": "string", + "format": "ulid" + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "analyzing" + ] + }, + { + "type": "string", + "enum": [ + "awaiting_answer" + ] + }, + { + "type": "string", + "enum": [ + "retry_required" + ] + }, + { + "type": "string", + "enum": [ + "draft_ready" + ] + } + ], + "description": "Clarification statuses that should appear on active conversation surfaces.", + "example": "awaiting_answer" + }, + "topicSummary": { + "type": "string" + }, + "engagementMode": { + "type": "string", + "enum": [ + "owner", + "linked" + ], + "description": "Whether the current conversation owns the clarification flow or is only linked to it.", + "example": "owner" + }, + "linkedConversationCount": { + "type": "integer", + "minimum": 0 + }, + "question": { + "type": [ + "string", + "null" + ] + }, + "currentSuggestedAnswers": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Exactly three suggested answers for the current question.", + "example": [ + "It applies to all plans.", + "It only applies to paid plans.", + "It depends on the feature flag." + ] + }, + "currentQuestionInputMode": { + "type": [ + "string", + "null" + ], + "enum": [ + "textarea_first", + "suggested_answers", + null + ], + "description": "How the current clarification question should be answered.", + "example": "textarea_first" + }, + "currentQuestionScope": { + "type": [ + "string", + "null" + ], + "enum": [ + "broad_discovery", + "narrow_detail", + null + ], + "description": "Whether the current clarification question is broad or narrow.", + "example": "broad_discovery" + }, + "stepIndex": { + "type": "integer", + "minimum": 0 + }, + "maxSteps": { + "type": "integer", + "minimum": 1 + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + }, + "progress": { + "type": [ + "object", + "null" + ], + "properties": { + "phase": { + "type": "string", + "enum": [ + "loading_context", + "reviewing_evidence", + "planning_questions", + "evaluating_answer", + "generating_draft", + "retrying_generation", + "finalizing_step" + ], + "description": "Current transient progress phase while a clarification step is being prepared.", + "example": "planning_questions" + }, + "label": { + "type": "string" + }, + "detail": { + "type": [ + "string", + "null" + ] + }, + "attempt": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "toolName": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "phase", + "label", + "detail", + "attempt", + "toolName", + "startedAt" + ] + } + }, + "required": [ + "requestId", + "status", + "topicSummary", + "engagementMode", + "linkedConversationCount", + "question", + "currentSuggestedAnswers", + "currentQuestionInputMode", + "currentQuestionScope", + "stepIndex", + "maxSteps", + "updatedAt", + "progress" + ], + "description": "Active knowledge clarification summary linked to the conversation, if any." + }, + "dashboardLocked": { + "type": "boolean", + "description": "Whether the dashboard has hard-limited access to the conversation." + }, + "dashboardLockReason": { + "anyOf": [ + { + "type": "string", + "enum": [ + "conversation_limit" + ] + }, + { + "type": "null" + } + ], + "description": "Why the dashboard access is locked, when applicable." + }, + "viewIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "View identifiers associated with the conversation." + }, + "seenData": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "conversationId": { + "type": "string" + }, + "userId": { + "type": [ + "string", + "null" + ] + }, + "visitorId": { + "type": [ + "string", + "null" + ] + }, + "aiAgentId": { + "type": [ + "string", + "null" + ] + }, + "lastSeenAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "id", + "conversationId", + "userId", + "visitorId", + "aiAgentId", + "lastSeenAt", + "createdAt", + "updatedAt", + "deletedAt" + ] + }, + "description": "Seen-state records associated with the conversation." + } + }, + "required": [ + "id", + "status", + "priority", + "prioritySource", + "organizationId", + "visitorId", + "visitor", + "websiteId", + "channel", + "title", + "titleSource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt", + "lastMessageAt", + "lastSeenAt", + "lastMessageTimelineItem", + "lastTimelineItem", + "activeClarification", + "viewIds", + "seenData" + ], + "description": "Conversation summary returned by the inbox listing endpoint." + }, + "description": "Inbox conversations for the authenticated website." + }, + "nextCursor": { + "type": [ + "string", + "null" + ], + "description": "Cursor for the next page, or null when the current page is final." + } + }, + "required": [ + "items", + "nextCursor" + ], + "description": "Cursor-paginated inbox conversations." + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "listInboxConversations" + } + }, + "/conversations/{conversationId}/context": { + "get": { + "summary": "Get conversation context", + "description": "Returns agent-ready private context for a conversation, including rich conversation state, visitor/contact profile, a private timeline page, and linked feedback.", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + }, + { + "schema": { + "type": "string", + "description": "The ID of the conversation to retrieve" + }, + "required": true, + "description": "The ID of the conversation to retrieve", + "name": "conversationId", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50, + "description": "Number of private timeline items to return." + }, + "required": false, + "description": "Number of private timeline items to return.", + "name": "timelineLimit", + "in": "query" + }, + { + "schema": { + "type": [ + "string", + "null" + ], + "description": "Cursor for paginating the private timeline page, using the same format as the timeline endpoint." + }, + "required": false, + "description": "Cursor for paginating the private timeline page, using the same format as the timeline endpoint.", + "name": "timelineCursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20, + "description": "Maximum linked feedback records to return." + }, + "required": false, + "description": "Maximum linked feedback records to return.", + "name": "feedbackLimit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Conversation context retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversation": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the conversation." + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "spam" + ], + "description": "Current status of the conversation." + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "description": "Current priority level of the conversation." + }, + "prioritySource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ], + "description": "Source currently owning the conversation priority value. null means legacy AI-managed." + }, + "organizationId": { + "type": "string", + "description": "Organization that owns the conversation." + }, + "visitorId": { + "type": "string", + "description": "Visitor who owns the conversation." + }, + "visitor": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The visitor's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "lastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was last seen.", + "example": "2021-01-01T00:00:00.000Z" + }, + "blockedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was blocked, if applicable.", + "example": "2024-01-01T12:00:00.000Z" + }, + "blockedByUserId": { + "type": [ + "string", + "null" + ], + "description": "Identifier of the team member who blocked the visitor.", + "example": "01JG000000000000000000001" + }, + "isBlocked": { + "type": "boolean", + "description": "Whether the visitor is currently blocked.", + "example": true + }, + "contact": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier.", + "example": "01JG000000000000000000000" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "description": "The contact's avatar/profile image URL.", + "example": "https://example.com/avatar.png" + }, + "metadataHash": { + "type": "string", + "description": "Hash of the contact's metadata. Used to detect if metadata has changed without comparing full objects.", + "example": "a1b2c3d4" + } + }, + "required": [ + "id", + "name", + "email", + "image" + ] + } + }, + "required": [ + "id", + "lastSeenAt", + "blockedAt", + "blockedByUserId", + "isBlocked", + "contact" + ], + "description": "Visitor profile summary for the conversation." + }, + "websiteId": { + "type": "string", + "description": "Website that owns the conversation." + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Public conversation metadata stored as flat key-value pairs.", + "example": { + "orderId": "ord_123", + "priority": "vip", + "mrr": 299 + } + }, + "channel": { + "type": "string", + "description": "Channel where the conversation started.", + "example": "widget" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Conversation title if one has been generated or set." + }, + "visitorTitle": { + "type": [ + "string", + "null" + ], + "description": "Conversation title localized for the visitor, when available." + }, + "visitorTitleLanguage": { + "type": [ + "string", + "null" + ], + "description": "Language code used for the visitor-facing conversation title." + }, + "visitorLanguage": { + "type": [ + "string", + "null" + ], + "description": "Detected visitor language for this conversation." + }, + "titleSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ], + "description": "Whether the title was set by AI or a user." + }, + "translationActivatedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Timestamp when automatic translation was first activated for this conversation.", + "example": "2026-04-06T14:37:05.820Z" + }, + "translationChargedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Timestamp when the one-time translation credit was charged for this conversation.", + "example": "2026-04-06T14:37:05.820Z" + }, + "sentiment": { + "type": [ + "string", + "null" + ], + "enum": [ + "positive", + "negative", + "neutral", + null + ], + "description": "Most recent inferred conversation sentiment." + }, + "sentimentConfidence": { + "type": [ + "number", + "null" + ], + "description": "Confidence score for the inferred sentiment, if available." + }, + "sentimentSource": { + "type": [ + "string", + "null" + ], + "enum": [ + "ai", + "user", + null + ], + "description": "Source currently owning the conversation sentiment value. null means legacy AI-managed." + }, + "resolutionTime": { + "type": [ + "number", + "null" + ], + "description": "Resolution time in seconds once the conversation has been resolved." + }, + "visitorRating": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 5, + "description": "Visitor satisfaction rating, if one has been submitted." + }, + "visitorRatingAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor rating was submitted.", + "example": "2026-04-06T14:37:05.820Z" + }, + "startedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the conversation was considered started.", + "example": "2026-04-06T14:37:05.820Z" + }, + "firstResponseAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the first response was sent, if available.", + "example": "2026-04-06T14:37:05.820Z" + }, + "resolvedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the conversation was resolved, if applicable.", + "example": "2026-04-06T14:37:05.820Z" + }, + "resolvedByUserId": { + "type": [ + "string", + "null" + ], + "description": "User who resolved the conversation, if applicable." + }, + "resolvedByAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that resolved the conversation, if applicable." + }, + "escalatedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the conversation was escalated, if applicable.", + "example": "2026-04-06T14:37:05.820Z" + }, + "escalatedByAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that escalated the conversation, if applicable." + }, + "escalationReason": { + "type": [ + "string", + "null" + ], + "description": "Reason provided for the escalation, if applicable." + }, + "escalationHandledAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the escalation was handled, if applicable.", + "example": "2026-04-06T14:37:05.820Z" + }, + "escalationHandledByUserId": { + "type": [ + "string", + "null" + ], + "description": "User who handled the escalation, if applicable." + }, + "aiPausedUntil": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "If AI replies are paused, when the pause expires.", + "example": "2026-04-06T14:37:05.820Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the conversation record was created.", + "example": "2026-04-06T14:37:05.820Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the conversation record was last updated.", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the conversation was archived, if applicable.", + "example": "2026-04-06T14:37:05.820Z" + }, + "lastMessageAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Timestamp of the latest message-like activity in the thread.", + "example": "2026-04-06T14:37:05.820Z" + }, + "lastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "User-specific last-seen timestamp when available, otherwise null.", + "example": "2026-04-06T14:37:05.820Z" + }, + "lastMessageTimelineItem": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the timeline item" + }, + "conversationId": { + "type": "string", + "description": "ID of the conversation this timeline item belongs to" + }, + "organizationId": { + "type": "string", + "description": "ID of the organization this timeline item belongs to" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility level of the timeline item" + }, + "type": { + "type": "string", + "enum": [ + "message", + "event", + "identification", + "tool" + ], + "description": "Type of timeline item - message, event, identification, or tool call" + }, + "text": { + "type": [ + "string", + "null" + ], + "description": "Main text content of the timeline item" + }, + "tool": { + "type": [ + "string", + "null" + ], + "description": "Optional tool identifier associated with this timeline item" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "Text content part - matches AI SDK TextUIPart" + }, + "text": { + "type": "string", + "description": "The text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "description": "AI reasoning/chain-of-thought - matches AI SDK ReasoningUIPart" + }, + "text": { + "type": "string", + "description": "The reasoning text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "pattern": "^tool-.+$", + "description": "Tool type following AI SDK pattern: tool-{toolName}" + }, + "toolCallId": { + "type": "string", + "description": "Unique identifier for this tool invocation" + }, + "toolName": { + "type": "string", + "description": "Name of the tool being invoked" + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input parameters passed to the tool" + }, + "output": { + "description": "Output returned by the tool (when state is 'result')" + }, + "state": { + "type": "string", + "enum": [ + "partial", + "result", + "error" + ], + "description": "AI SDK tool state: 'partial' = executing, 'result' = success, 'error' = failed" + }, + "errorText": { + "type": "string", + "description": "Error message when state is 'error'" + }, + "callProviderMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "toolCallId", + "toolName", + "input", + "state" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-url" + ], + "description": "URL source citation - matches AI SDK SourceUrlUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the source" + }, + "title": { + "type": "string", + "description": "Title of the source" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "url" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-document" + ], + "description": "Document source citation - matches AI SDK SourceDocumentUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the document" + }, + "title": { + "type": "string", + "description": "Title of the document" + }, + "filename": { + "type": "string", + "description": "Filename of the document" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "mediaType", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "step-start" + ], + "description": "Step boundary marker - matches AI SDK StepStartUIPart" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ], + "description": "File attachment - matches AI SDK FileUIPart" + }, + "url": { + "type": "string", + "description": "URL of the file (can be hosted URL or Data URL)" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the file" + }, + "filename": { + "type": "string", + "description": "Original filename" + }, + "size": { + "type": "number", + "description": "Size of the file in bytes" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ], + "description": "Image attachment with dimensions" + }, + "url": { + "type": "string", + "description": "URL of the image" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the image" + }, + "filename": { + "type": "string", + "description": "Original filename of the image" + }, + "size": { + "type": "number", + "description": "Size of the image in bytes" + }, + "width": { + "type": "number", + "description": "Width of the image in pixels" + }, + "height": { + "type": "number", + "description": "Height of the image in pixels" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "translation" + ], + "description": "Stored translation for a timeline item message, scoped to an audience" + }, + "text": { + "type": "string", + "description": "Translated text content" + }, + "sourceLanguage": { + "type": "string", + "description": "Detected/source language of the original message" + }, + "targetLanguage": { + "type": "string", + "description": "Language of the translated text" + }, + "audience": { + "type": "string", + "enum": [ + "team", + "visitor" + ], + "description": "Audience this translated text is meant for" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "description": "Whether this translation was created automatically or manually" + }, + "modelId": { + "type": [ + "string", + "null" + ], + "description": "Model identifier used to generate the translation, if any" + } + }, + "required": [ + "type", + "text", + "sourceLanguage", + "targetLanguage", + "audience", + "mode" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "event" + ], + "description": "Type of timeline part - always 'event' for event parts" + }, + "eventType": { + "type": "string", + "enum": [ + "assigned", + "unassigned", + "participant_requested", + "participant_joined", + "participant_left", + "status_changed", + "priority_changed", + "tag_added", + "tag_removed", + "resolved", + "reopened", + "visitor_blocked", + "visitor_unblocked", + "visitor_identified", + "ai_paused", + "ai_resumed" + ], + "description": "Type of event that occurred" + }, + "actorUserId": { + "type": [ + "string", + "null" + ], + "description": "User that triggered the event, if applicable" + }, + "actorAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that triggered the event, if applicable" + }, + "targetUserId": { + "type": [ + "string", + "null" + ], + "description": "User targeted by the event, if applicable" + }, + "targetAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent targeted by the event, if applicable" + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "Optional human readable message attached to the event" + } + }, + "required": [ + "type", + "eventType", + "actorUserId", + "actorAiAgentId", + "targetUserId", + "targetAiAgentId" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "metadata" + ], + "description": "Type of timeline part - always 'metadata' for metadata parts" + }, + "source": { + "type": "string", + "enum": [ + "email", + "widget", + "api" + ], + "description": "Source channel through which the message was created" + } + }, + "required": [ + "type", + "source" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "feedback" + ], + "description": "Type of timeline part - always 'feedback' for feedback parts" + }, + "feedbackId": { + "type": "string", + "description": "Feedback record associated with this timeline item" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Visitor rating from 1 to 5" + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Structured feedback topic selected by the visitor" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback" + }, + "source": { + "type": "string", + "description": "Source channel where the feedback was collected" + } + }, + "required": [ + "type", + "feedbackId", + "rating", + "source" + ] + } + ] + }, + "description": "Array of timeline parts that make up the timeline item content. Includes AI SDK compatible parts (text, reasoning, tool-*, source-url, source-document, step-start, file, image) and Cossistant-specific parts (event, metadata, feedback)." + }, + "userId": { + "type": [ + "string", + "null" + ], + "description": "ID of the user who created this timeline item, if applicable" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "description": "ID of the AI agent that created this timeline item, if applicable" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "ID of the visitor who created this timeline item, if applicable" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was created", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was deleted, if applicable", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "conversationId", + "organizationId", + "visibility", + "type", + "text", + "parts", + "userId", + "aiAgentId", + "visitorId", + "createdAt" + ], + "description": "Latest message timeline item for the conversation, if any." + }, + "lastTimelineItem": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the timeline item" + }, + "conversationId": { + "type": "string", + "description": "ID of the conversation this timeline item belongs to" + }, + "organizationId": { + "type": "string", + "description": "ID of the organization this timeline item belongs to" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility level of the timeline item" + }, + "type": { + "type": "string", + "enum": [ + "message", + "event", + "identification", + "tool" + ], + "description": "Type of timeline item - message, event, identification, or tool call" + }, + "text": { + "type": [ + "string", + "null" + ], + "description": "Main text content of the timeline item" + }, + "tool": { + "type": [ + "string", + "null" + ], + "description": "Optional tool identifier associated with this timeline item" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "Text content part - matches AI SDK TextUIPart" + }, + "text": { + "type": "string", + "description": "The text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "description": "AI reasoning/chain-of-thought - matches AI SDK ReasoningUIPart" + }, + "text": { + "type": "string", + "description": "The reasoning text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "pattern": "^tool-.+$", + "description": "Tool type following AI SDK pattern: tool-{toolName}" + }, + "toolCallId": { + "type": "string", + "description": "Unique identifier for this tool invocation" + }, + "toolName": { + "type": "string", + "description": "Name of the tool being invoked" + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input parameters passed to the tool" + }, + "output": { + "description": "Output returned by the tool (when state is 'result')" + }, + "state": { + "type": "string", + "enum": [ + "partial", + "result", + "error" + ], + "description": "AI SDK tool state: 'partial' = executing, 'result' = success, 'error' = failed" + }, + "errorText": { + "type": "string", + "description": "Error message when state is 'error'" + }, + "callProviderMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "toolCallId", + "toolName", + "input", + "state" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-url" + ], + "description": "URL source citation - matches AI SDK SourceUrlUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the source" + }, + "title": { + "type": "string", + "description": "Title of the source" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "url" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-document" + ], + "description": "Document source citation - matches AI SDK SourceDocumentUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the document" + }, + "title": { + "type": "string", + "description": "Title of the document" + }, + "filename": { + "type": "string", + "description": "Filename of the document" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "mediaType", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "step-start" + ], + "description": "Step boundary marker - matches AI SDK StepStartUIPart" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ], + "description": "File attachment - matches AI SDK FileUIPart" + }, + "url": { + "type": "string", + "description": "URL of the file (can be hosted URL or Data URL)" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the file" + }, + "filename": { + "type": "string", + "description": "Original filename" + }, + "size": { + "type": "number", + "description": "Size of the file in bytes" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ], + "description": "Image attachment with dimensions" + }, + "url": { + "type": "string", + "description": "URL of the image" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the image" + }, + "filename": { + "type": "string", + "description": "Original filename of the image" + }, + "size": { + "type": "number", + "description": "Size of the image in bytes" + }, + "width": { + "type": "number", + "description": "Width of the image in pixels" + }, + "height": { + "type": "number", + "description": "Height of the image in pixels" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "translation" + ], + "description": "Stored translation for a timeline item message, scoped to an audience" + }, + "text": { + "type": "string", + "description": "Translated text content" + }, + "sourceLanguage": { + "type": "string", + "description": "Detected/source language of the original message" + }, + "targetLanguage": { + "type": "string", + "description": "Language of the translated text" + }, + "audience": { + "type": "string", + "enum": [ + "team", + "visitor" + ], + "description": "Audience this translated text is meant for" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "description": "Whether this translation was created automatically or manually" + }, + "modelId": { + "type": [ + "string", + "null" + ], + "description": "Model identifier used to generate the translation, if any" + } + }, + "required": [ + "type", + "text", + "sourceLanguage", + "targetLanguage", + "audience", + "mode" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "event" + ], + "description": "Type of timeline part - always 'event' for event parts" + }, + "eventType": { + "type": "string", + "enum": [ + "assigned", + "unassigned", + "participant_requested", + "participant_joined", + "participant_left", + "status_changed", + "priority_changed", + "tag_added", + "tag_removed", + "resolved", + "reopened", + "visitor_blocked", + "visitor_unblocked", + "visitor_identified", + "ai_paused", + "ai_resumed" + ], + "description": "Type of event that occurred" + }, + "actorUserId": { + "type": [ + "string", + "null" + ], + "description": "User that triggered the event, if applicable" + }, + "actorAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that triggered the event, if applicable" + }, + "targetUserId": { + "type": [ + "string", + "null" + ], + "description": "User targeted by the event, if applicable" + }, + "targetAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent targeted by the event, if applicable" + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "Optional human readable message attached to the event" + } + }, + "required": [ + "type", + "eventType", + "actorUserId", + "actorAiAgentId", + "targetUserId", + "targetAiAgentId" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "metadata" + ], + "description": "Type of timeline part - always 'metadata' for metadata parts" + }, + "source": { + "type": "string", + "enum": [ + "email", + "widget", + "api" + ], + "description": "Source channel through which the message was created" + } + }, + "required": [ + "type", + "source" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "feedback" + ], + "description": "Type of timeline part - always 'feedback' for feedback parts" + }, + "feedbackId": { + "type": "string", + "description": "Feedback record associated with this timeline item" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Visitor rating from 1 to 5" + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Structured feedback topic selected by the visitor" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback" + }, + "source": { + "type": "string", + "description": "Source channel where the feedback was collected" + } + }, + "required": [ + "type", + "feedbackId", + "rating", + "source" + ] + } + ] + }, + "description": "Array of timeline parts that make up the timeline item content. Includes AI SDK compatible parts (text, reasoning, tool-*, source-url, source-document, step-start, file, image) and Cossistant-specific parts (event, metadata, feedback)." + }, + "userId": { + "type": [ + "string", + "null" + ], + "description": "ID of the user who created this timeline item, if applicable" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "description": "ID of the AI agent that created this timeline item, if applicable" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "ID of the visitor who created this timeline item, if applicable" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was created", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was deleted, if applicable", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "conversationId", + "organizationId", + "visibility", + "type", + "text", + "parts", + "userId", + "aiAgentId", + "visitorId", + "createdAt" + ], + "description": "Latest timeline item for the conversation, including private events." + }, + "activeClarification": { + "type": [ + "object", + "null" + ], + "properties": { + "requestId": { + "type": "string", + "format": "ulid" + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "analyzing" + ] + }, + { + "type": "string", + "enum": [ + "awaiting_answer" + ] + }, + { + "type": "string", + "enum": [ + "retry_required" + ] + }, + { + "type": "string", + "enum": [ + "draft_ready" + ] + } + ], + "description": "Clarification statuses that should appear on active conversation surfaces.", + "example": "awaiting_answer" + }, + "topicSummary": { + "type": "string" + }, + "engagementMode": { + "type": "string", + "enum": [ + "owner", + "linked" + ], + "description": "Whether the current conversation owns the clarification flow or is only linked to it.", + "example": "owner" + }, + "linkedConversationCount": { + "type": "integer", + "minimum": 0 + }, + "question": { + "type": [ + "string", + "null" + ] + }, + "currentSuggestedAnswers": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Exactly three suggested answers for the current question.", + "example": [ + "It applies to all plans.", + "It only applies to paid plans.", + "It depends on the feature flag." + ] + }, + "currentQuestionInputMode": { + "type": [ + "string", + "null" + ], + "enum": [ + "textarea_first", + "suggested_answers", + null + ], + "description": "How the current clarification question should be answered.", + "example": "textarea_first" + }, + "currentQuestionScope": { + "type": [ + "string", + "null" + ], + "enum": [ + "broad_discovery", + "narrow_detail", + null + ], + "description": "Whether the current clarification question is broad or narrow.", + "example": "broad_discovery" + }, + "stepIndex": { + "type": "integer", + "minimum": 0 + }, + "maxSteps": { + "type": "integer", + "minimum": 1 + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + }, + "progress": { + "type": [ + "object", + "null" + ], + "properties": { + "phase": { + "type": "string", + "enum": [ + "loading_context", + "reviewing_evidence", + "planning_questions", + "evaluating_answer", + "generating_draft", + "retrying_generation", + "finalizing_step" + ], + "description": "Current transient progress phase while a clarification step is being prepared.", + "example": "planning_questions" + }, + "label": { + "type": "string" + }, + "detail": { + "type": [ + "string", + "null" + ] + }, + "attempt": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "toolName": { + "type": [ + "string", + "null" + ] + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "phase", + "label", + "detail", + "attempt", + "toolName", + "startedAt" + ] + } + }, + "required": [ + "requestId", + "status", + "topicSummary", + "engagementMode", + "linkedConversationCount", + "question", + "currentSuggestedAnswers", + "currentQuestionInputMode", + "currentQuestionScope", + "stepIndex", + "maxSteps", + "updatedAt", + "progress" + ], + "description": "Active knowledge clarification summary linked to the conversation, if any." + }, + "dashboardLocked": { + "type": "boolean", + "description": "Whether the dashboard has hard-limited access to the conversation." + }, + "dashboardLockReason": { + "anyOf": [ + { + "type": "string", + "enum": [ + "conversation_limit" + ] + }, + { + "type": "null" + } + ], + "description": "Why the dashboard access is locked, when applicable." + }, + "viewIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "View identifiers associated with the conversation." + }, + "seenData": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "conversationId": { + "type": "string" + }, + "userId": { + "type": [ + "string", + "null" + ] + }, + "visitorId": { + "type": [ + "string", + "null" + ] + }, + "aiAgentId": { + "type": [ + "string", + "null" + ] + }, + "lastSeenAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision.", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "RFC 3339 / ISO 8601 timestamp in UTC with millisecond precision, or null.", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "id", + "conversationId", + "userId", + "visitorId", + "aiAgentId", + "lastSeenAt", + "createdAt", + "updatedAt", + "deletedAt" + ] + }, + "description": "Seen-state records associated with the conversation." + } + }, + "required": [ + "id", + "status", + "priority", + "prioritySource", + "organizationId", + "visitorId", + "visitor", + "websiteId", + "channel", + "title", + "titleSource", + "sentiment", + "sentimentConfidence", + "sentimentSource", + "resolutionTime", + "visitorRating", + "visitorRatingAt", + "startedAt", + "firstResponseAt", + "resolvedAt", + "resolvedByUserId", + "resolvedByAiAgentId", + "escalatedAt", + "escalatedByAiAgentId", + "escalationReason", + "escalationHandledAt", + "escalationHandledByUserId", + "aiPausedUntil", + "createdAt", + "updatedAt", + "deletedAt", + "lastMessageAt", + "lastSeenAt", + "lastMessageTimelineItem", + "lastTimelineItem", + "activeClarification", + "viewIds", + "seenData" + ], + "description": "Rich conversation summary and current state." + }, + "visitor": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The visitor's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "lastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was last seen.", + "example": "2021-01-01T00:00:00.000Z" + }, + "blockedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was blocked, if applicable.", + "example": "2024-01-01T12:00:00.000Z" + }, + "blockedByUserId": { + "type": [ + "string", + "null" + ], + "description": "Identifier of the team member who blocked the visitor.", + "example": "01JG000000000000000000001" + }, + "isBlocked": { + "type": "boolean", + "description": "Whether the visitor is currently blocked.", + "example": true + }, + "contact": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier.", + "example": "01JG000000000000000000000" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "description": "The contact's avatar/profile image URL.", + "example": "https://example.com/avatar.png" + }, + "metadataHash": { + "type": "string", + "description": "Hash of the contact's metadata. Used to detect if metadata has changed without comparing full objects.", + "example": "a1b2c3d4" + } + }, + "required": [ + "id", + "name", + "email", + "image" + ] + } + }, + "required": [ + "id", + "lastSeenAt", + "blockedAt", + "blockedByUserId", + "isBlocked", + "contact" + ], + "description": "Visitor profile linked to the conversation." + }, + "timeline": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the timeline item" + }, + "conversationId": { + "type": "string", + "description": "ID of the conversation this timeline item belongs to" + }, + "organizationId": { + "type": "string", + "description": "ID of the organization this timeline item belongs to" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility level of the timeline item" + }, + "type": { + "type": "string", + "enum": [ + "message", + "event", + "identification", + "tool" + ], + "description": "Type of timeline item - message, event, identification, or tool call" + }, + "text": { + "type": [ + "string", + "null" + ], + "description": "Main text content of the timeline item" + }, + "tool": { + "type": [ + "string", + "null" + ], + "description": "Optional tool identifier associated with this timeline item" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "Text content part - matches AI SDK TextUIPart" + }, + "text": { + "type": "string", + "description": "The text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "description": "AI reasoning/chain-of-thought - matches AI SDK ReasoningUIPart" + }, + "text": { + "type": "string", + "description": "The reasoning text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "pattern": "^tool-.+$", + "description": "Tool type following AI SDK pattern: tool-{toolName}" + }, + "toolCallId": { + "type": "string", + "description": "Unique identifier for this tool invocation" + }, + "toolName": { + "type": "string", + "description": "Name of the tool being invoked" + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input parameters passed to the tool" + }, + "output": { + "description": "Output returned by the tool (when state is 'result')" + }, + "state": { + "type": "string", + "enum": [ + "partial", + "result", + "error" + ], + "description": "AI SDK tool state: 'partial' = executing, 'result' = success, 'error' = failed" + }, + "errorText": { + "type": "string", + "description": "Error message when state is 'error'" + }, + "callProviderMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "toolCallId", + "toolName", + "input", + "state" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-url" + ], + "description": "URL source citation - matches AI SDK SourceUrlUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the source" + }, + "title": { + "type": "string", + "description": "Title of the source" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "url" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-document" + ], + "description": "Document source citation - matches AI SDK SourceDocumentUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the document" + }, + "title": { + "type": "string", + "description": "Title of the document" + }, + "filename": { + "type": "string", + "description": "Filename of the document" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "mediaType", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "step-start" + ], + "description": "Step boundary marker - matches AI SDK StepStartUIPart" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ], + "description": "File attachment - matches AI SDK FileUIPart" + }, + "url": { + "type": "string", + "description": "URL of the file (can be hosted URL or Data URL)" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the file" + }, + "filename": { + "type": "string", + "description": "Original filename" + }, + "size": { + "type": "number", + "description": "Size of the file in bytes" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ], + "description": "Image attachment with dimensions" + }, + "url": { + "type": "string", + "description": "URL of the image" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the image" + }, + "filename": { + "type": "string", + "description": "Original filename of the image" + }, + "size": { + "type": "number", + "description": "Size of the image in bytes" + }, + "width": { + "type": "number", + "description": "Width of the image in pixels" + }, + "height": { + "type": "number", + "description": "Height of the image in pixels" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "translation" + ], + "description": "Stored translation for a timeline item message, scoped to an audience" + }, + "text": { + "type": "string", + "description": "Translated text content" + }, + "sourceLanguage": { + "type": "string", + "description": "Detected/source language of the original message" + }, + "targetLanguage": { + "type": "string", + "description": "Language of the translated text" + }, + "audience": { + "type": "string", + "enum": [ + "team", + "visitor" + ], + "description": "Audience this translated text is meant for" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "description": "Whether this translation was created automatically or manually" + }, + "modelId": { + "type": [ + "string", + "null" + ], + "description": "Model identifier used to generate the translation, if any" + } + }, + "required": [ + "type", + "text", + "sourceLanguage", + "targetLanguage", + "audience", + "mode" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "event" + ], + "description": "Type of timeline part - always 'event' for event parts" + }, + "eventType": { + "type": "string", + "enum": [ + "assigned", + "unassigned", + "participant_requested", + "participant_joined", + "participant_left", + "status_changed", + "priority_changed", + "tag_added", + "tag_removed", + "resolved", + "reopened", + "visitor_blocked", + "visitor_unblocked", + "visitor_identified", + "ai_paused", + "ai_resumed" + ], + "description": "Type of event that occurred" + }, + "actorUserId": { + "type": [ + "string", + "null" + ], + "description": "User that triggered the event, if applicable" + }, + "actorAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that triggered the event, if applicable" + }, + "targetUserId": { + "type": [ + "string", + "null" + ], + "description": "User targeted by the event, if applicable" + }, + "targetAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent targeted by the event, if applicable" + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "Optional human readable message attached to the event" + } + }, + "required": [ + "type", + "eventType", + "actorUserId", + "actorAiAgentId", + "targetUserId", + "targetAiAgentId" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "metadata" + ], + "description": "Type of timeline part - always 'metadata' for metadata parts" + }, + "source": { + "type": "string", + "enum": [ + "email", + "widget", + "api" + ], + "description": "Source channel through which the message was created" + } + }, + "required": [ + "type", + "source" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "feedback" + ], + "description": "Type of timeline part - always 'feedback' for feedback parts" + }, + "feedbackId": { + "type": "string", + "description": "Feedback record associated with this timeline item" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Visitor rating from 1 to 5" + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Structured feedback topic selected by the visitor" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback" + }, + "source": { + "type": "string", + "description": "Source channel where the feedback was collected" + } + }, + "required": [ + "type", + "feedbackId", + "rating", + "source" + ] + } + ] + }, + "description": "Array of timeline parts that make up the timeline item content. Includes AI SDK compatible parts (text, reasoning, tool-*, source-url, source-document, step-start, file, image) and Cossistant-specific parts (event, metadata, feedback)." + }, + "userId": { + "type": [ + "string", + "null" + ], + "description": "ID of the user who created this timeline item, if applicable" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "description": "ID of the AI agent that created this timeline item, if applicable" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "ID of the visitor who created this timeline item, if applicable" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was created", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was deleted, if applicable", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "conversationId", + "organizationId", + "visibility", + "type", + "text", + "parts", + "userId", + "aiAgentId", + "visitorId", + "createdAt" + ] + }, + "description": "Array of timeline items in chronological order" + }, + "nextCursor": { + "type": [ + "string", + "null" + ], + "description": "Cursor for the next page, null if no more items are available" + }, + "hasNextPage": { + "type": "boolean", + "description": "Whether there are more items available to fetch" + } + }, + "required": [ + "items", + "nextCursor", + "hasNextPage" + ], + "description": "Private timeline page for the conversation." + }, + "feedback": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the feedback" + }, + "organizationId": { + "type": "string", + "description": "Organization ID" + }, + "websiteId": { + "type": "string", + "description": "Website ID the feedback belongs to" + }, + "conversationId": { + "type": [ + "string", + "null" + ], + "description": "Conversation ID if feedback is tied to a conversation" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "Visitor ID who submitted the feedback" + }, + "contactId": { + "type": [ + "string", + "null" + ], + "description": "Contact ID if visitor has one" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Rating from 1 to 5", + "example": 5 + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Optional structured topic selected by the visitor" + }, + "comment": { + "type": [ + "string", + "null" + ], + "description": "Optional written feedback" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback (e.g., 'churn', 'conversation_resolved', 'nps_survey')" + }, + "source": { + "type": "string", + "default": "widget", + "description": "Source of the feedback (e.g., 'widget', 'api', 'email')" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the feedback was submitted", + "example": "2026-04-06T14:37:05.820Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the feedback was last updated", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "id", + "organizationId", + "websiteId", + "conversationId", + "visitorId", + "contactId", + "rating", + "topic", + "comment", + "trigger", + "source", + "createdAt", + "updatedAt" + ], + "description": "Feedback record" + }, + "description": "Feedback records linked to the conversation." + } + }, + "required": [ + "conversation", + "visitor", + "timeline", + "feedback" + ], + "description": "Agent-ready conversation context including private timeline, visitor/contact profile, and linked feedback." + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "getConversationContext" + } + }, + "/conversations/{conversationId}/seen": { + "post": { + "summary": "Mark a conversation as seen by the visitor", + "description": "Record a visitor's last seen timestamp for a specific conversation.", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + }, + { + "name": "X-Visitor-Id", + "in": "header", + "description": "Visitor ID from localStorage.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "visitorId": { + "type": "string", + "description": "Visitor ID associated with the conversation. Optional if provided via the X-Visitor-Id header." + } + }, + "description": "Body for marking a conversation as seen. Either visitorId must be provided via body or headers." + } + } + } + }, + "responses": { + "200": { + "description": "Conversation seen timestamp recorded", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversationId": { + "type": "string", + "description": "The ID of the conversation that was marked as seen" + }, + "lastSeenAt": { + "type": "string", + "format": "date-time", + "description": "Timestamp indicating when the visitor last saw the conversation", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "conversationId", + "lastSeenAt" + ], + "description": "Response confirming the conversation has been marked as seen" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Public key origin validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "markConversationSeen" + }, + "get": { + "summary": "Get conversation seen data", + "description": "Fetch the seen data (read receipts) for a conversation, showing who has seen messages and when.", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + }, + { + "name": "X-Visitor-Id", + "in": "header", + "description": "Visitor ID from localStorage.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "Seen data retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "seenData": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The seen record's unique identifier" + }, + "conversationId": { + "type": "string", + "description": "The conversation ID" + }, + "userId": { + "type": [ + "string", + "null" + ], + "description": "The user ID who saw the conversation, if applicable" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "The visitor ID who saw the conversation, if applicable" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "description": "The AI agent ID who saw the conversation, if applicable" + }, + "lastSeenAt": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the conversation was last seen", + "example": "2026-04-06T14:37:05.820Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the seen record was created", + "example": "2026-04-06T14:37:05.820Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the seen record was last updated", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the seen record was deleted, if applicable", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "id", + "conversationId", + "userId", + "visitorId", + "aiAgentId", + "lastSeenAt", + "createdAt", + "updatedAt", + "deletedAt" + ] + } + } + }, + "required": [ + "seenData" + ], + "description": "Response containing seen data for a conversation" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Public key origin validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "getConversationSeen" + } + }, + "/conversations/{conversationId}/typing": { + "post": { + "summary": "Report a visitor typing state", + "description": "Emit a typing indicator event for the visitor. Either visitorId must be provided via body or headers.", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + }, + { + "name": "X-Visitor-Id", + "in": "header", + "description": "Visitor ID from localStorage.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "isTyping": { + "type": "boolean", + "description": "Whether the visitor is currently typing" + }, + "visitorPreview": { + "type": "string", + "maxLength": 2000, + "description": "Optional preview of the visitor's message while typing. Only processed when the visitor is typing." + }, + "visitorId": { + "type": "string", + "description": "Visitor ID associated with the conversation. Optional if provided via the X-Visitor-Id header." + } + }, + "required": [ + "isTyping" + ], + "description": "Body for reporting a visitor typing state. Either visitorId must be provided via body or headers." + } + } + } + }, + "responses": { + "200": { + "description": "Typing state recorded", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversationId": { + "type": "string", + "description": "The ID of the conversation receiving the typing update" + }, + "isTyping": { + "type": "boolean", + "description": "Echo of the reported typing state" + }, + "visitorPreview": { + "type": [ + "string", + "null" + ], + "description": "Preview text that was forwarded with the typing event, or null when none was sent." + }, + "sentAt": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the typing event was recorded", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "conversationId", + "isTyping", + "visitorPreview", + "sentAt" + ], + "description": "Response confirming the visitor typing state was recorded" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Public key origin validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "setConversationTyping" + } + }, + "/conversations/{conversationId}/rating": { + "post": { + "summary": "Submit a visitor rating for a conversation", + "description": "Record a visitor rating (1-5) for a resolved conversation. Requires visitor ownership.", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + }, + { + "name": "X-Visitor-Id", + "in": "header", + "description": "Visitor ID from localStorage.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Visitor rating for the conversation (1-5)", + "example": 5 + }, + "comment": { + "type": "string", + "description": "Optional written feedback about the conversation" + }, + "visitorId": { + "type": "string", + "description": "Visitor ID associated with the conversation. Optional if provided via the X-Visitor-Id header." + } + }, + "required": [ + "rating" + ], + "description": "Body for submitting a visitor rating on a conversation." + } + } + } + }, + "responses": { + "200": { + "description": "Conversation rating recorded", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversationId": { + "type": "string", + "description": "The ID of the conversation that was rated" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "The rating that was saved" + }, + "ratedAt": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the rating was recorded", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "conversationId", + "rating", + "ratedAt" + ], + "description": "Response confirming the conversation rating was recorded" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "createConversationRating" + } + }, + "/conversations/{conversationId}/export": { + "get": { + "summary": "Download a full conversation export", + "description": "Returns the full internal conversation transcript as plain text. This control-plane endpoint requires a private API key.", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "schema": { + "type": "string", + "description": "The ID of the conversation to retrieve" + }, + "required": true, + "description": "The ID of the conversation to retrieve", + "name": "conversationId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Conversation export generated successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "exportConversation" + } + }, + "/conversations/{conversationId}/timeline": { + "get": { + "summary": "Get conversation timeline items", + "description": "Fetch paginated timeline items (messages and events) for a conversation in chronological order.", + "tags": [ + "Conversations" + ], + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "The ID of the conversation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + }, + { + "name": "X-Visitor-Id", + "in": "header", + "description": "Visitor ID from localStorage.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + }, + { + "schema": { + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 50, + "description": "Number of timeline items to fetch per page" + }, + "required": false, + "description": "Number of timeline items to fetch per page", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": [ + "string", + "null" + ], + "description": "Cursor for pagination (timestamp_id format from previous response)" + }, + "required": false, + "description": "Cursor for pagination (timestamp_id format from previous response)", + "name": "cursor", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Timeline items retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the timeline item" + }, + "conversationId": { + "type": "string", + "description": "ID of the conversation this timeline item belongs to" + }, + "organizationId": { + "type": "string", + "description": "ID of the organization this timeline item belongs to" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility level of the timeline item" + }, + "type": { + "type": "string", + "enum": [ + "message", + "event", + "identification", + "tool" + ], + "description": "Type of timeline item - message, event, identification, or tool call" + }, + "text": { + "type": [ + "string", + "null" + ], + "description": "Main text content of the timeline item" + }, + "tool": { + "type": [ + "string", + "null" + ], + "description": "Optional tool identifier associated with this timeline item" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "Text content part - matches AI SDK TextUIPart" + }, + "text": { + "type": "string", + "description": "The text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "description": "AI reasoning/chain-of-thought - matches AI SDK ReasoningUIPart" + }, + "text": { + "type": "string", + "description": "The reasoning text content" + }, + "state": { + "type": "string", + "enum": [ + "streaming", + "done" + ], + "description": "AI SDK state: 'streaming' = still processing, 'done' = complete" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "pattern": "^tool-.+$", + "description": "Tool type following AI SDK pattern: tool-{toolName}" + }, + "toolCallId": { + "type": "string", + "description": "Unique identifier for this tool invocation" + }, + "toolName": { + "type": "string", + "description": "Name of the tool being invoked" + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "Input parameters passed to the tool" + }, + "output": { + "description": "Output returned by the tool (when state is 'result')" + }, + "state": { + "type": "string", + "enum": [ + "partial", + "result", + "error" + ], + "description": "AI SDK tool state: 'partial' = executing, 'result' = success, 'error' = failed" + }, + "errorText": { + "type": "string", + "description": "Error message when state is 'error'" + }, + "callProviderMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "toolCallId", + "toolName", + "input", + "state" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-url" + ], + "description": "URL source citation - matches AI SDK SourceUrlUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the source" + }, + "title": { + "type": "string", + "description": "Title of the source" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "url" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "source-document" + ], + "description": "Document source citation - matches AI SDK SourceDocumentUIPart" + }, + "sourceId": { + "type": "string", + "description": "Unique identifier for this source" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the document" + }, + "title": { + "type": "string", + "description": "Title of the document" + }, + "filename": { + "type": "string", + "description": "Filename of the document" + }, + "providerMetadata": { + "type": "object", + "properties": { + "cossistant": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Part-level visibility control for filtering" + }, + "progressMessage": { + "type": "string", + "description": "Custom progress message to display during execution" + }, + "knowledgeId": { + "type": "string", + "description": "Reference to a Cossistant knowledge entry" + }, + "toolTimeline": { + "type": "object", + "properties": { + "logType": { + "type": "string", + "enum": [ + "customer_facing", + "log", + "decision" + ], + "description": "Tool timeline classification: conversation-visible, log-only, or decision stage" + }, + "triggerMessageId": { + "type": "string", + "description": "Message ID that triggered the tool/decision workflow execution" + }, + "workflowRunId": { + "type": "string", + "description": "Workflow run identifier used to correlate tool timeline updates" + }, + "triggerVisibility": { + "type": "string", + "enum": [ + "public", + "private" + ], + "description": "Visibility of the trigger message at the time the tool was executed" + } + }, + "required": [ + "logType", + "triggerMessageId", + "workflowRunId" + ], + "description": "Tool timeline metadata used to classify visibility and trigger linkage" + } + } + } + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "sourceId", + "mediaType", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "step-start" + ], + "description": "Step boundary marker - matches AI SDK StepStartUIPart" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ], + "description": "File attachment - matches AI SDK FileUIPart" + }, + "url": { + "type": "string", + "description": "URL of the file (can be hosted URL or Data URL)" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the file" + }, + "filename": { + "type": "string", + "description": "Original filename" + }, + "size": { + "type": "number", + "description": "Size of the file in bytes" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ], + "description": "Image attachment with dimensions" + }, + "url": { + "type": "string", + "description": "URL of the image" + }, + "mediaType": { + "type": "string", + "description": "IANA media type of the image" + }, + "filename": { + "type": "string", + "description": "Original filename of the image" + }, + "size": { + "type": "number", + "description": "Size of the image in bytes" + }, + "width": { + "type": "number", + "description": "Width of the image in pixels" + }, + "height": { + "type": "number", + "description": "Height of the image in pixels" + } + }, + "required": [ + "type", + "url", + "mediaType" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "translation" + ], + "description": "Stored translation for a timeline item message, scoped to an audience" + }, + "text": { + "type": "string", + "description": "Translated text content" + }, + "sourceLanguage": { + "type": "string", + "description": "Detected/source language of the original message" + }, + "targetLanguage": { + "type": "string", + "description": "Language of the translated text" + }, + "audience": { + "type": "string", + "enum": [ + "team", + "visitor" + ], + "description": "Audience this translated text is meant for" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "description": "Whether this translation was created automatically or manually" + }, + "modelId": { + "type": [ + "string", + "null" + ], + "description": "Model identifier used to generate the translation, if any" + } + }, + "required": [ + "type", + "text", + "sourceLanguage", + "targetLanguage", + "audience", + "mode" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "event" + ], + "description": "Type of timeline part - always 'event' for event parts" + }, + "eventType": { + "type": "string", + "enum": [ + "assigned", + "unassigned", + "participant_requested", + "participant_joined", + "participant_left", + "status_changed", + "priority_changed", + "tag_added", + "tag_removed", + "resolved", + "reopened", + "visitor_blocked", + "visitor_unblocked", + "visitor_identified", + "ai_paused", + "ai_resumed" + ], + "description": "Type of event that occurred" + }, + "actorUserId": { + "type": [ + "string", + "null" + ], + "description": "User that triggered the event, if applicable" + }, + "actorAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent that triggered the event, if applicable" + }, + "targetUserId": { + "type": [ + "string", + "null" + ], + "description": "User targeted by the event, if applicable" + }, + "targetAiAgentId": { + "type": [ + "string", + "null" + ], + "description": "AI agent targeted by the event, if applicable" + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "Optional human readable message attached to the event" + } + }, + "required": [ + "type", + "eventType", + "actorUserId", + "actorAiAgentId", + "targetUserId", + "targetAiAgentId" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "metadata" + ], + "description": "Type of timeline part - always 'metadata' for metadata parts" + }, + "source": { + "type": "string", + "enum": [ + "email", + "widget", + "api" + ], + "description": "Source channel through which the message was created" + } + }, + "required": [ + "type", + "source" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "feedback" + ], + "description": "Type of timeline part - always 'feedback' for feedback parts" + }, + "feedbackId": { + "type": "string", + "description": "Feedback record associated with this timeline item" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Visitor rating from 1 to 5" + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Structured feedback topic selected by the visitor" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback" + }, + "source": { + "type": "string", + "description": "Source channel where the feedback was collected" + } + }, + "required": [ + "type", + "feedbackId", + "rating", + "source" + ] + } + ] + }, + "description": "Array of timeline parts that make up the timeline item content. Includes AI SDK compatible parts (text, reasoning, tool-*, source-url, source-document, step-start, file, image) and Cossistant-specific parts (event, metadata, feedback)." + }, + "userId": { + "type": [ + "string", + "null" + ], + "description": "ID of the user who created this timeline item, if applicable" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "description": "ID of the AI agent that created this timeline item, if applicable" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "ID of the visitor who created this timeline item, if applicable" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was created", + "example": "2026-04-06T14:37:05.820Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "ISO 8601 timestamp when the timeline item was deleted, if applicable", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "conversationId", + "organizationId", + "visibility", + "type", + "text", + "parts", + "userId", + "aiAgentId", + "visitorId", + "createdAt" + ] + }, + "description": "Array of timeline items in chronological order" + }, + "nextCursor": { + "type": [ + "string", + "null" + ], + "description": "Cursor for the next page, null if no more items are available" + }, + "hasNextPage": { + "type": "boolean", + "description": "Whether there are more items available to fetch" + } + }, + "required": [ + "items", + "nextCursor", + "hasNextPage" + ], + "description": "Response containing paginated timeline items" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Public key origin validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "getConversationTimeline" + } + }, + "/visitors/{id}/activity": { + "post": { + "summary": "Track live visitor activity", + "description": "Records live visitor activity for realtime dashboards. This endpoint is the canonical ingestion path for live visitor presence and page activity.", + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The visitor ID", + "schema": { + "type": "string" + } + }, + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "minLength": 1, + "description": "Stable per-tab or per-session identifier for the visitor.", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "activityType": { + "type": "string", + "enum": [ + "connected", + "focus", + "heartbeat", + "route_change" + ], + "description": "The kind of live visitor activity being reported.", + "example": "heartbeat" + }, + "attribution": { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ], + "description": "Schema version for the attribution payload.", + "example": 1 + }, + "firstTouch": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "enum": [ + "direct", + "email", + "paid", + "organic_search", + "social", + "referral" + ], + "description": "Derived acquisition channel.", + "example": "referral" + }, + "isDirect": { + "type": "boolean", + "description": "Whether the visit should be treated as direct traffic.", + "example": false + }, + "referrer": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized external referrer URL without arbitrary query data.", + "example": "https://news.ycombinator.com/item?id=123" + }, + "domain": { + "type": [ + "string", + "null" + ], + "description": "Normalized referrer hostname.", + "example": "news.ycombinator.com" + } + }, + "required": [ + "url", + "domain" + ] + }, + "landing": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized landing page URL including only supported attribution params.", + "example": "https://app.example.com/pricing?utm_source=hn&utm_medium=community" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Landing page path.", + "example": "/pricing" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Document title captured on the landing page.", + "example": "Pricing | Example" + } + }, + "required": [ + "url", + "path", + "title" + ] + }, + "utm": { + "type": "object", + "properties": { + "source": { + "type": [ + "string", + "null" + ], + "description": "UTM source value.", + "example": "hn" + }, + "medium": { + "type": [ + "string", + "null" + ], + "description": "UTM medium value.", + "example": "community" + }, + "campaign": { + "type": [ + "string", + "null" + ], + "description": "UTM campaign value.", + "example": "launch_week" + }, + "content": { + "type": [ + "string", + "null" + ], + "description": "UTM content value.", + "example": "hero_cta" + }, + "term": { + "type": [ + "string", + "null" + ], + "description": "UTM term value.", + "example": "ai support" + } + }, + "required": [ + "source", + "medium", + "campaign", + "content", + "term" + ] + }, + "clickIds": { + "type": "object", + "properties": { + "gclid": { + "type": [ + "string", + "null" + ], + "description": "Google Ads click identifier.", + "example": "gclid_123" + }, + "gbraid": { + "type": [ + "string", + "null" + ], + "description": "Google iOS app click identifier.", + "example": "gbraid_123" + }, + "wbraid": { + "type": [ + "string", + "null" + ], + "description": "Google web-to-app click identifier.", + "example": "wbraid_123" + }, + "fbclid": { + "type": [ + "string", + "null" + ], + "description": "Meta click identifier.", + "example": "fbclid_123" + }, + "msclkid": { + "type": [ + "string", + "null" + ], + "description": "Microsoft Ads click identifier.", + "example": "msclkid_123" + }, + "ttclid": { + "type": [ + "string", + "null" + ], + "description": "TikTok click identifier.", + "example": "ttclid_123" + }, + "li_fat_id": { + "type": [ + "string", + "null" + ], + "description": "LinkedIn click identifier.", + "example": "li_fat_id_123" + }, + "twclid": { + "type": [ + "string", + "null" + ], + "description": "X/Twitter click identifier.", + "example": "twclid_123" + } + }, + "required": [ + "gclid", + "gbraid", + "wbraid", + "fbclid", + "msclkid", + "ttclid", + "li_fat_id", + "twclid" + ] + }, + "capturedAt": { + "type": "string", + "format": "date-time", + "description": "When the first-touch attribution snapshot was captured.", + "example": "2026-03-12T10:00:00.000Z" + } + }, + "required": [ + "channel", + "isDirect", + "referrer", + "landing", + "utm", + "clickIds", + "capturedAt" + ] + } + }, + "required": [ + "version", + "firstTouch" + ], + "description": "Current first-touch attribution snapshot captured in the widget." + }, + "currentPage": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized current page URL including only supported attribution params.", + "example": "https://app.example.com/pricing" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Current page path.", + "example": "/pricing" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Current document title.", + "example": "Pricing | Example" + }, + "referrerUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized document referrer URL for the current page context.", + "example": "https://news.ycombinator.com/item?id=123" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the current page context was last updated.", + "example": "2026-03-12T10:00:05.000Z" + } + }, + "required": [ + "url", + "path", + "title", + "referrerUrl", + "updatedAt" + ], + "description": "Current page context captured in the widget." + }, + "occurredAt": { + "type": "string", + "format": "date-time", + "description": "Optional client-observed timestamp for diagnostics. Server ingestion time remains authoritative.", + "example": "2026-03-26T10:00:00.000Z" + } + }, + "required": [ + "sessionId", + "activityType", + "attribution", + "currentPage" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Live activity accepted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ], + "description": "Whether the activity event was accepted.", + "example": true + }, + "acceptedAt": { + "type": "string", + "format": "date-time", + "description": "Server timestamp when the activity event was accepted.", + "example": "2026-03-26T10:00:01.000Z" + } + }, + "required": [ + "ok", + "acceptedAt" + ] + } + } + } + }, + "400": { + "description": "Invalid request data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Public key origin validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Visitor not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "createVisitorsIdActivity", + "tags": [ + "Visitors" + ] + } + }, + "/visitors/{id}": { + "patch": { + "summary": "Update existing visitor information", + "description": "Updates an existing visitor's browser, device, and location data. The visitor must already exist in the system.", + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The visitor ID to update", + "schema": { + "type": "string" + } + }, + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "externalId": { + "type": "string", + "description": "External identifier for the visitor (e.g. from your system).", + "example": "user_12345" + }, + "name": { + "type": "string", + "description": "The visitor's name.", + "example": "John Doe" + }, + "email": { + "type": "string", + "format": "email", + "description": "The visitor's email address.", + "example": "john.doe@example.com" + }, + "browser": { + "type": "string", + "description": "The visitor's browser.", + "example": "Chrome" + }, + "browserVersion": { + "type": "string", + "description": "The visitor's browser version.", + "example": "120.0.0" + }, + "os": { + "type": "string", + "description": "The visitor's operating system.", + "example": "Windows" + }, + "osVersion": { + "type": "string", + "description": "The visitor's operating system version.", + "example": "11" + }, + "device": { + "type": "string", + "description": "The visitor's device.", + "example": "MacBook Pro" + }, + "deviceType": { + "type": "string", + "enum": [ + "desktop", + "mobile", + "tablet", + "unknown" + ], + "description": "The visitor's device type.", + "example": "desktop" + }, + "ip": { + "type": "string", + "description": "The visitor's IP address.", + "example": "192.168.1.1" + }, + "city": { + "type": "string", + "description": "The visitor's city.", + "example": "San Francisco" + }, + "region": { + "type": "string", + "description": "The visitor's region/state.", + "example": "California" + }, + "country": { + "type": "string", + "description": "The visitor's country.", + "example": "United States" + }, + "countryCode": { + "type": "string", + "maxLength": 2, + "description": "The visitor's country code (ISO 3166-1 alpha-2).", + "example": "US" + }, + "latitude": { + "type": "number", + "description": "The visitor's latitude.", + "example": 37.7749 + }, + "longitude": { + "type": "number", + "description": "The visitor's longitude.", + "example": -122.4194 + }, + "language": { + "type": "string", + "description": "The visitor's preferred language.", + "example": "en-US" + }, + "timezone": { + "type": "string", + "description": "The visitor's timezone.", + "example": "America/Los_Angeles" + }, + "screenResolution": { + "type": "string", + "description": "The visitor's screen resolution.", + "example": "1920x1080" + }, + "viewport": { + "type": "string", + "description": "The visitor's viewport size.", + "example": "1920x900" + }, + "attribution": { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ], + "description": "Schema version for the attribution payload.", + "example": 1 + }, + "firstTouch": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "enum": [ + "direct", + "email", + "paid", + "organic_search", + "social", + "referral" + ], + "description": "Derived acquisition channel.", + "example": "referral" + }, + "isDirect": { + "type": "boolean", + "description": "Whether the visit should be treated as direct traffic.", + "example": false + }, + "referrer": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized external referrer URL without arbitrary query data.", + "example": "https://news.ycombinator.com/item?id=123" + }, + "domain": { + "type": [ + "string", + "null" + ], + "description": "Normalized referrer hostname.", + "example": "news.ycombinator.com" + } + }, + "required": [ + "url", + "domain" + ] + }, + "landing": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized landing page URL including only supported attribution params.", + "example": "https://app.example.com/pricing?utm_source=hn&utm_medium=community" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Landing page path.", + "example": "/pricing" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Document title captured on the landing page.", + "example": "Pricing | Example" + } + }, + "required": [ + "url", + "path", + "title" + ] + }, + "utm": { + "type": "object", + "properties": { + "source": { + "type": [ + "string", + "null" + ], + "description": "UTM source value.", + "example": "hn" + }, + "medium": { + "type": [ + "string", + "null" + ], + "description": "UTM medium value.", + "example": "community" + }, + "campaign": { + "type": [ + "string", + "null" + ], + "description": "UTM campaign value.", + "example": "launch_week" + }, + "content": { + "type": [ + "string", + "null" + ], + "description": "UTM content value.", + "example": "hero_cta" + }, + "term": { + "type": [ + "string", + "null" + ], + "description": "UTM term value.", + "example": "ai support" + } + }, + "required": [ + "source", + "medium", + "campaign", + "content", + "term" + ] + }, + "clickIds": { + "type": "object", + "properties": { + "gclid": { + "type": [ + "string", + "null" + ], + "description": "Google Ads click identifier.", + "example": "gclid_123" + }, + "gbraid": { + "type": [ + "string", + "null" + ], + "description": "Google iOS app click identifier.", + "example": "gbraid_123" + }, + "wbraid": { + "type": [ + "string", + "null" + ], + "description": "Google web-to-app click identifier.", + "example": "wbraid_123" + }, + "fbclid": { + "type": [ + "string", + "null" + ], + "description": "Meta click identifier.", + "example": "fbclid_123" + }, + "msclkid": { + "type": [ + "string", + "null" + ], + "description": "Microsoft Ads click identifier.", + "example": "msclkid_123" + }, + "ttclid": { + "type": [ + "string", + "null" + ], + "description": "TikTok click identifier.", + "example": "ttclid_123" + }, + "li_fat_id": { + "type": [ + "string", + "null" + ], + "description": "LinkedIn click identifier.", + "example": "li_fat_id_123" + }, + "twclid": { + "type": [ + "string", + "null" + ], + "description": "X/Twitter click identifier.", + "example": "twclid_123" + } + }, + "required": [ + "gclid", + "gbraid", + "wbraid", + "fbclid", + "msclkid", + "ttclid", + "li_fat_id", + "twclid" + ] + }, + "capturedAt": { + "type": "string", + "format": "date-time", + "description": "When the first-touch attribution snapshot was captured.", + "example": "2026-03-12T10:00:00.000Z" + } + }, + "required": [ + "channel", + "isDirect", + "referrer", + "landing", + "utm", + "clickIds", + "capturedAt" + ] + } + }, + "required": [ + "version", + "firstTouch" + ], + "description": "Normalized acquisition data captured for this visitor." + }, + "currentPage": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized current page URL including only supported attribution params.", + "example": "https://app.example.com/pricing" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Current page path.", + "example": "/pricing" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Current document title.", + "example": "Pricing | Example" + }, + "referrerUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized document referrer URL for the current page context.", + "example": "https://news.ycombinator.com/item?id=123" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the current page context was last updated.", + "example": "2026-03-12T10:00:05.000Z" + } + }, + "required": [ + "url", + "path", + "title", + "referrerUrl", + "updatedAt" + ], + "description": "Latest page context captured for this visitor." + }, + "metadata": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the visitor.", + "example": { + "plan": "premium", + "role": "admin" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Visitor information successfully created or updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The visitor's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "browser": { + "type": [ + "string", + "null" + ], + "description": "The visitor's browser.", + "example": "Chrome" + }, + "browserVersion": { + "type": [ + "string", + "null" + ], + "description": "The visitor's browser version.", + "example": "120.0.0" + }, + "os": { + "type": [ + "string", + "null" + ], + "description": "The visitor's operating system.", + "example": "Windows" + }, + "osVersion": { + "type": [ + "string", + "null" + ], + "description": "The visitor's operating system version.", + "example": "11" + }, + "device": { + "type": [ + "string", + "null" + ], + "description": "The visitor's device.", + "example": "MacBook Pro" + }, + "deviceType": { + "type": [ + "string", + "null" + ], + "description": "The visitor's device type.", + "example": "desktop" + }, + "ip": { + "type": [ + "string", + "null" + ], + "description": "The visitor's IP address.", + "example": "192.168.1.1" + }, + "city": { + "type": [ + "string", + "null" + ], + "description": "The visitor's city.", + "example": "San Francisco" + }, + "region": { + "type": [ + "string", + "null" + ], + "description": "The visitor's region/state.", + "example": "California" + }, + "country": { + "type": [ + "string", + "null" + ], + "description": "The visitor's country.", + "example": "United States" + }, + "countryCode": { + "type": [ + "string", + "null" + ], + "description": "The visitor's country code (ISO 3166-1 alpha-2).", + "example": "US" + }, + "latitude": { + "type": [ + "number", + "null" + ], + "description": "The visitor's latitude.", + "example": 37.7749 + }, + "longitude": { + "type": [ + "number", + "null" + ], + "description": "The visitor's longitude.", + "example": -122.4194 + }, + "language": { + "type": [ + "string", + "null" + ], + "description": "The visitor's preferred language.", + "example": "en-US" + }, + "timezone": { + "type": [ + "string", + "null" + ], + "description": "The visitor's timezone.", + "example": "America/Los_Angeles" + }, + "screenResolution": { + "type": [ + "string", + "null" + ], + "description": "The visitor's screen resolution.", + "example": "1920x1080" + }, + "viewport": { + "type": [ + "string", + "null" + ], + "description": "The visitor's viewport size.", + "example": "1920x900" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the visitor was first seen.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the visitor record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + }, + "lastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was last connected or active.", + "example": "2021-01-01T00:00:00.000Z" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the visitor belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the visitor belongs to.", + "example": "01JG000000000000000000000" + }, + "blockedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was blocked, if applicable.", + "example": "2024-01-01T12:00:00.000Z" + }, + "blockedByUserId": { + "type": [ + "string", + "null" + ], + "description": "Identifier of the team member who blocked the visitor.", + "example": "01JG000000000000000000001" + }, + "isBlocked": { + "type": "boolean", + "description": "Whether the visitor is currently blocked.", + "example": true + }, + "attribution": { + "type": [ + "object", + "null" + ], + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ], + "description": "Schema version for the attribution payload.", + "example": 1 + }, + "firstTouch": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "enum": [ + "direct", + "email", + "paid", + "organic_search", + "social", + "referral" + ], + "description": "Derived acquisition channel.", + "example": "referral" + }, + "isDirect": { + "type": "boolean", + "description": "Whether the visit should be treated as direct traffic.", + "example": false + }, + "referrer": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized external referrer URL without arbitrary query data.", + "example": "https://news.ycombinator.com/item?id=123" + }, + "domain": { + "type": [ + "string", + "null" + ], + "description": "Normalized referrer hostname.", + "example": "news.ycombinator.com" + } + }, + "required": [ + "url", + "domain" + ] + }, + "landing": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized landing page URL including only supported attribution params.", + "example": "https://app.example.com/pricing?utm_source=hn&utm_medium=community" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Landing page path.", + "example": "/pricing" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Document title captured on the landing page.", + "example": "Pricing | Example" + } + }, + "required": [ + "url", + "path", + "title" + ] + }, + "utm": { + "type": "object", + "properties": { + "source": { + "type": [ + "string", + "null" + ], + "description": "UTM source value.", + "example": "hn" + }, + "medium": { + "type": [ + "string", + "null" + ], + "description": "UTM medium value.", + "example": "community" + }, + "campaign": { + "type": [ + "string", + "null" + ], + "description": "UTM campaign value.", + "example": "launch_week" + }, + "content": { + "type": [ + "string", + "null" + ], + "description": "UTM content value.", + "example": "hero_cta" + }, + "term": { + "type": [ + "string", + "null" + ], + "description": "UTM term value.", + "example": "ai support" + } + }, + "required": [ + "source", + "medium", + "campaign", + "content", + "term" + ] + }, + "clickIds": { + "type": "object", + "properties": { + "gclid": { + "type": [ + "string", + "null" + ], + "description": "Google Ads click identifier.", + "example": "gclid_123" + }, + "gbraid": { + "type": [ + "string", + "null" + ], + "description": "Google iOS app click identifier.", + "example": "gbraid_123" + }, + "wbraid": { + "type": [ + "string", + "null" + ], + "description": "Google web-to-app click identifier.", + "example": "wbraid_123" + }, + "fbclid": { + "type": [ + "string", + "null" + ], + "description": "Meta click identifier.", + "example": "fbclid_123" + }, + "msclkid": { + "type": [ + "string", + "null" + ], + "description": "Microsoft Ads click identifier.", + "example": "msclkid_123" + }, + "ttclid": { + "type": [ + "string", + "null" + ], + "description": "TikTok click identifier.", + "example": "ttclid_123" + }, + "li_fat_id": { + "type": [ + "string", + "null" + ], + "description": "LinkedIn click identifier.", + "example": "li_fat_id_123" + }, + "twclid": { + "type": [ + "string", + "null" + ], + "description": "X/Twitter click identifier.", + "example": "twclid_123" + } + }, + "required": [ + "gclid", + "gbraid", + "wbraid", + "fbclid", + "msclkid", + "ttclid", + "li_fat_id", + "twclid" + ] + }, + "capturedAt": { + "type": "string", + "format": "date-time", + "description": "When the first-touch attribution snapshot was captured.", + "example": "2026-03-12T10:00:00.000Z" + } + }, + "required": [ + "channel", + "isDirect", + "referrer", + "landing", + "utm", + "clickIds", + "capturedAt" + ] + } + }, + "required": [ + "version", + "firstTouch" + ], + "description": "Normalized acquisition data captured for this visitor." + }, + "currentPage": { + "type": [ + "object", + "null" + ], + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized current page URL including only supported attribution params.", + "example": "https://app.example.com/pricing" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Current page path.", + "example": "/pricing" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Current document title.", + "example": "Pricing | Example" + }, + "referrerUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized document referrer URL for the current page context.", + "example": "https://news.ycombinator.com/item?id=123" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the current page context was last updated.", + "example": "2026-03-12T10:00:05.000Z" + } + }, + "required": [ + "url", + "path", + "title", + "referrerUrl", + "updatedAt" + ], + "description": "Latest page context captured for this visitor." + }, + "contact": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "External identifier for the contact.", + "example": "user_12345" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email", + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "The contact's avatar/image URL.", + "example": "https://example.com/avatar.png" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the contact.", + "example": { + "plan": "premium", + "role": "admin" + } + }, + "contactOrganizationId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The contact organization ID this contact belongs to.", + "example": "01JG000000000000000000000" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "userId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The user ID if the contact is linked to a registered user.", + "example": "01JG000000000000000000000" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the contact was first created.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the contact record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "externalId", + "name", + "email", + "image", + "metadata", + "contactOrganizationId", + "websiteId", + "organizationId", + "userId", + "createdAt", + "updatedAt" + ] + } + }, + "required": [ + "id", + "browser", + "browserVersion", + "os", + "osVersion", + "device", + "deviceType", + "ip", + "city", + "region", + "country", + "countryCode", + "latitude", + "longitude", + "language", + "timezone", + "screenResolution", + "viewport", + "createdAt", + "updatedAt", + "lastSeenAt", + "websiteId", + "organizationId", + "blockedAt", + "blockedByUserId", + "isBlocked", + "attribution", + "currentPage", + "contact" + ] + } + } + } + }, + "400": { + "description": "Invalid request data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Public key origin validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Visitor not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "updateVisitor", + "tags": [ + "Visitors" + ] + }, + "get": { + "summary": "Get visitor information", + "description": "Retrieves visitor information by visitor ID", + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The visitor ID", + "schema": { + "type": "string" + } + }, + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + } + ], + "responses": { + "200": { + "description": "Visitor information retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The visitor's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "browser": { + "type": [ + "string", + "null" + ], + "description": "The visitor's browser.", + "example": "Chrome" + }, + "browserVersion": { + "type": [ + "string", + "null" + ], + "description": "The visitor's browser version.", + "example": "120.0.0" + }, + "os": { + "type": [ + "string", + "null" + ], + "description": "The visitor's operating system.", + "example": "Windows" + }, + "osVersion": { + "type": [ + "string", + "null" + ], + "description": "The visitor's operating system version.", + "example": "11" + }, + "device": { + "type": [ + "string", + "null" + ], + "description": "The visitor's device.", + "example": "MacBook Pro" + }, + "deviceType": { + "type": [ + "string", + "null" + ], + "description": "The visitor's device type.", + "example": "desktop" + }, + "ip": { + "type": [ + "string", + "null" + ], + "description": "The visitor's IP address.", + "example": "192.168.1.1" + }, + "city": { + "type": [ + "string", + "null" + ], + "description": "The visitor's city.", + "example": "San Francisco" + }, + "region": { + "type": [ + "string", + "null" + ], + "description": "The visitor's region/state.", + "example": "California" + }, + "country": { + "type": [ + "string", + "null" + ], + "description": "The visitor's country.", + "example": "United States" + }, + "countryCode": { + "type": [ + "string", + "null" + ], + "description": "The visitor's country code (ISO 3166-1 alpha-2).", + "example": "US" + }, + "latitude": { + "type": [ + "number", + "null" + ], + "description": "The visitor's latitude.", + "example": 37.7749 + }, + "longitude": { + "type": [ + "number", + "null" + ], + "description": "The visitor's longitude.", + "example": -122.4194 + }, + "language": { + "type": [ + "string", + "null" + ], + "description": "The visitor's preferred language.", + "example": "en-US" + }, + "timezone": { + "type": [ + "string", + "null" + ], + "description": "The visitor's timezone.", + "example": "America/Los_Angeles" + }, + "screenResolution": { + "type": [ + "string", + "null" + ], + "description": "The visitor's screen resolution.", + "example": "1920x1080" + }, + "viewport": { + "type": [ + "string", + "null" + ], + "description": "The visitor's viewport size.", + "example": "1920x900" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the visitor was first seen.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the visitor record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + }, + "lastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was last connected or active.", + "example": "2021-01-01T00:00:00.000Z" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the visitor belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the visitor belongs to.", + "example": "01JG000000000000000000000" + }, + "blockedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was blocked, if applicable.", + "example": "2024-01-01T12:00:00.000Z" + }, + "blockedByUserId": { + "type": [ + "string", + "null" + ], + "description": "Identifier of the team member who blocked the visitor.", + "example": "01JG000000000000000000001" + }, + "isBlocked": { + "type": "boolean", + "description": "Whether the visitor is currently blocked.", + "example": true + }, + "attribution": { + "type": [ + "object", + "null" + ], + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ], + "description": "Schema version for the attribution payload.", + "example": 1 + }, + "firstTouch": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "enum": [ + "direct", + "email", + "paid", + "organic_search", + "social", + "referral" + ], + "description": "Derived acquisition channel.", + "example": "referral" + }, + "isDirect": { + "type": "boolean", + "description": "Whether the visit should be treated as direct traffic.", + "example": false + }, + "referrer": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized external referrer URL without arbitrary query data.", + "example": "https://news.ycombinator.com/item?id=123" + }, + "domain": { + "type": [ + "string", + "null" + ], + "description": "Normalized referrer hostname.", + "example": "news.ycombinator.com" + } + }, + "required": [ + "url", + "domain" + ] + }, + "landing": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized landing page URL including only supported attribution params.", + "example": "https://app.example.com/pricing?utm_source=hn&utm_medium=community" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Landing page path.", + "example": "/pricing" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Document title captured on the landing page.", + "example": "Pricing | Example" + } + }, + "required": [ + "url", + "path", + "title" + ] + }, + "utm": { + "type": "object", + "properties": { + "source": { + "type": [ + "string", + "null" + ], + "description": "UTM source value.", + "example": "hn" + }, + "medium": { + "type": [ + "string", + "null" + ], + "description": "UTM medium value.", + "example": "community" + }, + "campaign": { + "type": [ + "string", + "null" + ], + "description": "UTM campaign value.", + "example": "launch_week" + }, + "content": { + "type": [ + "string", + "null" + ], + "description": "UTM content value.", + "example": "hero_cta" + }, + "term": { + "type": [ + "string", + "null" + ], + "description": "UTM term value.", + "example": "ai support" + } + }, + "required": [ + "source", + "medium", + "campaign", + "content", + "term" + ] + }, + "clickIds": { + "type": "object", + "properties": { + "gclid": { + "type": [ + "string", + "null" + ], + "description": "Google Ads click identifier.", + "example": "gclid_123" + }, + "gbraid": { + "type": [ + "string", + "null" + ], + "description": "Google iOS app click identifier.", + "example": "gbraid_123" + }, + "wbraid": { + "type": [ + "string", + "null" + ], + "description": "Google web-to-app click identifier.", + "example": "wbraid_123" + }, + "fbclid": { + "type": [ + "string", + "null" + ], + "description": "Meta click identifier.", + "example": "fbclid_123" + }, + "msclkid": { + "type": [ + "string", + "null" + ], + "description": "Microsoft Ads click identifier.", + "example": "msclkid_123" + }, + "ttclid": { + "type": [ + "string", + "null" + ], + "description": "TikTok click identifier.", + "example": "ttclid_123" + }, + "li_fat_id": { + "type": [ + "string", + "null" + ], + "description": "LinkedIn click identifier.", + "example": "li_fat_id_123" + }, + "twclid": { + "type": [ + "string", + "null" + ], + "description": "X/Twitter click identifier.", + "example": "twclid_123" + } + }, + "required": [ + "gclid", + "gbraid", + "wbraid", + "fbclid", + "msclkid", + "ttclid", + "li_fat_id", + "twclid" + ] + }, + "capturedAt": { + "type": "string", + "format": "date-time", + "description": "When the first-touch attribution snapshot was captured.", + "example": "2026-03-12T10:00:00.000Z" + } + }, + "required": [ + "channel", + "isDirect", + "referrer", + "landing", + "utm", + "clickIds", + "capturedAt" + ] + } + }, + "required": [ + "version", + "firstTouch" + ], + "description": "Normalized acquisition data captured for this visitor." + }, + "currentPage": { + "type": [ + "object", + "null" + ], + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized current page URL including only supported attribution params.", + "example": "https://app.example.com/pricing" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Current page path.", + "example": "/pricing" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Current document title.", + "example": "Pricing | Example" + }, + "referrerUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized document referrer URL for the current page context.", + "example": "https://news.ycombinator.com/item?id=123" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the current page context was last updated.", + "example": "2026-03-12T10:00:05.000Z" + } + }, + "required": [ + "url", + "path", + "title", + "referrerUrl", + "updatedAt" + ], + "description": "Latest page context captured for this visitor." + }, + "contact": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "External identifier for the contact.", + "example": "user_12345" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email", + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "The contact's avatar/image URL.", + "example": "https://example.com/avatar.png" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the contact.", + "example": { + "plan": "premium", + "role": "admin" + } + }, + "contactOrganizationId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The contact organization ID this contact belongs to.", + "example": "01JG000000000000000000000" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "userId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The user ID if the contact is linked to a registered user.", + "example": "01JG000000000000000000000" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the contact was first created.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the contact record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "externalId", + "name", + "email", + "image", + "metadata", + "contactOrganizationId", + "websiteId", + "organizationId", + "userId", + "createdAt", + "updatedAt" + ] + } + }, + "required": [ + "id", + "browser", + "browserVersion", + "os", + "osVersion", + "device", + "deviceType", + "ip", + "city", + "region", + "country", + "countryCode", + "latitude", + "longitude", + "language", + "timezone", + "screenResolution", + "viewport", + "createdAt", + "updatedAt", + "lastSeenAt", + "websiteId", + "organizationId", + "blockedAt", + "blockedByUserId", + "isBlocked", + "attribution", + "currentPage", + "contact" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Public key origin validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Visitor not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "getVisitor", + "tags": [ + "Visitors" + ] + } + }, + "/visitors/{id}/metadata": { + "patch": { + "summary": "Update contact metadata for a visitor", + "description": "Merges the provided metadata into the contact profile associated with the visitor. The visitor must be identified first (linked to a contact) via the /contacts/identify endpoint.", + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The visitor ID", + "schema": { + "type": "string" + } + }, + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "metadata": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Metadata payload to merge into the visitor's profile.", + "example": { + "plan": "premium", + "role": "admin" + } + } + }, + "required": [ + "metadata" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Contact metadata updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The visitor's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "browser": { + "type": [ + "string", + "null" + ], + "description": "The visitor's browser.", + "example": "Chrome" + }, + "browserVersion": { + "type": [ + "string", + "null" + ], + "description": "The visitor's browser version.", + "example": "120.0.0" + }, + "os": { + "type": [ + "string", + "null" + ], + "description": "The visitor's operating system.", + "example": "Windows" + }, + "osVersion": { + "type": [ + "string", + "null" + ], + "description": "The visitor's operating system version.", + "example": "11" + }, + "device": { + "type": [ + "string", + "null" + ], + "description": "The visitor's device.", + "example": "MacBook Pro" + }, + "deviceType": { + "type": [ + "string", + "null" + ], + "description": "The visitor's device type.", + "example": "desktop" + }, + "ip": { + "type": [ + "string", + "null" + ], + "description": "The visitor's IP address.", + "example": "192.168.1.1" + }, + "city": { + "type": [ + "string", + "null" + ], + "description": "The visitor's city.", + "example": "San Francisco" + }, + "region": { + "type": [ + "string", + "null" + ], + "description": "The visitor's region/state.", + "example": "California" + }, + "country": { + "type": [ + "string", + "null" + ], + "description": "The visitor's country.", + "example": "United States" + }, + "countryCode": { + "type": [ + "string", + "null" + ], + "description": "The visitor's country code (ISO 3166-1 alpha-2).", + "example": "US" + }, + "latitude": { + "type": [ + "number", + "null" + ], + "description": "The visitor's latitude.", + "example": 37.7749 + }, + "longitude": { + "type": [ + "number", + "null" + ], + "description": "The visitor's longitude.", + "example": -122.4194 + }, + "language": { + "type": [ + "string", + "null" + ], + "description": "The visitor's preferred language.", + "example": "en-US" + }, + "timezone": { + "type": [ + "string", + "null" + ], + "description": "The visitor's timezone.", + "example": "America/Los_Angeles" + }, + "screenResolution": { + "type": [ + "string", + "null" + ], + "description": "The visitor's screen resolution.", + "example": "1920x1080" + }, + "viewport": { + "type": [ + "string", + "null" + ], + "description": "The visitor's viewport size.", + "example": "1920x900" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the visitor was first seen.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the visitor record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + }, + "lastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was last connected or active.", + "example": "2021-01-01T00:00:00.000Z" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the visitor belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the visitor belongs to.", + "example": "01JG000000000000000000000" + }, + "blockedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was blocked, if applicable.", + "example": "2024-01-01T12:00:00.000Z" + }, + "blockedByUserId": { + "type": [ + "string", + "null" + ], + "description": "Identifier of the team member who blocked the visitor.", + "example": "01JG000000000000000000001" + }, + "isBlocked": { + "type": "boolean", + "description": "Whether the visitor is currently blocked.", + "example": true + }, + "attribution": { + "type": [ + "object", + "null" + ], + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ], + "description": "Schema version for the attribution payload.", + "example": 1 + }, + "firstTouch": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "enum": [ + "direct", + "email", + "paid", + "organic_search", + "social", + "referral" + ], + "description": "Derived acquisition channel.", + "example": "referral" + }, + "isDirect": { + "type": "boolean", + "description": "Whether the visit should be treated as direct traffic.", + "example": false + }, + "referrer": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized external referrer URL without arbitrary query data.", + "example": "https://news.ycombinator.com/item?id=123" + }, + "domain": { + "type": [ + "string", + "null" + ], + "description": "Normalized referrer hostname.", + "example": "news.ycombinator.com" + } + }, + "required": [ + "url", + "domain" + ] + }, + "landing": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized landing page URL including only supported attribution params.", + "example": "https://app.example.com/pricing?utm_source=hn&utm_medium=community" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Landing page path.", + "example": "/pricing" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Document title captured on the landing page.", + "example": "Pricing | Example" + } + }, + "required": [ + "url", + "path", + "title" + ] + }, + "utm": { + "type": "object", + "properties": { + "source": { + "type": [ + "string", + "null" + ], + "description": "UTM source value.", + "example": "hn" + }, + "medium": { + "type": [ + "string", + "null" + ], + "description": "UTM medium value.", + "example": "community" + }, + "campaign": { + "type": [ + "string", + "null" + ], + "description": "UTM campaign value.", + "example": "launch_week" + }, + "content": { + "type": [ + "string", + "null" + ], + "description": "UTM content value.", + "example": "hero_cta" + }, + "term": { + "type": [ + "string", + "null" + ], + "description": "UTM term value.", + "example": "ai support" + } + }, + "required": [ + "source", + "medium", + "campaign", + "content", + "term" + ] + }, + "clickIds": { + "type": "object", + "properties": { + "gclid": { + "type": [ + "string", + "null" + ], + "description": "Google Ads click identifier.", + "example": "gclid_123" + }, + "gbraid": { + "type": [ + "string", + "null" + ], + "description": "Google iOS app click identifier.", + "example": "gbraid_123" + }, + "wbraid": { + "type": [ + "string", + "null" + ], + "description": "Google web-to-app click identifier.", + "example": "wbraid_123" + }, + "fbclid": { + "type": [ + "string", + "null" + ], + "description": "Meta click identifier.", + "example": "fbclid_123" + }, + "msclkid": { + "type": [ + "string", + "null" + ], + "description": "Microsoft Ads click identifier.", + "example": "msclkid_123" + }, + "ttclid": { + "type": [ + "string", + "null" + ], + "description": "TikTok click identifier.", + "example": "ttclid_123" + }, + "li_fat_id": { + "type": [ + "string", + "null" + ], + "description": "LinkedIn click identifier.", + "example": "li_fat_id_123" + }, + "twclid": { + "type": [ + "string", + "null" + ], + "description": "X/Twitter click identifier.", + "example": "twclid_123" + } + }, + "required": [ + "gclid", + "gbraid", + "wbraid", + "fbclid", + "msclkid", + "ttclid", + "li_fat_id", + "twclid" + ] + }, + "capturedAt": { + "type": "string", + "format": "date-time", + "description": "When the first-touch attribution snapshot was captured.", + "example": "2026-03-12T10:00:00.000Z" + } + }, + "required": [ + "channel", + "isDirect", + "referrer", + "landing", + "utm", + "clickIds", + "capturedAt" + ] + } + }, + "required": [ + "version", + "firstTouch" + ], + "description": "Normalized acquisition data captured for this visitor." + }, + "currentPage": { + "type": [ + "object", + "null" + ], + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized current page URL including only supported attribution params.", + "example": "https://app.example.com/pricing" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Current page path.", + "example": "/pricing" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Current document title.", + "example": "Pricing | Example" + }, + "referrerUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized document referrer URL for the current page context.", + "example": "https://news.ycombinator.com/item?id=123" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the current page context was last updated.", + "example": "2026-03-12T10:00:05.000Z" + } + }, + "required": [ + "url", + "path", + "title", + "referrerUrl", + "updatedAt" + ], + "description": "Latest page context captured for this visitor." + }, + "contact": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "External identifier for the contact.", + "example": "user_12345" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email", + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "The contact's avatar/image URL.", + "example": "https://example.com/avatar.png" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the contact.", + "example": { + "plan": "premium", + "role": "admin" + } + }, + "contactOrganizationId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The contact organization ID this contact belongs to.", + "example": "01JG000000000000000000000" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "userId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The user ID if the contact is linked to a registered user.", + "example": "01JG000000000000000000000" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the contact was first created.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the contact record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "externalId", + "name", + "email", + "image", + "metadata", + "contactOrganizationId", + "websiteId", + "organizationId", + "userId", + "createdAt", + "updatedAt" + ] + } + }, + "required": [ + "id", + "browser", + "browserVersion", + "os", + "osVersion", + "device", + "deviceType", + "ip", + "city", + "region", + "country", + "countryCode", + "latitude", + "longitude", + "language", + "timezone", + "screenResolution", + "viewport", + "createdAt", + "updatedAt", + "lastSeenAt", + "websiteId", + "organizationId", + "blockedAt", + "blockedByUserId", + "isBlocked", + "attribution", + "currentPage", + "contact" + ] + } + } + } + }, + "400": { + "description": "Invalid request data or visitor not identified", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Public key origin validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Visitor not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "updateVisitorMetadata", + "tags": [ + "Visitors" + ] + } + }, + "/visitors/{id}/block": { + "post": { + "summary": "Block a visitor", + "description": "Blocks a visitor from sending new messages. Requires a private API key and an acting teammate.", + "operationId": "blockVisitor", + "tags": [ + "Visitors" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The visitor ID", + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "Visitor blocked successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The visitor's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "browser": { + "type": [ + "string", + "null" + ], + "description": "The visitor's browser.", + "example": "Chrome" + }, + "browserVersion": { + "type": [ + "string", + "null" + ], + "description": "The visitor's browser version.", + "example": "120.0.0" + }, + "os": { + "type": [ + "string", + "null" + ], + "description": "The visitor's operating system.", + "example": "Windows" + }, + "osVersion": { + "type": [ + "string", + "null" + ], + "description": "The visitor's operating system version.", + "example": "11" + }, + "device": { + "type": [ + "string", + "null" + ], + "description": "The visitor's device.", + "example": "MacBook Pro" + }, + "deviceType": { + "type": [ + "string", + "null" + ], + "description": "The visitor's device type.", + "example": "desktop" + }, + "ip": { + "type": [ + "string", + "null" + ], + "description": "The visitor's IP address.", + "example": "192.168.1.1" + }, + "city": { + "type": [ + "string", + "null" + ], + "description": "The visitor's city.", + "example": "San Francisco" + }, + "region": { + "type": [ + "string", + "null" + ], + "description": "The visitor's region/state.", + "example": "California" + }, + "country": { + "type": [ + "string", + "null" + ], + "description": "The visitor's country.", + "example": "United States" + }, + "countryCode": { + "type": [ + "string", + "null" + ], + "description": "The visitor's country code (ISO 3166-1 alpha-2).", + "example": "US" + }, + "latitude": { + "type": [ + "number", + "null" + ], + "description": "The visitor's latitude.", + "example": 37.7749 + }, + "longitude": { + "type": [ + "number", + "null" + ], + "description": "The visitor's longitude.", + "example": -122.4194 + }, + "language": { + "type": [ + "string", + "null" + ], + "description": "The visitor's preferred language.", + "example": "en-US" + }, + "timezone": { + "type": [ + "string", + "null" + ], + "description": "The visitor's timezone.", + "example": "America/Los_Angeles" + }, + "screenResolution": { + "type": [ + "string", + "null" + ], + "description": "The visitor's screen resolution.", + "example": "1920x1080" + }, + "viewport": { + "type": [ + "string", + "null" + ], + "description": "The visitor's viewport size.", + "example": "1920x900" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the visitor was first seen.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the visitor record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + }, + "lastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was last connected or active.", + "example": "2021-01-01T00:00:00.000Z" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the visitor belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the visitor belongs to.", + "example": "01JG000000000000000000000" + }, + "blockedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was blocked, if applicable.", + "example": "2024-01-01T12:00:00.000Z" + }, + "blockedByUserId": { + "type": [ + "string", + "null" + ], + "description": "Identifier of the team member who blocked the visitor.", + "example": "01JG000000000000000000001" + }, + "isBlocked": { + "type": "boolean", + "description": "Whether the visitor is currently blocked.", + "example": true + }, + "attribution": { + "type": [ + "object", + "null" + ], + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ], + "description": "Schema version for the attribution payload.", + "example": 1 + }, + "firstTouch": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "enum": [ + "direct", + "email", + "paid", + "organic_search", + "social", + "referral" + ], + "description": "Derived acquisition channel.", + "example": "referral" + }, + "isDirect": { + "type": "boolean", + "description": "Whether the visit should be treated as direct traffic.", + "example": false + }, + "referrer": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized external referrer URL without arbitrary query data.", + "example": "https://news.ycombinator.com/item?id=123" + }, + "domain": { + "type": [ + "string", + "null" + ], + "description": "Normalized referrer hostname.", + "example": "news.ycombinator.com" + } + }, + "required": [ + "url", + "domain" + ] + }, + "landing": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized landing page URL including only supported attribution params.", + "example": "https://app.example.com/pricing?utm_source=hn&utm_medium=community" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Landing page path.", + "example": "/pricing" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Document title captured on the landing page.", + "example": "Pricing | Example" + } + }, + "required": [ + "url", + "path", + "title" + ] + }, + "utm": { + "type": "object", + "properties": { + "source": { + "type": [ + "string", + "null" + ], + "description": "UTM source value.", + "example": "hn" + }, + "medium": { + "type": [ + "string", + "null" + ], + "description": "UTM medium value.", + "example": "community" + }, + "campaign": { + "type": [ + "string", + "null" + ], + "description": "UTM campaign value.", + "example": "launch_week" + }, + "content": { + "type": [ + "string", + "null" + ], + "description": "UTM content value.", + "example": "hero_cta" + }, + "term": { + "type": [ + "string", + "null" + ], + "description": "UTM term value.", + "example": "ai support" + } + }, + "required": [ + "source", + "medium", + "campaign", + "content", + "term" + ] + }, + "clickIds": { + "type": "object", + "properties": { + "gclid": { + "type": [ + "string", + "null" + ], + "description": "Google Ads click identifier.", + "example": "gclid_123" + }, + "gbraid": { + "type": [ + "string", + "null" + ], + "description": "Google iOS app click identifier.", + "example": "gbraid_123" + }, + "wbraid": { + "type": [ + "string", + "null" + ], + "description": "Google web-to-app click identifier.", + "example": "wbraid_123" + }, + "fbclid": { + "type": [ + "string", + "null" + ], + "description": "Meta click identifier.", + "example": "fbclid_123" + }, + "msclkid": { + "type": [ + "string", + "null" + ], + "description": "Microsoft Ads click identifier.", + "example": "msclkid_123" + }, + "ttclid": { + "type": [ + "string", + "null" + ], + "description": "TikTok click identifier.", + "example": "ttclid_123" + }, + "li_fat_id": { + "type": [ + "string", + "null" + ], + "description": "LinkedIn click identifier.", + "example": "li_fat_id_123" + }, + "twclid": { + "type": [ + "string", + "null" + ], + "description": "X/Twitter click identifier.", + "example": "twclid_123" + } + }, + "required": [ + "gclid", + "gbraid", + "wbraid", + "fbclid", + "msclkid", + "ttclid", + "li_fat_id", + "twclid" + ] + }, + "capturedAt": { + "type": "string", + "format": "date-time", + "description": "When the first-touch attribution snapshot was captured.", + "example": "2026-03-12T10:00:00.000Z" + } + }, + "required": [ + "channel", + "isDirect", + "referrer", + "landing", + "utm", + "clickIds", + "capturedAt" + ] + } + }, + "required": [ + "version", + "firstTouch" + ], + "description": "Normalized acquisition data captured for this visitor." + }, + "currentPage": { + "type": [ + "object", + "null" + ], + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized current page URL including only supported attribution params.", + "example": "https://app.example.com/pricing" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Current page path.", + "example": "/pricing" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Current document title.", + "example": "Pricing | Example" + }, + "referrerUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized document referrer URL for the current page context.", + "example": "https://news.ycombinator.com/item?id=123" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the current page context was last updated.", + "example": "2026-03-12T10:00:05.000Z" + } + }, + "required": [ + "url", + "path", + "title", + "referrerUrl", + "updatedAt" + ], + "description": "Latest page context captured for this visitor." + }, + "contact": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "External identifier for the contact.", + "example": "user_12345" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email", + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "The contact's avatar/image URL.", + "example": "https://example.com/avatar.png" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the contact.", + "example": { + "plan": "premium", + "role": "admin" + } + }, + "contactOrganizationId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The contact organization ID this contact belongs to.", + "example": "01JG000000000000000000000" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "userId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The user ID if the contact is linked to a registered user.", + "example": "01JG000000000000000000000" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the contact was first created.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the contact record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "externalId", + "name", + "email", + "image", + "metadata", + "contactOrganizationId", + "websiteId", + "organizationId", + "userId", + "createdAt", + "updatedAt" + ] + } + }, + "required": [ + "id", + "browser", + "browserVersion", + "os", + "osVersion", + "device", + "deviceType", + "ip", + "city", + "region", + "country", + "countryCode", + "latitude", + "longitude", + "language", + "timezone", + "screenResolution", + "viewport", + "createdAt", + "updatedAt", + "lastSeenAt", + "websiteId", + "organizationId", + "blockedAt", + "blockedByUserId", + "isBlocked", + "attribution", + "currentPage", + "contact" + ] + } + } + } + }, + "400": { + "description": "Bad request - Missing actor for an unlinked private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor user not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Visitor not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/visitors/{id}/unblock": { + "post": { + "summary": "Unblock a visitor", + "description": "Unblocks a visitor so they can send messages again. Requires a private API key and an acting teammate.", + "operationId": "unblockVisitor", + "tags": [ + "Visitors" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The visitor ID", + "schema": { + "type": "string" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "Visitor unblocked successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The visitor's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "browser": { + "type": [ + "string", + "null" + ], + "description": "The visitor's browser.", + "example": "Chrome" + }, + "browserVersion": { + "type": [ + "string", + "null" + ], + "description": "The visitor's browser version.", + "example": "120.0.0" + }, + "os": { + "type": [ + "string", + "null" + ], + "description": "The visitor's operating system.", + "example": "Windows" + }, + "osVersion": { + "type": [ + "string", + "null" + ], + "description": "The visitor's operating system version.", + "example": "11" + }, + "device": { + "type": [ + "string", + "null" + ], + "description": "The visitor's device.", + "example": "MacBook Pro" + }, + "deviceType": { + "type": [ + "string", + "null" + ], + "description": "The visitor's device type.", + "example": "desktop" + }, + "ip": { + "type": [ + "string", + "null" + ], + "description": "The visitor's IP address.", + "example": "192.168.1.1" + }, + "city": { + "type": [ + "string", + "null" + ], + "description": "The visitor's city.", + "example": "San Francisco" + }, + "region": { + "type": [ + "string", + "null" + ], + "description": "The visitor's region/state.", + "example": "California" + }, + "country": { + "type": [ + "string", + "null" + ], + "description": "The visitor's country.", + "example": "United States" + }, + "countryCode": { + "type": [ + "string", + "null" + ], + "description": "The visitor's country code (ISO 3166-1 alpha-2).", + "example": "US" + }, + "latitude": { + "type": [ + "number", + "null" + ], + "description": "The visitor's latitude.", + "example": 37.7749 + }, + "longitude": { + "type": [ + "number", + "null" + ], + "description": "The visitor's longitude.", + "example": -122.4194 + }, + "language": { + "type": [ + "string", + "null" + ], + "description": "The visitor's preferred language.", + "example": "en-US" + }, + "timezone": { + "type": [ + "string", + "null" + ], + "description": "The visitor's timezone.", + "example": "America/Los_Angeles" + }, + "screenResolution": { + "type": [ + "string", + "null" + ], + "description": "The visitor's screen resolution.", + "example": "1920x1080" + }, + "viewport": { + "type": [ + "string", + "null" + ], + "description": "The visitor's viewport size.", + "example": "1920x900" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the visitor was first seen.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the visitor record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + }, + "lastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was last connected or active.", + "example": "2021-01-01T00:00:00.000Z" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the visitor belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the visitor belongs to.", + "example": "01JG000000000000000000000" + }, + "blockedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the visitor was blocked, if applicable.", + "example": "2024-01-01T12:00:00.000Z" + }, + "blockedByUserId": { + "type": [ + "string", + "null" + ], + "description": "Identifier of the team member who blocked the visitor.", + "example": "01JG000000000000000000001" + }, + "isBlocked": { + "type": "boolean", + "description": "Whether the visitor is currently blocked.", + "example": true + }, + "attribution": { + "type": [ + "object", + "null" + ], + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ], + "description": "Schema version for the attribution payload.", + "example": 1 + }, + "firstTouch": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "enum": [ + "direct", + "email", + "paid", + "organic_search", + "social", + "referral" + ], + "description": "Derived acquisition channel.", + "example": "referral" + }, + "isDirect": { + "type": "boolean", + "description": "Whether the visit should be treated as direct traffic.", + "example": false + }, + "referrer": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized external referrer URL without arbitrary query data.", + "example": "https://news.ycombinator.com/item?id=123" + }, + "domain": { + "type": [ + "string", + "null" + ], + "description": "Normalized referrer hostname.", + "example": "news.ycombinator.com" + } + }, + "required": [ + "url", + "domain" + ] + }, + "landing": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized landing page URL including only supported attribution params.", + "example": "https://app.example.com/pricing?utm_source=hn&utm_medium=community" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Landing page path.", + "example": "/pricing" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Document title captured on the landing page.", + "example": "Pricing | Example" + } + }, + "required": [ + "url", + "path", + "title" + ] + }, + "utm": { + "type": "object", + "properties": { + "source": { + "type": [ + "string", + "null" + ], + "description": "UTM source value.", + "example": "hn" + }, + "medium": { + "type": [ + "string", + "null" + ], + "description": "UTM medium value.", + "example": "community" + }, + "campaign": { + "type": [ + "string", + "null" + ], + "description": "UTM campaign value.", + "example": "launch_week" + }, + "content": { + "type": [ + "string", + "null" + ], + "description": "UTM content value.", + "example": "hero_cta" + }, + "term": { + "type": [ + "string", + "null" + ], + "description": "UTM term value.", + "example": "ai support" + } + }, + "required": [ + "source", + "medium", + "campaign", + "content", + "term" + ] + }, + "clickIds": { + "type": "object", + "properties": { + "gclid": { + "type": [ + "string", + "null" + ], + "description": "Google Ads click identifier.", + "example": "gclid_123" + }, + "gbraid": { + "type": [ + "string", + "null" + ], + "description": "Google iOS app click identifier.", + "example": "gbraid_123" + }, + "wbraid": { + "type": [ + "string", + "null" + ], + "description": "Google web-to-app click identifier.", + "example": "wbraid_123" + }, + "fbclid": { + "type": [ + "string", + "null" + ], + "description": "Meta click identifier.", + "example": "fbclid_123" + }, + "msclkid": { + "type": [ + "string", + "null" + ], + "description": "Microsoft Ads click identifier.", + "example": "msclkid_123" + }, + "ttclid": { + "type": [ + "string", + "null" + ], + "description": "TikTok click identifier.", + "example": "ttclid_123" + }, + "li_fat_id": { + "type": [ + "string", + "null" + ], + "description": "LinkedIn click identifier.", + "example": "li_fat_id_123" + }, + "twclid": { + "type": [ + "string", + "null" + ], + "description": "X/Twitter click identifier.", + "example": "twclid_123" + } + }, + "required": [ + "gclid", + "gbraid", + "wbraid", + "fbclid", + "msclkid", + "ttclid", + "li_fat_id", + "twclid" + ] + }, + "capturedAt": { + "type": "string", + "format": "date-time", + "description": "When the first-touch attribution snapshot was captured.", + "example": "2026-03-12T10:00:00.000Z" + } + }, + "required": [ + "channel", + "isDirect", + "referrer", + "landing", + "utm", + "clickIds", + "capturedAt" + ] + } + }, + "required": [ + "version", + "firstTouch" + ], + "description": "Normalized acquisition data captured for this visitor." + }, + "currentPage": { + "type": [ + "object", + "null" + ], + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized current page URL including only supported attribution params.", + "example": "https://app.example.com/pricing" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "Current page path.", + "example": "/pricing" + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Current document title.", + "example": "Pricing | Example" + }, + "referrerUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Sanitized document referrer URL for the current page context.", + "example": "https://news.ycombinator.com/item?id=123" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the current page context was last updated.", + "example": "2026-03-12T10:00:05.000Z" + } + }, + "required": [ + "url", + "path", + "title", + "referrerUrl", + "updatedAt" + ], + "description": "Latest page context captured for this visitor." + }, + "contact": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "External identifier for the contact.", + "example": "user_12345" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email", + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "The contact's avatar/image URL.", + "example": "https://example.com/avatar.png" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the contact.", + "example": { + "plan": "premium", + "role": "admin" + } + }, + "contactOrganizationId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The contact organization ID this contact belongs to.", + "example": "01JG000000000000000000000" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "userId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The user ID if the contact is linked to a registered user.", + "example": "01JG000000000000000000000" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the contact was first created.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the contact record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "externalId", + "name", + "email", + "image", + "metadata", + "contactOrganizationId", + "websiteId", + "organizationId", + "userId", + "createdAt", + "updatedAt" + ] + } + }, + "required": [ + "id", + "browser", + "browserVersion", + "os", + "osVersion", + "device", + "deviceType", + "ip", + "city", + "region", + "country", + "countryCode", + "latitude", + "longitude", + "language", + "timezone", + "screenResolution", + "viewport", + "createdAt", + "updatedAt", + "lastSeenAt", + "websiteId", + "organizationId", + "blockedAt", + "blockedByUserId", + "isBlocked", + "attribution", + "currentPage", + "contact" + ] + } + } + } + }, + "400": { + "description": "Bad request - Missing actor for an unlinked private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required or actor user not allowed for this website", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Visitor not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/contacts/identify": { + "post": { + "summary": "Identify a visitor", + "description": "Creates or updates a contact for a visitor. If a contact with the same externalId or email exists, it will be updated. The visitor will be linked to the contact. Public callers may pass the visitor ID in the request body or via X-Visitor-Id; when both are provided they must match.", + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + }, + { + "name": "X-Visitor-Id", + "in": "header", + "description": "Visitor ID from localStorage.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "Optional contact ID to update when linking the visitor to an existing contact.", + "example": "01JG000000000000000000000" + }, + "visitorId": { + "type": "string", + "format": "ulid", + "description": "The visitor ID to link to the contact. Optional when passed via the X-Visitor-Id header. If both are provided, the body value wins.", + "example": "01JG000000000000000000000" + }, + "externalId": { + "type": "string", + "description": "External identifier for the contact. Used to find existing contacts.", + "example": "user_12345" + }, + "name": { + "type": "string", + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": "string", + "format": "email", + "description": "The contact's email address. Used to find existing contacts.", + "example": "john.doe@example.com" + }, + "image": { + "type": "string", + "format": "uri", + "description": "The contact's avatar/image URL.", + "example": "https://example.com/avatar.png" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the contact.", + "example": { + "plan": "premium", + "role": "admin" + } + }, + "contactOrganizationId": { + "type": "string", + "format": "ulid", + "description": "The contact organization ID this contact belongs to.", + "example": "01JG000000000000000000000" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Contact identified successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "contact": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "External identifier for the contact.", + "example": "user_12345" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email", + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "The contact's avatar/image URL.", + "example": "https://example.com/avatar.png" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the contact.", + "example": { + "plan": "premium", + "role": "admin" + } + }, + "contactOrganizationId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The contact organization ID this contact belongs to.", + "example": "01JG000000000000000000000" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "userId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The user ID if the contact is linked to a registered user.", + "example": "01JG000000000000000000000" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the contact was first created.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the contact record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "externalId", + "name", + "email", + "image", + "metadata", + "contactOrganizationId", + "websiteId", + "organizationId", + "userId", + "createdAt", + "updatedAt" + ] + }, + "visitorId": { + "type": "string", + "format": "ulid", + "description": "The visitor ID that was linked to the contact.", + "example": "01JG000000000000000000000" + } + }, + "required": [ + "contact", + "visitorId" + ] + } + } + } + }, + "400": { + "description": "Invalid request data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Visitor not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "identifyContact", + "tags": [ + "Contacts" + ] + } + }, + "/contacts": { + "get": { + "summary": "List contacts", + "description": "Returns a paginated list of contacts for the authenticated website.", + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number for pagination." + }, + "required": false, + "description": "Page number for pagination.", + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20, + "description": "Maximum number of contacts to return." + }, + "required": false, + "description": "Maximum number of contacts to return.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Optional case-insensitive search against contact name/email.", + "example": "alice" + }, + "required": false, + "description": "Optional case-insensitive search against contact name/email.", + "name": "search", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "name", + "email", + "createdAt", + "updatedAt", + "visitorCount", + "lastSeenAt" + ], + "description": "Field used to sort the result set.", + "example": "updatedAt" + }, + "required": false, + "description": "Field used to sort the result set.", + "name": "sortBy", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "Sort direction.", + "example": "desc" + }, + "required": false, + "description": "Sort direction.", + "name": "sortOrder", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "all", + "withVisitors", + "withoutVisitors" + ], + "default": "all", + "description": "Optional filter based on whether a contact has linked visitors.", + "example": "all" + }, + "required": false, + "description": "Optional filter based on whether a contact has linked visitors.", + "name": "visitorStatus", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Contact list retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier.", + "example": "01JG000000000000000000000" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "Alice Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email", + "description": "The contact's email address.", + "example": "alice@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "The contact's avatar URL.", + "example": "https://example.com/avatar.png" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the contact was created.", + "example": "2026-04-07T10:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the contact was last updated.", + "example": "2026-04-07T10:00:00.000Z" + }, + "visitorCount": { + "type": "integer", + "minimum": 0, + "description": "How many visitors are linked to this contact.", + "example": 3 + }, + "lastSeenAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "The latest last-seen timestamp across linked visitors.", + "example": "2026-04-07T10:00:00.000Z" + }, + "contactOrganizationId": { + "type": [ + "string", + "null" + ], + "description": "The linked contact organization identifier, if any.", + "example": "01JG000000000000000000000" + }, + "contactOrganizationName": { + "type": [ + "string", + "null" + ], + "description": "The linked contact organization name, if any.", + "example": "Acme Corp" + } + }, + "required": [ + "id", + "name", + "email", + "image", + "createdAt", + "updatedAt", + "visitorCount", + "lastSeenAt", + "contactOrganizationId", + "contactOrganizationName" + ], + "description": "Summary row returned when listing contacts." + }, + "description": "Paginated contact results." + }, + "page": { + "type": "integer", + "minimum": 1, + "description": "Current page number.", + "example": 1 + }, + "pageSize": { + "type": "integer", + "minimum": 1, + "description": "Number of items returned per page.", + "example": 20 + }, + "totalCount": { + "type": "integer", + "minimum": 0, + "description": "Total number of matching contacts.", + "example": 120 + } + }, + "required": [ + "items", + "page", + "pageSize", + "totalCount" + ], + "description": "Paginated list of contacts for a website." + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "listContacts", + "tags": [ + "Contacts" + ] + }, + "post": { + "summary": "Create a contact", + "description": "Creates a new contact for the website. If externalId is provided and already exists for the website, the contact is updated and returned.", + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "externalId": { + "type": "string", + "description": "External identifier for the contact (e.g. from your CRM).", + "example": "user_12345" + }, + "name": { + "type": "string", + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": "string", + "format": "email", + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": "string", + "format": "uri", + "description": "The contact's avatar/image URL.", + "example": "https://example.com/avatar.png" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the contact.", + "example": { + "plan": "premium", + "role": "admin" + } + }, + "contactOrganizationId": { + "type": "string", + "format": "ulid", + "description": "The contact organization ID this contact belongs to.", + "example": "01JG000000000000000000000" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Contact updated successfully via externalId upsert", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "External identifier for the contact.", + "example": "user_12345" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email", + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "The contact's avatar/image URL.", + "example": "https://example.com/avatar.png" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the contact.", + "example": { + "plan": "premium", + "role": "admin" + } + }, + "contactOrganizationId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The contact organization ID this contact belongs to.", + "example": "01JG000000000000000000000" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "userId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The user ID if the contact is linked to a registered user.", + "example": "01JG000000000000000000000" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the contact was first created.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the contact record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "externalId", + "name", + "email", + "image", + "metadata", + "contactOrganizationId", + "websiteId", + "organizationId", + "userId", + "createdAt", + "updatedAt" + ] + } + } + } + }, + "201": { + "description": "Contact created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "External identifier for the contact.", + "example": "user_12345" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email", + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "The contact's avatar/image URL.", + "example": "https://example.com/avatar.png" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the contact.", + "example": { + "plan": "premium", + "role": "admin" + } + }, + "contactOrganizationId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The contact organization ID this contact belongs to.", + "example": "01JG000000000000000000000" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "userId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The user ID if the contact is linked to a registered user.", + "example": "01JG000000000000000000000" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the contact was first created.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the contact record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "externalId", + "name", + "email", + "image", + "metadata", + "contactOrganizationId", + "websiteId", + "organizationId", + "userId", + "createdAt", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "Invalid request data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "createContact", + "tags": [ + "Contacts" + ] + } + }, + "/contacts/{id}": { + "get": { + "summary": "Get a contact", + "description": "Retrieves a contact by ID.", + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The contact ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Contact retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "External identifier for the contact.", + "example": "user_12345" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email", + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "The contact's avatar/image URL.", + "example": "https://example.com/avatar.png" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the contact.", + "example": { + "plan": "premium", + "role": "admin" + } + }, + "contactOrganizationId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The contact organization ID this contact belongs to.", + "example": "01JG000000000000000000000" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "userId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The user ID if the contact is linked to a registered user.", + "example": "01JG000000000000000000000" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the contact was first created.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the contact record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "externalId", + "name", + "email", + "image", + "metadata", + "contactOrganizationId", + "websiteId", + "organizationId", + "userId", + "createdAt", + "updatedAt" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Contact not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "getContact", + "tags": [ + "Contacts" + ] + }, + "patch": { + "summary": "Update a contact", + "description": "Updates an existing contact.", + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The contact ID", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "externalId": { + "type": "string", + "description": "External identifier for the contact.", + "example": "user_12345" + }, + "name": { + "type": "string", + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": "string", + "format": "email", + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": "string", + "format": "uri", + "description": "The contact's avatar/image URL.", + "example": "https://example.com/avatar.png" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the contact.", + "example": { + "plan": "premium", + "role": "admin" + } + }, + "contactOrganizationId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The contact organization ID this contact belongs to.", + "example": "01JG000000000000000000000" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Contact updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "External identifier for the contact.", + "example": "user_12345" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email", + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "The contact's avatar/image URL.", + "example": "https://example.com/avatar.png" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the contact.", + "example": { + "plan": "premium", + "role": "admin" + } + }, + "contactOrganizationId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The contact organization ID this contact belongs to.", + "example": "01JG000000000000000000000" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "userId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The user ID if the contact is linked to a registered user.", + "example": "01JG000000000000000000000" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the contact was first created.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the contact record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "externalId", + "name", + "email", + "image", + "metadata", + "contactOrganizationId", + "websiteId", + "organizationId", + "userId", + "createdAt", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "Invalid request data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Contact not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "updateContact", + "tags": [ + "Contacts" + ] + }, + "delete": { + "summary": "Delete a contact", + "description": "Soft deletes a contact.", + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The contact ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Contact deleted successfully" + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Contact not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "deleteContact", + "tags": [ + "Contacts" + ] + } + }, + "/contacts/{id}/metadata": { + "patch": { + "summary": "Update contact metadata", + "description": "Merges the provided metadata into the contact profile.", + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The contact ID", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "metadata": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Metadata payload to merge into the contact's profile.", + "example": { + "plan": "premium", + "role": "admin" + } + } + }, + "required": [ + "metadata" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Contact metadata updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The contact's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "External identifier for the contact.", + "example": "user_12345" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "The contact's name.", + "example": "John Doe" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email", + "description": "The contact's email address.", + "example": "john.doe@example.com" + }, + "image": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "The contact's avatar/image URL.", + "example": "https://example.com/avatar.png" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the contact.", + "example": { + "plan": "premium", + "role": "admin" + } + }, + "contactOrganizationId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The contact organization ID this contact belongs to.", + "example": "01JG000000000000000000000" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the contact belongs to.", + "example": "01JG000000000000000000000" + }, + "userId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The user ID if the contact is linked to a registered user.", + "example": "01JG000000000000000000000" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the contact was first created.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the contact record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "externalId", + "name", + "email", + "image", + "metadata", + "contactOrganizationId", + "websiteId", + "organizationId", + "userId", + "createdAt", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "Invalid request data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Contact not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "updateContactMetadata", + "tags": [ + "Contacts" + ] + } + }, + "/contacts/organizations": { + "post": { + "summary": "Create a contact organization", + "description": "Creates a new contact organization for the website.", + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The organization name.", + "example": "Acme Corporation" + }, + "externalId": { + "type": "string", + "description": "External identifier for the organization (e.g. from your CRM).", + "example": "org_12345" + }, + "domain": { + "type": "string", + "description": "The organization's domain.", + "example": "acme.com" + }, + "description": { + "type": "string", + "description": "Description of the organization.", + "example": "A leading provider of enterprise solutions" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the organization.", + "example": { + "industry": "technology", + "employees": 500 + } + } + }, + "required": [ + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Contact organization created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "name": { + "type": "string", + "description": "The organization name.", + "example": "Acme Corporation" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "External identifier for the organization.", + "example": "org_12345" + }, + "domain": { + "type": [ + "string", + "null" + ], + "description": "The organization's domain.", + "example": "acme.com" + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Description of the organization.", + "example": "A leading provider of enterprise solutions" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the organization.", + "example": { + "industry": "technology", + "employees": 500 + } + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the organization belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the organization belongs to.", + "example": "01JG000000000000000000000" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the organization was first created.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the organization record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "name", + "externalId", + "domain", + "description", + "metadata", + "websiteId", + "organizationId", + "createdAt", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "Invalid request data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "createContactOrganization", + "tags": [ + "Contacts" + ] + } + }, + "/contacts/organizations/{id}": { + "get": { + "summary": "Get a contact organization", + "description": "Retrieves a contact organization by ID.", + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The contact organization ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Contact organization retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "name": { + "type": "string", + "description": "The organization name.", + "example": "Acme Corporation" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "External identifier for the organization.", + "example": "org_12345" + }, + "domain": { + "type": [ + "string", + "null" + ], + "description": "The organization's domain.", + "example": "acme.com" + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Description of the organization.", + "example": "A leading provider of enterprise solutions" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the organization.", + "example": { + "industry": "technology", + "employees": 500 + } + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the organization belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the organization belongs to.", + "example": "01JG000000000000000000000" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the organization was first created.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the organization record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "name", + "externalId", + "domain", + "description", + "metadata", + "websiteId", + "organizationId", + "createdAt", + "updatedAt" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Contact organization not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "getContactOrganization", + "tags": [ + "Contacts" + ] + }, + "patch": { + "summary": "Update a contact organization", + "description": "Updates an existing contact organization.", + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The contact organization ID", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The organization name.", + "example": "Acme Corporation" + }, + "externalId": { + "type": "string", + "description": "External identifier for the organization.", + "example": "org_12345" + }, + "domain": { + "type": "string", + "description": "The organization's domain.", + "example": "acme.com" + }, + "description": { + "type": "string", + "description": "Description of the organization.", + "example": "A leading provider of enterprise solutions" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the organization.", + "example": { + "industry": "technology", + "employees": 500 + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Contact organization updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier (ULID).", + "example": "01JG000000000000000000000" + }, + "name": { + "type": "string", + "description": "The organization name.", + "example": "Acme Corporation" + }, + "externalId": { + "type": [ + "string", + "null" + ], + "description": "External identifier for the organization.", + "example": "org_12345" + }, + "domain": { + "type": [ + "string", + "null" + ], + "description": "The organization's domain.", + "example": "acme.com" + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Description of the organization.", + "example": "A leading provider of enterprise solutions" + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "description": "Additional custom metadata for the organization.", + "example": { + "industry": "technology", + "employees": 500 + } + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "The website's unique identifier that the organization belongs to.", + "example": "01JG000000000000000000000" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "The organization's unique identifier that the organization belongs to.", + "example": "01JG000000000000000000000" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the organization was first created.", + "example": "2021-01-01T00:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the organization record was last updated.", + "example": "2021-01-01T00:00:00.000Z" + } + }, + "required": [ + "id", + "name", + "externalId", + "domain", + "description", + "metadata", + "websiteId", + "organizationId", + "createdAt", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "Invalid request data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Contact organization not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "updateContactOrganization", + "tags": [ + "Contacts" + ] + }, + "delete": { + "summary": "Delete a contact organization", + "description": "Soft deletes a contact organization.", + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The contact organization ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Contact organization deleted successfully" + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Contact organization not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "deleteContactOrganization", + "tags": [ + "Contacts" + ] + } + }, + "/support/state": { + "get": { + "summary": "Get support state", + "description": "Returns resolved feature flags and onboarding progress for the current visitor.", + "tags": [ + "Support" + ], + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + }, + { + "name": "X-Visitor-Id", + "in": "header", + "description": "Visitor ID from localStorage.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "responses": { + "200": { + "description": "Support state retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "featureFlags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Resolved feature flags enabled for the current visitor/contact.", + "example": [ + "new-message", + "billing-v2" + ] + }, + "onboarding": { + "type": "object", + "properties": { + "steps": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "completed": { + "type": "boolean", + "description": "Whether the onboarding step is completed.", + "example": false + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "Step-scoped onboarding metadata persisted for this user.", + "example": { + "invitedEmails": [ + "team@example.com" + ] + } + } + }, + "required": [ + "completed", + "metadata" + ] + }, + "description": "Onboarding state keyed by configured step ID." + } + }, + "required": [ + "steps" + ], + "description": "Persisted onboarding progress for the current visitor/contact." + } + }, + "required": [ + "featureFlags", + "onboarding" + ] + } + } + } + }, + "400": { + "description": "Visitor ID is required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Visitor not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "getSupportState" + } + }, + "/support/onboarding": { + "patch": { + "summary": "Update onboarding state", + "description": "Updates onboarding progress or metadata for the current visitor/contact.", + "tags": [ + "Support" + ], + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + }, + { + "name": "X-Visitor-Id", + "in": "header", + "description": "Visitor ID from localStorage.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stepId": { + "type": "string", + "minLength": 1, + "description": "Configured onboarding step ID to update.", + "example": "invite-team" + }, + "completed": { + "type": "boolean", + "description": "Optional completed state for the step.", + "example": true + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "Optional metadata replacement for the step. null clears metadata.", + "example": { + "invitedEmails": [ + "team@example.com" + ] + } + }, + "reset": { + "type": "boolean", + "description": "When true, clears all onboarding progress.", + "example": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Onboarding state updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "featureFlags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Resolved feature flags enabled for the current visitor/contact.", + "example": [ + "new-message", + "billing-v2" + ] + }, + "onboarding": { + "type": "object", + "properties": { + "steps": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "completed": { + "type": "boolean", + "description": "Whether the onboarding step is completed.", + "example": false + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "Step-scoped onboarding metadata persisted for this user.", + "example": { + "invitedEmails": [ + "team@example.com" + ] + } + } + }, + "required": [ + "completed", + "metadata" + ] + }, + "description": "Onboarding state keyed by configured step ID." + } + }, + "required": [ + "steps" + ], + "description": "Persisted onboarding progress for the current visitor/contact." + } + }, + "required": [ + "featureFlags", + "onboarding" + ] + } + } + } + }, + "400": { + "description": "Invalid request data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Visitor not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "updateSupportOnboarding" + } + }, + "/support/feature-flags": { + "patch": { + "summary": "Mutate feature flags", + "description": "Adds, removes, or replaces feature flags on a visitor, contact, or contact organization.", + "tags": [ + "Support" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "target": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "visitor", + "contact", + "contactOrganization" + ], + "description": "The entity level where feature flags should be stored.", + "example": "contact" + }, + "id": { + "type": "string", + "minLength": 1, + "description": "The target entity ID.", + "example": "01JG000000000000000000000" + } + }, + "required": [ + "type", + "id" + ] + }, + "operation": { + "type": "string", + "enum": [ + "add", + "remove", + "set" + ], + "description": "How to apply the provided flags to the target.", + "example": "add" + }, + "flags": { + "type": "array", + "items": { + "type": "string", + "description": "Feature flag name. Commas are reserved for storage.", + "example": "new-message" + }, + "description": "Feature flag names to add, remove, or set.", + "example": [ + "new-message" + ] + } + }, + "required": [ + "target", + "operation", + "flags" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Feature flags updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "target": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "visitor", + "contact", + "contactOrganization" + ], + "description": "The entity level where feature flags should be stored.", + "example": "contact" + }, + "id": { + "type": "string", + "minLength": 1, + "description": "The target entity ID.", + "example": "01JG000000000000000000000" + } + }, + "required": [ + "type", + "id" + ] + }, + "flags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The target entity's stored feature flags after mutation.", + "example": [ + "new-message" + ] + } + }, + "required": [ + "target", + "flags" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Target not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "updateSupportFeatureFlags" + } + }, + "/uploads/sign-url": { + "post": { + "summary": "Generate a signed S3 upload URL", + "description": "Creates a temporary signed URL that can be used to upload a file directly to the configured S3 bucket.", + "tags": [ + "Uploads" + ], + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "contentType": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "MIME type of the file to upload.", + "example": "image/png" + }, + "websiteId": { + "type": "string" + }, + "scope": { + "oneOf": [ + { + "type": "object", + "properties": { + "organizationId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Identifier of the organization that owns the uploaded file.", + "example": "org_01HZYFG9W5V6YB5R6T6V7N9M2Q" + }, + "websiteId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Identifier of the website associated with the uploaded file.", + "example": "site_01HZYFH3KJ3MYHJJ3JJ6Y2RNAV" + }, + "type": { + "type": "string", + "enum": [ + "conversation" + ] + }, + "conversationId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Conversation identifier that will scope the uploaded asset.", + "example": "conv_01HZYFJ5P7DQ0VE8F68G5VYBAQ" + } + }, + "required": [ + "organizationId", + "websiteId", + "type", + "conversationId" + ], + "description": "Scope uploads to a specific conversation. Files will be placed under /{organizationId}/{websiteId}/{conversationId}." + }, + { + "type": "object", + "properties": { + "organizationId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Identifier of the organization that owns the uploaded file.", + "example": "org_01HZYFG9W5V6YB5R6T6V7N9M2Q" + }, + "websiteId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Identifier of the website associated with the uploaded file.", + "example": "site_01HZYFH3KJ3MYHJJ3JJ6Y2RNAV" + }, + "type": { + "type": "string", + "enum": [ + "user" + ] + }, + "userId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "User identifier that will scope the uploaded asset.", + "example": "user_01HZYFKJS3K0M9W6PQZ0J6G1WR" + } + }, + "required": [ + "organizationId", + "websiteId", + "type", + "userId" + ], + "description": "Scope uploads to a specific user. Files will be placed under /{organizationId}/{websiteId}/{userId}." + }, + { + "type": "object", + "properties": { + "organizationId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Identifier of the organization that owns the uploaded file.", + "example": "org_01HZYFG9W5V6YB5R6T6V7N9M2Q" + }, + "websiteId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Identifier of the website associated with the uploaded file.", + "example": "site_01HZYFH3KJ3MYHJJ3JJ6Y2RNAV" + }, + "type": { + "type": "string", + "enum": [ + "contact" + ] + }, + "contactId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Contact identifier that will scope the uploaded asset.", + "example": "contact_01HZYFMN7J2J4F2SW3Q2N1H0D9" + } + }, + "required": [ + "organizationId", + "websiteId", + "type", + "contactId" + ], + "description": "Scope uploads to a specific contact. Files will be placed under /{organizationId}/{websiteId}/{contactId}." + }, + { + "type": "object", + "properties": { + "organizationId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Identifier of the organization that owns the uploaded file.", + "example": "org_01HZYFG9W5V6YB5R6T6V7N9M2Q" + }, + "websiteId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Identifier of the website associated with the uploaded file.", + "example": "site_01HZYFH3KJ3MYHJJ3JJ6Y2RNAV" + }, + "type": { + "type": "string", + "enum": [ + "visitor" + ] + }, + "visitorId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Visitor identifier that will scope the uploaded asset.", + "example": "visitor_01HZYFPQ8R2FK1D9V7ZQ6CG6TN" + } + }, + "required": [ + "organizationId", + "websiteId", + "type", + "visitorId" + ], + "description": "Scope uploads to a specific visitor. Files will be placed under /{organizationId}/{websiteId}/{visitorId}." + } + ], + "description": "Defines how uploaded files should be grouped inside the S3 bucket." + }, + "path": { + "type": "string", + "maxLength": 512, + "description": "Optional relative path used to group uploads inside the bucket. Nested paths are supported.", + "example": "assets/avatars" + }, + "fileName": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^\\\\/]+$", + "description": "Optional file name to use for the object. Invalid characters will be sanitized on the server side.", + "example": "profile-picture.png" + }, + "fileExtension": { + "type": "string", + "minLength": 1, + "maxLength": 16, + "pattern": "^[a-zA-Z0-9]+$", + "description": "Optional file extension without the leading dot. Use this when providing a custom file name without an extension.", + "example": "png" + }, + "useCdn": { + "type": "boolean", + "description": "Set to true to place the file under the /cdn prefix so it is cached by the CDN.", + "example": true + }, + "expiresInSeconds": { + "type": "integer", + "minimum": 60, + "maximum": 3600, + "description": "Number of seconds before the signed URL expires. Defaults to 900 seconds (15 minutes).", + "example": 900 + } + }, + "required": [ + "contentType", + "websiteId", + "scope" + ], + "description": "Request payload to create a signed S3 upload URL." + } + } + } + }, + "responses": { + "200": { + "description": "Signed URL generated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uploadUrl": { + "type": "string", + "format": "uri", + "description": "Pre-signed URL that accepts a PUT request to upload the file to S3.", + "example": "https://example-bucket.s3.amazonaws.com/org-id/file.png?X-Amz-Signature=..." + }, + "key": { + "type": "string", + "description": "Resolved object key that can be used to reference the uploaded asset.", + "example": "01JG000000000000000000000/assets/file.png" + }, + "bucket": { + "type": "string", + "description": "Name of the S3 bucket that will receive the upload.", + "example": "cossistant-uploads" + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "description": "ISO timestamp indicating when the signed URL will expire.", + "example": "2024-01-01T12:00:00.000Z" + }, + "contentType": { + "type": "string", + "description": "MIME type that should be used when uploading the file.", + "example": "image/png" + }, + "publicUrl": { + "type": "string", + "format": "uri", + "description": "Publicly accessible URL (or CDN URL when requested) that can be used to read the uploaded file.", + "example": "https://cdn.example.com/org-id/file.png" + } + }, + "required": [ + "uploadUrl", + "key", + "bucket", + "expiresAt", + "contentType", + "publicUrl" + ], + "description": "Response payload containing the signed upload URL." + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Public key origin validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "createUploadSignUrl" + } + }, + "/knowledge": { + "get": { + "summary": "List knowledge entries", + "description": "Returns a paginated list of knowledge entries for the website. Supports filtering by type, AI agent, training inclusion, and link source.", + "operationId": "listKnowledge", + "tags": [ + "Knowledge" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "url", + "faq", + "article" + ], + "description": "Filter by knowledge type", + "example": "url" + }, + "required": false, + "description": "Filter by knowledge type", + "name": "type", + "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "type": "string", + "format": "ulid" + }, + { + "type": "string", + "enum": [ + "null" + ] + }, + { + "type": "string", + "enum": [ + "" + ] + } + ], + "description": "Filter by AI agent ID. Pass a valid ULID to filter by agent, pass \"null\" or empty string to filter for shared/website-scoped entries only, or omit entirely to return all entries.", + "example": "01JG000000000000000000002" + }, + "required": false, + "description": "Filter by AI agent ID. Pass a valid ULID to filter by agent, pass \"null\" or empty string to filter for shared/website-scoped entries only, or omit entirely to return all entries.", + "name": "aiAgentId", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ], + "description": "Filter by training inclusion. Pass \"true\" for included entries or \"false\" for excluded entries.", + "example": "true" + }, + "required": false, + "description": "Filter by training inclusion. Pass \"true\" for included entries or \"false\" for excluded entries.", + "name": "isIncluded", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "ulid", + "description": "Filter by the link source that created this entry.", + "example": "01JG000000000000000000003" + }, + "required": false, + "description": "Filter by the link source that created this entry.", + "name": "linkSourceId", + "in": "query" + }, + { + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 1, + "description": "Page number (1-indexed)", + "example": 1 + }, + "required": false, + "description": "Page number (1-indexed)", + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 100, + "default": 20, + "description": "Items per page (max 100)", + "example": 20 + }, + "required": false, + "description": "Items per page (max 100)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Knowledge entries retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "Knowledge entry identifier", + "example": "01JG00000000000000000000A" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "Owning organization identifier", + "example": "01JG000000000000000000000" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "Website identifier", + "example": "01JG000000000000000000001" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "Optional AI agent identifier; null means shared at website scope", + "example": "01JG000000000000000000002" + }, + "linkSourceId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "Reference to the link source that created this entry", + "example": "01JG000000000000000000003" + }, + "type": { + "type": "string", + "enum": [ + "url", + "faq", + "article" + ], + "description": "Knowledge entry type", + "example": "url" + }, + "sourceUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Origin URL for this entry", + "example": "https://docs.cossistant.com/getting-started" + }, + "sourceTitle": { + "type": [ + "string", + "null" + ], + "description": "Readable title captured during scraping", + "example": "Getting started with the Cossistant dashboard" + }, + "origin": { + "type": "string", + "description": "How this entry was created (crawl, manual, agent, etc.)", + "example": "crawl" + }, + "createdBy": { + "type": "string", + "description": "Identifier of the actor that created this entry", + "example": "user_01JG00000000000000000000" + }, + "contentHash": { + "type": "string", + "description": "Deterministic hash of the payload for deduping", + "example": "5d41402abc4b2a76b9719d911017c592" + }, + "payload": { + "anyOf": [ + { + "type": "object", + "properties": { + "markdown": { + "type": "string", + "minLength": 1, + "description": "Scraped markdown body", + "example": "# Welcome to the Help Center" + }, + "headings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "description": "Heading level (1-6)", + "example": 2 + }, + "text": { + "type": "string", + "minLength": 1, + "description": "Heading text content", + "example": "Getting started" + } + }, + "required": [ + "level", + "text" + ] + }, + "default": [] + }, + "links": { + "type": "array", + "items": { + "type": "string", + "format": "uri", + "description": "Absolute URL discovered in the document", + "example": "https://docs.cossistant.com/guide" + }, + "default": [] + }, + "images": { + "type": "array", + "items": { + "type": "object", + "properties": { + "src": { + "type": "string", + "format": "uri", + "description": "Image URL captured during scraping", + "example": "https://cdn.cossistant.com/assets/hero.png" + }, + "alt": { + "type": [ + "string", + "null" + ], + "description": "Optional alt text attached to the image", + "example": "Agent dashboard hero illustration" + } + }, + "required": [ + "src", + "alt" + ] + }, + "default": [] + }, + "estimatedTokens": { + "type": "integer", + "minimum": 0, + "description": "Heuristic token count to assist chunking", + "example": 2048 + } + }, + "required": [ + "markdown" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "question": { + "type": "string", + "minLength": 1, + "description": "FAQ question", + "example": "How do I reset my password?" + }, + "answer": { + "type": "string", + "minLength": 1, + "description": "Answer shown to customers", + "example": "Go to Settings → Security and click Reset password." + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "relatedQuestions": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "required": [ + "question", + "answer" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "description": "Article title", + "example": "Billing and invoicing overview" + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Short synopsis or excerpt", + "example": "Understand how billing cycles and invoices are generated." + }, + "markdown": { + "type": "string", + "minLength": 1, + "description": "Article body in markdown format", + "example": "## Billing cycles\n\nCossistant bills you monthly..." + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "heroImage": { + "type": "object", + "properties": { + "src": { + "type": "string", + "format": "uri", + "description": "Image URL captured during scraping", + "example": "https://cdn.cossistant.com/assets/hero.png" + }, + "alt": { + "type": [ + "string", + "null" + ], + "description": "Optional alt text attached to the image", + "example": "Agent dashboard hero illustration" + } + }, + "required": [ + "src", + "alt" + ] + } + }, + "required": [ + "title", + "markdown" + ], + "additionalProperties": {} + } + ] + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary metadata such as locale or crawl depth", + "example": { + "locale": "en-US", + "source": "firecrawl" + } + }, + "isIncluded": { + "type": "boolean", + "description": "Whether this entry is included in training", + "example": true + }, + "sizeBytes": { + "type": "integer", + "minimum": 0, + "description": "Size of this entry in bytes", + "example": 4096 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp", + "example": "2024-06-10T12:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp", + "example": "2024-06-11T08:00:00.000Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Soft delete timestamp", + "example": null + } + }, + "required": [ + "id", + "organizationId", + "websiteId", + "aiAgentId", + "linkSourceId", + "type", + "sourceUrl", + "sourceTitle", + "origin", + "createdBy", + "contentHash", + "payload", + "isIncluded", + "sizeBytes", + "createdAt", + "updatedAt", + "deletedAt" + ], + "description": "Knowledge entry response" + }, + "description": "Array of knowledge entries" + }, + "pagination": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Current page number", + "example": 1 + }, + "limit": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Items per page", + "example": 20 + }, + "total": { + "type": "integer", + "minimum": 0, + "description": "Total number of items", + "example": 100 + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items available", + "example": true + } + }, + "required": [ + "page", + "limit", + "total", + "hasMore" + ], + "description": "Pagination metadata" + } + }, + "required": [ + "items", + "pagination" + ], + "description": "Paginated list of knowledge entries" + } + } + } + }, + "400": { + "description": "Bad request - Invalid query parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + }, + "post": { + "summary": "Create a knowledge entry", + "description": "Creates a new knowledge entry for the website", + "tags": [ + "Knowledge" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "aiAgentId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "Optional AI agent ID; null/omit for shared at website scope", + "example": "01JG000000000000000000002" + }, + "type": { + "type": "string", + "enum": [ + "url", + "faq", + "article" + ], + "description": "Knowledge entry type", + "example": "url" + }, + "sourceUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Origin URL for this entry", + "example": "https://docs.cossistant.com/getting-started" + }, + "sourceTitle": { + "type": [ + "string", + "null" + ], + "description": "Readable title", + "example": "Getting started with the Cossistant dashboard" + }, + "origin": { + "type": "string", + "minLength": 1, + "description": "How this entry was created (crawl, manual, agent, etc.)", + "example": "manual" + }, + "payload": { + "anyOf": [ + { + "type": "object", + "properties": { + "markdown": { + "type": "string", + "minLength": 1, + "description": "Scraped markdown body", + "example": "# Welcome to the Help Center" + }, + "headings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "description": "Heading level (1-6)", + "example": 2 + }, + "text": { + "type": "string", + "minLength": 1, + "description": "Heading text content", + "example": "Getting started" + } + }, + "required": [ + "level", + "text" + ] + }, + "default": [] + }, + "links": { + "type": "array", + "items": { + "type": "string", + "format": "uri", + "description": "Absolute URL discovered in the document", + "example": "https://docs.cossistant.com/guide" + }, + "default": [] + }, + "images": { + "type": "array", + "items": { + "type": "object", + "properties": { + "src": { + "type": "string", + "format": "uri", + "description": "Image URL captured during scraping", + "example": "https://cdn.cossistant.com/assets/hero.png" + }, + "alt": { + "type": [ + "string", + "null" + ], + "description": "Optional alt text attached to the image", + "example": "Agent dashboard hero illustration" + } + }, + "required": [ + "src", + "alt" + ] + }, + "default": [] + }, + "estimatedTokens": { + "type": "integer", + "minimum": 0, + "description": "Heuristic token count to assist chunking", + "example": 2048 + } + }, + "required": [ + "markdown" + ], + "description": "Structured payload for raw page content" + }, + { + "type": "object", + "properties": { + "question": { + "type": "string", + "minLength": 1, + "description": "FAQ question", + "example": "How do I reset my password?" + }, + "answer": { + "type": "string", + "minLength": 1, + "description": "Answer shown to customers", + "example": "Go to Settings → Security and click Reset password." + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "relatedQuestions": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "required": [ + "question", + "answer" + ], + "description": "Payload describing a single FAQ entry" + }, + { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "description": "Article title", + "example": "Billing and invoicing overview" + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Short synopsis or excerpt", + "example": "Understand how billing cycles and invoices are generated." + }, + "markdown": { + "type": "string", + "minLength": 1, + "description": "Article body in markdown format", + "example": "## Billing cycles\n\nCossistant bills you monthly..." + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "heroImage": { + "type": "object", + "properties": { + "src": { + "type": "string", + "format": "uri", + "description": "Image URL captured during scraping", + "example": "https://cdn.cossistant.com/assets/hero.png" + }, + "alt": { + "type": [ + "string", + "null" + ], + "description": "Optional alt text attached to the image", + "example": "Agent dashboard hero illustration" + } + }, + "required": [ + "src", + "alt" + ] + } + }, + "required": [ + "title", + "markdown" + ], + "description": "Payload describing a full article or help doc" + } + ] + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary metadata such as locale or crawl depth", + "example": { + "locale": "en-US", + "source": "firecrawl" + } + } + }, + "required": [ + "type", + "origin", + "payload" + ], + "description": "Request to create a new knowledge entry (REST)" + } + } + } + }, + "responses": { + "201": { + "description": "Knowledge entry created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "Knowledge entry identifier", + "example": "01JG00000000000000000000A" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "Owning organization identifier", + "example": "01JG000000000000000000000" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "Website identifier", + "example": "01JG000000000000000000001" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "Optional AI agent identifier; null means shared at website scope", + "example": "01JG000000000000000000002" + }, + "linkSourceId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "Reference to the link source that created this entry", + "example": "01JG000000000000000000003" + }, + "type": { + "type": "string", + "enum": [ + "url", + "faq", + "article" + ], + "description": "Knowledge entry type", + "example": "url" + }, + "sourceUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Origin URL for this entry", + "example": "https://docs.cossistant.com/getting-started" + }, + "sourceTitle": { + "type": [ + "string", + "null" + ], + "description": "Readable title captured during scraping", + "example": "Getting started with the Cossistant dashboard" + }, + "origin": { + "type": "string", + "description": "How this entry was created (crawl, manual, agent, etc.)", + "example": "crawl" + }, + "createdBy": { + "type": "string", + "description": "Identifier of the actor that created this entry", + "example": "user_01JG00000000000000000000" + }, + "contentHash": { + "type": "string", + "description": "Deterministic hash of the payload for deduping", + "example": "5d41402abc4b2a76b9719d911017c592" + }, + "payload": { + "anyOf": [ + { + "type": "object", + "properties": { + "markdown": { + "type": "string", + "minLength": 1, + "description": "Scraped markdown body", + "example": "# Welcome to the Help Center" + }, + "headings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "description": "Heading level (1-6)", + "example": 2 + }, + "text": { + "type": "string", + "minLength": 1, + "description": "Heading text content", + "example": "Getting started" + } + }, + "required": [ + "level", + "text" + ] + }, + "default": [] + }, + "links": { + "type": "array", + "items": { + "type": "string", + "format": "uri", + "description": "Absolute URL discovered in the document", + "example": "https://docs.cossistant.com/guide" + }, + "default": [] + }, + "images": { + "type": "array", + "items": { + "type": "object", + "properties": { + "src": { + "type": "string", + "format": "uri", + "description": "Image URL captured during scraping", + "example": "https://cdn.cossistant.com/assets/hero.png" + }, + "alt": { + "type": [ + "string", + "null" + ], + "description": "Optional alt text attached to the image", + "example": "Agent dashboard hero illustration" + } + }, + "required": [ + "src", + "alt" + ] + }, + "default": [] + }, + "estimatedTokens": { + "type": "integer", + "minimum": 0, + "description": "Heuristic token count to assist chunking", + "example": 2048 + } + }, + "required": [ + "markdown" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "question": { + "type": "string", + "minLength": 1, + "description": "FAQ question", + "example": "How do I reset my password?" + }, + "answer": { + "type": "string", + "minLength": 1, + "description": "Answer shown to customers", + "example": "Go to Settings → Security and click Reset password." + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "relatedQuestions": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "required": [ + "question", + "answer" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "description": "Article title", + "example": "Billing and invoicing overview" + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Short synopsis or excerpt", + "example": "Understand how billing cycles and invoices are generated." + }, + "markdown": { + "type": "string", + "minLength": 1, + "description": "Article body in markdown format", + "example": "## Billing cycles\n\nCossistant bills you monthly..." + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "heroImage": { + "type": "object", + "properties": { + "src": { + "type": "string", + "format": "uri", + "description": "Image URL captured during scraping", + "example": "https://cdn.cossistant.com/assets/hero.png" + }, + "alt": { + "type": [ + "string", + "null" + ], + "description": "Optional alt text attached to the image", + "example": "Agent dashboard hero illustration" + } + }, + "required": [ + "src", + "alt" + ] + } + }, + "required": [ + "title", + "markdown" + ], + "additionalProperties": {} + } + ] + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary metadata such as locale or crawl depth", + "example": { + "locale": "en-US", + "source": "firecrawl" + } + }, + "isIncluded": { + "type": "boolean", + "description": "Whether this entry is included in training", + "example": true + }, + "sizeBytes": { + "type": "integer", + "minimum": 0, + "description": "Size of this entry in bytes", + "example": 4096 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp", + "example": "2024-06-10T12:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp", + "example": "2024-06-11T08:00:00.000Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Soft delete timestamp", + "example": null + } + }, + "required": [ + "id", + "organizationId", + "websiteId", + "aiAgentId", + "linkSourceId", + "type", + "sourceUrl", + "sourceTitle", + "origin", + "createdBy", + "contentHash", + "payload", + "isIncluded", + "sizeBytes", + "createdAt", + "updatedAt", + "deletedAt" + ], + "description": "Knowledge entry response" + } + } + } + }, + "400": { + "description": "Invalid request data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Plan knowledge limits prevent creating this entry", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "createKnowledge" + } + }, + "/knowledge/search": { + "get": { + "summary": "Search knowledge", + "description": "Runs private semantic retrieval against indexed knowledge chunks for the authenticated website. Designed for CLI, MCP, and support-agent tools that need source-backed context.", + "operationId": "searchKnowledge", + "tags": [ + "Knowledge" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 2000, + "description": "Natural-language retrieval query.", + "example": "How do I update billing details?" + }, + "required": true, + "description": "Natural-language retrieval query.", + "name": "query", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "default": 4, + "description": "Maximum number of chunks to return." + }, + "required": false, + "description": "Maximum number of chunks to return.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "maximum": 1, + "default": 0.3, + "description": "Minimum cosine similarity score to include." + }, + "required": false, + "description": "Minimum cosine similarity score to include.", + "name": "minSimilarity", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "ulid", + "description": "Optional knowledge entry to restrict retrieval to.", + "example": "01JG00000000000000000000A" + }, + "required": false, + "description": "Optional knowledge entry to restrict retrieval to.", + "name": "knowledgeId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Knowledge search completed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Normalized query used for retrieval." + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Chunk identifier." + }, + "content": { + "type": "string", + "description": "Full chunk content." + }, + "snippet": { + "type": "string", + "description": "Short readable excerpt from the chunk." + }, + "metadata": { + "description": "Chunk metadata captured during indexing." + }, + "similarity": { + "type": "number", + "description": "Similarity score from 0 to 1." + }, + "sourceType": { + "type": "string", + "description": "Chunk source type.", + "example": "knowledge" + }, + "knowledgeId": { + "type": [ + "string", + "null" + ], + "description": "Knowledge entry ID that produced the chunk." + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "Visitor memory source ID, null for knowledge chunks." + }, + "contactId": { + "type": [ + "string", + "null" + ], + "description": "Contact memory source ID, null for knowledge chunks." + }, + "chunkIndex": { + "type": [ + "integer", + "null" + ], + "description": "Chunk index within its source document." + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Readable source title when available." + }, + "sourceUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Source URL when available." + } + }, + "required": [ + "id", + "content", + "snippet", + "similarity", + "sourceType", + "knowledgeId", + "visitorId", + "contactId", + "chunkIndex", + "title", + "sourceUrl" + ] + } + }, + "totalFound": { + "type": "integer", + "description": "Number of results returned." + }, + "maxSimilarity": { + "type": [ + "number", + "null" + ], + "description": "Highest similarity score among returned results." + }, + "retrievalQuality": { + "type": "string", + "enum": [ + "none", + "low", + "medium", + "high" + ], + "description": "Coarse quality band inferred from the best match." + } + }, + "required": [ + "query", + "results", + "totalFound", + "maxSimilarity", + "retrievalQuality" + ], + "description": "Private knowledge retrieval response." + } + } + } + }, + "400": { + "description": "Bad request - Invalid query parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/knowledge/{id}": { + "get": { + "summary": "Get a knowledge entry", + "description": "Retrieves a single knowledge entry by ID", + "operationId": "getKnowledge", + "tags": [ + "Knowledge" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The knowledge entry ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Knowledge entry retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "Knowledge entry identifier", + "example": "01JG00000000000000000000A" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "Owning organization identifier", + "example": "01JG000000000000000000000" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "Website identifier", + "example": "01JG000000000000000000001" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "Optional AI agent identifier; null means shared at website scope", + "example": "01JG000000000000000000002" + }, + "linkSourceId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "Reference to the link source that created this entry", + "example": "01JG000000000000000000003" + }, + "type": { + "type": "string", + "enum": [ + "url", + "faq", + "article" + ], + "description": "Knowledge entry type", + "example": "url" + }, + "sourceUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Origin URL for this entry", + "example": "https://docs.cossistant.com/getting-started" + }, + "sourceTitle": { + "type": [ + "string", + "null" + ], + "description": "Readable title captured during scraping", + "example": "Getting started with the Cossistant dashboard" + }, + "origin": { + "type": "string", + "description": "How this entry was created (crawl, manual, agent, etc.)", + "example": "crawl" + }, + "createdBy": { + "type": "string", + "description": "Identifier of the actor that created this entry", + "example": "user_01JG00000000000000000000" + }, + "contentHash": { + "type": "string", + "description": "Deterministic hash of the payload for deduping", + "example": "5d41402abc4b2a76b9719d911017c592" + }, + "payload": { + "anyOf": [ + { + "type": "object", + "properties": { + "markdown": { + "type": "string", + "minLength": 1, + "description": "Scraped markdown body", + "example": "# Welcome to the Help Center" + }, + "headings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "description": "Heading level (1-6)", + "example": 2 + }, + "text": { + "type": "string", + "minLength": 1, + "description": "Heading text content", + "example": "Getting started" + } + }, + "required": [ + "level", + "text" + ] + }, + "default": [] + }, + "links": { + "type": "array", + "items": { + "type": "string", + "format": "uri", + "description": "Absolute URL discovered in the document", + "example": "https://docs.cossistant.com/guide" + }, + "default": [] + }, + "images": { + "type": "array", + "items": { + "type": "object", + "properties": { + "src": { + "type": "string", + "format": "uri", + "description": "Image URL captured during scraping", + "example": "https://cdn.cossistant.com/assets/hero.png" + }, + "alt": { + "type": [ + "string", + "null" + ], + "description": "Optional alt text attached to the image", + "example": "Agent dashboard hero illustration" + } + }, + "required": [ + "src", + "alt" + ] + }, + "default": [] + }, + "estimatedTokens": { + "type": "integer", + "minimum": 0, + "description": "Heuristic token count to assist chunking", + "example": 2048 + } + }, + "required": [ + "markdown" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "question": { + "type": "string", + "minLength": 1, + "description": "FAQ question", + "example": "How do I reset my password?" + }, + "answer": { + "type": "string", + "minLength": 1, + "description": "Answer shown to customers", + "example": "Go to Settings → Security and click Reset password." + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "relatedQuestions": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "required": [ + "question", + "answer" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "description": "Article title", + "example": "Billing and invoicing overview" + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Short synopsis or excerpt", + "example": "Understand how billing cycles and invoices are generated." + }, + "markdown": { + "type": "string", + "minLength": 1, + "description": "Article body in markdown format", + "example": "## Billing cycles\n\nCossistant bills you monthly..." + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "heroImage": { + "type": "object", + "properties": { + "src": { + "type": "string", + "format": "uri", + "description": "Image URL captured during scraping", + "example": "https://cdn.cossistant.com/assets/hero.png" + }, + "alt": { + "type": [ + "string", + "null" + ], + "description": "Optional alt text attached to the image", + "example": "Agent dashboard hero illustration" + } + }, + "required": [ + "src", + "alt" + ] + } + }, + "required": [ + "title", + "markdown" + ], + "additionalProperties": {} + } + ] + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary metadata such as locale or crawl depth", + "example": { + "locale": "en-US", + "source": "firecrawl" + } + }, + "isIncluded": { + "type": "boolean", + "description": "Whether this entry is included in training", + "example": true + }, + "sizeBytes": { + "type": "integer", + "minimum": 0, + "description": "Size of this entry in bytes", + "example": 4096 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp", + "example": "2024-06-10T12:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp", + "example": "2024-06-11T08:00:00.000Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Soft delete timestamp", + "example": null + } + }, + "required": [ + "id", + "organizationId", + "websiteId", + "aiAgentId", + "linkSourceId", + "type", + "sourceUrl", + "sourceTitle", + "origin", + "createdBy", + "contentHash", + "payload", + "isIncluded", + "sizeBytes", + "createdAt", + "updatedAt", + "deletedAt" + ], + "description": "Knowledge entry response" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Knowledge entry not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + }, + "patch": { + "summary": "Update a knowledge entry", + "description": "Updates an existing knowledge entry", + "tags": [ + "Knowledge" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The knowledge entry ID", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "aiAgentId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "Update AI agent association", + "example": "01JG000000000000000000002" + }, + "sourceUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Update origin URL", + "example": "https://docs.cossistant.com/getting-started" + }, + "sourceTitle": { + "type": [ + "string", + "null" + ], + "description": "Update readable title", + "example": "Getting started with the Cossistant dashboard" + }, + "payload": { + "anyOf": [ + { + "type": "object", + "properties": { + "markdown": { + "type": "string", + "minLength": 1, + "description": "Scraped markdown body", + "example": "# Welcome to the Help Center" + }, + "headings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "description": "Heading level (1-6)", + "example": 2 + }, + "text": { + "type": "string", + "minLength": 1, + "description": "Heading text content", + "example": "Getting started" + } + }, + "required": [ + "level", + "text" + ] + }, + "default": [] + }, + "links": { + "type": "array", + "items": { + "type": "string", + "format": "uri", + "description": "Absolute URL discovered in the document", + "example": "https://docs.cossistant.com/guide" + }, + "default": [] + }, + "images": { + "type": "array", + "items": { + "type": "object", + "properties": { + "src": { + "type": "string", + "format": "uri", + "description": "Image URL captured during scraping", + "example": "https://cdn.cossistant.com/assets/hero.png" + }, + "alt": { + "type": [ + "string", + "null" + ], + "description": "Optional alt text attached to the image", + "example": "Agent dashboard hero illustration" + } + }, + "required": [ + "src", + "alt" + ] + }, + "default": [] + }, + "estimatedTokens": { + "type": "integer", + "minimum": 0, + "description": "Heuristic token count to assist chunking", + "example": 2048 + } + }, + "required": [ + "markdown" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "question": { + "type": "string", + "minLength": 1, + "description": "FAQ question", + "example": "How do I reset my password?" + }, + "answer": { + "type": "string", + "minLength": 1, + "description": "Answer shown to customers", + "example": "Go to Settings → Security and click Reset password." + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "relatedQuestions": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "required": [ + "question", + "answer" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "description": "Article title", + "example": "Billing and invoicing overview" + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Short synopsis or excerpt", + "example": "Understand how billing cycles and invoices are generated." + }, + "markdown": { + "type": "string", + "minLength": 1, + "description": "Article body in markdown format", + "example": "## Billing cycles\n\nCossistant bills you monthly..." + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "heroImage": { + "type": "object", + "properties": { + "src": { + "type": "string", + "format": "uri", + "description": "Image URL captured during scraping", + "example": "https://cdn.cossistant.com/assets/hero.png" + }, + "alt": { + "type": [ + "string", + "null" + ], + "description": "Optional alt text attached to the image", + "example": "Agent dashboard hero illustration" + } + }, + "required": [ + "src", + "alt" + ] + } + }, + "required": [ + "title", + "markdown" + ], + "additionalProperties": {} + } + ] + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary metadata such as locale or crawl depth", + "example": { + "locale": "en-US", + "source": "firecrawl" + } + } + }, + "description": "Request to update an existing knowledge entry (REST)" + } + } + } + }, + "responses": { + "200": { + "description": "Knowledge entry updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "ulid", + "description": "Knowledge entry identifier", + "example": "01JG00000000000000000000A" + }, + "organizationId": { + "type": "string", + "format": "ulid", + "description": "Owning organization identifier", + "example": "01JG000000000000000000000" + }, + "websiteId": { + "type": "string", + "format": "ulid", + "description": "Website identifier", + "example": "01JG000000000000000000001" + }, + "aiAgentId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "Optional AI agent identifier; null means shared at website scope", + "example": "01JG000000000000000000002" + }, + "linkSourceId": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "Reference to the link source that created this entry", + "example": "01JG000000000000000000003" + }, + "type": { + "type": "string", + "enum": [ + "url", + "faq", + "article" + ], + "description": "Knowledge entry type", + "example": "url" + }, + "sourceUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Origin URL for this entry", + "example": "https://docs.cossistant.com/getting-started" + }, + "sourceTitle": { + "type": [ + "string", + "null" + ], + "description": "Readable title captured during scraping", + "example": "Getting started with the Cossistant dashboard" + }, + "origin": { + "type": "string", + "description": "How this entry was created (crawl, manual, agent, etc.)", + "example": "crawl" + }, + "createdBy": { + "type": "string", + "description": "Identifier of the actor that created this entry", + "example": "user_01JG00000000000000000000" + }, + "contentHash": { + "type": "string", + "description": "Deterministic hash of the payload for deduping", + "example": "5d41402abc4b2a76b9719d911017c592" + }, + "payload": { + "anyOf": [ + { + "type": "object", + "properties": { + "markdown": { + "type": "string", + "minLength": 1, + "description": "Scraped markdown body", + "example": "# Welcome to the Help Center" + }, + "headings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "description": "Heading level (1-6)", + "example": 2 + }, + "text": { + "type": "string", + "minLength": 1, + "description": "Heading text content", + "example": "Getting started" + } + }, + "required": [ + "level", + "text" + ] + }, + "default": [] + }, + "links": { + "type": "array", + "items": { + "type": "string", + "format": "uri", + "description": "Absolute URL discovered in the document", + "example": "https://docs.cossistant.com/guide" + }, + "default": [] + }, + "images": { + "type": "array", + "items": { + "type": "object", + "properties": { + "src": { + "type": "string", + "format": "uri", + "description": "Image URL captured during scraping", + "example": "https://cdn.cossistant.com/assets/hero.png" + }, + "alt": { + "type": [ + "string", + "null" + ], + "description": "Optional alt text attached to the image", + "example": "Agent dashboard hero illustration" + } + }, + "required": [ + "src", + "alt" + ] + }, + "default": [] + }, + "estimatedTokens": { + "type": "integer", + "minimum": 0, + "description": "Heuristic token count to assist chunking", + "example": 2048 + } + }, + "required": [ + "markdown" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "question": { + "type": "string", + "minLength": 1, + "description": "FAQ question", + "example": "How do I reset my password?" + }, + "answer": { + "type": "string", + "minLength": 1, + "description": "Answer shown to customers", + "example": "Go to Settings → Security and click Reset password." + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "relatedQuestions": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "required": [ + "question", + "answer" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "description": "Article title", + "example": "Billing and invoicing overview" + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Short synopsis or excerpt", + "example": "Understand how billing cycles and invoices are generated." + }, + "markdown": { + "type": "string", + "minLength": 1, + "description": "Article body in markdown format", + "example": "## Billing cycles\n\nCossistant bills you monthly..." + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "heroImage": { + "type": "object", + "properties": { + "src": { + "type": "string", + "format": "uri", + "description": "Image URL captured during scraping", + "example": "https://cdn.cossistant.com/assets/hero.png" + }, + "alt": { + "type": [ + "string", + "null" + ], + "description": "Optional alt text attached to the image", + "example": "Agent dashboard hero illustration" + } + }, + "required": [ + "src", + "alt" + ] + } + }, + "required": [ + "title", + "markdown" + ], + "additionalProperties": {} + } + ] + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "Arbitrary metadata such as locale or crawl depth", + "example": { + "locale": "en-US", + "source": "firecrawl" + } + }, + "isIncluded": { + "type": "boolean", + "description": "Whether this entry is included in training", + "example": true + }, + "sizeBytes": { + "type": "integer", + "minimum": 0, + "description": "Size of this entry in bytes", + "example": 4096 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp", + "example": "2024-06-10T12:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp", + "example": "2024-06-11T08:00:00.000Z" + }, + "deletedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Soft delete timestamp", + "example": null + } + }, + "required": [ + "id", + "organizationId", + "websiteId", + "aiAgentId", + "linkSourceId", + "type", + "sourceUrl", + "sourceTitle", + "origin", + "createdBy", + "contentHash", + "payload", + "isIncluded", + "sizeBytes", + "createdAt", + "updatedAt", + "deletedAt" + ], + "description": "Knowledge entry response" + } + } + } + }, + "400": { + "description": "Invalid request data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Plan knowledge limits prevent updating this entry", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Knowledge entry not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "updateKnowledge" + }, + "delete": { + "summary": "Delete a knowledge entry", + "description": "Permanently deletes a knowledge entry", + "tags": [ + "Knowledge" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The knowledge entry ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Knowledge entry deleted successfully" + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Knowledge entry not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "deleteKnowledge" + } + }, + "/feedback": { + "post": { + "summary": "Submit feedback", + "description": "Submit feedback with a rating, optional topic, and optional comment. Can be tied to a conversation or standalone.", + "tags": [ + "Feedback" + ], + "security": [ + { + "PrivateApiKey": [] + }, + { + "PublicApiKey": [] + } + ], + "parameters": [ + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + }, + { + "name": "X-Visitor-Id", + "in": "header", + "description": "Visitor ID from localStorage.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Rating from 1 to 5", + "example": 5 + }, + "topic": { + "type": "string", + "description": "Optional structured topic selected by the visitor" + }, + "comment": { + "type": "string", + "description": "Optional written feedback" + }, + "trigger": { + "type": "string", + "description": "What triggered this feedback (e.g., 'churn', 'conversation_resolved', 'nps_survey')" + }, + "source": { + "type": "string", + "default": "widget", + "description": "Source of the feedback (e.g., 'widget', 'api', 'email')" + }, + "conversationId": { + "type": "string", + "description": "Conversation ID if feedback is tied to a conversation" + }, + "visitorId": { + "type": "string", + "description": "Visitor ID who submitted the feedback" + }, + "contactId": { + "type": "string", + "description": "Contact ID if visitor has one" + } + }, + "required": [ + "rating" + ], + "description": "Request body for submitting feedback" + } + } + } + }, + "responses": { + "201": { + "description": "Feedback submitted successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "feedback": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the feedback" + }, + "organizationId": { + "type": "string", + "description": "Organization ID" + }, + "websiteId": { + "type": "string", + "description": "Website ID the feedback belongs to" + }, + "conversationId": { + "type": [ + "string", + "null" + ], + "description": "Conversation ID if feedback is tied to a conversation" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "Visitor ID who submitted the feedback" + }, + "contactId": { + "type": [ + "string", + "null" + ], + "description": "Contact ID if visitor has one" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Rating from 1 to 5", + "example": 5 + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Optional structured topic selected by the visitor" + }, + "comment": { + "type": [ + "string", + "null" + ], + "description": "Optional written feedback" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback (e.g., 'churn', 'conversation_resolved', 'nps_survey')" + }, + "source": { + "type": "string", + "default": "widget", + "description": "Source of the feedback (e.g., 'widget', 'api', 'email')" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the feedback was submitted", + "example": "2026-04-06T14:37:05.820Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the feedback was last updated", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "id", + "organizationId", + "websiteId", + "conversationId", + "visitorId", + "contactId", + "rating", + "topic", + "comment", + "trigger", + "source", + "createdAt", + "updatedAt" + ], + "description": "Feedback record" + } + }, + "required": [ + "feedback" + ], + "description": "Response containing the created feedback" + } + } + } + }, + "400": { + "description": "Invalid request data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Conversation not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "createFeedback" + }, + "get": { + "summary": "List feedback", + "description": "Returns a paginated list of feedback for the website. Supports filtering by trigger, source, conversation, visitor, contact, topic, rating, and creation time.", + "tags": [ + "Feedback" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "Filter by trigger type" + }, + "required": false, + "description": "Filter by trigger type", + "name": "trigger", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Filter by source" + }, + "required": false, + "description": "Filter by source", + "name": "source", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Filter by conversation ID" + }, + "required": false, + "description": "Filter by conversation ID", + "name": "conversationId", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Filter by visitor ID" + }, + "required": false, + "description": "Filter by visitor ID", + "name": "visitorId", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Filter by contact ID" + }, + "required": false, + "description": "Filter by contact ID", + "name": "contactId", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Filter by structured topic" + }, + "required": false, + "description": "Filter by structured topic", + "name": "topic", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Filter by rating from 1 to 5" + }, + "required": false, + "description": "Filter by rating from 1 to 5", + "name": "rating", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only include feedback created at or after this timestamp" + }, + "required": false, + "description": "Only include feedback created at or after this timestamp", + "name": "createdAtFrom", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only include feedback created before this timestamp" + }, + "required": false, + "description": "Only include feedback created before this timestamp", + "name": "createdAtTo", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc", + "description": "Sort order by creation time" + }, + "required": false, + "description": "Sort order by creation time", + "name": "order", + "in": "query" + }, + { + "schema": { + "type": "number", + "minimum": 1, + "default": 1, + "description": "Page number for pagination" + }, + "required": false, + "description": "Page number for pagination", + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 20, + "description": "Number of items per page" + }, + "required": false, + "description": "Number of items per page", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Feedback list retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "feedback": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the feedback" + }, + "organizationId": { + "type": "string", + "description": "Organization ID" + }, + "websiteId": { + "type": "string", + "description": "Website ID the feedback belongs to" + }, + "conversationId": { + "type": [ + "string", + "null" + ], + "description": "Conversation ID if feedback is tied to a conversation" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "Visitor ID who submitted the feedback" + }, + "contactId": { + "type": [ + "string", + "null" + ], + "description": "Contact ID if visitor has one" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Rating from 1 to 5", + "example": 5 + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Optional structured topic selected by the visitor" + }, + "comment": { + "type": [ + "string", + "null" + ], + "description": "Optional written feedback" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback (e.g., 'churn', 'conversation_resolved', 'nps_survey')" + }, + "source": { + "type": "string", + "default": "widget", + "description": "Source of the feedback (e.g., 'widget', 'api', 'email')" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the feedback was submitted", + "example": "2026-04-06T14:37:05.820Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the feedback was last updated", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "id", + "organizationId", + "websiteId", + "conversationId", + "visitorId", + "contactId", + "rating", + "topic", + "comment", + "trigger", + "source", + "createdAt", + "updatedAt" + ], + "description": "Feedback record" + } + }, + "pagination": { + "type": "object", + "properties": { + "page": { + "type": "number" + }, + "limit": { + "type": "number" + }, + "total": { + "type": "number" + }, + "totalPages": { + "type": "number" + }, + "hasMore": { + "type": "boolean" + } + }, + "required": [ + "page", + "limit", + "total", + "totalPages", + "hasMore" + ] + } + }, + "required": [ + "feedback", + "pagination" + ], + "description": "Paginated list of feedback" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "listFeedback" + } + }, + "/feedback/summary": { + "get": { + "summary": "Summarize feedback", + "description": "Returns aggregate feedback metrics for the website using the same filters as the feedback list endpoint.", + "tags": [ + "Feedback" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "description": "Filter by trigger type" + }, + "required": false, + "description": "Filter by trigger type", + "name": "trigger", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Filter by source" + }, + "required": false, + "description": "Filter by source", + "name": "source", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Filter by conversation ID" + }, + "required": false, + "description": "Filter by conversation ID", + "name": "conversationId", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Filter by visitor ID" + }, + "required": false, + "description": "Filter by visitor ID", + "name": "visitorId", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Filter by contact ID" + }, + "required": false, + "description": "Filter by contact ID", + "name": "contactId", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Filter by structured topic" + }, + "required": false, + "description": "Filter by structured topic", + "name": "topic", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Filter by rating from 1 to 5" + }, + "required": false, + "description": "Filter by rating from 1 to 5", + "name": "rating", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only include feedback created at or after this timestamp" + }, + "required": false, + "description": "Only include feedback created at or after this timestamp", + "name": "createdAtFrom", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only include feedback created before this timestamp" + }, + "required": false, + "description": "Only include feedback created before this timestamp", + "name": "createdAtTo", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Feedback summary retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "description": "Total feedback items matching the filters" + }, + "averageRating": { + "type": [ + "number", + "null" + ], + "description": "Average rating for matching feedback" + }, + "byRating": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5 + }, + "count": { + "type": "integer" + } + }, + "required": [ + "rating", + "count" + ] + } + }, + "byTopic": { + "type": "array", + "items": { + "type": "object", + "properties": { + "topic": { + "type": [ + "string", + "null" + ] + }, + "count": { + "type": "integer" + } + }, + "required": [ + "topic", + "count" + ] + } + }, + "byTrigger": { + "type": "array", + "items": { + "type": "object", + "properties": { + "trigger": { + "type": [ + "string", + "null" + ] + }, + "count": { + "type": "integer" + } + }, + "required": [ + "trigger", + "count" + ] + } + } + }, + "required": [ + "total", + "averageRating", + "byRating", + "byTopic", + "byTrigger" + ], + "description": "Aggregated feedback metrics for matching filters" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "getFeedbackSummary" + } + }, + "/feedback/{id}": { + "get": { + "summary": "Get feedback by ID", + "description": "Retrieves a single feedback entry by ID", + "tags": [ + "Feedback" + ], + "security": [ + { + "PrivateApiKey": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The feedback ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Feedback retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "feedback": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the feedback" + }, + "organizationId": { + "type": "string", + "description": "Organization ID" + }, + "websiteId": { + "type": "string", + "description": "Website ID the feedback belongs to" + }, + "conversationId": { + "type": [ + "string", + "null" + ], + "description": "Conversation ID if feedback is tied to a conversation" + }, + "visitorId": { + "type": [ + "string", + "null" + ], + "description": "Visitor ID who submitted the feedback" + }, + "contactId": { + "type": [ + "string", + "null" + ], + "description": "Contact ID if visitor has one" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Rating from 1 to 5", + "example": 5 + }, + "topic": { + "type": [ + "string", + "null" + ], + "description": "Optional structured topic selected by the visitor" + }, + "comment": { + "type": [ + "string", + "null" + ], + "description": "Optional written feedback" + }, + "trigger": { + "type": [ + "string", + "null" + ], + "description": "What triggered this feedback (e.g., 'churn', 'conversation_resolved', 'nps_survey')" + }, + "source": { + "type": "string", + "default": "widget", + "description": "Source of the feedback (e.g., 'widget', 'api', 'email')" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the feedback was submitted", + "example": "2026-04-06T14:37:05.820Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the feedback was last updated", + "example": "2026-04-06T14:37:05.820Z" + } + }, + "required": [ + "id", + "organizationId", + "websiteId", + "conversationId", + "visitorId", + "contactId", + "rating", + "topic", + "comment", + "trigger", + "source", + "createdAt", + "updatedAt" + ], + "description": "Feedback record" + } + }, + "required": [ + "feedback" + ], + "description": "Response containing a single feedback record" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing private API key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden - Private API key required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Feedback not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "operationId": "getFeedback" + } + }, + "/ws": { + "get": { + "description": "Opens a realtime WebSocket connection for conversation, visitor, and support events. Public browser clients normally authenticate with `X-Public-Key` plus `Origin`, or with a short-lived `token` query parameter when custom headers are unavailable. Private integrations can authenticate with `Authorization: Bearer `.", + "operationId": "connectRealtime", + "parameters": [ + { + "name": "Origin", + "in": "header", + "description": "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + "required": false, + "schema": { + "type": "string", + "format": "uri", + "example": "https://example.com" + } + }, + { + "name": "X-Visitor-Id", + "in": "header", + "description": "Visitor ID from localStorage.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + }, + { + "name": "X-Actor-User-Id", + "in": "header", + "description": "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + "required": false, + "schema": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "example": "01JG000000000000000000000" + } + }, + { + "description": "Realtime authentication token obtained from `POST /conversations/{conversationId}/seen` or another session bootstrap endpoint. Required when using browser WebSocket APIs that cannot send custom headers.", + "in": "query", + "name": "token", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Optional stable client connection identifier. When omitted the realtime service assigns one.", + "in": "query", + "name": "connectionId", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Optional browser session identifier used to correlate reconnects and visitor activity.", + "in": "query", + "name": "sessionId", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Optional transport hint for clients that share one connection URL across native WebSocket and fallback transports.", + "in": "query", + "name": "transport", + "required": false, + "schema": { + "enum": [ + "websocket" + ], + "type": "string" + } + } + ], + "responses": { + "101": { + "description": "Switching Protocols. The connection is upgraded to WebSocket." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestErrorResponse" + } + } + }, + "description": "Invalid realtime connection request." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestErrorResponse" + } + } + }, + "description": "Missing or invalid realtime credentials." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestErrorResponse" + } + } + }, + "description": "The supplied credentials cannot access realtime data." + } + }, + "security": [ + { + "PublicApiKey": [] + }, + { + "PrivateApiKey": [] + } + ], + "servers": [ + { + "url": "wss://api.cossistant.com" + } + ], + "summary": "Open realtime WebSocket connection", + "tags": [ + "Realtime" + ], + "x-websocket-message-schemas": { + "inbound": { + "oneOf": [ + { + "$ref": "#/components/schemas/RealtimeOutboundEvent" + } + ] + }, + "outbound": { + "oneOf": [ + { + "$ref": "#/components/schemas/RealtimeConnectionAccepted" + }, + { + "$ref": "#/components/schemas/RealtimeOutboundEvent" + }, + { + "$ref": "#/components/schemas/RealtimeErrorMessage" + } + ] + } + } + } + } + }, + "webhooks": {} +} diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 000000000..61fadb9ee --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,59 @@ +{ + "name": "@cossistant/protocol", + "type": "module", + "version": "0.2.0", + "private": false, + "author": "Cossistant Team", + "description": "Canonical HTTP and WebSocket wire contract for the Cossistant API", + "keywords": [ + "openapi", + "protocol", + "contract", + "hono", + "api" + ], + "license": "MIT", + "exports": { + ".": "./src/index.ts", + "./routes": "./src/routes/index.ts", + "./openapi": "./src/openapi.ts", + "./testkit": "./src/testkit.ts", + "./openapi.json": "./openapi.json" + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "sideEffects": false, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "devDependencies": { + "@cossistant/typescript-config": "workspace:*", + "hono": "^4.12.32", + "@types/bun": "^1.3.6" + }, + "dependencies": { + "@cossistant/types": "workspace:*", + "@hono/zod-openapi": "^1.5.1", + "openapi3-ts": "^4.5.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "hono": ">=4.10.0" + }, + "scripts": { + "build": "tsdown && bun run generate:openapi && bun run prepare-package", + "generate:openapi": "bun run scripts/generate-openapi.ts", + "check:openapi": "bun run scripts/generate-openapi.ts --check", + "prepare-package": "bun ../../scripts/prepare-package.ts", + "pub:release": "bun run build && npm publish --access public", + "pub:beta": "bun run build && npm publish --access public --tag beta --no-git-checks", + "pub:next": "bun run build && npm publish --access public --tag next --no-git-checks", + "lint": "biome check .", + "test": "bun test src", + "check-types": "tsc --noEmit --project tsconfig.json" + } +} diff --git a/packages/protocol/scripts/generate-openapi.ts b/packages/protocol/scripts/generate-openapi.ts new file mode 100644 index 000000000..ee1ff5886 --- /dev/null +++ b/packages/protocol/scripts/generate-openapi.ts @@ -0,0 +1,42 @@ +/** + * Generate the canonical openapi.json artifact. + * + * Runs with no environment variables and no infrastructure: the document is + * built purely from package-owned route descriptors. + * + * bun run scripts/generate-openapi.ts # write packages/protocol/openapi.json + * bun run scripts/generate-openapi.ts --check # fail if the committed file is stale + */ +import { dirname, join } from "node:path"; +import { buildCossistantOpenApiDocument } from "../src/openapi"; + +const checkOnly = process.argv.includes("--check"); +const packageRoot = dirname(import.meta.dir); +const outputPath = join(packageRoot, "openapi.json"); + +const serialized = `${JSON.stringify(buildCossistantOpenApiDocument(), null, 2)}\n`; + +if (checkOnly) { + const existing = await Bun.file(outputPath) + .text() + .catch(() => null); + + if (existing === null) { + console.error( + `[protocol] ${outputPath} is missing. Run \`bun run generate:openapi\`.` + ); + process.exit(1); + } + + if (existing !== serialized) { + console.error( + `[protocol] ${outputPath} is stale. Run \`bun run generate:openapi\` and commit the result.` + ); + process.exit(1); + } + + console.log("[protocol] openapi.json is up to date"); +} else { + await Bun.write(outputPath, serialized); + console.log(`[protocol] wrote ${outputPath}`); +} diff --git a/packages/protocol/src/auth.ts b/packages/protocol/src/auth.ts new file mode 100644 index 000000000..e99a56a29 --- /dev/null +++ b/packages/protocol/src/auth.ts @@ -0,0 +1,83 @@ +import { + actorUserIdHeader, + type OpenAPIParameter, + privateApiKeyAuthorizationHeader, + publicApiKeyHeader, + publicApiKeyOriginHeader, + visitorIdHeader, +} from "./headers"; + +export const PRIVATE_API_KEY_SECURITY_SCHEME = "PrivateApiKey" as const; +export const PUBLIC_API_KEY_SECURITY_SCHEME = "PublicApiKey" as const; + +export type OpenAPIAuthOptions = { + parameters?: OpenAPIParameter[]; + includeVisitorIdHeader?: boolean; + includeActorUserIdHeader?: boolean; +}; + +export const openApiSecuritySchemes = { + [PRIVATE_API_KEY_SECURITY_SCHEME]: { + type: "http", + scheme: "bearer", + bearerFormat: "API key", + description: + "Private API key in Bearer token format. Example: `Authorization: Bearer sk_live_...`.", + }, + [PUBLIC_API_KEY_SECURITY_SCHEME]: { + type: "apiKey", + in: "header", + name: "X-Public-Key", + description: + "Public API key for browser-based authentication. Requests must include an `Origin` header that matches the website allowlist.", + }, +} as const; + +/** + * Security + parameter declarations for private-key-only control routes. + * + * Declaration only: this installs no middleware. The serving application is + * responsible for enforcing the private API key. + */ +export function privateControlAuth(options: OpenAPIAuthOptions = {}) { + const security = [ + { [PRIVATE_API_KEY_SECURITY_SCHEME]: [] as string[] }, + ] as Record[]; + const parameters = [ + ...(options.parameters ?? []), + privateApiKeyAuthorizationHeader, + ...(options.includeActorUserIdHeader ? [actorUserIdHeader] : []), + ...(options.includeVisitorIdHeader ? [visitorIdHeader] : []), + ]; + + return { + security, + parameters, + }; +} + +/** + * Security + parameter declarations for runtime routes reachable with either a + * public (browser) or private (server) API key. + * + * Declaration only: this installs no middleware. + */ +export function runtimeDualAuth(options: OpenAPIAuthOptions = {}) { + const security = [ + { [PRIVATE_API_KEY_SECURITY_SCHEME]: [] as string[] }, + { [PUBLIC_API_KEY_SECURITY_SCHEME]: [] as string[] }, + ] as Record[]; + const parameters = [ + ...(options.parameters ?? []), + privateApiKeyAuthorizationHeader, + publicApiKeyHeader, + publicApiKeyOriginHeader, + ...(options.includeActorUserIdHeader ? [actorUserIdHeader] : []), + ...(options.includeVisitorIdHeader ? [visitorIdHeader] : []), + ]; + + return { + security, + parameters, + }; +} diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts new file mode 100644 index 000000000..48c120288 --- /dev/null +++ b/packages/protocol/src/errors.ts @@ -0,0 +1,24 @@ +import { z } from "@hono/zod-openapi"; + +export const restErrorResponseSchema = z.object({ + error: z.string(), + message: z.string().optional(), +}); + +/** + * Shared error response declaration. + * + * The return type is deliberately left inferred: annotating it as + * `ResponseConfig` collapses the schema property and every 4xx response in + * every route descriptor loses its content typing. + */ +export function errorJsonResponse(description: string) { + return { + description, + content: { + "application/json": { + schema: restErrorResponseSchema, + }, + }, + }; +} diff --git a/packages/protocol/src/headers.ts b/packages/protocol/src/headers.ts new file mode 100644 index 000000000..b91bfe29c --- /dev/null +++ b/packages/protocol/src/headers.ts @@ -0,0 +1,91 @@ +/** + * Shared protocol header parameters. + * + * These are OpenAPI *declarations* only. Importing them installs no + * authentication middleware — every server must enforce the declared security + * contract itself. + */ + +export type OpenAPIParameter = { + name: string; + in: "header" | "path" | "query"; + description: string; + required: boolean; + schema: { + type: + | "array" + | "boolean" + | "integer" + | "null" + | "number" + | "object" + | "string"; + example?: unknown; + format?: string; + pattern?: string; + }; +}; + +export const privateApiKeyAuthorizationHeader = { + name: "Authorization", + in: "header", + description: + "Private API key in Bearer token format. Use this for server-to-server authentication. Format: `Bearer sk_[live|test]_...`", + required: false, + schema: { + type: "string", + pattern: "^Bearer sk_(live|test)_[a-f0-9]{64}$", + example: "Bearer sk_test_xxx", + }, +} satisfies OpenAPIParameter; + +export const publicApiKeyHeader = { + name: "X-Public-Key", + in: "header", + description: + "Public API key for browser-based authentication. Can only be used from whitelisted domains. Format: `pk_[live|test]_...`", + required: false, + schema: { + type: "string", + pattern: "^pk_(live|test)_[a-f0-9]{64}$", + example: "pk_test_xxx", + }, +} satisfies OpenAPIParameter; + +export const publicApiKeyOriginHeader = { + name: "Origin", + in: "header", + description: + "Required when using public API keys. Must match one of the website's whitelisted domains. Browsers send this automatically.", + required: false, + schema: { + type: "string", + format: "uri", + example: "https://example.com", + }, +} satisfies OpenAPIParameter; + +export const visitorIdHeader = { + name: "X-Visitor-Id", + in: "header", + description: "Visitor ID from localStorage.", + required: false, + schema: { + type: "string", + pattern: "^[0-9A-HJKMNP-TV-Z]{26}$", + example: "01JG000000000000000000000", + }, +} satisfies OpenAPIParameter; + +export const actorUserIdHeader = { + name: "X-Actor-User-Id", + in: "header", + description: + "Acting teammate identifier for unlinked private API keys. Required on actor-aware private routes when the private key is not linked to a team member. Ignored when the private key is linked.", + required: false, + schema: { + type: "string", + pattern: "^[0-9A-HJKMNP-TV-Z]{26}$", + example: "01JG000000000000000000000", + }, +} satisfies OpenAPIParameter; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts new file mode 100644 index 000000000..8d273a19d --- /dev/null +++ b/packages/protocol/src/index.ts @@ -0,0 +1,8 @@ +export * from "./auth"; +export * from "./errors"; +export * from "./headers"; +export * from "./mount"; +export * from "./normalize"; +export * from "./openapi"; +export * from "./routes"; +export * from "./websocket"; diff --git a/packages/protocol/src/mount.ts b/packages/protocol/src/mount.ts new file mode 100644 index 000000000..499aa6785 --- /dev/null +++ b/packages/protocol/src/mount.ts @@ -0,0 +1,36 @@ +import type { createRoute } from "@hono/zod-openapi"; + +export type RouteDescriptor = ReturnType; + +/** + * A mounted group of route descriptors. + * + * The table is recursive because several Cossistant routers register two + * sub-routers at the same prefix with different auth middleware (public-key + * runtime routes vs private-key control routes). Flattening those into one + * array would emit identical paths, but it would erase the record of which + * middleware each route belongs to — and a control route accidentally + * registered on a runtime sub-router is reachable with a public key without + * changing the OpenAPI document at all. + * + * Order matters: `.route()` copies definitions in array order, and path-key + * order in the generated document follows registration order. + */ +export type RouteGroup = { + /** Mount prefix, e.g. `/contacts`. `/` for a nested auth split. */ + prefix: string; + /** Descriptors registered directly on this group, in registration order. */ + routes?: readonly RouteDescriptor[]; + /** Nested sub-routers, in mount order. */ + groups?: readonly RouteGroup[]; +}; + +/** Every descriptor in a group tree, depth-first in registration order. */ +export function flattenRouteGroup( + group: RouteGroup +): readonly RouteDescriptor[] { + return [ + ...(group.routes ?? []), + ...(group.groups ?? []).flatMap((child) => flattenRouteGroup(child)), + ]; +} diff --git a/packages/protocol/src/normalize.ts b/packages/protocol/src/normalize.ts new file mode 100644 index 000000000..43679a31e --- /dev/null +++ b/packages/protocol/src/normalize.ts @@ -0,0 +1,164 @@ +/** + * Operation-ID and tag normalization. + * + * Most route descriptors do not declare an `operationId` or `tags`; they are + * backfilled here. This layer is part of the wire contract — changing it + * renames operations for every generated client. + */ + +export type OpenApiOperation = { + operationId?: string; + tags?: string[]; + parameters?: Array<{ name?: string } & Record>; + [key: string]: unknown; +}; + +export type OpenApiPathItem = Record; + +const HTTP_METHODS = new Set([ + "delete", + "get", + "head", + "options", + "patch", + "post", + "put", + "trace", +]); + +/** + * Auth headers are declared per-route so they render in request examples, but + * they are stripped from the served document because the security schemes + * already describe them. + */ +const AUTH_HEADER_PARAMETER_NAMES = new Set(["Authorization", "X-Public-Key"]); + +const HTTP_METHOD_OPERATION_PREFIXES: Record = { + delete: "delete", + get: "get", + patch: "update", + post: "create", + put: "replace", +}; + +const TAG_BY_PATH_PREFIX: Record = { + "ai-agents": "AI Agents", + contacts: "Contacts", + conversations: "Conversations", + feedback: "Feedback", + knowledge: "Knowledge", + messages: "Messages", + organizations: "Organizations", + support: "Support", + uploads: "Uploads", + visitors: "Visitors", + websites: "Websites", + ws: "Realtime", +}; + +/** + * Some entries no longer match any live path (e.g. `GET /ai-agents/{agentId}` + * where the real path is `/{id}`). They are dead but kept verbatim: removing + * them cannot change output, and editing this map is how operation IDs + * accidentally get renamed. + */ +const OPERATION_ID_OVERRIDES: Record = { + "GET /ai-agents": "listAiAgents", + "GET /ai-agents/{agentId}": "getAiAgent", + "GET /contacts": "listContacts", + "POST /contacts": "createContact", + "GET /contacts/{id}": "getContact", + "PATCH /contacts/{id}": "updateContact", + "DELETE /contacts/{id}": "deleteContact", + "PATCH /contacts/{id}/metadata": "updateContactMetadata", + "POST /contacts/identify": "identifyContact", + "POST /contacts/organizations": "createContactOrganization", + "GET /contacts/organizations/{id}": "getContactOrganization", + "PATCH /contacts/organizations/{id}": "updateContactOrganization", + "DELETE /contacts/organizations/{id}": "deleteContactOrganization", + "POST /conversations": "createConversation", + "GET /conversations": "listConversations", + "GET /conversations/inbox": "listInboxConversations", + "GET /conversations/{conversationId}": "getConversation", + "GET /conversations/{conversationId}/context": "getConversationContext", + "GET /conversations/{conversationId}/export": "exportConversation", + "POST /conversations/{conversationId}/rating": "createConversationRating", + "GET /conversations/{conversationId}/seen": "getConversationSeen", + "POST /conversations/{conversationId}/seen": "markConversationSeen", + "GET /conversations/{conversationId}/timeline": "getConversationTimeline", + "POST /conversations/{conversationId}/typing": "setConversationTyping", + "GET /feedback": "listFeedback", + "POST /feedback": "createFeedback", + "GET /feedback/summary": "getFeedbackSummary", + "GET /feedback/{id}": "getFeedback", + "GET /knowledge": "listKnowledge", + "POST /knowledge": "createKnowledge", + "GET /knowledge/search": "searchKnowledge", + "GET /knowledge/{id}": "getKnowledge", + "PATCH /knowledge/{id}": "updateKnowledge", + "DELETE /knowledge/{id}": "deleteKnowledge", + "POST /messages": "createMessage", + "GET /organizations/{id}": "getOrganization", + "PATCH /support/feature-flags": "updateSupportFeatureFlags", + "PATCH /support/onboarding": "updateSupportOnboarding", + "GET /support/state": "getSupportState", + "GET /team-members": "listTeamMembers", + "POST /uploads/sign-url": "createUploadSignUrl", + "POST /visitors/{id}/block": "blockVisitor", + "POST /visitors/{id}/unblock": "unblockVisitor", + "GET /visitors/{id}": "getVisitor", + "PATCH /visitors/{id}": "updateVisitor", + "GET /visitors/{id}/activity": "getVisitorActivity", + "GET /visitors/{id}/metadata": "getVisitorMetadata", + "PATCH /visitors/{id}/metadata": "updateVisitorMetadata", + "GET /websites": "listWebsites", +}; + +function isOpenApiOperation(value: unknown): value is OpenApiOperation { + return Boolean(value && typeof value === "object" && !("$ref" in value)); +} + +function toPascalCase(value: string) { + return value + .split(/[-_]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); +} + +export function toOperationId(method: string, path: string) { + const prefix = HTTP_METHOD_OPERATION_PREFIXES[method] ?? method; + const segments = path + .split("/") + .filter(Boolean) + .map((segment) => segment.replace(/[{}]/g, "")) + .map(toPascalCase); + + return `${prefix}${segments.join("")}`; +} + +export function tagForPath(path: string) { + const [firstSegment] = path.split("/").filter(Boolean); + // Behaviourally identical to `TAG_BY_PATH_PREFIX[firstSegment] ?? "API"`; + // the guard exists only because this package enables noUncheckedIndexedAccess. + return (firstSegment ? TAG_BY_PATH_PREFIX[firstSegment] : undefined) ?? "API"; +} + +export function normalizePathItem(path: string, pathItem: OpenApiPathItem) { + for (const [method, operation] of Object.entries(pathItem)) { + if (!(HTTP_METHODS.has(method) && isOpenApiOperation(operation))) { + continue; + } + + const key = `${method.toUpperCase()} ${path}`; + operation.operationId ??= + OPERATION_ID_OVERRIDES[key] ?? toOperationId(method, path); + operation.tags = operation.tags?.length + ? operation.tags + : [tagForPath(path)]; + operation.parameters = operation.parameters?.filter( + (parameter) => + !(parameter.name && AUTH_HEADER_PARAMETER_NAMES.has(parameter.name)) + ); + } +} diff --git a/packages/protocol/src/openapi.ts b/packages/protocol/src/openapi.ts new file mode 100644 index 000000000..1c3310aca --- /dev/null +++ b/packages/protocol/src/openapi.ts @@ -0,0 +1,117 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; +import { openApiSecuritySchemes } from "./auth"; +import { flattenRouteGroup, type RouteGroup } from "./mount"; +import { normalizePathItem, type OpenApiPathItem } from "./normalize"; +import { REST_MOUNT_TABLE } from "./routes"; +import { + buildWebsocketPathItem, + DEFAULT_WEBSOCKET_SERVER_URL, + realtimeComponentSchemas, +} from "./websocket"; + +export const DEFAULT_REST_SERVER_URL = "https://api.cossistant.com/v1"; + +export const COSSISTANT_API_INFO = { + description: + "Public REST and realtime API for Cossistant. Use public keys for browser/widget visitor flows and private keys for trusted server, CLI, MCP, and support-agent integrations.", + title: "Cossistant API", + version: "1.0.0", +}; + +export type BuildOpenApiDocumentOptions = { + /** REST server URL advertised in `servers[0].url`. */ + restServerUrl?: string; + /** WebSocket server URL advertised on the `/ws` operation. */ + websocketServerUrl?: string; + info?: typeof COSSISTANT_API_INFO; +}; + +/** + * Handlers are irrelevant to document generation, but `OpenAPIHono.openapi()` + * requires one. Registering a no-op lets us reuse Hono's own path merging + * instead of reimplementing it — `mergePath` has a `sub === "/"` special case + * that is the only reason routes declared at `path: "/"` render as + * `/conversations` rather than `/conversations/`. + */ +const NOOP_HANDLER = (() => new Response(null)) as never; + +function buildGroupApp(group: RouteGroup): OpenAPIHono { + const app = new OpenAPIHono(); + + for (const route of group.routes ?? []) { + app.openapi(route, NOOP_HANDLER); + } + + for (const child of group.groups ?? []) { + app.route(child.prefix, buildGroupApp(child)); + } + + return app; +} + +/** + * A handler-free Hono app carrying every protocol route descriptor. + * + * Building this imports no application services: no database, Redis, S3, + * billing, auth, queues, or environment parsing. + */ +export function buildRestApp(): OpenAPIHono { + const root = new OpenAPIHono(); + + for (const group of REST_MOUNT_TABLE) { + root.route(group.prefix, buildGroupApp(group)); + } + + return root; +} + +/** Every route descriptor owned by the protocol, in registration order. */ +export function getCossistantRouteDefinitions() { + return REST_MOUNT_TABLE.flatMap((group) => flattenRouteGroup(group)); +} + +/** + * Build the canonical Cossistant OpenAPI 3.1 document. + * + * Pure: safe to call in a process with no DATABASE_URL, Redis, S3, billing + * credentials, or auth secrets. + */ +export function buildCossistantOpenApiDocument( + options: BuildOpenApiDocumentOptions = {} +) { + const { + restServerUrl = DEFAULT_REST_SERVER_URL, + websocketServerUrl = DEFAULT_WEBSOCKET_SERVER_URL, + info = COSSISTANT_API_INFO, + } = options; + + const restDocument = buildRestApp().getOpenAPI31Document({ + info, + openapi: "3.1.0", + servers: [{ url: restServerUrl }], + }); + + const document = { + ...restDocument, + components: { + ...restDocument.components, + schemas: { + ...realtimeComponentSchemas, + ...(restDocument.components?.schemas ?? {}), + }, + securitySchemes: openApiSecuritySchemes, + }, + paths: { + ...(restDocument.paths ?? {}), + "/ws": buildWebsocketPathItem(websocketServerUrl), + }, + }; + + for (const [path, pathItem] of Object.entries( + document.paths as Record + )) { + normalizePathItem(path, pathItem); + } + + return document; +} diff --git a/packages/protocol/src/protocol.test.ts b/packages/protocol/src/protocol.test.ts new file mode 100644 index 000000000..ef53d1693 --- /dev/null +++ b/packages/protocol/src/protocol.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from "bun:test"; +import { openApiSecuritySchemes } from "./auth"; +import { + buildCossistantOpenApiDocument, + getCossistantRouteDefinitions, +} from "./openapi"; +import { REST_MOUNT_TABLE } from "./routes"; +import { + assertCossistantProtocolCompatibility, + checkCossistantProtocolCompatibility, +} from "./testkit"; + +const HTTP_METHODS = new Set([ + "delete", + "get", + "head", + "options", + "patch", + "post", + "put", + "trace", +]); + +const EXPECTED_OPERATION_COUNT = 65; +const EXPECTED_REST_ROUTE_COUNT = 64; + +type Doc = ReturnType; + +/** + * Clone the document with one path omitted. Not `delete` (banned by lint), and + * deliberately not `paths[key] = undefined` — that leaves an enumerable + * undefined entry the checker would then try to read. + */ +function withoutPath(path: string) { + const clone = JSON.parse( + JSON.stringify(buildCossistantOpenApiDocument()) + ) as { paths: Record> }; + const { [path]: _removed, ...rest } = clone.paths; + clone.paths = rest; + return clone; +} + +function listOperations(doc: Doc) { + const out: Array<{ key: string; operation: Record }> = []; + for (const [path, item] of Object.entries(doc.paths ?? {})) { + for (const [method, operation] of Object.entries( + item as Record + )) { + if (HTTP_METHODS.has(method)) { + out.push({ + key: `${method.toUpperCase()} ${path}`, + operation: operation as Record, + }); + } + } + } + return out; +} + +describe("Cossistant protocol document", () => { + it("emits exactly the expected number of operations", () => { + expect(listOperations(buildCossistantOpenApiDocument())).toHaveLength( + EXPECTED_OPERATION_COUNT + ); + }); + + it("owns every REST descriptor in the mount table", () => { + expect(getCossistantRouteDefinitions()).toHaveLength( + EXPECTED_REST_ROUTE_COUNT + ); + }); + + it("has unique, non-empty operation IDs", () => { + const ids = listOperations(buildCossistantOpenApiDocument()).map( + ({ operation }) => operation.operationId as string | undefined + ); + + for (const id of ids) { + expect(id).toBeTruthy(); + } + expect(new Set(ids).size).toBe(ids.length); + }); + + it("is deterministic across repeated builds", () => { + expect(JSON.stringify(buildCossistantOpenApiDocument())).toBe( + JSON.stringify(buildCossistantOpenApiDocument()) + ); + }); + + it("takes server URLs as arguments rather than reading the environment", () => { + const doc = buildCossistantOpenApiDocument({ + restServerUrl: "https://example.test/v9", + websocketServerUrl: "wss://example.test", + }); + + expect(doc.servers?.[0]?.url).toBe("https://example.test/v9"); + const ws = doc.paths["/ws"] as { get: { servers: Array<{ url: string }> } }; + expect(ws.get.servers[0]?.url).toBe("wss://example.test"); + }); + + it("documents the WebSocket handshake with dual security", () => { + const ws = buildCossistantOpenApiDocument().paths["/ws"] as { + get: { operationId: string; security: Record[] }; + }; + + expect(ws.get.operationId).toBe("connectRealtime"); + expect(ws.get.security.flatMap((s) => Object.keys(s)).sort()).toEqual([ + "PrivateApiKey", + "PublicApiKey", + ]); + }); + + it("only references declared security schemes", () => { + const declared = new Set(Object.keys(openApiSecuritySchemes)); + + for (const { operation } of listOperations( + buildCossistantOpenApiDocument() + )) { + const security = (operation.security ?? []) as Record[]; + for (const entry of security) { + for (const scheme of Object.keys(entry)) { + expect(declared).toContain(scheme); + } + } + } + }); + + it("strips auth headers that the security schemes already describe", () => { + for (const { operation } of listOperations( + buildCossistantOpenApiDocument() + )) { + const names = ( + (operation.parameters ?? []) as Array<{ name?: string }> + ).map((p) => p.name); + expect(names).not.toContain("Authorization"); + expect(names).not.toContain("X-Public-Key"); + } + }); + + /** + * These three routes declare `conversationId` twice: once through the auth + * spread's explicit path parameter, and once derived from + * `request.params: getConversationRequestSchema`. OpenAPI forbids duplicate + * name+in pairs, so this is a defect — but it predates this package and is + * part of the currently published contract, so it is pinned rather than + * fixed. Removing a duplicate is a contract change and needs its own review. + */ + const KNOWN_DUPLICATE_PARAMETERS = new Set([ + "GET /conversations/{conversationId}", + "GET /conversations/{conversationId}/context", + "GET /conversations/{conversationId}/export", + ]); + + it("declares at most one parameter per name, outside the known exceptions", () => { + const offenders: string[] = []; + + for (const { key, operation } of listOperations( + buildCossistantOpenApiDocument() + )) { + const names = ( + (operation.parameters ?? []) as Array<{ name?: string }> + ).map((p) => p.name); + if (names.length !== new Set(names).size) { + offenders.push(key); + } + } + + expect(offenders.sort()).toEqual([...KNOWN_DUPLICATE_PARAMETERS].sort()); + }); + + it("declares a path parameter for every templated path segment", () => { + for (const { key, operation } of listOperations( + buildCossistantOpenApiDocument() + )) { + const path = key.slice(key.indexOf(" ") + 1); + const declared = new Set( + ((operation.parameters ?? []) as Array<{ name?: string; in?: string }>) + .filter((p) => p.in === "path") + .map((p) => p.name) + ); + + for (const match of path.matchAll(/\{([^}]+)\}/g)) { + expect(`${key}:${match[1]}`).toBe( + `${key}:${declared.has(match[1]) ? match[1] : "MISSING"}` + ); + } + } + }); + + it("guards against the mount table silently emptying", () => { + expect(REST_MOUNT_TABLE.length).toBeGreaterThan(8); + }); +}); + +describe("compatibility testkit", () => { + it("accepts the canonical document", () => { + expect(() => + assertCossistantProtocolCompatibility(buildCossistantOpenApiDocument()) + ).not.toThrow(); + }); + + it("rejects a removed route", () => { + const broken = withoutPath("/conversations/{conversationId}/resolve"); + + const violations = checkCossistantProtocolCompatibility(broken); + expect(violations).toContainEqual({ + operation: "POST /conversations/{conversationId}/resolve", + field: "*", + detail: "operation is missing", + }); + }); + + it("rejects a flipped required flag on a parameter", () => { + const broken = JSON.parse(JSON.stringify(buildCossistantOpenApiDocument())); + broken.paths["/organizations/{id}"].get.parameters[0].required = false; + + const violations = checkCossistantProtocolCompatibility(broken); + expect( + violations.some( + (v) => + v.operation === "GET /organizations/{id}" && v.field === "parameters" + ) + ).toBe(true); + }); + + it("rejects a changed success schema", () => { + const broken = JSON.parse(JSON.stringify(buildCossistantOpenApiDocument())); + broken.paths["/organizations/{id}"].get.responses["200"].content[ + "application/json" + ].schema = { type: "string" }; + + const violations = checkCossistantProtocolCompatibility(broken); + expect( + violations.some( + (v) => + v.operation === "GET /organizations/{id}" && + v.field === "responses.200.schema" + ) + ).toBe(true); + }); + + it("rejects a missing WebSocket handshake", () => { + const broken = withoutPath("/ws"); + + const violations = checkCossistantProtocolCompatibility(broken); + expect(violations.some((v) => v.operation === "GET /ws")).toBe(true); + }); + + it("honours an explicit allowlist entry", () => { + const broken = withoutPath("/conversations/{conversationId}/resolve"); + + const violations = checkCossistantProtocolCompatibility(broken, { + allow: [ + { + operation: "POST /conversations/{conversationId}/resolve", + field: "*", + reason: "Not implemented by this service yet.", + }, + ], + }); + + expect(violations).toHaveLength(0); + }); + + it("produces a readable error listing each violation", () => { + const broken = withoutPath("/ws"); + + expect(() => assertCossistantProtocolCompatibility(broken)).toThrow( + /WebSocket handshake is not documented/ + ); + }); +}); diff --git a/packages/protocol/src/routes/ai-agents.ts b/packages/protocol/src/routes/ai-agents.ts new file mode 100644 index 000000000..6e50bbef4 --- /dev/null +++ b/packages/protocol/src/routes/ai-agents.ts @@ -0,0 +1,117 @@ +import { + aiAgentResponseSchema, + aiAgentStartTrainingResponseSchema, + aiAgentTrainingStatusResponseSchema, +} from "@cossistant/types"; +import { createRoute } from "@hono/zod-openapi"; +import { privateControlAuth } from "../auth"; +import { errorJsonResponse } from "../errors"; + +export const aiAgentIdPathParameter = { + name: "id", + in: "path", + required: true, + description: "The AI agent ID", + schema: { + type: "string", + pattern: "^[0-9A-HJKMNP-TV-Z]{26}$", + example: "01JG000000000000000000000", + }, +} as const; + +export const getAiAgentTrainingStatusRoute = createRoute({ + method: "get", + path: "/{id}/training", + summary: "Get AI agent training status", + description: + "Returns the current public and internal knowledge base training status for a specific AI agent.", + operationId: "getAiAgentTrainingStatus", + responses: { + 200: { + description: "AI agent training status retrieved successfully", + content: { + "application/json": { + schema: aiAgentTrainingStatusResponseSchema, + }, + }, + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("AI agent not found"), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["AI Agents"], + ...privateControlAuth({ + parameters: [aiAgentIdPathParameter], + }), +}); + +export const startAiAgentTrainingRoute = createRoute({ + method: "post", + path: "/{id}/training", + summary: "Start AI agent training", + description: + "Queues a retraining job for the AI agent knowledge base. Requires a private API key. When using an unlinked private key, send `X-Actor-User-Id` with a valid website teammate ID.", + operationId: "startAiAgentTraining", + responses: { + 202: { + description: "AI agent training job queued successfully", + content: { + "application/json": { + schema: aiAgentStartTrainingResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Missing required actor header for an unlinked private key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor is not allowed for this website" + ), + 404: errorJsonResponse("AI agent not found"), + 409: errorJsonResponse("Conflict - Training is already in progress"), + 429: errorJsonResponse( + "Too Many Requests - Training cooldown has not elapsed yet" + ), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["AI Agents"], + ...privateControlAuth({ + parameters: [aiAgentIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const getAiAgentRoute = createRoute({ + method: "get", + path: "/{id}", + summary: "Get an AI agent", + description: + "Retrieves a single AI agent by ID for the authenticated website.", + operationId: "getAiAgent", + responses: { + 200: { + description: "AI agent retrieved successfully", + content: { + "application/json": { + schema: aiAgentResponseSchema, + }, + }, + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("AI agent not found"), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["AI Agents"], + ...privateControlAuth({ + parameters: [aiAgentIdPathParameter], + }), +}); + +export const AI_AGENT_ROUTES = [ + getAiAgentTrainingStatusRoute, + startAiAgentTrainingRoute, + getAiAgentRoute, +] as const; diff --git a/packages/protocol/src/routes/contacts.ts b/packages/protocol/src/routes/contacts.ts new file mode 100644 index 000000000..63a8ac164 --- /dev/null +++ b/packages/protocol/src/routes/contacts.ts @@ -0,0 +1,356 @@ +import { + contactOrganizationResponseSchema, + contactResponseSchema, + createContactOrganizationRequestSchema, + createContactRequestSchema, + identifyContactRequestSchema, + identifyContactResponseSchema, + listContactsRequestSchema, + listContactsRestResponseSchema, + updateContactMetadataRequestSchema, + updateContactOrganizationRequestSchema, + updateContactRequestSchema, +} from "@cossistant/types"; +import { createRoute } from "@hono/zod-openapi"; +import { privateControlAuth, runtimeDualAuth } from "../auth"; +import { errorJsonResponse } from "../errors"; + +export const contactIdPathParameter = { + name: "id", + in: "path", + required: true, + description: "The contact ID", + schema: { type: "string" }, +} as const; + +export const contactOrganizationIdPathParameter = { + name: "id", + in: "path", + required: true, + description: "The contact organization ID", + schema: { type: "string" }, +} as const; + +export const identifyContactRoute = createRoute({ + method: "post", + path: "/identify", + summary: "Identify a visitor", + description: + "Creates or updates a contact for a visitor. If a contact with the same externalId or email exists, it will be updated. The visitor will be linked to the contact. Public callers may pass the visitor ID in the request body or via X-Visitor-Id; when both are provided they must match.", + request: { + body: { + content: { + "application/json": { + schema: identifyContactRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: identifyContactResponseSchema, + }, + }, + description: "Contact identified successfully", + }, + 400: errorJsonResponse("Invalid request data"), + 401: errorJsonResponse("Unauthorized - Invalid API key"), + 404: errorJsonResponse("Visitor not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...runtimeDualAuth({ includeVisitorIdHeader: true }), +}); + +export const listContactsRoute = createRoute({ + method: "get", + path: "/", + summary: "List contacts", + description: + "Returns a paginated list of contacts for the authenticated website.", + request: { + query: listContactsRequestSchema, + }, + responses: { + 200: { + content: { + "application/json": { + schema: listContactsRestResponseSchema, + }, + }, + description: "Contact list retrieved successfully", + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth(), +}); + +export const createContactRoute = createRoute({ + method: "post", + path: "/", + summary: "Create a contact", + description: + "Creates a new contact for the website. If externalId is provided and already exists for the website, the contact is updated and returned.", + request: { + body: { + content: { + "application/json": { + schema: createContactRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: contactResponseSchema, + }, + }, + description: "Contact updated successfully via externalId upsert", + }, + 201: { + content: { + "application/json": { + schema: contactResponseSchema, + }, + }, + description: "Contact created successfully", + }, + 400: errorJsonResponse("Invalid request data"), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth(), +}); + +export const getContactRoute = createRoute({ + method: "get", + path: "/{id}", + summary: "Get a contact", + description: "Retrieves a contact by ID.", + responses: { + 200: { + content: { + "application/json": { + schema: contactResponseSchema, + }, + }, + description: "Contact retrieved successfully", + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("Contact not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ parameters: [contactIdPathParameter] }), +}); + +export const updateContactRoute = createRoute({ + method: "patch", + path: "/{id}", + summary: "Update a contact", + description: "Updates an existing contact.", + request: { + body: { + content: { + "application/json": { + schema: updateContactRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: contactResponseSchema, + }, + }, + description: "Contact updated successfully", + }, + 400: errorJsonResponse("Invalid request data"), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("Contact not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ parameters: [contactIdPathParameter] }), +}); + +export const updateContactMetadataRoute = createRoute({ + method: "patch", + path: "/{id}/metadata", + summary: "Update contact metadata", + description: "Merges the provided metadata into the contact profile.", + request: { + body: { + content: { + "application/json": { + schema: updateContactMetadataRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: contactResponseSchema, + }, + }, + description: "Contact metadata updated successfully", + }, + 400: errorJsonResponse("Invalid request data"), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("Contact not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ parameters: [contactIdPathParameter] }), +}); + +export const deleteContactRoute = createRoute({ + method: "delete", + path: "/{id}", + summary: "Delete a contact", + description: "Soft deletes a contact.", + responses: { + 204: { + description: "Contact deleted successfully", + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("Contact not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ parameters: [contactIdPathParameter] }), +}); + +export const createContactOrganizationRoute = createRoute({ + method: "post", + path: "/organizations", + summary: "Create a contact organization", + description: "Creates a new contact organization for the website.", + request: { + body: { + content: { + "application/json": { + schema: createContactOrganizationRequestSchema, + }, + }, + }, + }, + responses: { + 201: { + content: { + "application/json": { + schema: contactOrganizationResponseSchema, + }, + }, + description: "Contact organization created successfully", + }, + 400: errorJsonResponse("Invalid request data"), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth(), +}); + +export const getContactOrganizationRoute = createRoute({ + method: "get", + path: "/organizations/{id}", + summary: "Get a contact organization", + description: "Retrieves a contact organization by ID.", + responses: { + 200: { + content: { + "application/json": { + schema: contactOrganizationResponseSchema, + }, + }, + description: "Contact organization retrieved successfully", + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("Contact organization not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ parameters: [contactOrganizationIdPathParameter] }), +}); + +export const updateContactOrganizationRoute = createRoute({ + method: "patch", + path: "/organizations/{id}", + summary: "Update a contact organization", + description: "Updates an existing contact organization.", + request: { + body: { + content: { + "application/json": { + schema: updateContactOrganizationRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: contactOrganizationResponseSchema, + }, + }, + description: "Contact organization updated successfully", + }, + 400: errorJsonResponse("Invalid request data"), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("Contact organization not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ parameters: [contactOrganizationIdPathParameter] }), +}); + +export const deleteContactOrganizationRoute = createRoute({ + method: "delete", + path: "/organizations/{id}", + summary: "Delete a contact organization", + description: "Soft deletes a contact organization.", + responses: { + 204: { + description: "Contact organization deleted successfully", + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("Contact organization not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ parameters: [contactOrganizationIdPathParameter] }), +}); + +/** + * Public-key runtime routes (`contactRuntimeRouter` in apps/api). + * + * Mounted first so POST /contacts/identify is not swallowed by the private + * middleware on the control router. + */ +export const CONTACT_RUNTIME_ROUTES = [identifyContactRoute] as const; + +/** Private-key control routes (`contactControlRouter` in apps/api). */ +export const CONTACT_CONTROL_ROUTES = [ + listContactsRoute, + createContactRoute, + getContactRoute, + updateContactRoute, + updateContactMetadataRoute, + deleteContactRoute, + createContactOrganizationRoute, + getContactOrganizationRoute, + updateContactOrganizationRoute, + deleteContactOrganizationRoute, +] as const; diff --git a/packages/protocol/src/routes/conversations.ts b/packages/protocol/src/routes/conversations.ts new file mode 100644 index 000000000..bab679c6b --- /dev/null +++ b/packages/protocol/src/routes/conversations.ts @@ -0,0 +1,970 @@ +import { + conversationContextRequestSchema, + conversationContextResponseSchema, + createConversationConflictResponseSchema, + createConversationRequestSchema, + createConversationResponseSchema, + getConversationRequestSchema, + getConversationResponseSchema, + getConversationSeenDataResponseSchema, + listConversationsRequestSchema, + listConversationsResponseSchema, + listInboxConversationsRequestSchema, + listInboxConversationsResponseSchema, + markConversationSeenRequestSchema, + markConversationSeenResponseSchema, + pauseConversationAiRestRequestSchema, + privateConversationMutationResponseSchema, + setConversationTypingRequestSchema, + setConversationTypingResponseSchema, + submitConversationRatingRequestSchema, + submitConversationRatingResponseSchema, + updateConversationMetadataRequestSchema, + updateConversationPriorityRestRequestSchema, + updateConversationSentimentRestRequestSchema, + updateConversationTitleRestRequestSchema, +} from "@cossistant/types/api/conversation"; +import { + getConversationTimelineItemsRequestSchema, + getConversationTimelineItemsResponseSchema, +} from "@cossistant/types/api/timeline-item"; +import { createRoute, z } from "@hono/zod-openapi"; +import { privateControlAuth, runtimeDualAuth } from "../auth"; +import { errorJsonResponse } from "../errors"; + +export const conversationIdPathParameter = { + name: "conversationId", + in: "path", + description: "The ID of the conversation.", + required: true, + schema: { + type: "string", + }, +} as const; + +/** + * Also consumed at runtime by the export handler in apps/api + * (`safelyExtractRequestQuery(c, emptyQuerySchema)`). Import it from here rather + * than redefining it, or the documented contract and the runtime parse drift. + */ +export const emptyQuerySchema = z.object({}); + +export const createConversationRoute = createRoute({ + method: "post", + path: "/", + summary: "Create a conversation (optionally with initial timeline items)", + description: + "Create a conversation; optionally pass a conversationId, public metadata, and a set of default timeline items. When a default item's createdAt is omitted, the server assigns the timestamp. Historical timestamps are allowed. Timestamps more than 5 minutes in the future are rejected.", + tags: ["Conversations"], + request: { + body: { + required: true, + content: { + "application/json": { + schema: createConversationRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Conversation created", + content: { + "application/json": { + schema: createConversationResponseSchema, + }, + }, + }, + 409: { + description: + "Conversation ID conflict (already exists for a different visitor or tenant)", + content: { + "application/json": { + schema: createConversationConflictResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Invalid request or defaultTimelineItems createdAt more than 5 minutes in the future" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), + 403: errorJsonResponse("Forbidden - Public key origin validation failed"), + }, + ...runtimeDualAuth({ includeVisitorIdHeader: true }), +}); + +export const listConversationsRoute = createRoute({ + method: "get", + path: "/", + summary: "List conversations for a visitor", + description: + "Fetch paginated list of conversations for a specific visitor with optional filters. Public conversation metadata is included when present.", + tags: ["Conversations"], + request: { + query: listConversationsRequestSchema, + }, + responses: { + 200: { + description: "List of conversations retrieved successfully", + content: { + "application/json": { + schema: listConversationsResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Invalid request"), + 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), + 403: errorJsonResponse("Forbidden - Public key origin validation failed"), + }, + ...runtimeDualAuth({ includeVisitorIdHeader: true }), +}); + +export const resolveConversationRoute = createRoute({ + method: "post", + path: "/{conversationId}/resolve", + summary: "Resolve a conversation", + description: + "Marks a conversation as resolved. Requires a private API key. When using an unlinked private key, send `X-Actor-User-Id` with a valid website teammate ID.", + operationId: "resolveConversation", + tags: ["Conversations"], + responses: { + 200: { + description: "Conversation resolved successfully", + content: { + "application/json": { + schema: privateConversationMutationResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Missing actor for an unlinked private API key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor user not allowed for this website" + ), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const reopenConversationRoute = createRoute({ + method: "post", + path: "/{conversationId}/reopen", + summary: "Reopen a conversation", + description: + "Reopens a previously resolved or spam conversation. Requires a private API key and an acting teammate.", + operationId: "reopenConversation", + tags: ["Conversations"], + responses: { + 200: { + description: "Conversation reopened successfully", + content: { + "application/json": { + schema: privateConversationMutationResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Missing actor for an unlinked private API key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor user not allowed for this website" + ), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const markConversationAsSpamRoute = createRoute({ + method: "post", + path: "/{conversationId}/spam", + summary: "Mark a conversation as spam", + description: + "Marks a conversation as spam. Requires a private API key and an acting teammate.", + operationId: "markConversationAsSpam", + tags: ["Conversations"], + responses: { + 200: { + description: "Conversation marked as spam successfully", + content: { + "application/json": { + schema: privateConversationMutationResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Missing actor for an unlinked private API key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor user not allowed for this website" + ), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const markConversationAsNotSpamRoute = createRoute({ + method: "post", + path: "/{conversationId}/not-spam", + summary: "Mark a conversation as not spam", + description: + "Restores a spam conversation back to the open state. Requires a private API key and an acting teammate.", + operationId: "markConversationAsNotSpam", + tags: ["Conversations"], + responses: { + 200: { + description: "Conversation marked as not spam successfully", + content: { + "application/json": { + schema: privateConversationMutationResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Missing actor for an unlinked private API key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor user not allowed for this website" + ), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const archiveConversationRoute = createRoute({ + method: "post", + path: "/{conversationId}/archive", + summary: "Archive a conversation", + description: + "Archives a conversation from the inbox. This matches the dashboard delete behavior and requires a private API key plus an acting teammate.", + operationId: "archiveConversation", + tags: ["Conversations"], + responses: { + 200: { + description: "Conversation archived successfully", + content: { + "application/json": { + schema: privateConversationMutationResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Missing actor for an unlinked private API key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor user not allowed for this website" + ), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const unarchiveConversationRoute = createRoute({ + method: "post", + path: "/{conversationId}/unarchive", + summary: "Unarchive a conversation", + description: + "Restores an archived conversation. Requires a private API key and an acting teammate.", + operationId: "unarchiveConversation", + tags: ["Conversations"], + responses: { + 200: { + description: "Conversation unarchived successfully", + content: { + "application/json": { + schema: privateConversationMutationResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Missing actor for an unlinked private API key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor user not allowed for this website" + ), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const markConversationAsReadRoute = createRoute({ + method: "post", + path: "/{conversationId}/read", + summary: "Mark a conversation as read", + description: + "Marks a conversation as read for the acting teammate. Requires a private API key and an acting teammate.", + operationId: "markConversationAsRead", + tags: ["Conversations"], + responses: { + 200: { + description: "Conversation marked as read successfully", + content: { + "application/json": { + schema: privateConversationMutationResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Missing actor for an unlinked private API key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor user not allowed for this website" + ), + 404: errorJsonResponse("Conversation not found"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const markConversationAsUnreadRoute = createRoute({ + method: "post", + path: "/{conversationId}/unread", + summary: "Mark a conversation as unread", + description: + "Clears the acting teammate's read marker for a conversation. Requires a private API key and an acting teammate.", + operationId: "markConversationAsUnread", + tags: ["Conversations"], + responses: { + 200: { + description: "Conversation marked as unread successfully", + content: { + "application/json": { + schema: privateConversationMutationResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Missing actor for an unlinked private API key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor user not allowed for this website" + ), + 404: errorJsonResponse("Conversation not found"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const updateConversationMetadataRoute = createRoute({ + method: "patch", + path: "/{conversationId}/metadata", + summary: "Update conversation metadata", + description: + "Merges metadata into a conversation. Conversation metadata are public and retrievable on public conversation endpoints, but this post-creation update route requires a private API key.", + operationId: "updateConversationMetadata", + tags: ["Conversations"], + request: { + body: { + required: true, + content: { + "application/json": { + schema: updateConversationMetadataRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Conversation metadata updated successfully", + content: { + "application/json": { + schema: privateConversationMutationResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Bad request - Invalid request payload"), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + }), +}); + +export const updateConversationPriorityRoute = createRoute({ + method: "patch", + path: "/{conversationId}/priority", + summary: "Update a conversation priority", + description: + "Updates the conversation priority and marks it as human-owned. Requires a private API key and an acting teammate.", + operationId: "updateConversationPriority", + tags: ["Conversations"], + request: { + body: { + required: true, + content: { + "application/json": { + schema: updateConversationPriorityRestRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Conversation priority updated successfully", + content: { + "application/json": { + schema: privateConversationMutationResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Invalid request payload or missing actor for an unlinked private API key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor user not allowed for this website" + ), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const updateConversationSentimentRoute = createRoute({ + method: "patch", + path: "/{conversationId}/sentiment", + summary: "Update a conversation sentiment", + description: + "Updates the conversation sentiment and marks it as human-owned. Pass null to mark sentiment as unknown. Requires a private API key and an acting teammate.", + operationId: "updateConversationSentiment", + tags: ["Conversations"], + request: { + body: { + required: true, + content: { + "application/json": { + schema: updateConversationSentimentRestRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Conversation sentiment updated successfully", + content: { + "application/json": { + schema: privateConversationMutationResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Invalid request payload or missing actor for an unlinked private API key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor user not allowed for this website" + ), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const updateConversationTitleRoute = createRoute({ + method: "patch", + path: "/{conversationId}", + summary: "Update a conversation title", + description: + "Updates the conversation title. This private control route does not require an acting teammate in v1.", + operationId: "updateConversationTitle", + tags: ["Conversations"], + request: { + body: { + required: true, + content: { + "application/json": { + schema: updateConversationTitleRestRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Conversation title updated successfully", + content: { + "application/json": { + schema: privateConversationMutationResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Bad request - Invalid request payload"), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + }), +}); + +export const pauseConversationAiRoute = createRoute({ + method: "post", + path: "/{conversationId}/ai/pause", + summary: "Pause AI replies for a conversation", + description: + "Pauses AI replies for a conversation for the provided duration. Requires a private API key and an acting teammate.", + operationId: "pauseConversationAi", + tags: ["Conversations"], + request: { + body: { + required: false, + content: { + "application/json": { + schema: pauseConversationAiRestRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Conversation AI paused successfully", + content: { + "application/json": { + schema: privateConversationMutationResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Invalid request payload or missing actor for an unlinked private API key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor user not allowed for this website" + ), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const resumeConversationAiRoute = createRoute({ + method: "post", + path: "/{conversationId}/ai/resume", + summary: "Resume AI replies for a conversation", + description: + "Resumes AI replies for a conversation. Requires a private API key and an acting teammate.", + operationId: "resumeConversationAi", + tags: ["Conversations"], + responses: { + 200: { + description: "Conversation AI resumed successfully", + content: { + "application/json": { + schema: privateConversationMutationResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Missing actor for an unlinked private API key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor user not allowed for this website" + ), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const joinConversationEscalationRoute = createRoute({ + method: "post", + path: "/{conversationId}/join-escalation", + summary: "Join an escalated conversation", + description: + "Marks an escalation as handled and adds the acting teammate as a participant if needed. Requires a private API key and an acting teammate.", + operationId: "joinConversationEscalation", + tags: ["Conversations"], + responses: { + 200: { + description: "Escalation joined successfully", + content: { + "application/json": { + schema: privateConversationMutationResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Missing actor for an unlinked private API key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor user not allowed for this website" + ), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const listInboxConversationsRoute = createRoute({ + method: "get", + path: "/inbox", + summary: "List inbox conversations", + description: + "Returns a cursor-paginated inbox view for the authenticated website. This control-plane endpoint requires a private API key.", + tags: ["Conversations"], + request: { + query: listInboxConversationsRequestSchema, + }, + responses: { + 200: { + description: "Inbox conversations retrieved successfully", + content: { + "application/json": { + schema: listInboxConversationsResponseSchema, + }, + }, + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ + includeActorUserIdHeader: true, + }), +}); + +export const getConversationContextRoute = createRoute({ + method: "get", + path: "/{conversationId}/context", + summary: "Get conversation context", + description: + "Returns agent-ready private context for a conversation, including rich conversation state, visitor/contact profile, a private timeline page, and linked feedback.", + tags: ["Conversations"], + request: { + params: getConversationRequestSchema, + query: conversationContextRequestSchema, + }, + responses: { + 200: { + description: "Conversation context retrieved successfully", + content: { + "application/json": { + schema: conversationContextResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Invalid request"), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const getConversationRoute = createRoute({ + method: "get", + path: "/{conversationId}", + summary: "Get a single conversation by ID", + description: + "Fetch a specific conversation by its ID, including any public conversation metadata.", + tags: ["Conversations"], + request: { + params: getConversationRequestSchema, + }, + responses: { + 200: { + description: "Conversation retrieved successfully", + content: { + "application/json": { + schema: getConversationResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Invalid request"), + 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), + 403: errorJsonResponse("Forbidden - Public key origin validation failed"), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...runtimeDualAuth({ + parameters: [conversationIdPathParameter], + includeVisitorIdHeader: true, + }), +}); + +export const markConversationSeenRoute = createRoute({ + method: "post", + path: "/{conversationId}/seen", + summary: "Mark a conversation as seen by the visitor", + description: + "Record a visitor's last seen timestamp for a specific conversation.", + tags: ["Conversations"], + request: { + body: { + required: false, + content: { + "application/json": { + schema: markConversationSeenRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Conversation seen timestamp recorded", + content: { + "application/json": { + schema: markConversationSeenResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Invalid request"), + 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), + 403: errorJsonResponse("Forbidden - Public key origin validation failed"), + 404: errorJsonResponse("Conversation not found"), + }, + ...runtimeDualAuth({ + parameters: [conversationIdPathParameter], + includeVisitorIdHeader: true, + }), +}); + +export const setConversationTypingRoute = createRoute({ + method: "post", + path: "/{conversationId}/typing", + summary: "Report a visitor typing state", + description: + "Emit a typing indicator event for the visitor. Either visitorId must be provided via body or headers.", + tags: ["Conversations"], + request: { + body: { + required: true, + content: { + "application/json": { + schema: setConversationTypingRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Typing state recorded", + content: { + "application/json": { + schema: setConversationTypingResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Invalid request"), + 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), + 403: errorJsonResponse("Forbidden - Public key origin validation failed"), + 404: errorJsonResponse("Conversation not found"), + }, + ...runtimeDualAuth({ + parameters: [conversationIdPathParameter], + includeVisitorIdHeader: true, + }), +}); + +export const createConversationRatingRoute = createRoute({ + method: "post", + path: "/{conversationId}/rating", + summary: "Submit a visitor rating for a conversation", + description: + "Record a visitor rating (1-5) for a resolved conversation. Requires visitor ownership.", + tags: ["Conversations"], + request: { + body: { + required: true, + content: { + "application/json": { + schema: submitConversationRatingRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Conversation rating recorded", + content: { + "application/json": { + schema: submitConversationRatingResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Invalid request"), + 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), + 403: errorJsonResponse("Forbidden"), + 404: errorJsonResponse("Conversation not found"), + }, + ...runtimeDualAuth({ + parameters: [conversationIdPathParameter], + includeVisitorIdHeader: true, + }), +}); + +export const getConversationSeenRoute = createRoute({ + method: "get", + path: "/{conversationId}/seen", + summary: "Get conversation seen data", + description: + "Fetch the seen data (read receipts) for a conversation, showing who has seen messages and when.", + tags: ["Conversations"], + responses: { + 200: { + description: "Seen data retrieved successfully", + content: { + "application/json": { + schema: getConversationSeenDataResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Invalid request"), + 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), + 403: errorJsonResponse("Forbidden - Public key origin validation failed"), + 404: errorJsonResponse("Conversation not found"), + }, + ...runtimeDualAuth({ + parameters: [conversationIdPathParameter], + includeVisitorIdHeader: true, + }), +}); + +export const exportConversationRoute = createRoute({ + method: "get", + path: "/{conversationId}/export", + summary: "Download a full conversation export", + description: + "Returns the full internal conversation transcript as plain text. This control-plane endpoint requires a private API key.", + tags: ["Conversations"], + request: { + query: emptyQuerySchema, + params: getConversationRequestSchema, + }, + responses: { + 200: { + description: "Conversation export generated successfully", + content: { + "text/plain": { + schema: z.string(), + }, + }, + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("Conversation not found"), + }, + ...privateControlAuth({ + parameters: [conversationIdPathParameter], + }), +}); + +export const getConversationTimelineRoute = createRoute({ + method: "get", + path: "/{conversationId}/timeline", + summary: "Get conversation timeline items", + description: + "Fetch paginated timeline items (messages and events) for a conversation in chronological order.", + tags: ["Conversations"], + request: { + query: getConversationTimelineItemsRequestSchema, + }, + responses: { + 200: { + description: "Timeline items retrieved successfully", + content: { + "application/json": { + schema: getConversationTimelineItemsResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Invalid request"), + 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), + 403: errorJsonResponse("Forbidden - Public key origin validation failed"), + 404: errorJsonResponse("Conversation not found"), + }, + ...runtimeDualAuth({ + parameters: [conversationIdPathParameter], + includeVisitorIdHeader: true, + }), +}); + +export const CONVERSATION_ROUTES = [ + createConversationRoute, + listConversationsRoute, + resolveConversationRoute, + reopenConversationRoute, + markConversationAsSpamRoute, + markConversationAsNotSpamRoute, + archiveConversationRoute, + unarchiveConversationRoute, + markConversationAsReadRoute, + markConversationAsUnreadRoute, + updateConversationMetadataRoute, + updateConversationPriorityRoute, + updateConversationSentimentRoute, + updateConversationTitleRoute, + pauseConversationAiRoute, + resumeConversationAiRoute, + joinConversationEscalationRoute, + listInboxConversationsRoute, + getConversationContextRoute, + getConversationRoute, + markConversationSeenRoute, + setConversationTypingRoute, + createConversationRatingRoute, + getConversationSeenRoute, + exportConversationRoute, + getConversationTimelineRoute, +] as const; diff --git a/packages/protocol/src/routes/feedback.ts b/packages/protocol/src/routes/feedback.ts new file mode 100644 index 000000000..7abe7be54 --- /dev/null +++ b/packages/protocol/src/routes/feedback.ts @@ -0,0 +1,143 @@ +import { + feedbackSummaryRequestSchema, + feedbackSummaryResponseSchema, + getFeedbackResponseSchema, + listFeedbackRequestSchema, + listFeedbackResponseSchema, + submitFeedbackRequestSchema, + submitFeedbackResponseSchema, +} from "@cossistant/types/api/feedback"; +import { createRoute } from "@hono/zod-openapi"; +import { privateControlAuth, runtimeDualAuth } from "../auth"; +import { errorJsonResponse } from "../errors"; + +export const createFeedbackRoute = createRoute({ + method: "post", + path: "/", + summary: "Submit feedback", + description: + "Submit feedback with a rating, optional topic, and optional comment. Can be tied to a conversation or standalone.", + request: { + body: { + content: { + "application/json": { + schema: submitFeedbackRequestSchema, + }, + }, + }, + }, + responses: { + 201: { + description: "Feedback submitted successfully", + content: { + "application/json": { + schema: submitFeedbackResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Invalid request data"), + 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), + 403: errorJsonResponse("Forbidden - API key required"), + 404: errorJsonResponse("Conversation not found"), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["Feedback"], + ...runtimeDualAuth({ includeVisitorIdHeader: true }), +}); + +export const listFeedbackRoute = createRoute({ + method: "get", + path: "/", + summary: "List feedback", + description: + "Returns a paginated list of feedback for the website. Supports filtering by trigger, source, conversation, visitor, contact, topic, rating, and creation time.", + request: { + query: listFeedbackRequestSchema, + }, + responses: { + 200: { + description: "Feedback list retrieved successfully", + content: { + "application/json": { + schema: listFeedbackResponseSchema, + }, + }, + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["Feedback"], + ...privateControlAuth(), +}); + +export const getFeedbackSummaryRoute = createRoute({ + method: "get", + path: "/summary", + summary: "Summarize feedback", + description: + "Returns aggregate feedback metrics for the website using the same filters as the feedback list endpoint.", + request: { + query: feedbackSummaryRequestSchema, + }, + responses: { + 200: { + description: "Feedback summary retrieved successfully", + content: { + "application/json": { + schema: feedbackSummaryResponseSchema, + }, + }, + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["Feedback"], + ...privateControlAuth(), +}); + +export const getFeedbackRoute = createRoute({ + method: "get", + path: "/{id}", + summary: "Get feedback by ID", + description: "Retrieves a single feedback entry by ID", + responses: { + 200: { + description: "Feedback retrieved successfully", + content: { + "application/json": { + schema: getFeedbackResponseSchema, + }, + }, + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("Feedback not found"), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["Feedback"], + ...privateControlAuth({ + parameters: [ + { + name: "id", + in: "path", + required: true, + description: "The feedback ID", + schema: { + type: "string", + }, + }, + ], + }), +}); + +/** Public-key runtime routes (`feedbackCreateRouter` in apps/api). */ +export const FEEDBACK_CREATE_ROUTES = [createFeedbackRoute] as const; + +/** Private-key control routes (`feedbackReadRouter` in apps/api). */ +export const FEEDBACK_READ_ROUTES = [ + listFeedbackRoute, + getFeedbackSummaryRoute, + getFeedbackRoute, +] as const; diff --git a/packages/protocol/src/routes/index.ts b/packages/protocol/src/routes/index.ts new file mode 100644 index 000000000..a3cff8488 --- /dev/null +++ b/packages/protocol/src/routes/index.ts @@ -0,0 +1,62 @@ +import type { RouteGroup } from "../mount"; +import { AI_AGENT_ROUTES } from "./ai-agents"; +import { CONTACT_CONTROL_ROUTES, CONTACT_RUNTIME_ROUTES } from "./contacts"; +import { CONVERSATION_ROUTES } from "./conversations"; +import { FEEDBACK_CREATE_ROUTES, FEEDBACK_READ_ROUTES } from "./feedback"; +import { KNOWLEDGE_ROUTES } from "./knowledge"; +import { MESSAGES_ROUTES } from "./messages"; +import { ORGANIZATION_ROUTES } from "./organizations"; +import { SUPPORT_CONTROL_ROUTES, SUPPORT_RUNTIME_ROUTES } from "./support"; +import { UPLOAD_ROUTES } from "./uploads"; +import { VISITOR_ROUTES } from "./visitors"; +import { WEBSITE_ROUTES } from "./websites"; + +export * from "./ai-agents"; +export * from "./contacts"; +export * from "./conversations"; +export * from "./feedback"; +export * from "./knowledge"; +export * from "./messages"; +export * from "./organizations"; +export * from "./support"; +export * from "./uploads"; +export * from "./visitors"; +export * from "./websites"; + +/** + * Mount table mirroring `apps/api/src/rest/routers/index.ts`. + * + * Order is load-bearing: path-key order in the generated document follows + * registration order. + */ +export const REST_MOUNT_TABLE: readonly RouteGroup[] = [ + { prefix: "/ai-agents", routes: AI_AGENT_ROUTES }, + { prefix: "/organizations", routes: ORGANIZATION_ROUTES }, + { prefix: "/websites", routes: WEBSITE_ROUTES }, + { prefix: "/messages", routes: MESSAGES_ROUTES }, + { prefix: "/conversations", routes: CONVERSATION_ROUTES }, + { prefix: "/visitors", routes: VISITOR_ROUTES }, + { + prefix: "/contacts", + groups: [ + { prefix: "/", routes: CONTACT_RUNTIME_ROUTES }, + { prefix: "/", routes: CONTACT_CONTROL_ROUTES }, + ], + }, + { + prefix: "/support", + groups: [ + { prefix: "/", routes: SUPPORT_RUNTIME_ROUTES }, + { prefix: "/", routes: SUPPORT_CONTROL_ROUTES }, + ], + }, + { prefix: "/uploads", routes: UPLOAD_ROUTES }, + { prefix: "/knowledge", routes: KNOWLEDGE_ROUTES }, + { + prefix: "/feedback", + groups: [ + { prefix: "/", routes: FEEDBACK_CREATE_ROUTES }, + { prefix: "/", routes: FEEDBACK_READ_ROUTES }, + ], + }, +]; diff --git a/packages/protocol/src/routes/knowledge.ts b/packages/protocol/src/routes/knowledge.ts new file mode 100644 index 000000000..03e2cab21 --- /dev/null +++ b/packages/protocol/src/routes/knowledge.ts @@ -0,0 +1,201 @@ +import { + createKnowledgeRestRequestSchema, + knowledgeResponseSchema, + knowledgeSearchRequestSchema, + knowledgeSearchResponseSchema, + listKnowledgeResponseSchema, + listKnowledgeRestRequestSchema, + updateKnowledgeRestRequestSchema, +} from "@cossistant/types"; +import { createRoute } from "@hono/zod-openapi"; +import { privateControlAuth } from "../auth"; +import { errorJsonResponse } from "../errors"; + +const knowledgeIdPathParameter = { + name: "id", + in: "path", + required: true, + description: "The knowledge entry ID", + schema: { + type: "string", + }, +} as const; + +export const listKnowledgeRoute = createRoute({ + method: "get", + path: "/", + summary: "List knowledge entries", + description: + "Returns a paginated list of knowledge entries for the website. Supports filtering by type, AI agent, training inclusion, and link source.", + operationId: "listKnowledge", + request: { + query: listKnowledgeRestRequestSchema, + }, + responses: { + 200: { + description: "Knowledge entries retrieved successfully", + content: { + "application/json": { + schema: listKnowledgeResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Bad request - Invalid query parameters"), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["Knowledge"], + ...privateControlAuth(), +}); + +export const searchKnowledgeRoute = createRoute({ + method: "get", + path: "/search", + summary: "Search knowledge", + description: + "Runs private semantic retrieval against indexed knowledge chunks for the authenticated website. Designed for CLI, MCP, and support-agent tools that need source-backed context.", + operationId: "searchKnowledge", + request: { + query: knowledgeSearchRequestSchema, + }, + responses: { + 200: { + description: "Knowledge search completed successfully", + content: { + "application/json": { + schema: knowledgeSearchResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Bad request - Invalid query parameters"), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["Knowledge"], + ...privateControlAuth(), +}); + +export const getKnowledgeRoute = createRoute({ + method: "get", + path: "/{id}", + summary: "Get a knowledge entry", + description: "Retrieves a single knowledge entry by ID", + operationId: "getKnowledge", + responses: { + 200: { + description: "Knowledge entry retrieved successfully", + content: { + "application/json": { + schema: knowledgeResponseSchema, + }, + }, + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 404: errorJsonResponse("Knowledge entry not found"), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["Knowledge"], + ...privateControlAuth({ + parameters: [knowledgeIdPathParameter], + }), +}); + +export const createKnowledgeRoute = createRoute({ + method: "post", + path: "/", + summary: "Create a knowledge entry", + description: "Creates a new knowledge entry for the website", + request: { + body: { + content: { + "application/json": { + schema: createKnowledgeRestRequestSchema, + }, + }, + }, + }, + responses: { + 201: { + description: "Knowledge entry created successfully", + content: { + "application/json": { + schema: knowledgeResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Invalid request data"), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Plan knowledge limits prevent creating this entry" + ), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["Knowledge"], + ...privateControlAuth(), +}); + +export const updateKnowledgeRoute = createRoute({ + method: "patch", + path: "/{id}", + summary: "Update a knowledge entry", + description: "Updates an existing knowledge entry", + request: { + body: { + content: { + "application/json": { + schema: updateKnowledgeRestRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Knowledge entry updated successfully", + content: { + "application/json": { + schema: knowledgeResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Invalid request data"), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Plan knowledge limits prevent updating this entry" + ), + 404: errorJsonResponse("Knowledge entry not found"), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["Knowledge"], + ...privateControlAuth({ + parameters: [knowledgeIdPathParameter], + }), +}); + +export const deleteKnowledgeRoute = createRoute({ + method: "delete", + path: "/{id}", + summary: "Delete a knowledge entry", + description: "Permanently deletes a knowledge entry", + responses: { + 204: { + description: "Knowledge entry deleted successfully", + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 404: errorJsonResponse("Knowledge entry not found"), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["Knowledge"], + ...privateControlAuth({ + parameters: [knowledgeIdPathParameter], + }), +}); + +export const KNOWLEDGE_ROUTES = [ + listKnowledgeRoute, + searchKnowledgeRoute, + getKnowledgeRoute, + createKnowledgeRoute, + updateKnowledgeRoute, + deleteKnowledgeRoute, +] as const; diff --git a/packages/protocol/src/routes/messages.ts b/packages/protocol/src/routes/messages.ts new file mode 100644 index 000000000..f284014a8 --- /dev/null +++ b/packages/protocol/src/routes/messages.ts @@ -0,0 +1,51 @@ +import { + sendTimelineItemRequestSchema, + sendTimelineItemResponseSchema, +} from "@cossistant/types/api/timeline-item"; +import { createRoute } from "@hono/zod-openapi"; +import { runtimeDualAuth } from "../auth"; +import { errorJsonResponse } from "../errors"; + +// GET /messages endpoint removed - use /conversations/:id/timeline instead +export const createMessageRoute = createRoute({ + method: "post", + path: "/", + summary: "Send a timeline item to a conversation", + description: + "Send a new timeline item to an existing conversation. When item.createdAt is omitted, the server assigns the timestamp. Historical timestamps are allowed. Timestamps more than 5 minutes in the future are rejected.", + tags: ["Messages", "Timeline item"], + request: { + body: { + required: true, + content: { + "application/json": { + schema: sendTimelineItemRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Timeline item sent successfully", + content: { + "application/json": { + schema: sendTimelineItemResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Invalid request or item.createdAt more than 5 minutes in the future" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), + 403: errorJsonResponse( + "Forbidden - Public key origin validation failed or actor constraints failed" + ), + 404: errorJsonResponse("Conversation not found"), + }, + ...runtimeDualAuth({ + includeActorUserIdHeader: true, + includeVisitorIdHeader: true, + }), +}); + +export const MESSAGES_ROUTES = [createMessageRoute] as const; diff --git a/packages/protocol/src/routes/organizations.ts b/packages/protocol/src/routes/organizations.ts new file mode 100644 index 000000000..e5e629d51 --- /dev/null +++ b/packages/protocol/src/routes/organizations.ts @@ -0,0 +1,41 @@ +import { organizationResponseSchema } from "@cossistant/types"; +import { createRoute } from "@hono/zod-openapi"; +import { privateControlAuth } from "../auth"; +import { errorJsonResponse } from "../errors"; + +export const getOrganizationRoute = createRoute({ + method: "get", + path: "/{id}", + summary: "Retrieve an organization", + description: + "Retrieve an organization by its ID for the authenticated organization.", + tags: ["Organizations"], + responses: { + 200: { + description: "Organization details", + content: { + "application/json": { + schema: organizationResponseSchema, + }, + }, + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("Organization not found"), + }, + ...privateControlAuth({ + parameters: [ + { + name: "id", + in: "path", + description: "The organization ID to retrieve.", + required: true, + schema: { + type: "string", + }, + }, + ], + }), +}); + +export const ORGANIZATION_ROUTES = [getOrganizationRoute] as const; diff --git a/packages/protocol/src/routes/support.ts b/packages/protocol/src/routes/support.ts new file mode 100644 index 000000000..be160977f --- /dev/null +++ b/packages/protocol/src/routes/support.ts @@ -0,0 +1,105 @@ +import { + supportFeatureFlagMutationRequestSchema, + supportFeatureFlagMutationResponseSchema, + supportOnboardingUpdateRequestSchema, + supportStateResponseSchema, +} from "@cossistant/types/api/support"; +import { createRoute } from "@hono/zod-openapi"; +import { privateControlAuth, runtimeDualAuth } from "../auth"; +import { errorJsonResponse } from "../errors"; + +export const getSupportStateRoute = createRoute({ + method: "get", + path: "/state", + summary: "Get support state", + description: + "Returns resolved feature flags and onboarding progress for the current visitor.", + tags: ["Support"], + responses: { + 200: { + description: "Support state retrieved successfully", + content: { + "application/json": { + schema: supportStateResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Visitor ID is required"), + 401: errorJsonResponse("Unauthorized - Invalid API key"), + 404: errorJsonResponse("Visitor not found"), + }, + ...runtimeDualAuth({ includeVisitorIdHeader: true }), +}); + +export const updateSupportOnboardingRoute = createRoute({ + method: "patch", + path: "/onboarding", + summary: "Update onboarding state", + description: + "Updates onboarding progress or metadata for the current visitor/contact.", + tags: ["Support"], + request: { + body: { + content: { + "application/json": { + schema: supportOnboardingUpdateRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Onboarding state updated successfully", + content: { + "application/json": { + schema: supportStateResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Invalid request data"), + 401: errorJsonResponse("Unauthorized - Invalid API key"), + 404: errorJsonResponse("Visitor not found"), + }, + ...runtimeDualAuth({ includeVisitorIdHeader: true }), +}); + +export const updateSupportFeatureFlagsRoute = createRoute({ + method: "patch", + path: "/feature-flags", + summary: "Mutate feature flags", + description: + "Adds, removes, or replaces feature flags on a visitor, contact, or contact organization.", + tags: ["Support"], + request: { + body: { + content: { + "application/json": { + schema: supportFeatureFlagMutationRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Feature flags updated successfully", + content: { + "application/json": { + schema: supportFeatureFlagMutationResponseSchema, + }, + }, + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 404: errorJsonResponse("Target not found"), + }, + ...privateControlAuth(), +}); + +/** Public-key runtime routes (`supportRuntimeRouter` in apps/api). */ +export const SUPPORT_RUNTIME_ROUTES = [ + getSupportStateRoute, + updateSupportOnboardingRoute, +] as const; + +/** Private-key control routes (`supportControlRouter` in apps/api). */ +export const SUPPORT_CONTROL_ROUTES = [updateSupportFeatureFlagsRoute] as const; diff --git a/packages/protocol/src/routes/uploads.ts b/packages/protocol/src/routes/uploads.ts new file mode 100644 index 000000000..17ac1d6d0 --- /dev/null +++ b/packages/protocol/src/routes/uploads.ts @@ -0,0 +1,42 @@ +import { + generateUploadUrlRequestSchema, + generateUploadUrlResponseSchema, +} from "@cossistant/types/api/upload"; +import { createRoute } from "@hono/zod-openapi"; +import { runtimeDualAuth } from "../auth"; +import { errorJsonResponse } from "../errors"; + +export const createUploadSignUrlRoute = createRoute({ + method: "post", + path: "/sign-url", + summary: "Generate a signed S3 upload URL", + description: + "Creates a temporary signed URL that can be used to upload a file directly to the configured S3 bucket.", + tags: ["Uploads"], + request: { + body: { + required: true, + content: { + "application/json": { + schema: generateUploadUrlRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Signed URL generated successfully", + content: { + "application/json": { + schema: generateUploadUrlResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Invalid request"), + 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), + 403: errorJsonResponse("Forbidden - Public key origin validation failed"), + }, + ...runtimeDualAuth(), +}); + +export const UPLOAD_ROUTES = [createUploadSignUrlRoute] as const; diff --git a/packages/protocol/src/routes/visitors.ts b/packages/protocol/src/routes/visitors.ts new file mode 100644 index 000000000..90d4ed441 --- /dev/null +++ b/packages/protocol/src/routes/visitors.ts @@ -0,0 +1,236 @@ +import { + updateVisitorMetadataRequestSchema, + updateVisitorRequestSchema, + visitorActivityRequestSchema, + visitorActivityResponseSchema, + visitorResponseSchema, +} from "@cossistant/types"; +import { createRoute } from "@hono/zod-openapi"; +import { privateControlAuth, runtimeDualAuth } from "../auth"; +import { errorJsonResponse } from "../errors"; + +const visitorIdPathParameter = { + name: "id", + in: "path", + required: true, + description: "The visitor ID", + schema: { + type: "string", + }, +} as const; + +export const getVisitorActivityRoute = createRoute({ + method: "post", + path: "/{id}/activity", + summary: "Track live visitor activity", + description: + "Records live visitor activity for realtime dashboards. This endpoint is the canonical ingestion path for live visitor presence and page activity.", + request: { + body: { + content: { + "application/json": { + schema: visitorActivityRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Live activity accepted", + content: { + "application/json": { + schema: visitorActivityResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Invalid request data"), + 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), + 403: errorJsonResponse("Forbidden - Public key origin validation failed"), + 404: errorJsonResponse("Visitor not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...runtimeDualAuth({ + parameters: [visitorIdPathParameter], + }), +}); + +export const updateVisitorRoute = createRoute({ + method: "patch", + path: "/{id}", + summary: "Update existing visitor information", + description: + "Updates an existing visitor's browser, device, and location data. The visitor must already exist in the system.", + request: { + body: { + content: { + "application/json": { + schema: updateVisitorRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: visitorResponseSchema, + }, + }, + description: "Visitor information successfully created or updated", + }, + 400: errorJsonResponse("Invalid request data"), + 401: errorJsonResponse("Unauthorized - Invalid API key"), + 403: errorJsonResponse("Forbidden - Public key origin validation failed"), + 404: errorJsonResponse("Visitor not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...runtimeDualAuth({ + parameters: [ + { + name: "id", + in: "path", + required: true, + // Deliberately not the shared constant: this route's description + // differs and the wording is part of the published contract. + description: "The visitor ID to update", + schema: { + type: "string", + }, + }, + ], + }), +}); + +export const updateVisitorMetadataRoute = createRoute({ + method: "patch", + path: "/{id}/metadata", + summary: "Update contact metadata for a visitor", + description: + "Merges the provided metadata into the contact profile associated with the visitor. The visitor must be identified first (linked to a contact) via the /contacts/identify endpoint.", + request: { + body: { + content: { + "application/json": { + schema: updateVisitorMetadataRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Contact metadata updated successfully", + content: { + "application/json": { + schema: visitorResponseSchema, + }, + }, + }, + 400: errorJsonResponse("Invalid request data or visitor not identified"), + 401: errorJsonResponse("Unauthorized - Invalid API key"), + 403: errorJsonResponse("Forbidden - Public key origin validation failed"), + 404: errorJsonResponse("Visitor not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...runtimeDualAuth({ + parameters: [visitorIdPathParameter], + }), +}); + +export const blockVisitorRoute = createRoute({ + method: "post", + path: "/{id}/block", + summary: "Block a visitor", + description: + "Blocks a visitor from sending new messages. Requires a private API key and an acting teammate.", + operationId: "blockVisitor", + responses: { + 200: { + description: "Visitor blocked successfully", + content: { + "application/json": { + schema: visitorResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Missing actor for an unlinked private API key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor user not allowed for this website" + ), + 404: errorJsonResponse("Visitor not found"), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["Visitors"], + ...privateControlAuth({ + parameters: [visitorIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const unblockVisitorRoute = createRoute({ + method: "post", + path: "/{id}/unblock", + summary: "Unblock a visitor", + description: + "Unblocks a visitor so they can send messages again. Requires a private API key and an acting teammate.", + operationId: "unblockVisitor", + responses: { + 200: { + description: "Visitor unblocked successfully", + content: { + "application/json": { + schema: visitorResponseSchema, + }, + }, + }, + 400: errorJsonResponse( + "Bad request - Missing actor for an unlinked private API key" + ), + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse( + "Forbidden - Private API key required or actor user not allowed for this website" + ), + 404: errorJsonResponse("Visitor not found"), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["Visitors"], + ...privateControlAuth({ + parameters: [visitorIdPathParameter], + includeActorUserIdHeader: true, + }), +}); + +export const getVisitorRoute = createRoute({ + method: "get", + path: "/{id}", + summary: "Get visitor information", + description: "Retrieves visitor information by visitor ID", + responses: { + 200: { + content: { + "application/json": { + schema: visitorResponseSchema, + }, + }, + description: "Visitor information retrieved successfully", + }, + 401: errorJsonResponse("Unauthorized - Invalid API key"), + 403: errorJsonResponse("Forbidden - Public key origin validation failed"), + 404: errorJsonResponse("Visitor not found"), + 500: errorJsonResponse("Internal server error"), + }, + ...runtimeDualAuth({ + parameters: [visitorIdPathParameter], + }), +}); + +export const VISITOR_ROUTES = [ + getVisitorActivityRoute, + updateVisitorRoute, + updateVisitorMetadataRoute, + blockVisitorRoute, + unblockVisitorRoute, + getVisitorRoute, +] as const; diff --git a/packages/protocol/src/routes/websites.ts b/packages/protocol/src/routes/websites.ts new file mode 100644 index 000000000..5e88770d8 --- /dev/null +++ b/packages/protocol/src/routes/websites.ts @@ -0,0 +1,64 @@ +import { + publicWebsiteResponseSchema, + websiteTeamMembersResponseSchema, +} from "@cossistant/types"; +import { createRoute } from "@hono/zod-openapi"; +import { privateControlAuth, runtimeDualAuth } from "../auth"; +import { errorJsonResponse } from "../errors"; + +// GET /website - Get website information linked to the API key +// NOTE: `tags: ["Website"]` is singular here while the tag fallback map uses +// "Websites". Preserved verbatim — the tag is part of the published contract. +export const getWebsiteRoute = createRoute({ + method: "get", + path: "/", + summary: "Get website information", + description: + "Returns the website information associated with the provided API key. This endpoint supports both public and private API keys with different authentication methods.", + responses: { + 200: { + description: "Website information successfully retrieved", + content: { + "application/json": { + schema: publicWebsiteResponseSchema, + }, + }, + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing API key"), + 403: errorJsonResponse( + "Forbidden - Origin validation failed for public key or domain not whitelisted" + ), + 404: errorJsonResponse("Website not found for this API key"), + }, + tags: ["Website"], + ...runtimeDualAuth({ includeVisitorIdHeader: true }), +}); + +export const listWebsiteTeamMembersRoute = createRoute({ + method: "get", + path: "/team-members", + summary: "List website team members", + description: + "Returns the website-access teammates that can be linked to private API keys or used as actor IDs on actor-aware private API routes.", + operationId: "listWebsiteTeamMembers", + responses: { + 200: { + description: "Website team members retrieved successfully", + content: { + "application/json": { + schema: websiteTeamMembersResponseSchema, + }, + }, + }, + 401: errorJsonResponse("Unauthorized - Invalid or missing private API key"), + 403: errorJsonResponse("Forbidden - Private API key required"), + 500: errorJsonResponse("Internal server error"), + }, + tags: ["Website"], + ...privateControlAuth(), +}); + +export const WEBSITE_ROUTES = [ + getWebsiteRoute, + listWebsiteTeamMembersRoute, +] as const; diff --git a/packages/protocol/src/testkit.ts b/packages/protocol/src/testkit.ts new file mode 100644 index 000000000..dec20bbd4 --- /dev/null +++ b/packages/protocol/src/testkit.ts @@ -0,0 +1,233 @@ +/** + * Compatibility testkit. + * + * Lets a second implementation prove its served document still matches the + * canonical Cossistant contract. Deliberately a focused structural comparison, + * not a general-purpose API testing framework. + */ +import { buildCossistantOpenApiDocument } from "./openapi"; + +const HTTP_METHODS = new Set([ + "delete", + "get", + "head", + "options", + "patch", + "post", + "put", + "trace", +]); + +type AnyDocument = { + paths?: Record>; + components?: { securitySchemes?: Record }; +}; + +type Operation = { + operationId?: string; + security?: Record[]; + parameters?: { name?: string; in?: string; required?: boolean }[]; + requestBody?: { content?: Record }; + responses?: Record }>; +}; + +/** + * An intentional, reviewed difference. Each entry must name the exact operation + * and field being relaxed, and say why. + */ +export type CompatibilityAllowance = { + /** e.g. `GET /conversations/{conversationId}` */ + operation: string; + /** e.g. `responses.200.schema`, `parameters`, or `*` for the whole operation */ + field: string; + reason: string; +}; + +export type CompatibilityOptions = { + /** Compare against this document instead of the canonical one. */ + expected?: AnyDocument; + allow?: CompatibilityAllowance[]; +}; + +export type CompatibilityViolation = { + operation: string; + field: string; + detail: string; +}; + +function operations(doc: AnyDocument) { + const out = new Map(); + for (const [path, item] of Object.entries(doc.paths ?? {})) { + for (const [method, operation] of Object.entries(item)) { + if ( + HTTP_METHODS.has(method) && + operation && + typeof operation === "object" + ) { + out.set(`${method.toUpperCase()} ${path}`, operation as Operation); + } + } + } + return out; +} + +function parameterKeys(operation: Operation) { + return (operation.parameters ?? []) + .map((p) => `${p.in}:${p.name}:${p.required ? "required" : "optional"}`) + .sort(); +} + +function securityKeys(operation: Operation) { + return (operation.security ?? []) + .flatMap((entry) => Object.keys(entry)) + .sort(); +} + +function isAllowed( + allow: CompatibilityAllowance[], + operation: string, + field: string +) { + return allow.some( + (a) => a.operation === operation && (a.field === field || a.field === "*") + ); +} + +/** + * Compare an implementation's OpenAPI document against the canonical protocol. + * Returns every violation; an empty array means the implementation conforms. + */ +export function checkCossistantProtocolCompatibility( + actual: AnyDocument, + options: CompatibilityOptions = {} +): CompatibilityViolation[] { + const expected = options.expected ?? buildCossistantOpenApiDocument(); + const allow = options.allow ?? []; + const violations: CompatibilityViolation[] = []; + + const expectedOps = operations(expected as AnyDocument); + const actualOps = operations(actual); + + const add = (operation: string, field: string, detail: string) => { + if (!isAllowed(allow, operation, field)) { + violations.push({ operation, field, detail }); + } + }; + + for (const [key, expectedOp] of expectedOps) { + const actualOp = actualOps.get(key); + if (!actualOp) { + add(key, "*", "operation is missing"); + continue; + } + + if (expectedOp.operationId !== actualOp.operationId) { + add( + key, + "operationId", + `expected "${expectedOp.operationId}", got "${actualOp.operationId}"` + ); + } + + const expectedParams = parameterKeys(expectedOp).join(", "); + const actualParams = parameterKeys(actualOp).join(", "); + if (expectedParams !== actualParams) { + add( + key, + "parameters", + `expected [${expectedParams}], got [${actualParams}]` + ); + } + + const expectedSecurity = securityKeys(expectedOp).join(", "); + const actualSecurity = securityKeys(actualOp).join(", "); + if (expectedSecurity !== actualSecurity) { + add( + key, + "security", + `expected [${expectedSecurity}], got [${actualSecurity}]` + ); + } + + const expectedBody = Object.keys( + expectedOp.requestBody?.content ?? {} + ).sort(); + const actualBody = Object.keys(actualOp.requestBody?.content ?? {}).sort(); + if (expectedBody.join(",") !== actualBody.join(",")) { + add( + key, + "requestBody", + `expected media types [${expectedBody}], got [${actualBody}]` + ); + } + + for (const [status, expectedResponse] of Object.entries( + expectedOp.responses ?? {} + )) { + const actualResponse = actualOp.responses?.[status]; + if (!actualResponse) { + add(key, `responses.${status}`, "status code is missing"); + continue; + } + const expectedMedia = Object.keys(expectedResponse.content ?? {}).sort(); + const actualMedia = Object.keys(actualResponse.content ?? {}).sort(); + if (expectedMedia.join(",") !== actualMedia.join(",")) { + add( + key, + `responses.${status}`, + `expected media types [${expectedMedia}], got [${actualMedia}]` + ); + } + if ( + JSON.stringify(expectedResponse.content) !== + JSON.stringify(actualResponse.content) + ) { + add(key, `responses.${status}.schema`, "response schema differs"); + } + } + } + + // The /ws handshake and security schemes are protocol-level guarantees. + if (!actualOps.has("GET /ws")) { + add("GET /ws", "*", "WebSocket handshake is not documented"); + } + + const expectedSchemes = Object.keys( + (expected as AnyDocument).components?.securitySchemes ?? {} + ).sort(); + const actualSchemes = Object.keys( + actual.components?.securitySchemes ?? {} + ).sort(); + if (expectedSchemes.join(",") !== actualSchemes.join(",")) { + add( + "components", + "securitySchemes", + `expected [${expectedSchemes}], got [${actualSchemes}]` + ); + } + + return violations; +} + +/** + * Throwing variant for use directly in a test. + */ +export function assertCossistantProtocolCompatibility( + actual: AnyDocument, + options: CompatibilityOptions = {} +): void { + const violations = checkCossistantProtocolCompatibility(actual, options); + if (violations.length === 0) { + return; + } + + const detail = violations + .map((v) => ` - ${v.operation} [${v.field}]: ${v.detail}`) + .join("\n"); + + throw new Error( + `Document is not compatible with the Cossistant protocol (${violations.length} violation${ + violations.length === 1 ? "" : "s" + }):\n${detail}` + ); +} diff --git a/packages/protocol/src/websocket.ts b/packages/protocol/src/websocket.ts new file mode 100644 index 000000000..ccd24f473 --- /dev/null +++ b/packages/protocol/src/websocket.ts @@ -0,0 +1,233 @@ +import { + openApiSecuritySchemes, + PRIVATE_API_KEY_SECURITY_SCHEME, + PUBLIC_API_KEY_SECURITY_SCHEME, +} from "./auth"; +import { + actorUserIdHeader, + privateApiKeyAuthorizationHeader, + publicApiKeyHeader, + publicApiKeyOriginHeader, + visitorIdHeader, +} from "./headers"; + +export const DEFAULT_WEBSOCKET_SERVER_URL = "wss://api.cossistant.com"; + +const websocketTokenQueryParameter = { + description: + "Realtime authentication token obtained from `POST /conversations/{conversationId}/seen` or another session bootstrap endpoint. Required when using browser WebSocket APIs that cannot send custom headers.", + in: "query" as const, + name: "token", + required: false, + schema: { + type: "string", + }, +}; + +const websocketConnectionIdQueryParameter = { + description: + "Optional stable client connection identifier. When omitted the realtime service assigns one.", + in: "query" as const, + name: "connectionId", + required: false, + schema: { + type: "string", + }, +}; + +const websocketSessionIdQueryParameter = { + description: + "Optional browser session identifier used to correlate reconnects and visitor activity.", + in: "query" as const, + name: "sessionId", + required: false, + schema: { + type: "string", + }, +}; + +const websocketTransportQueryParameter = { + description: + "Optional transport hint for clients that share one connection URL across native WebSocket and fallback transports.", + in: "query" as const, + name: "transport", + required: false, + schema: { + enum: ["websocket"], + type: "string", + }, +}; + +export const realtimeComponentSchemas = { + RealtimeConnectionAccepted: { + additionalProperties: false, + properties: { + connectionId: { + description: "Server-assigned connection identifier.", + type: "string", + }, + type: { + enum: ["connection.accepted"], + type: "string", + }, + }, + required: ["type", "connectionId"], + type: "object", + }, + RealtimeErrorMessage: { + additionalProperties: false, + properties: { + code: { + description: "Machine-readable error code.", + type: "string", + }, + message: { + description: "Human-readable error detail.", + type: "string", + }, + type: { + enum: ["error"], + type: "string", + }, + }, + required: ["type", "code", "message"], + type: "object", + }, + RealtimeOutboundEvent: { + additionalProperties: true, + description: + "Realtime event payloads. Event names mirror conversation and visitor updates emitted by the REST API.", + properties: { + type: { + description: "Realtime event type.", + type: "string", + }, + }, + required: ["type"], + type: "object", + }, + RestErrorResponse: { + additionalProperties: false, + properties: { + error: { + properties: { + code: { + type: "string", + }, + message: { + type: "string", + }, + type: { + type: "string", + }, + }, + required: ["type", "code", "message"], + type: "object", + }, + }, + required: ["error"], + type: "object", + }, +}; + +/** + * The `/ws` handshake is declared by hand because the upgrade is not served by + * an `OpenAPIHono` route. + */ +export function buildWebsocketPathItem( + serverUrl: string = DEFAULT_WEBSOCKET_SERVER_URL +) { + return { + get: { + description: + "Opens a realtime WebSocket connection for conversation, visitor, and support events. Public browser clients normally authenticate with `X-Public-Key` plus `Origin`, or with a short-lived `token` query parameter when custom headers are unavailable. Private integrations can authenticate with `Authorization: Bearer `.", + operationId: "connectRealtime", + parameters: [ + publicApiKeyHeader, + privateApiKeyAuthorizationHeader, + publicApiKeyOriginHeader, + visitorIdHeader, + actorUserIdHeader, + websocketTokenQueryParameter, + websocketConnectionIdQueryParameter, + websocketSessionIdQueryParameter, + websocketTransportQueryParameter, + ], + responses: { + "101": { + description: + "Switching Protocols. The connection is upgraded to WebSocket.", + }, + "400": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/RestErrorResponse", + }, + }, + }, + description: "Invalid realtime connection request.", + }, + "401": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/RestErrorResponse", + }, + }, + }, + description: "Missing or invalid realtime credentials.", + }, + "403": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/RestErrorResponse", + }, + }, + }, + description: "The supplied credentials cannot access realtime data.", + }, + }, + security: [ + { + [PUBLIC_API_KEY_SECURITY_SCHEME]: [], + }, + { + [PRIVATE_API_KEY_SECURITY_SCHEME]: [], + }, + ], + servers: [ + { + url: serverUrl, + }, + ], + summary: "Open realtime WebSocket connection", + tags: ["Realtime"], + "x-websocket-message-schemas": { + inbound: { + oneOf: [ + { + $ref: "#/components/schemas/RealtimeOutboundEvent", + }, + ], + }, + outbound: { + oneOf: [ + { + $ref: "#/components/schemas/RealtimeConnectionAccepted", + }, + { + $ref: "#/components/schemas/RealtimeOutboundEvent", + }, + { + $ref: "#/components/schemas/RealtimeErrorMessage", + }, + ], + }, + }, + }, + }; +} + +export const websocketSecuritySchemes = openApiSecuritySchemes; diff --git a/packages/protocol/tsconfig.json b/packages/protocol/tsconfig.json new file mode 100644 index 000000000..738249058 --- /dev/null +++ b/packages/protocol/tsconfig.json @@ -0,0 +1,37 @@ +{ + "extends": "@cossistant/typescript-config/base.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "target": "es2022", + "lib": ["es2022", "DOM", "DOM.Iterable"], + "forceConsistentCasingInFileNames": true, + "strict": true, + "noEmit": false, + "declaration": true, + "declarationDir": "./dist", + "emitDeclarationOnly": true, + "baseUrl": "./", + "allowSyntheticDefaultImports": true, + "noErrorTruncation": false, + "allowJs": true, + "skipLibCheck": true, + "outDir": "./dist", + "paths": { + "@cossistant/protocol": ["./src"], + "@cossistant/protocol/*": ["./src/*"], + "@cossistant/types": ["../types/src"], + "@cossistant/types/*": ["../types/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": [ + "**/.*/", + "**/build", + "**/build-tests", + "**/node_modules", + "docs/export", + "dist", + "*.config.*" + ] +} diff --git a/packages/protocol/tsdown.config.ts b/packages/protocol/tsdown.config.ts new file mode 100644 index 000000000..52fb9436a --- /dev/null +++ b/packages/protocol/tsdown.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts", "src/**/*.ts", "!src/**/*.test.ts"], + clean: true, + dts: { + // Resolve and bundle all type dependencies to avoid exposing zod's .d.cts files + resolve: true, + }, + hash: false, + minify: false, + sourcemap: true, + treeshake: true, + unbundle: true, + outExtensions: () => ({ + js: ".js", + dts: ".d.ts", + }), + // Keep schema/route dependencies external so builds reference their package + // specifiers instead of Bun's internal store paths that break in npm installs + external: [ + "@cossistant/types", + "@hono/zod-openapi", + "hono", + "openapi3-ts", + "zod", + ], +}); diff --git a/packages/types/package.json b/packages/types/package.json index aad5899e2..892c899d4 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -34,8 +34,8 @@ "@cossistant/typescript-config": "workspace:*" }, "dependencies": { - "zod": "^4.1.8", - "@hono/zod-openapi": "^1.1.0" + "zod": "^4.3.6", + "@hono/zod-openapi": "^1.5.1" }, "scripts": { "build": "tsdown && bun run prepare-package", diff --git a/scripts/prepare-package.ts b/scripts/prepare-package.ts index 99ff570bb..c8b90bcf8 100644 --- a/scripts/prepare-package.ts +++ b/scripts/prepare-package.ts @@ -70,6 +70,11 @@ const toDistExport = (value: unknown) => { return value; } const normalized = stripSrcPrefix(value); + // Data files are published as-is; mapping them to {types,import} would point + // at .d.ts/.js files that do not exist. + if (normalized.endsWith(".json")) { + return normalized; + } if (normalized.endsWith(".css")) { // CSS files should strip ./dist/ prefix since the package.json is in dist/ if (normalized.startsWith("./dist/")) { @@ -141,7 +146,7 @@ const main = async () => { const distPkgPath = path.join(distDir, "package.json"); await writeFile(distPkgPath, `${JSON.stringify(distPkg, null, 2)}\n`, "utf8"); - for (const fileName of ["README.md", "LICENSE"]) { + for (const fileName of ["README.md", "LICENSE", "openapi.json"]) { const source = path.join(packageDir, fileName); try { await copyFile(source, path.join(distDir, fileName)); diff --git a/turbo.json b/turbo.json index 028c386ac..6874271bf 100644 --- a/turbo.json +++ b/turbo.json @@ -172,6 +172,15 @@ "cache": false, "dependsOn": ["^build"], "outputs": [] + }, + "test": { + "dependsOn": ["^build"], + "outputs": [] + }, + "check:openapi": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": ["src/**", "openapi.json"] } } }