Skip to content

Chat streaming and frontend migration

POST /chatbot/api/v2/chat_streaming accepts the same JSON ChatInput as POST /chatbot/api/v2/chat. Chatbot 2.34 introduces a breaking SSE event contract on this existing route. Coordinate the frontend rollout with the backend. The original SSE migration preserved the JSON business response. The generic interaction contract below replaces the learning-plan-specific bot output. The learning-card change below applies to both transports.

The backend consumes native LangGraph V3 events. The public SSE envelope has its own version, currently 1; it does not expose LangGraph protocol details.

curl --no-buffer 'http://localhost:8000/chatbot/api/v2/chat_streaming' \
  --header 'X-API-Key: <api-key>' \
  --header 'Content-Type: application/json' \
  --data '{
    "userApplicationId": "user-123",
    "userConversationId": "conversation-123",
    "messageApplicationId": "message-123",
    "sender": "user", "type": "text",
    "message": "What is the relocation policy?",
    "locale": "en", "source": "Web", "data": {}
  }'

Envelope and events

Every application frame has an SSE event, an id of <run_id>:<seq>, and JSON data containing version, run_id, seq and the event-specific data object. Sequences start at 1 and increase within one execution. Run and activity IDs are opaque. Heartbeat comments are transport keep-alives, not application events.

Event Event-specific data Frontend behavior
run.started conversation_id, request message_id, answer response_id, locale Associate the request and allocate a provisional answer.
activity.started activity_id, parent_activity_id, kind, name, label Create a visible activity. kind is step, agent or tool.
activity.progress Same identity plus step and its localized label Update the activity's progress label.
activity.message Activity identity, content, locale, temporary: true Display a temporary public research comment separately from the answer.
activity.completed Activity identity and elapsed_ms Mark that invocation complete.
activity.failed Activity identity, elapsed_ms, safe code Mark that invocation failed; the run can still recover.
text.delta activity_id, message_id, content Optionally append genuine public text to a provisional preview.
run.interrupted response with type interaction Stop loading, clear temporary activities and ask for user input; preserve interactionId.
run.completed response: complete ChatOutput Render the authoritative response and stop the busy indicator.
run.failed error: safe code and message; response: complete ChatOutput Show the error response and stop the busy indicator.

parent_activity_id is null for top-level activities. Repeated invocations of the same agent/tool have different IDs; do not group them by name. Progress keys and names are stable identifiers; display the localized label rather than raw technical names. Labels describe current work, never hidden reasoning.

run.started.message_id identifies the incoming user message. Its response_id identifies the assistant answer: every text.delta.message_id and the terminal response.messageApplicationId use this same ID, including early and error replies.

Eligible specialist text responses use genuine provider streaming on OpenAI (Chat Completions or Responses), Azure OpenAI and Anthropic. OVH retains complete response delivery. Do not require deltas: tool results, cards, safety responses, early replies and source-validated Knowledge Base answers remain complete results. Private research, reasoning and tool arguments are never forwarded as text deltas.

When tools are available, the specialist first selects its action privately. If no tool is needed, a separate, tool-free call generates the public answer progressively, using the same configured model and reasoning profile. This adds one model call for that final response; JSON/MCP invocation retains its existing path. If no tools are available, the public phase starts directly. No extra generation call is added for direct tool or Knowledge Base delivery.

Existing postprocessing still selects the business response type, attaches its metadata and clears temporary state. It does not rewrite free text. The frontend can display arriving fragments while waiting for this final processing, then use the complete terminal payload as the authoritative result.

Required frontend changes

  1. Replace the old step, tool, token, done listeners with the event names above. There is one active contract, with no legacy-stream option.
  2. Parse JSON.parse(frame.data) and read the payload from envelope.data. Read the terminal business response from envelope.data.response.
  3. Keep activities by activity_id and use parent_activity_id for nesting. Knowledge Base is nested under Career Chat; its source search is a tool child. Parallel work may complete in a different order from its start order.
  4. Treat run.completed, run.interrupted and run.failed as terminal. Clear remaining spinners and all temporary comments in either case, and replace any provisional text with the terminal response. Append text.delta.content to the provisional answer identified by message_id; never append activity.message.content to it. Preserve existing card rendering using response.type and response.data.
  5. Ignore unknown future event names/progress keys and SSE heartbeat comments. Use run_id and seq to deduplicate frames within the same connection.
  6. Use streaming fetch (POST with authentication and JSON body), not browser EventSource (GET). Parse complete SSE frames across network chunk boundaries. Cancel the request with AbortController when the user leaves or cancels.

A dropped connection or EOF without a terminal event means interrupted, not success. Clear temporary comments and discard or mark the partial answer as interrupted; never save it as a completed response. There is no transport replay support and Last-Event-ID does not resume a run. Do not automatically reconnect or re-POST: that can repeat business actions. Request validation/authentication and language resolution happen before SSE opens and may return a normal HTTP error. Once started, handled failures use run.failed instead of changing HTTP status. Client disconnection cancels and awaits graph work; a terminal frame cannot be guaranteed on a disconnected transport.

Learning explanations on demand

Learning cards no longer contain data.trainingSuggestionList[].rationale, including learning-plan and simulation suggestion items using that schema. Remove rendering of that property. The collection title, subtitle, phase descriptions and rationaleContext remain available; the SSE event envelope is unchanged.

When the user requests an explanation for a catalog learning, call authenticated GET /api/v1/get_training_matching_rationale with user_id, the item's id as training_id, and locale. Optionally send search_query (up to 4,000 characters): the original request or rationaleContext.resolvedQuery can supply the search intent, especially after a contextual follow-up. URL-encode query parameters and render the returned JSON string as text. A nonblank query requests a search-based explanation in 1–2 sentences; omission or blank text keeps the profile-based 80–120-character prompt. Synthetic simulation suggestions are not catalog trainings and must not be sent to this endpoint.

See Training Matching for examples and not-found behavior. Request explanations as needed, rather than fetching one for every suggestion automatically.

Knowledge Base progress

Progress reflects work actually performed, without exposing source content:

Key English label French label
knowledge.documents Searching your knowledge documents… Recherche dans les documents de connaissances…
knowledge.web Consulting the configured public pages… Consultation des pages publiques configurées…
knowledge.comparison Comparing information across sources… Rapprochement des informations entre les sources…
knowledge.page Consulting a relevant page… Consultation d’une page pertinente…
knowledge.section Reading the selected section… Lecture de la section sélectionnée…
knowledge.continuation Continuing the reading… Poursuite de la lecture…
knowledge.refinement Refining the document search… Affinement de la recherche dans les documents…
knowledge.answer Reviewing the retrieved information… Examen des informations recueillies…

Document and web steps can overlap. Comparison appears when both sources return evidence. The stable knowledge.answer key now describes examination of retrieved information before a model call; that call may choose further research rather than produce the final answer. Empty or disabled sources are not announced. knowledge.refinement appears only for the second actual document search, not when reusing cached web discovery or when the search budget rejects another call. An explicit page read emits knowledge.page, knowledge.section, or knowledge.continuation according to the requested action (continuation takes precedence over section selection). These labels describe an attempt, not a successful retrieval. Reads before discovery do not announce page consultation. An enabled public-page source can also use its ordinary shared page cache. If no source is available, Career Chat handles the request without a Knowledge Base activity.

All activity labels and fixed chat status/error messages cover the application's 12 locales: en, en-GB, fr, fr-CA, es, es-419, pt-BR, de, ja, ko, zh-CN, and zh-TW. The resolved locale also instructs final generation and public commentary. English is used for unsupported locales. These are eight language families, with regional variants preserved.

Knowledge Base can emit one short public action comment when starting a search (at most two searches per research run) or an explicit page read. It is deliberately written for the user, not the model's hidden reasoning or raw scratch messages. It describes the search underway without quotations, personal data, internal IDs, filenames or invented findings. Comments are bounded to 240 characters, excluded from conversation history and emitted without an extra model call. They are generated text, so language and content quality are checked by semantic evaluations, not guaranteed by a static translation catalogue. Render them as text, never trusted HTML.

For example, a French event's data can be:

{"activity_id":"knowledge-1","parent_activity_id":"career-1","kind":"agent","name":"knowledge_base","label":"Recherche dans les sources de connaissances…","content":"Je consulte les sources disponibles.","locale":"fr","temporary":true}

Keep these comments in a separate activity panel and remove them on either terminal event, cancellation or disconnection. Do not wait for activity.completed to decide whether the whole conversation run succeeded.

Tool progress

All progress uses the existing activity.progress event and envelope version 1. No frontend schema migration is needed: display the supplied localized label and associate it using activity_id. Applications maintaining their own name or step allowlists should add the entries below. Labels are static translations; the additional events introduce no model calls and do not change final responses.

Opportunity search, talent search, and person identification now have visible specialist activities (opportunities_suggestion_agent, talent_finder_agent, person_disambiguation_agent). Their tools are visible too, together with get_objective_tool, suggest_opportunities_based_on_objective_tool, and read_knowledge_page. Each invocation has its own activity ID and completion or failure event; empty results alone do not mean technical failure.

Workflow Progress keys When emitted
Opportunity search opportunity.request/context/matches/comparison/summary Existing search stages, now forwarded to the visible search_opportunities_tool activity.
Talent search talent.request/criteria/matches/summary Existing search stages, now forwarded to the visible talent_finder_tool activity.
Objective-based opportunities opportunity.objective, opportunity.matches, opportunity.summary Objective inspection; search only with usable objective skills; presentation only when suggestions exist.
Simulation simulation.contact, simulation.scenario, simulation.skills Contact lookup only when requested and supported; scenario generation; skill mapping only when generated skills exist.
Training search learning.criteria, learning.refinement Resolve the search criteria; widen the language scope only when the initial language-filtered search is empty. Neither is emitted in IDs-only mode.
Task assistance task.answer Draft advice during the existing single generation call. No drafting event for the no-configuration response.

Slash-separated suffixes denote individual keys with the same prefix. Existing learning.request/context/options/comparison/personalization/plan and profile.review/priorities/actions/summary also remain available. Profile-analysis operations may overlap; labels do not impose a fictional sequential workflow.

Example additional labels:

Key English label French label
opportunity.objective Identifying the skills linked to your objective… Identification des compétences liées à votre objectif…
simulation.contact Looking up the person concerned… Recherche de la personne concernée…
simulation.scenario Building the scenario and exercise objectives… Construction du scénario et des objectifs de l’exercice…
simulation.skills Linking practice skills to the scenario… Association des compétences travaillées au scénario…
learning.criteria Refining the search criteria… Précision des critères de recherche…
learning.refinement Searching for trainings in other languages… Recherche de formations dans d’autres langues…
task.answer Drafting guidance for your task… Rédaction des conseils pour réaliser votre tâche…

Example: nested research and direct delivery

This short sequence illustrates nested activities. IDs are readable synthetic values; production IDs are opaque UUIDs. Additional source-tool events can occur.

event: run.started
id: run-1:1
data: {"version":1,"run_id":"run-1","seq":1,"data":{"conversation_id":"conversation-123","message_id":"message-123","response_id":"response-123","locale":"en"}}

event: activity.started
id: run-1:2
data: {"version":1,"run_id":"run-1","seq":2,"data":{"activity_id":"career-1","parent_activity_id":null,"kind":"agent","name":"career_chat_agent","label":"Preparing career guidance…"}}

event: activity.started
id: run-1:3
data: {"version":1,"run_id":"run-1","seq":3,"data":{"activity_id":"knowledge-1","parent_activity_id":"career-1","kind":"agent","name":"knowledge_base","label":"Researching knowledge sources…"}}

event: activity.progress
id: run-1:4
data: {"version":1,"run_id":"run-1","seq":4,"data":{"activity_id":"knowledge-1","parent_activity_id":"career-1","kind":"agent","name":"knowledge_base","step":"knowledge.documents","label":"Searching your knowledge documents…"}}

event: activity.completed
id: run-1:5
data: {"version":1,"run_id":"run-1","seq":5,"data":{"activity_id":"knowledge-1","parent_activity_id":"career-1","kind":"agent","name":"knowledge_base","label":"Researching knowledge sources…","elapsed_ms":900}}

event: activity.completed
id: run-1:6
data: {"version":1,"run_id":"run-1","seq":6,"data":{"activity_id":"career-1","parent_activity_id":null,"kind":"agent","name":"career_chat_agent","label":"Preparing career guidance…","elapsed_ms":1200}}

event: run.completed
id: run-1:7
data: {"version":1,"run_id":"run-1","seq":7,"data":{"response":{"userApplicationId":"user-123","userConversationId":"conversation-123","messageApplicationId":"response-123","type":"text","locale":"en","message":"The relocation allowance is 500 EUR.","data":{},"sender":"bot-chat"}}}

Knowledge Base delivers its supported answer unchanged, without an extra Career Chat generation call. Direct-return card tools use the same terminal response contract. Early replies (for example history cleared) can contain only run.started and run.completed.

Failures and recovery

An optional Knowledge Base failure can emit the following activity outcome, followed by run.completed containing Career Chat's clarification:

event: activity.failed
id: run-2:5
data: {"version":1,"run_id":"run-2","seq":5,"data":{"activity_id":"knowledge-2","parent_activity_id":"career-2","kind":"agent","name":"knowledge_base","label":"Researching knowledge sources…","elapsed_ms":45000,"code":"activity_failed"}}

A required failure ends the execution:

event: run.failed
id: run-3:4
data: {"version":1,"run_id":"run-3","seq":4,"data":{"error":{"code":"execution_timeout","message":"[SYSTEM MESSAGE] Something goes wrong during the answer generation processing."},"response":{"userApplicationId":"user-123","userConversationId":"conversation-123","messageApplicationId":"response-123","type":"text","locale":"en","message":"[SYSTEM MESSAGE] Something goes wrong during the answer generation processing.","data":{},"sender":"bot-chat"}}}

Safe terminal codes are execution_timeout, execution_failed, safety_unavailable, and request_rejected. Raw exception details are not public. A normal safety refusal is a successful run.completed response; a failed safety check uses run.failed and performs no unchecked model-driven action. Failure to persist chat history is logged independently and does not invalidate a generated response. The response language is resolved before SSE opens and remains fixed for the execution; see language detection.

Generic human-in-the-loop interactions

JSON and SSE share the same questionnaire contract for learning-plan and simulation scoping, person/objective/action-plan selection, ambiguous topics or sources, empty-search refinement and essential work-task details. No business subtype is required. The routes remain POST /chatbot/api/v2/chat and POST /chatbot/api/v2/chat_streaming.

Frontend changes are required: render the ordered data.questions array using each question's inputType, options and allowFreeText. Send decisions in data.answers, with an empty top-level message. The bot output remains type: "interaction"; routes, interactionId, the SSE envelope and run.interrupted are unchanged. There is no top-level skip submission.

Receive all questions at once

A request needing clarification returns HTTP 200 with this shape:

{
  "userApplicationId": "user-123",
  "userConversationId": "conversation-123",
  "messageApplicationId": "response-123",
  "sender": "bot-chat",
  "type": "interaction",
  "locale": "en",
  "message": "1. What situation would you like to practise?\n2. Who will you speak with?\n3. What outcome are you aiming for?",
  "data": {
    "interactionId": "interaction-123",
    "allowedActions": ["answer", "skip", "cancel"],
    "questions": [
      {"id": "q1", "message": "What situation would you like to practise?", "inputType": "text", "options": [], "allowFreeText": false},
      {"id": "q2", "message": "Who will you speak with?", "inputType": "text", "options": [], "allowFreeText": false},
      {"id": "q3", "message": "What outcome are you aiming for?", "inputType": "text", "options": [], "allowFreeText": false}
    ]
  }
}

For SSE, this object is the terminal run.interrupted event's data.response. The envelope version remains 1. Stop the spinner, close transient activities (including a paused tool with no completion event) and show the questionnaire. Do not retry the original request automatically: the graph is intentionally paused.

message is a readable transcript for history; the stepper renders each data.questions[index].message. Calculate the counter as ${index + 1}/${data.questions.length}. The server supplies 1–10 questions; prompts recommend at most three unless more are indispensable. Three is a UX recommendation, not a frontend limit. Do not split question text or parse choice lists from it. The examples describe payload shapes, not a mandatory questionnaire: only essential missing information should produce a question.

Render text, single and multiple selections

inputType Widget Answer payload
text Text input Nonblank message, no selected options
single_select Radio buttons Exactly one ID in selectedOptionIds
multi_select Checkboxes One or more unique IDs in selectedOptionIds

Each option is { "id": "o1", "label": "SQL queries" }. IDs are opaque and scoped to their question and interaction; use them verbatim. They are not person, objective or document identifiers. Duplicate display labels can exist for real candidates, so never use the label as an identity. There can be up to 100 options per question; agents should propose a small, readable set.

For example, a question about compatible learning interests can be:

{
  "id": "q1",
  "message": "Which areas would you like to include?",
  "inputType": "multi_select",
  "options": [
    {"id": "o1", "label": "Market risk analysis"},
    {"id": "o2", "label": "Financial modelling"},
    {"id": "o3", "label": "Financial regulation"},
    {"id": "o4", "label": "Artificial intelligence"}
  ],
  "allowFreeText": true
}

The agent chooses the format in its existing model call. One priority or mutually exclusive source uses single_select; compatible interests can use multi_select; an open professional situation uses text. Options must be grounded in the conversation or retrieved context. Person and objective lookup tools supply closed single_select questions from their actual candidates.

When allowFreeText is true, provide an alternative input such as “Another topic”. Switching to this input clears selected options, and selecting an option clears the alternative text. Send either text or option IDs, never both. The text replaces the proposed selection; it is not an extra comment on checked options. When false, a selection question only accepts option IDs. For text, the flag is false because free text is already inherent to that input type.

A structured answer to the example is:

{"questionId": "q1", "action": "answer", "selectedOptionIds": ["o1", "o4"]}

An allowed alternative is:

{"questionId": "q1", "action": "answer", "message": "Cloud architecture"}

Omitted message defaults to ""; omitted selectedOptionIds defaults to []. Do not submit numbers or labels in message as a substitute for an option ID. The backend resolves selected labels from the checkpoint and uses them in the safety check, conversation transcript and resumed workflow. Option order in the request does not express priority: canonical transcripts use displayed order.

Stepper behavior

All questions arrive in one backend interruption. Keep decisions locally until the final step; no API request or LLM call occurs between screens.

User action Local behavior Backend request
Answer Validate the current input format, record it and display the next question Only after the last question
Skip this question Record action: "skip", empty text and no option IDs, then display the next question Only after the last question
Back (optional) Edit an earlier decision before submission None
Cancel request Discard local answers and close the entire request Immediately, from any step

A skip never resumes the graph early. On the last question, either Answer or Skip submits the complete decision list. Disable duplicate submissions while waiting. The server accepts any answer order and restores checkpoint order. Question IDs are scoped to the opaque interaction ID: keep them exactly as received and never reuse them with a newer interaction.

Submit answers and skipped questions

Send this request once after the last step, through JSON or SSE:

{
  "userApplicationId": "user-123",
  "userConversationId": "conversation-123",
  "messageApplicationId": "reply-123",
  "sender": "user",
  "type": "interaction",
  "message": "",
  "locale": "en",
  "source": "Web",
  "data": {
    "interactionId": "interaction-123",
    "action": "answer",
    "answers": [
      {"questionId": "q1", "action": "answer", "message": "Giving constructive feedback."},
      {"questionId": "q2", "action": "skip", "message": ""},
      {"questionId": "q3", "action": "answer", "message": "Agree on a practical next step."}
    ]
  }
}

Global action: "answer" means submit the questionnaire, even when every question was skipped. Include exactly one decision for every supplied question. Unknown, repeated or missing question IDs are rejected without consuming the pause. Each answered item requires either nonblank text or selected option IDs, according to its checkpoint-owned question. A skipped item requires empty message and selectedOptionIds. The top-level message must always be empty. The server combines canonical questions, selected labels and answers into a readable graph transcript.

Cancel from any question

Send the same envelope immediately with message: "" and:

{
  "interactionId": "interaction-123",
  "action": "cancel",
  "answers": []
}

This is the data object. Do not include previously collected answers: cancel ends the whole request without another search or generation. Send a normal text message afterwards to start a different request.

Continuation, errors and recovery

  • The backend continues with answered information and available context. It does not automatically ask skipped questions again. A learning plan or simulation can use a useful general version. Skipping a required person/objective/source selection ends that targeted operation without choosing an arbitrary object. Skipping empty-search refinement stops the search without broadening criteria. Skipping an uninterpretable course-search topic stops that lookup. Skipping a suggestion question that permits alternative text continues with known context and other answers; it never implicitly selects one of the proposed options.
  • Unknown or duplicate option IDs, multiple selections for single_select, text on a closed selection, and mixed text/IDs are rejected before resuming. Keep the current questionnaire and draft answers: the same interruption remains pending and no business action or safety-model call occurs for these invalid submissions. A genuinely insufficient allowed text answer can still cause a new clarification after resumption; replace the interaction only when the backend returns a new one.
  • All actions require a nonblank conversation ID, a new message ID and the current interaction ID. Extra fields and attachments are rejected. Omitted source retains its Web default. Document clarifications resume without reuploading.
  • JSON returns 422 for invalid requests, question coverage or option selections, 409 for stale, consumed or concurrent interactions (and normal text while paused), and 404 for another user's or tenant's conversation/checkpoint. After SSE opens, a rejection uses terminal run.failed with request_rejected. Rejected messages are not saved. Refresh local state rather than blindly retrying.
  • Every supplied answer is checked with the existing safety gate and canonical question context before graph resumption. All-skipped submissions and cancel contain no user text and need no safety-model call. A refusal or unavailable safety service keeps the pause pending; SSE reports safety_rejected or safety_unavailable. The user may answer differently or cancel.
  • Published questions and submitted replies are persisted in chat history. The stored user message contains a readable transcript with canonical questions and replies/skips (or cancellation), while data.answers preserves the structured decisions. This history representation differs from the incoming request, whose top-level message must be empty. Do not reuse a history object as a resume request without rebuilding that request. Local unfinished stepper drafts are not saved by the backend. After a reload, restore the latest interaction, including option IDs and input formats; persist draft progress locally only if the frontend needs that behavior. Older interaction cards may be stale. The checkpoint survives a backend restart.

The backend reserves resumes atomically. Execution is bounded to 240 seconds; the reservation expires after 600 seconds to allow recovery after a worker crash. Expired leases do not make consumed checkpoints valid. This is not an exactly-once guarantee for arbitrary external side effects. Completed pre-pause reads/searches and candidate identities are retained in the checkpoint. Existing access filters and show-more exclusions remain active.

Only HTTP chat enables this flow. Voice and identifier-only callers retain their noninteractive contracts. Ordinary coaching questions, role-play dialogue and closing invitations stay conversational. Optional preferences must not produce unnecessary questionnaires.

Frontend rollout checklist

  • Regenerate types: InteractionQuestion.inputType, options, allowFreeText, InteractionAnswer.selectedOptionIds, and existing data.allowedActions (answer, skip, cancel).
  • Add the stepper and derive 1/x from array position and length, without a fixed three-screen limit. Render a text input, radio buttons or checkboxes per question; add mutually exclusive alternative text only when allowFreeText is true.
  • Submit data.answers with text or option IDs; keep top-level message empty. Submit once at completion, or cancel immediately with an empty answers list.
  • Keep opaque-ID handling and run.interrupted handling; support validation, safety rejection, concurrent submission and a replacement questionnaire.
  • Coordinate frontend/backend deployment. Finish existing pauses on the previous backend first: checkpointed tool code and old payloads are not migrated. In particular, old numbered-text selections must be completed or cancelled before deploying structured choices. There is no legacy number/label parser.