Skip to content

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 started event and a completed event 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_input accepts 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 carries sessionId, 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 a completed stop), results (the stop's node-keyed results), pausedReason and, on a failed stop 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 a failed stop, 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()); pipelineId is the run that was cancelled, if any.
  • SessionResetEvent (flowdrop_session.reset): the session was reset to idle; cancelledCount rows still waiting were cancelled.
  • SessionTurnFailedEvent (flowdrop_session.turn.failed): a turn was lost outside its run. reason says 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, no turnId) or stuck (cron released a session stuck in running or awaiting input with no active run; previousStatus, no turnId).

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_input happens through the interrupt API; the continuation of the resumed pipeline is observable via pipeline and node events, not a second turn event.
  • Resuming after paused is 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.