Feature Specification: AI Conversation
Feature ID: ai-conversation
Branch: docs/spec-ai-conversation
Status: Retrospective
Created: 2026-05-08
Last updated: 2026-05-08
Owner: Ever Works Team
1. Overview
The AI Conversation feature lets a signed-in user hold persistent,
chat-style conversations with the AI provider configured for their
work. Each conversation is owned by a user, optionally scoped to a
work, and stores the full message timeline (system / user / assistant /
tool messages, tool-call deltas, model + token usage). The feature also
exposes an OpenAI-compatible POST /api/v1/chat/completions endpoint
so that any AI SDK client (Vercel AI SDK, LangChain, raw OpenAI SDK,
etc.) can drive the same provider chain — streaming or non-streaming —
without knowing which underlying plugin (OpenAI, Anthropic, Google,
Groq, Mistral, Ollama, OpenRouter, Vercel AI Gateway, …) is currently
active for the user. Conversation titles are auto-generated from the
first user message and, once the conversation reaches four messages,
are upgraded to an AI-summarised title in the background.
2. User Scenarios
2.1 Primary scenarios
- Given I am signed in, when I
POST /api/conversationswith{title?, providerId?}, then a new conversation row is created foruserId=auth.userIdand returned. - Given I have created a conversation, when I
POST /api/conversations/:id/messageswith the first user message, then the messages are appended in order and the conversation title is set to the first 60 chars of that user message (with the trailing...ellipsis if the original message exceeded 60 chars). - Given my conversation has at least four messages and no AI title
yet, when I append a new message, then a fire-and-forget
background task asks the AI facade to summarise the last four
user/assistant messages into a ≤50-char title and stores it with
metadata.aiTitle = true. - Given I
GET /api/conversations, when the request resolves, then I see only my own conversations sorted byupdatedAt DESC, paginated atlimit=50/offset=0by default, without the message bodies (the list endpoint selects onlyid, title, providerId, model, createdAt, updatedAt). - Given I
GET /api/conversations/:id, when the conversation belongs to me, then I receive the conversation with its messages ordered bycreatedAt ASC. - Given I send
POST /api/v1/chat/completionswithstream=false, when the request validates, then the OpenAI-compatible service forwards(messages, model, temperature, max_tokens, …)to the AI facade and returns an OpenAI-shaped{id, object:'chat.completion', created, model, choices, usage?}envelope. - Given I send
POST /api/v1/chat/completionswithstream=true, when the service receives the chunk stream from the facade, then the controller writes SSE framesdata: {…ChatCompletionChunkResponse}\n\nfollowed bydata: [DONE]\n\nand emits the headersContent-Type: text/event-stream,Cache-Control: no-cache,Connection: keep-alive,X-Accel-Buffering: no. - Given I send
model: "auto"in the request, when the service maps it to internal options, thenmodelis passed asundefinedso the AI facade resolves the real model from plugin settings. - Given I send tools in the request, when the service maps
them, then every tool entry is passed through with
type: 'function'and the originalfunction.name,description,parametersshape so the AI facade can route through the active tool loop.
2.2 Edge cases & failures
- Given my user has multiple works but I do NOT pass
x-work-idonchat/completions, when the service resolves work context, then it picksworkRepository.findByUser(userId)[0].id— arbitrary but deterministic for that user. - Given I
GET /api/conversations/:idfor a conversation owned by another user, when the repository'sfindById(id, userId)filter returnsnull, then the controller throwsNotFoundException(no leakage of existence). - Given I
DELETE /api/conversations/:idwith a non-existent or cross-user id, when the repository'sdeletereturnsaffected === 0, then the controller throwsNotFoundException. - Given I
DELETE /api/conversations(the bulk endpoint), when the request resolves, then the response is{deleted: <count>}(not 204) and only my conversations are deleted. - Given the AI provider plugin throws during streaming
before any chunk has been written (i.e.
res.headersSentis stillfalse), when the error reaches the catch block, then the service flips status to502, setsContent-Type: application/json, and writes{error: {message, type:'provider_error', code:'ai_provider_error'}}. - Given the AI provider throws after at least one chunk has
flushed (so
res.headersSent === true), when the catch block runs, then it cannot rewrite the status — it instead destroys the response with the originalError(or a wrapped one when the thrown value isn't anError) so downstream proxies see a broken stream rather than a malformed JSON tail. - Given an error message contains an API key, when
sanitizeErrorMessageruns, then any token matching\b(sk-|key-|token-|Bearer\s+)[A-Za-z0-9_-]{10,}\bis replaced with[redacted]and the message is truncated to 300 chars with.... - Given the thrown value is not an
Errorinstance, whensanitizeErrorMessageruns, then the response carries the generic message"Something went wrong. Please try again.". - Given the upstream chunk has
delta.role, when the service maps it to OpenAI shape, then the role is normalised to'assistant'regardless of what the provider sent. - Given the upstream chunk has tool-call deltas, when the
service maps them, then
id,type, andfunction.nameare written only on the first chunk of each tool call (whenchunk.idis present). Continuation chunks carry onlyindexandfunction.arguments. (This shape is required by@ai-sdk/openai-compatible's parser, which usesid == nullto detect continuation.) - Given the upstream
delta.contentisundefined, when the service maps it, then the field is omitted from the SSE payload entirely (vs being written asnull). - Given the upstream
message.contentis not a string (e.g. multimodal parts array), when the response mapper runs, then the OpenAI-shapecontentis set tonull(since OpenAI non-stream responses only carry text content). - Given I append messages to a conversation that already has a title, when the controller's title-derivation block runs, then it short-circuits — the existing title is preserved.
- Given the auto-derived title contains repeated whitespace or
newlines, when the controller normalises it, then internal
whitespace runs collapse to a single space (
/\s+/g, ' ') and leading/trailing space is trimmed before the 60-char cap. - Given the AI title-generation request fails (provider error,
empty response, network drop), when the title service catches
it, then the failure is logged at
debuglevel and the conversation keeps the existing first-message title. - Given the AI title response is empty, whitespace-only, or not a string, when the service evaluates it, then no title update is written.
- Given the AI title response is longer than 100 chars, when the service writes it, then the title is truncated at 100 chars before persistence (the prompt asks for ≤50 chars, but this is a defensive cap).
- Given my conversation message has empty
contentbut non-emptyparts, when the title service summarises, thenextractMessageTextfalls back to the joinedparts[].textof the text-typed parts, so multimodal-only messages still contribute to the summary. - Given the title service can't resolve a work for the user
(
workRepository.findByUserthrows), when it builds the facade context, then it gracefully falls back to{userId}(noworkId) so the title still generates against the user's default provider. - Given I append two or more messages in a single
POST /api/conversations/:id/messagescall, when the repository saves them, then each row gets acreatedAtofnew Date(baseTime + i)so a subsequentfindByIdreturning rows ordered bycreatedAt ASCis guaranteed deterministic — even when the underlying database would otherwise stamp identical timestamps.
3. Functional Requirements
- FR-1 Every conversation MUST belong to exactly one user
(
userId, FK withON DELETE CASCADE); no cross-user reads or writes are possible at the repository layer. - FR-2
POST /api/conversationsMUST accept{title?: string, providerId?: string}and MUST associate the new row withauth.userIdfrom the session guard. - FR-3
GET /api/conversationsMUST list onlyauth.userId's conversations, sort byupdatedAt DESC, default tolimit=50/offset=0, accept querylimitandoffsetparsed viaparseInt(_, 10)(each independentlyundefined-able), and MUST return{conversations, total}projecting onlyid, title, providerId, model, createdAt, updatedAt. - FR-4
GET /api/conversations/:idMUST throwNotFoundExceptionwhen the row is missing OR owned by a different user, and MUST include themessagesrelation ordered bycreatedAt ASCon success. - FR-5
PATCH /api/conversations/:idMUST update only the title forauth.userId's row, MUST return204, and MUST throwNotFoundExceptionfor missing or cross-user ids. - FR-6
POST /api/conversations/:id/messagesMUST require the conversation to belong toauth.userId(elseNotFoundException) before appending; MUST persistrole,content, optionalparts,model, andusagefor every supplied message; MUST set the conversation title from the first user message — collapsing whitespace runs to a single space, trimming, and capping at 60 chars (with...suffix when truncated) — only when the conversation has no title yet; and MUST kick off a fire-and-forget AI title generation viatitleService.maybeGenerateTitle(...).catch(() => {}). - FR-7
DELETE /api/conversations/:idMUST return204on success andNotFoundExceptionwhen nothing was deleted (so a cross-user delete is indistinguishable from a missing id). - FR-8
DELETE /api/conversationsMUST return200with{deleted: <count>}and MUST delete onlyauth.userId's rows. - FR-9 Appended messages MUST be persisted sequentially with
createdAt = new Date(baseTime + i)so that ordering survives even on databases that stamp identical bulk-save timestamps; the conversation'supdatedAtMUST be touched after the batch. - FR-10 AI title generation MUST run only when the conversation
has at least 4 messages AND
metadata.aiTitle !== true; the prompt MUST be built from the last 4 user/assistant messages, each truncated to 200 chars; the AI request MUST usetemperature: 0.3,maxTokens: 30, and the system prompt "Generate a short title (max 50 chars) for this conversation. Return ONLY the title, no quotes, no explanation." - FR-11 When the AI title returns a non-empty trimmed string, the
service MUST persist it via
updateTitletruncated to 100 chars withmetadata: {aiTitle: true}. When the AI request fails or returns nothing usable, the service MUST log atdebugand leave the title untouched. - FR-12
POST /api/v1/chat/completionsMUST accept the OpenAIchat/completionsrequest body, run it through aValidationPipe({whitelist: true, transform: true})(NOforbidNonWhitelisted— many AI SDK clients append fields likestream_options,logprobs,parallel_tool_calls, etc. and we MUST NOT reject them), and MUST forward the optional headersx-provider-overrideandx-work-idto the AI facade options. - FR-13 When
stream === true, the controller MUST emit headersContent-Type: text/event-stream,Cache-Control: no-cache,Connection: keep-alive,X-Accel-Buffering: noBEFORE the first chunk and MUST end the stream withdata: [DONE]\n\n. - FR-14 When
stream === false, the controller MUST setContent-Type: application/jsonand MUST return the full OpenAI-shape response. - FR-15 The internal-options mapper MUST translate
model === 'auto'tomodel: undefinedso the facade resolves from plugin settings; all other fields MUST be passed through unchanged (incl.temperature,max_tokens → maxTokens,top_p → topP,frequency_penalty → frequencyPenalty,presence_penalty → presencePenalty,stop,stream,tools,tool_choice → toolChoice,response_format → responseFormat,user). - FR-16 The message mapper MUST handle three shapes:
(a)
assistantwith non-emptytool_callsproduces a{role:'assistant', content, toolCalls:[...]}ChatMessage; (b)toolproduces{role:'tool', content, toolCallId}; (c) any other role produces{role, content, name?}with the optionalnameonly when truthy.content === nullMUST be coerced to''. - FR-17 The non-stream response mapper MUST produce
created: Math.floor(response.created / 1000)(OpenAI uses seconds, internal uses millis), MUST emitobject: 'chat.completion', MUST includeusageonly when the internal response carries it, and MUST setcontenttonullwhenever the upstreammessage.contentis not a string. - FR-18 The stream chunk mapper MUST set
role: 'assistant'whenever the upstreamdelta.roleis present (regardless of value), MUST omitcontententirely when it isundefined, and MUST emit tool-callid/type/function.nameONLY for the first chunk of each tool call (whenchunk.idis set) — continuation chunks carry onlyindexandfunction.arguments. - FR-19 Streaming errors that happen BEFORE
res.headersSentMUST be converted to502JSON with the body{error: {message, type:'provider_error', code:'ai_provider_error'}}. Streaming errors that happen AFTER headers have been sent MUST destroy the response with the originalError(or a freshErrorbuilt from the sanitised message when the thrown value is not anError). - FR-20
sanitizeErrorMessageMUST replace any\b(sk-|key-|token-|Bearer\s+)[A-Za-z0-9_-]{10,}\bmatch with[redacted], MUST truncate the result to 300 chars with..., and MUST return"Something went wrong. Please try again."when the thrown value is not anError. - FR-21 When
facadeOptions.workIdis omitted onchat/completions, the service MUST attemptworkRepository.findByUser(userId)and use the first work's id; when the user has zero works, the service MUST proceed with noworkId(the AI facade decides whether to error). - FR-22 All conversation endpoints MUST be guarded by the global
AuthSessionGuard(none are@Public()); unauthenticated requests MUST receive401. - FR-23 All endpoints MUST extract identity from
@CurrentUser() auth: AuthenticatedUserand MUST NOT trust anyuserIdfield in request bodies or query params.
4. Non-Functional Requirements
- Performance: AI-title generation MUST be fire-and-forget (the message-append response MUST NOT wait for it). Streaming chat completions MUST flush each chunk as soon as the upstream provider emits it (no buffering past the SSE encoder).
- Reliability: A failure in AI-title generation MUST NOT cause a
message-append failure. A streaming-provider failure MUST result in
a clean response — either
502 + JSON envelope(pre-headers) orsocket.destroy()(post-headers) — and MUST NOT leave a half-open connection. - Security & privacy: API keys, OAuth bearer tokens, and any
alphanumeric secret-shaped substring in error messages MUST be
redacted via
sanitizeErrorMessage. Cross-user reads MUST surface asNotFoundException(no existence leakage). The OpenAI-compat endpoint MUST NOT echo anyAuthorizationheader back to the client. - Observability:
OpenAiCompatService.handleStreamingCompletionMUST log streaming errors vialogger.error('Streaming completion error', error).ConversationTitleServiceMUST log AI-title failures vialogger.debug('AI title generation failed', err). No PII is logged. - Compatibility: The
chat/completionsenvelope MUST match the OpenAI 2024-08-06 chat-completions schema sufficiently for the Vercel AI SDK, the OpenAI Node SDK, and@ai-sdk/openai-compatibleto consume it without custom decoders.
5. Key Entities & Domain Concepts
| Entity / concept | Description |
|---|---|
Conversation | A per-user chat thread (id, userId, optional title, providerId, model, metadata, createdAt, updatedAt). |
ConversationMessage | A single turn in a conversation (role, content, optional parts, model, usage). role is one of user / assistant / system / tool. |
ConversationRepository | The data-access boundary: create, findById, findByUser, appendMessage, appendMessages, updateTitle, delete, deleteAllByUser. All read paths apply the (id, userId) filter. |
OpenAiCompatService | Translates between OpenAI's wire format and the platform's internal ChatMessage / ChatCompletionOptions / ChatCompletionResponse types and drives AiFacadeService. |
OpenAiCompatController | The POST /api/v1/chat/completions HTTP surface — chooses streaming vs JSON based on body.stream and emits the right headers in either case. |
ConversationController | The /api/conversations REST surface — list / create / get / update-title / append-messages / delete-one / delete-all. |
ConversationTitleService | The fire-and-forget AI title summariser; gated on messageCount >= 4 && !metadata.aiTitle. |
FacadeOptions | The {userId, workId?, providerOverride?} shape forwarded to AiFacadeService so that plugin settings, AI provider routing, and per-work overrides resolve consistently. |
6. Out of Scope
- A WebSocket transport for chat (current implementation is HTTP + SSE only).
- Multi-user conversations / sharing / collaboration on a single conversation thread.
- Server-side tool execution beyond what
AiFacadeServicealready routes through (function-calling tools are passed through verbatim; there is no platform-side function registry in this feature). - Multi-modal request bodies (image inputs, audio inputs) — the
message mapper coerces non-string
contentto''on the request side and tonullon the response side, but the spec does not guarantee multimodal round-trip. - Per-conversation memory / RAG / retrieval — conversation history is
stored, but the AI provider sees only what the client sends in the
request
messagesarray. (The web app's chat page replays the whole history; that's a UI concern.) - Rate limiting beyond what
@nestjs/throttleralready does globally. - A push/notification firing when an AI title finishes — the UI is expected to refetch on demand.
- Persisting messages on the OpenAI-compat endpoint. Message
persistence happens via the conversation endpoints; the
chat/completionssurface is stateless from the platform's point of view.
7. Acceptance Criteria
-
POST /api/conversationscreates a row with the authenticated user's id and returns it. -
GET /api/conversationsreturns only the caller's rows, sorted newest-first, paginated, without message bodies. -
GET /api/conversations/:idreturns 404 for cross-user reads. -
PATCH /api/conversations/:idupdates the title and returns 204; missing/cross-user → 404. -
POST /api/conversations/:id/messagesappends the supplied messages, sets the title from the first user message when no title is set, and fires a fire-and-forget AI-title task. -
DELETE /api/conversations/:idreturns 204 on success and 404 when nothing is deleted. -
DELETE /api/conversationsreturns{deleted: <count>}. -
POST /api/v1/chat/completions(non-streaming) returns an OpenAI-shape JSON response withcreatedin seconds,usagepresent only when the upstream returned it, andcontentset tonullwhen the upstream returned non-string content. -
POST /api/v1/chat/completions(streaming) flushes SSEdata: …\n\nframes followed bydata: [DONE]\n\nand emits the four streaming headers. - A pre-headers streaming error returns
502JSON; a post-headers streaming error destroys the socket. -
sanitizeErrorMessageredactssk-…/Bearer …tokens and truncates at 300 chars. -
model === 'auto'is forwarded to the facade asundefined. - AI-title generation runs only at
messageCount >= 4and only whenmetadata.aiTitle !== true; an empty/failed AI response is silently dropped. - Tool-call deltas emit
id/type/nameonly on the first chunk per tool call. - All existing functional requirements have a passing unit test
(currently 56 tests across
ConversationController(16),ConversationTitleService(15),OpenAiCompatController(4),OpenAiCompatService(21) — see PR #484).
8. Open Questions
[NEEDS CLARIFICATION: OQ-1]Should the OpenAI-compat endpoint optionally persist the user/assistant turn into a Conversation whenx-conversation-idis supplied as a header? The current code path has comments hinting at this ("Creates/reuses a conversation, streams SSE, then persists messages.") but the persistence step is not actually wired.[NEEDS CLARIFICATION: OQ-2]The first-message-title rule strips'\s+'but does NOT strip emoji / zero-width characters. Should the regex be tightened (/[\s-]+/g) to keep titles human-readable when users paste from formatted sources?[NEEDS CLARIFICATION: OQ-3]resolveWorkContextandresolveFacadeOptionsboth pickfindByUser(userId)[0].id— whichever work TypeORM hands back first — when nox-work-id/workIdis supplied. Should this be deterministic (oldest work? most-recently-active work? user'sdefaultWorkIdif we add one)? Today the order is repository-stable but undocumented.[NEEDS CLARIFICATION: OQ-4]AI title generation runs against the user's first work too. If a user has multiple works under different AI providers, the conversation can therefore be titled by a provider unrelated to where the conversation was originally scoped. Should we either (a) resolve provider fromconversation.providerIdif set, or (b) drop the work scope entirely for title generation?[NEEDS CLARIFICATION: OQ-5]appendMessagessaves rows sequentially to guarantee ordering. On hot conversations (long histories), this is N round-trips per request. Should we switch to a single batch save with explicitcreatedAtoverrides once the database wrapper supports it cleanly across all three drivers?[NEEDS CLARIFICATION: OQ-6]OpenAiCompatServicedoes not check whetheruserIdexists in the database before forwarding to the AI facade — the guard establishes authentication, but thefindByUserquery is the only side effect that would fail on a hard-deleted user. Should we guard explicitly?
9. Constitution Gates
- Plugin-first: not introducing a new external integration —
reuses
AiFacadeServiceandWorkRepository. (Principle I) - Capability-driven resolution: the OpenAI-compat path defers
provider/model resolution to
AiFacadeServicevia themodel === 'auto'→undefinedtranslation. (Principle II) - Source-of-truth repos preserved: messages live in the platform DB, not in any work's repo. No content escapes the user boundary. (Principle III)
- Long-running work via Trigger.dev: N/A — chat completions are request/response or SSE-streamed within the request lifetime. (Principle IV)
- Schema changes ship as forward-only migrations: the
conversationsandconversation_messagestables shipped via forward-only migrations underapps/api/src/database/migrations/. (Principle V) - Tests accompany the change: 56 unit tests across the four classes (PR #484). (Principle VI)
- Secrets handled per
x-secretrules: API keys are sourced from plugin settings (already secret-tagged); the OpenAI-compat surface accepts no key from the client. (Principle VII) - Plugin counts touch the canonical doc only: N/A. (Principle VIII)
- Behaviour-first — no implementation in this spec. (Principle IX)
- Backwards-compatible API/SDK/schema changes: the OpenAI wire format is a stable public contract; all internal fields are optional or additive. (Principle X)
10. References
- Related features:
auth-jwt-oauth,plugin-system,subscriptions. - Source:
apps/api/src/ai-conversation/,packages/agent/src/database/repositories/conversation.repository.ts,packages/agent/src/entities/conversation.entity.ts,packages/agent/src/entities/conversation-message.entity.ts. - Tests:
apps/api/src/ai-conversation/conversation.controller.spec.ts,apps/api/src/ai-conversation/conversation-title.service.spec.ts,apps/api/src/ai-conversation/openai-compat.controller.spec.ts,apps/api/src/ai-conversation/openai-compat.service.spec.ts. - Test PR: #484.