OpenWOP openwop.dev
Event API

AsyncAPI — streamed event surface

OpenWOP's event surface is documented as an AsyncAPI 3 document. Hosts stream run.* events over SSE and fan out to subscribers over signed webhooks. The wire shape is normative; the AsyncAPI source below is the same contract in machine-readable form.

Source: api/asyncapi.yaml in the repo. Prefer the prose specs (stream-modes.md, webhooks.md, observability.md) for normative claims; the AsyncAPI document is a structured restatement.

asyncapi: 3.1.0

info:
  title: Workflow Orchestration Protocol (openwop) SSE Event Stream
  version: "1.0"
  externalDocs:
    description: openwop spec v1 corpus
    url: https://openwop.dev/spec/v1/
  description: |
    Canonical AsyncAPI 3.0 specification for the openwop server's
    Server-Sent Events surface. Formalizes `stream-modes.md`
    and references the run-event JSON Schema via `$ref` so external SDK
    authors can codegen typed consumers without re-reading the prose.

    Four canonical stream modes are exposed via the `streamMode` query
    parameter on a single endpoint (`GET /v1/runs/{runId}/events`):

    - `updates` — minimal state-change deltas (default; lowest bandwidth)
    - `values`  — full `state.snapshot` after every transition
    - `messages` — LLM token chunks for chat-style UIs
    - `debug`   — full event firehose including internal events

    Each mode is modeled as a separate AsyncAPI channel because the
    payload union differs per mode. The underlying transport (HTTPS SSE)
    is shared; only the filter + synthesis layer differs.

    See `stream-modes.md` for the complete event-to-mode mapping table.
  contact:
    name: openwop spec working group
    url: https://openwop.dev/spec/v1/
  license:
    name: Apache-2.0

defaultContentType: text/event-stream

# ─────────────────────────────────────────────────────────────────────────────
# SERVERS
# ─────────────────────────────────────────────────────────────────────────────
servers:
  production:
    host: '{host}'
    pathname: /v1
    protocol: https
    description: openwop-compliant server
    variables:
      host:
        default: api.example.com
        description: Replace with your server's hostname.
    security:
      - $ref: '#/components/securitySchemes/ApiKeyAuth'

# ─────────────────────────────────────────────────────────────────────────────
# CHANNELS — one per streamMode (filter contract differs)
# ─────────────────────────────────────────────────────────────────────────────
channels:

  heartbeatEvents:
    address: /heartbeats/{heartbeatId}/events
    title: Heartbeat evaluation events (RFC 0060)
    summary: Per-tick heartbeat evaluation + state-change notifications.
    description: |
      RFC 0060 `host.heartbeat`. Heartbeat-scoped (NOT a run-event
      stream): a host advertising `capabilities.heartbeat.supported: true`
      emits `heartbeat.evaluated` every tick and `heartbeat.stateChanged`
      only on a predicate-state transition. Both are observability-only;
      consumers MAY ignore them.
    parameters:
      heartbeatId:
        description: Host-assigned heartbeat identifier.
    messages:
      heartbeatEvaluated:    { $ref: '#/components/messages/HeartbeatEvaluated' }
      heartbeatStateChanged: { $ref: '#/components/messages/HeartbeatStateChanged' }

  runEventsUpdates:
    address: /runs/{runId}/events
    title: SSE — updates mode (default)
    summary: Minimal state-change deltas for UI/CLI consumers.
    description: |
      Default consumption mode. Emits an SSE event for each terminal
      node transition, suspension transition, run transition, and
      artifact production. Payloads are deltas (the change since the
      last event), NOT full snapshots.

      Termination: server closes the connection on a terminal run
      event (`run.completed`, `run.failed`, `run.cancelled`).

      Selected via `?streamMode=updates` (or by omitting the query
      parameter — `updates` is the default per `stream-modes.md`).
    parameters:
      runId:
        $ref: '#/components/parameters/RunId'
    messages:
      runStarted:        { $ref: '#/components/messages/RunStarted' }
      runCompleted:      { $ref: '#/components/messages/RunCompleted' }
      runFailed:         { $ref: '#/components/messages/RunFailed' }
      runCancelled:      { $ref: '#/components/messages/RunCancelled' }
      runPaused:         { $ref: '#/components/messages/RunPaused' }
      runResumed:        { $ref: '#/components/messages/RunResumed' }
      runAnnotated:      { $ref: '#/components/messages/RunAnnotated' }
      workspaceUpdated:  { $ref: '#/components/messages/WorkspaceUpdated' }
      nodeCompleted:     { $ref: '#/components/messages/NodeCompleted' }
      nodeFailed:        { $ref: '#/components/messages/NodeFailed' }
      nodeSkipped:       { $ref: '#/components/messages/NodeSkipped' }
      nodeSuspended:     { $ref: '#/components/messages/NodeSuspended' }
      nodeDispatched:    { $ref: '#/components/messages/NodeDispatched' }
      approvalRequested: { $ref: '#/components/messages/ApprovalRequested' }
      approvalReceived:  { $ref: '#/components/messages/ApprovalReceived' }
      clarificationRequested: { $ref: '#/components/messages/ClarificationRequested' }
      clarificationResolved:  { $ref: '#/components/messages/ClarificationResolved' }
      interruptRequested: { $ref: '#/components/messages/InterruptRequested' }
      interruptResolved:  { $ref: '#/components/messages/InterruptResolved' }
      artifactCreated:   { $ref: '#/components/messages/ArtifactCreated' }
      evalStarted:       { $ref: '#/components/messages/EvalStarted' }
      evalScored:        { $ref: '#/components/messages/EvalScored' }
      evalCompleted:     { $ref: '#/components/messages/EvalCompleted' }
      deploymentPromoted:       { $ref: '#/components/messages/DeploymentPromoted' }
      deploymentRolledBack:     { $ref: '#/components/messages/DeploymentRolledBack' }
      deploymentCanaryAdjusted: { $ref: '#/components/messages/DeploymentCanaryAdjusted' }
      deploymentStateChanged:   { $ref: '#/components/messages/DeploymentStateChanged' }

  runEventsValues:
    address: /runs/{runId}/events
    title: SSE — values mode
    summary: Full state snapshots after every transition.
    description: |
      Higher-bandwidth mode for consumers that don't maintain their
      own state machine. Emits a synthesized `state.snapshot` event
      after each `updates`-tier transition. Payload is the complete
      `ProjectedRunState` (status, nodeStates, variables,
      currentNodeId, channels).

      On resumption (`Last-Event-ID` header), the server MUST emit a
      fresh `state.snapshot` first so the resuming client gets a
      baseline before continuing with subsequent snapshots.

      Selected via `?streamMode=values`.
    parameters:
      runId:
        $ref: '#/components/parameters/RunId'
    messages:
      stateSnapshot: { $ref: '#/components/messages/StateSnapshot' }

  runEventsMessages:
    address: /runs/{runId}/events
    title: SSE — messages mode
    summary: LLM token chunks for chat-style UIs.
    description: |
      Per-token chunks from any AI node currently streaming
      (`core.ai.callPrompt`, `core.ai.generateFromPrompt`, etc).
      Other event types are filtered out — consumers wanting state
      transitions should pair this with a separate `updates` stream.

      If no AI nodes execute during the run, the stream is empty
      until termination.

      Selected via `?streamMode=messages`.
    parameters:
      runId:
        $ref: '#/components/parameters/RunId'
    messages:
      aiMessageChunk: { $ref: '#/components/messages/AiMessageChunk' }

  runEventsDebug:
    address: /runs/{runId}/events
    title: SSE — debug mode
    summary: Full event firehose including internal events.
    description: |
      Every `RunEventDoc` from the durable event log, including
      events filtered out of `updates`: `log.appended`,
      `variable.changed`, `version.pinned`, `lease.*`,
      `node.retried`, internal projection writes, and any
      vendor-extension events.

      Highest bandwidth. Used by replay tools, debuggers, and
      conformance tests.

      Selected via `?streamMode=debug`.
    parameters:
      runId:
        $ref: '#/components/parameters/RunId'
    messages:
      anyRunEvent:  { $ref: '#/components/messages/AnyRunEvent' }
      runAnnotated: { $ref: '#/components/messages/RunAnnotated' }
      workspaceUpdated: { $ref: '#/components/messages/WorkspaceUpdated' }

# ─────────────────────────────────────────────────────────────────────────────
# OPERATIONS — consumer-side (receive)
# ─────────────────────────────────────────────────────────────────────────────
operations:

  subscribeUpdates:
    action: receive
    channel:
      $ref: '#/channels/runEventsUpdates'
    title: Subscribe to updates stream
    summary: Receive minimal state-change events for a run.
    description: |
      Long-lived SSE subscription. Connection auto-closes on
      terminal run event. Honor the `Last-Event-ID` request header
      for resumption — server begins streaming from the sequence
      AFTER the supplied ID and MUST NOT re-emit the resumption
      point itself.
    bindings:
      http:
        method: GET
        query:
          type: object
          properties:
            streamMode:
              type: string
              enum: [updates]
              default: updates

  subscribeValues:
    action: receive
    channel:
      $ref: '#/channels/runEventsValues'
    title: Subscribe to values stream
    summary: Receive full state snapshots after every transition.
    bindings:
      http:
        method: GET
        query:
          type: object
          required: [streamMode]
          properties:
            streamMode:
              type: string
              enum: [values]

  subscribeMessages:
    action: receive
    channel:
      $ref: '#/channels/runEventsMessages'
    title: Subscribe to messages stream
    summary: Receive per-token AI chunks.
    bindings:
      http:
        method: GET
        query:
          type: object
          required: [streamMode]
          properties:
            streamMode:
              type: string
              enum: [messages]

  subscribeDebug:
    action: receive
    channel:
      $ref: '#/channels/runEventsDebug'
    title: Subscribe to debug stream
    summary: Receive every engine event including internal/log/lease.
    bindings:
      http:
        method: GET
        query:
          type: object
          required: [streamMode]
          properties:
            streamMode:
              type: string
              enum: [debug]

# ─────────────────────────────────────────────────────────────────────────────
# COMPONENTS
# ─────────────────────────────────────────────────────────────────────────────
components:

  securitySchemes:
    ApiKeyAuth:
      type: httpApiKey
      in: header
      name: Authorization
      description: |
        Bearer-style API key. Format implementation-defined; reference
        impl uses `hk_`/`hk_test_` prefixes. Required scopes:
        `runs:read` to subscribe. See `auth.md`.

  parameters:
    RunId:
      description: The run to subscribe to. Format opaque; clients MUST treat as a string.

  # ── Messages ─────────────────────────────────────────────────────────────
  # All `updates`/`debug`-mode messages share the canonical RunEventDoc shape
  # (run-event.schema.json). Each named message below pins the `type` field
  # to a specific RunEventType discriminator so codegens can emit narrowed
  # consumer handlers.

  messages:

    # ── Agent evaluation (RFC 0081) — content-free recorded-fact events ───
    EvalStarted:
      name: eval.started
      title: Eval run started (RFC 0081)
      summary: An eval run began. Content-free — suite provenance + counts only. Gated on capabilities.agents.evalSuite.supported.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/EvalStartedPayload'
    EvalScored:
      name: eval.scored
      title: Eval task scored (RFC 0081)
      summary: One eval task was scored (emitted per task, after its terminal agent.decided). Content-free — taskId + score + scalars only, never task output (eval-summary-no-content-leak).
      contentType: application/json
      payload:
        $ref: '#/components/schemas/EvalScoredPayload'
    EvalCompleted:
      name: eval.completed
      title: Eval run completed (RFC 0081)
      summary: An eval run finished. Content-free aggregate scalars; the full scorecard is the run output, read via GET /v1/runs/{runId}/eval-summary.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/EvalCompletedPayload'

    # ── Agent deployment lifecycle (RFC 0082) — content-free audit events ─
    DeploymentPromoted:
      name: deployment.promoted
      title: Deployment promoted (RFC 0082)
      summary: A version was promoted into a new lifecycle state (gated by RFC 0049 deploy:* + RFC 0051 approvalGate + RFC 0081 requiredEval). Content-free (deployment-event-no-content-leak). Principal-stamped + audit-logged.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/DeploymentPromotedPayload'
    DeploymentRolledBack:
      name: deployment.rolled-back
      title: Deployment rolled back (RFC 0082)
      summary: An active version was rolled back and a prior version restored. Content-free; recorded-fact; audit-logged.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/DeploymentRolledBackPayload'
    DeploymentCanaryAdjusted:
      name: deployment.canary.adjusted
      title: Deployment canary adjusted (RFC 0082)
      summary: An active version's canary traffic share changed. Content-free; recorded-fact; audit-logged.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/DeploymentCanaryAdjustedPayload'
    DeploymentStateChanged:
      name: deployment.state.changed
      title: Deployment state changed (RFC 0082)
      summary: A non-promotion lifecycle transition (pause / resume / deprecate). Content-free; recorded-fact; audit-logged.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/DeploymentStateChangedPayload'

    # ── Run-lifecycle ────────────────────────────────────────────────────
    RunStarted:
      name: run.started
      title: Run started
      summary: A new run was registered and execution began.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    RunCompleted:
      name: run.completed
      title: Run completed (terminal)
      summary: Run reached terminal success state. SSE connection closes after this event.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    RunFailed:
      name: run.failed
      title: Run failed (terminal)
      summary: Run reached terminal failure state. SSE connection closes after this event.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    RunCancelled:
      name: run.cancelled
      title: Run cancelled (terminal)
      summary: Run was cancelled by user or admin. SSE connection closes after this event.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    RunPaused:
      name: run.paused
      title: Run paused
      summary: Run paused (e.g., capability limit reached, manual pause).
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    RunResumed:
      name: run.resumed
      title: Run resumed
      summary: Run resumed from pause/suspend.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    RunAnnotated:
      name: run.annotated
      title: Run annotated (RFC 0056)
      summary: A non-blocking quality annotation was recorded for the run. Live notification ONLY — NOT a replayable run-event-log entry; its payload is an Annotation (not a RunEventDoc), so it is excluded from fork/replay (RFC 0056 §B/§D).
      contentType: application/json
      payload:
        $ref: '#/components/schemas/Annotation'

    WorkspaceUpdated:
      name: workspace.updated
      title: Workspace file updated (RFC 0059)
      summary: A workspace file was created, replaced, or deleted via the host.workspace store. Content-free — carries the file path + resulting version only (the body is served by the read-side, SR-1-redacted). A replayable run-event-log entry (re-read from the log on replay, never regenerated); gated on capabilities.workspace.supported.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/WorkspaceUpdatedPayload'

    # RFC 0060. Heartbeat-scoped observability events — NOT RunEventDocs,
    # NOT replayable run-event-log entries. Emitted on the heartbeat channel.
    HeartbeatEvaluated:
      name: heartbeat.evaluated
      title: Heartbeat evaluated (RFC 0060)
      summary: A heartbeat predicate was evaluated this tick (status + changed flag). Heartbeat-scoped observability.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/HeartbeatEvaluated'

    HeartbeatStateChanged:
      name: heartbeat.stateChanged
      title: Heartbeat state changed (RFC 0060)
      summary: A heartbeat predicate's state transitioned; emitted ONLY on change (never on an unchanged tick). Heartbeat-scoped.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/HeartbeatStateChanged'

    # ── Node-lifecycle ───────────────────────────────────────────────────
    NodeCompleted:
      name: node.completed
      title: Node completed successfully
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    NodeFailed:
      name: node.failed
      title: Node failed
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    NodeSkipped:
      name: node.skipped
      title: Node skipped due to edge condition
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    NodeSuspended:
      name: node.suspended
      title: Node suspended (HITL or external-event wait)
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    NodeDispatched:
      name: node.dispatched
      title: core.dispatch spawned a child workflow (RFC 0007 §D + RFC 0022 §A)
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    # ── HITL ─────────────────────────────────────────────────────────────
    ApprovalRequested:
      name: approval.requested
      title: Approval requested
      summary: Engine emitted an approval interrupt awaiting user resolution.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    ApprovalReceived:
      name: approval.received
      title: Approval received
      summary: User resolved an approval interrupt (accept/reject/refine/edit).
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    ClarificationRequested:
      name: clarification.requested
      title: Clarification requested
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    ClarificationResolved:
      name: clarification.resolved
      title: Clarification resolved
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    InterruptRequested:
      name: interrupt.requested
      title: Interrupt requested (canonical HITL primitive)
      summary: |
        The discriminated-union form of approval/clarification/external-event/custom.
        Servers emitting `interrupt.requested` SHOULD also emit the legacy
        kind-specific event (`approval.requested` etc) for backward compat
        until consumers migrate.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    InterruptResolved:
      name: interrupt.resolved
      title: Interrupt resolved
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    # ── Artifacts ────────────────────────────────────────────────────────
    ArtifactCreated:
      name: artifact.created
      title: Artifact produced by a node
      summary: A typed artifact (PRD, plan, theme, etc) was created and registered.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

    # ── Synthesized for `values` mode ────────────────────────────────────
    StateSnapshot:
      name: state.snapshot
      title: Full projected run state
      summary: |
        Synthesized event emitted by the server in `values` mode after
        each `updates`-tier transition. NOT a member of the canonical
        `RunEventType` enum — this is a per-mode synthetic.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/StateSnapshotPayload'

    # ── Synthesized for `messages` mode ──────────────────────────────────
    AiMessageChunk:
      name: ai.message.chunk
      title: AI token chunk
      summary: Per-token streaming chunk from a `core.ai.*` node.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/AiMessageChunkPayload'

    # ── Catch-all for `debug` mode ───────────────────────────────────────
    AnyRunEvent:
      name: any
      title: Any RunEventDoc
      summary: |
        Type-erased handler for `debug` mode — discriminate on the
        `type` field per the `RunEventType` enum in the run-event
        JSON Schema (the authoritative, exhaustive event list; the
        named messages above are a curated `updates`-tier subset).
        Includes events filtered out of `updates`: `log.appended`,
        `variable.changed`, `version.pinned`, `lease.*`, `node.retried`,
        `replay.diverged`, `connector.authorized`,
        `connector.auth_expired` (RFC 0047), `authorization.decided`
        (RFC 0049), `approval.granted` / `approval.rejected` /
        `approval.overridden` (RFC 0051), etc.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/RunEventDoc'

  # ── Schemas ────────────────────────────────────────────────────────────
  schemas:

    # The canonical persisted-event shape. Defined externally so the same
    # contract is shared with REST event-poll responses (rest-endpoints.md
    # `GET /v1/runs/{runId}/events/poll`) and offline replay tools.
    RunEventDoc:
      $ref: '../schemas/run-event.schema.json'

    # RFC 0081 — eval event payloads.
    EvalStartedPayload:   { $ref: '../schemas/run-event-payloads.schema.json#/$defs/evalStarted' }
    EvalScoredPayload:    { $ref: '../schemas/run-event-payloads.schema.json#/$defs/evalScored' }
    EvalCompletedPayload: { $ref: '../schemas/run-event-payloads.schema.json#/$defs/evalCompleted' }
    # RFC 0082 — deployment event payloads.
    DeploymentPromotedPayload:       { $ref: '../schemas/run-event-payloads.schema.json#/$defs/deploymentPromoted' }
    DeploymentRolledBackPayload:     { $ref: '../schemas/run-event-payloads.schema.json#/$defs/deploymentRolledBack' }
    DeploymentCanaryAdjustedPayload: { $ref: '../schemas/run-event-payloads.schema.json#/$defs/deploymentCanaryAdjusted' }
    DeploymentStateChangedPayload:   { $ref: '../schemas/run-event-payloads.schema.json#/$defs/deploymentStateChanged' }

    # RFC 0056. The run.annotated notification carries an Annotation —
    # NOT a RunEventDoc — because annotations are a side-resource, not
    # replayable run-event-log entries (RFC 0056 §B/§D).
    Annotation:
      $ref: '../schemas/annotation.schema.json'

    # RFC 0059. The workspace.updated event payload — content-free
    # {path, version}. Definition lives at
    # run-event-payloads.schema.json#$defs.workspaceUpdated so the SSE
    # consumer + run-event log share one shape contract.
    WorkspaceUpdatedPayload:
      $ref: '../schemas/run-event-payloads.schema.json#/$defs/workspaceUpdated'
    # RFC 0060 heartbeat events (heartbeat-scoped; see host-capabilities.md §host.heartbeat).
    HeartbeatEvaluated:
      $ref: '../schemas/heartbeat-evaluated.schema.json'
    HeartbeatStateChanged:
      $ref: '../schemas/heartbeat-state-changed.schema.json'

    StateSnapshotPayload:
      # S1 closure (2026-04-27): reuse the canonical RunSnapshot
      # projection shape verbatim. Same type returned by
      # `GET /v1/runs/{runId}` — consumers can swap polling for
      # values-mode SSE without re-modeling state.
      $ref: '../schemas/run-snapshot.schema.json'

    AiMessageChunkPayload:
      # S2 closure (2026-04-27): tiered metadata. Bare {nodeId, runId,
      # chunk, isLast} is the minimum compliant payload; `meta`
      # adds Tier 1 typed slots (finishReason / logprobs / toolCalls /
      # model / usage) and a Tier 2 provider-pass-through escape hatch.
      # Schema definition lives at run-event-payloads.schema.json#$defs.outputChunk
      # — referenced here verbatim so the SSE consumer + run-event log
      # share a single shape contract.
      type: object
      required: [nodeId, runId, chunk, isLast]
      properties:
        nodeId: { type: string }
        runId: { type: string }
        chunk:
          type: string
          description: The new token(s) since the previous chunk.
        isLast:
          type: boolean
          description: True for the final chunk of a given AI node call.
        meta:
          type: object
          description: |
            Tiered metadata. Tier 1: typed slots — `finishReason`
            ("stop"|"length"|"tool_calls"|"content_filter"), `logprobs`,
            `toolCalls`, `model`, `usage` ({promptTokens,
            completionTokens, totalTokens}). Tier 2: provider-pass-through
            via `provider` + `providerExtensions`. Consumers SHOULD prefer
            Tier 1; Tier 2 is the escape hatch for fields the spec hasn't
            typed yet. See run-event-payloads.schema.json#$defs._chunkMeta
            for the full definition + per-field constraints.
          additionalProperties: true