Session Turns & Events¶
The chat turn is the first-class verb for "post a user message and run
the session's workflow on it", paired with a session-keyed event contract a
frontend can subscribe to. Together they replace the polling / reflection
patterns built around the deprecated sendMessage().
Stability
The turn endpoint, the event payloads, and the broadcast shapes below are stable in 1.x and evolve additive-only: fields are never removed or renamed; new fields may appear. Pin your consumers to the keys you use, not the full payload shape. See the BC Policy.
The turn model¶
A turn is the user-message-initiated unit of work:
- One turn = one user message = one
started+ onecompletedevent. - The turn id is the user message id — there is no separate turn entity.
- A turn ends at any pipeline stop; the status says why (see table
below). In particular, when the workflow asks the user something
(human-in-the-loop), that input request is the assistant's reply for
this turn — the turn ends with
awaiting_input. - Turns are serialized per session: a second turn while one is running is
refused with 409 Conflict. A session in
awaiting_inputaccepts the next turn.
| Turn status | Meaning |
|---|---|
queued |
Accepted; handed to the queue worker (async sessions). HTTP-only — never appears in events. |
running |
Accepted; executes after the HTTP response (deferred-sync sessions). HTTP-only — never appears in events. |
completed |
The workflow ran to the end; assistantMessageIds carries the write-backs. |
awaiting_input |
The workflow asked the user something. Answer via the interrupt API; the answer resumes the same pipeline. |
paused |
The pipeline stopped on a budget/signal pause; pausedReason says which. Resume via Pause-signal resolution — not chat input. |
failed |
Execution failed; the session stream carries the error bubble. |
Executing a turn¶
POST /api/flowdrop/session/{sessionId}/turn
Body:
{
"content": "What's the weather in Hamburg?",
"inputs": { "units": "metric" }
}
inputs is optional — extra named workflow inputs resolved through the
workflow's declared input ports, merged with the message.
Response — 202 Accepted:
{
"success": true,
"data": {
"sessionId": "12",
"userMessageId": "345",
"pipelineId": null,
"status": "running",
"assistantMessageIds": []
}
}
userMessageId doubles as the turn id in the events below.
pipelineId is null at this point — the pipeline entity is created
after the HTTP response; the turn_started event carries it.
Errors:
| Status | Meaning |
|---|---|
| 400 | Malformed JSON, missing content, or non-object inputs. |
| 404 | Session does not exist. |
| 403 | Missing execute session workflow permission or no view access to the session. |
| 409 | A turn is already running on this session. Wait for its turn_completed event, then post again. |
| 422 | The session has no associated workflow. |
Authentication, CSRF, and the response envelope follow the
REST API conventions. Execution is a distinct capability
(execute session workflow) on top of view access to the session.
Drupal events (PHP subscribers)¶
Server-side consumers subscribe to two events in
Drupal\flowdrop_session\Event:
SessionTurnStartedEvent¶
Name: flowdrop_session.turn.started. Fired once the turn's pipeline
entity exists.
| Property | Type | Meaning |
|---|---|---|
sessionId |
string | The session the turn runs on. |
turnId |
string | The user message id that initiated the turn. |
pipelineId |
string | The pipeline executing this turn (always set). |
workflowId |
string | The workflow being executed. |
SessionTurnCompletedEvent¶
Name: flowdrop_session.turn.completed. Fired at any pipeline stop.
| Property | Type | Meaning |
|---|---|---|
sessionId |
string | The session the turn ran on. |
turnId |
string | The user message id that initiated the turn. |
pipelineId |
string|null | Null only when execution failed before a pipeline entity was created. |
status |
string | completed | awaiting_input | paused | failed. |
assistantMessageIds |
string[] | Assistant write-backs, in conversation order. |
pausedReason |
string|null | Set for paused; null otherwise. |
Both events fire for every top-level message-driven execution regardless
of entry verb (the turn endpoint, executeTurn() in PHP, the deprecated
sendMessage(), the queue worker). Nested sub-workflow invocations on a
shared session are not turns and fire nothing.
RealTime broadcasts¶
A bridge forwards both events onto the single broadcast channel
(RealTimeBroadcastEvent, name flowdrop_runtime.real_time_event) so one
subscription point carries node status, pipeline status, and turn
lifecycle — all keyed:
type—'session'(node events use'node', pipeline/execution events'execution').executionId— the session id (each type is keyed by its primary id).event—'turn_started'or'turn_completed'.data— snake_case mirror of the event payload:turn_id,pipeline_id,workflow_id(started);turn_id,pipeline_id,status,assistant_message_ids,paused_reason(completed).
The kill-the-polling recipe: post a turn, remember userMessageId, and
react to the turn_completed broadcast whose executionId matches your
session and data.turn_id matches your turn.
Transport
These broadcasts are in-process Drupal events. A wire transport for browsers (SSE) is a separate, explicit decision point — it will ride this same contract when it lands. Until then, frontends poll the session/message endpoints and PHP/server-side consumers subscribe directly.
What a turn does not cover¶
- Resuming after
awaiting_inputhappens through the interrupt API; the continuation of the resumed pipeline is observable via pipeline and node events, not a second turn event. - Resuming after
pausedis a Pause-signal resolution (see the pipeline docs); same observation rule. - Plain posts (
SessionService::postMessage()) append to the conversation without executing anything and never fire turn events.