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 2.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
startedevent and acompletedevent at every stop of its run: the first stop, and each stop of the run after an interrupt or pause was resolved (same turn id, the new status). - 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 writes no row for it; with the Playground installed its "Execution failed" row is in the stream (#3592467). |
Executing a turn¶
POST /api/flowdrop/sessions/{sessionUuid}/turn
/api/flowdrop/session/{id}/turn, keyed by the entity id, is a deprecated
alias until 3.0.
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. content is what
the workflow interface's message turn port receives (and the history,
session_id and message_id ports are filled from the session); a turn may
send inputs alone. A workflow that declares turn ports but no message
port is form-only and refuses content. A workflow that declares no turn
port at all still gets the deprecated guess (the first node whose id
contains chat_input. or text_input.) until 3.0.
Response — 202 Accepted:
{
"success": true,
"data": {
"sessionId": "12",
"userMessageId": "345",
"pipelineId": null,
"status": "running",
"assistantMessageIds": [],
"finalAssistantMessageId": null,
"finalAssistantMessage": null,
"artifacts": [],
"outputs": {}
}
}
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, neither content nor inputs, or non-object inputs (INVALID_REQUEST_BODY); inputs the workflow does not accept (INVALID_INPUTS); content sent to a form-only workflow (MESSAGE_NOT_ACCEPTED, the message names the fix). |
| 404 | Session does not exist. |
| 403 | Missing execute session workflow permission or no write access to the session. A turn writes to the conversation, so it needs the session entity's update operation: owners always hold it on their own session; a non-owner needs edit any flowdrop_session. view any flowdrop_session alone is an observer permission and is refused. |
| 409 | A turn is already running on this session. Wait for its turn_completed event, then post again. |
| 409 | The session has no associated workflow — a state problem on the session; attach a workflow, then post again. |
Authentication, CSRF, and the response envelope follow the
REST API conventions. Execution is a distinct capability
(execute session workflow) on top of write access to the session — the
turn runs under the session OWNER's identity, including the owner's user
memory scope, so observing a session is never enough to drive it.
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). completed also fires at the stop of a
top-level run resumed after an interrupt was answered, under the turn id of
the run's first message. A sub-workflow running in its parent's session is
not a turn: it fires neither started nor completed (it does fire
SessionTurnResultsEvent, below).
Companion and state events (@internal)¶
These ride beside the two turn events above and are not part of the
broadcast. They are @internal until they have been used unchanged for a
minor; the Playground's observers are their first subscribers.
SessionTurnResultsEvent(flowdrop_session.turn.results): fired on every stop of a run on a session, wider than a turn: nested shared-session sub-workflow runs (nested: true) and the stop of a run resumed after an interrupt was answered (resumed: true) included, for success, failure, HITL pause and reasoned pause. It fires before the session is released. It carriessessionId,turnId(the person's message the run answers: the turn's own message, or for a nested run the parent turn's message; null when there is none),pipelineId,workflow(the workflow that ran, which on a shared session may not be the session's own),status(the four values above),outputs(the workflow interface's declared outputs, name → value, read only from acompletedstop),results(the stop's node-keyed results),pausedReasonand, on afailedstop of a turn's run that threw,errorType(the class of what was thrown; the message is not carried). Every failure of a turn's run is announced as afailedstop, also when a stop already announced fails in its bookkeeping afterwards (then with no results).SessionStoppedEvent(flowdrop_session.stopped): a person stopped the session's run (SessionRunControlInterface::stop());pipelineIdis the run that was cancelled, if any.SessionResetEvent(flowdrop_session.reset): the session was reset to idle;cancelledCountrows still waiting were cancelled.SessionTurnFailedEvent(flowdrop_session.turn.failed): a turn was lost outside its run.reasonsays how:timeout(cron gave up on a row stuck in processing after its retries;turnId,retryCount),resume_error(resuming the run after an answered interrupt threw;interruptId, noturnId) orstuck(cron released a session stuck in running or awaiting input with no active run;previousStatus, noturnId).
The session service writes no reporting rows of its own, and no failure
rows (#3592467). "started", the per-node job logs, the run outcome notice or
card, "no output", the stop, reset and timeout notices, every "Execution
failed" row and cron's recovery notice are posted by the Playground's
observers on these events (and on JobCompletedEvent /
PipelineCompletedEvent), with origin: playground. A site without the
Playground gets none of them: a room holds only what people and workflows
wrote, failures included, and a headless client reads a failure from
TurnResult (a waited turn throws), these events or the pipeline's status.
Two keys in flowdrop_playground.settings filter the reporting rows:
persist_job_logs (the job log rows) and notice_level (full,
outcome_only without "started", none). The error rows (the cron
timeout notice, the failure rows and the recovery notice) are never filtered. See flowdrop_playground.
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 (
RoomWriterInterface::post()) append to the conversation without executing anything and never fire turn events.