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 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 + one completed event.
  • 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 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').
  • executionIdthe 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 (SessionService::postMessage()) append to the conversation without executing anything and never fire turn events.