Event streaming (SSE) and XML responses

Handle text/event-stream and XML payloads in HTTP Actions. Learn how streams terminate, aggregate data: chunks safely, and parse XML outputs.

HTTP Actions can call APIs that return either streaming Server‑Sent Events (SSE) or XML. This guide explains how SSE responses terminate and how the HTTP Action behaves, how to aggregate data: event chunks into a single output, common pitfalls, and simple approaches for XML parsing.

📘

XML Support

Moveworks converts all XML responses to JSON automatically at the HTTP action layer, so XML responses can be used easily in conversational processes and compound actions.

This also allows XML responses from on-prem systems (via the Moveworks Agent) to be used.

What to expect at runtime

  • Blocking, not live streaming: HTTP Actions do not surface partial chunks. They buffer the response and return it only after the server closes the connection.
  • Termination matters: If the SSE endpoint never closes the connection (e.g., test endpoints that stream forever), the action will remain in "Processing" until a timeout.
  • Timeouts and limits:
    • Requests time out after ~60 seconds of inactivity.
    • Responses larger than ~200 KB are rejected.

Server‑Sent Events (text/event-stream)

  • Request setup:
    • Add header: Accept: text/event-stream.
    • Configure the HTTP Action normally (no special streaming toggle is required).
  • Response shape: The action captures the raw SSE payload as a single text block under your output_key. Typical shape:
data: {"thread_id": "...", "date": "2025-11-11T20:37:49.053856Z"}

data: {"chunk": "..."}

data: {"chunk": "..."}
  • Reserved keys: Avoid naming your HTTP Action output_key as result or data to prevent collisions. Use something like action_output.

Aggregate chunked messages into one string

In a Compound Action, set the HTTP Action output_key to action_output, then map the aggregated text plus any metadata:

steps:
  - action:
      action_name: chat_stream
      output_key: action_output
      headers:
        Accept: text/event-stream
  - return:
      output_mapper:
        thread_id: >
          data.action_output
            .$TRIM()
            .$SPLIT("\n\n")[0]
            .$REPLACE("^data: ", "")
            .$PARSE_JSON()
            .thread_id
        date: >
          data.action_output
            .$TRIM()
            .$SPLIT("\n\n")[0]
            .$REPLACE("^data: ", "")
            .$PARSE_JSON()
            .date
        text: >
          data.action_output
            .$TRIM()
            .$SPLIT("\n\n")
            .$MAP(x => x.$REPLACE("^data: ", "").$PARSE_JSON().chunk)
            .$CONCAT(" ", true)

Notes:

  • SSE frames are delimited by a blank line (\n\n).
  • You must strip the data: prefix on each frame before parsing JSON ($REPLACE("^data: ", "")).
  • Some servers emit heartbeats like : keep-alive. Filter them out if present, e.g.:
text: >
  data.action_output
    .$TRIM()
    .$SPLIT("\n\n")
    .$FILTER(line => !line.$STARTS_WITH(":"))
    .$MAP(x => x.$REPLACE("^data: ", "").$PARSE_JSON().chunk)
    .$CONCAT(" ", true)

Copy‑paste recipe from Slack discussion (array trick)

The following mapping aggregates SSE frames by converting the raw stream into a JSON array, then parsing once. This is useful when frames are well‑formed JSON objects and delimited by blank lines:

steps:
  - action:
      output_key: action_output
      action_name: test_stream
  - return:
      output_mapper:
        thread_id: >
          $CONCAT(["[",data.action_output.$REPLACE("data: ", ",").$TRIM()[1:],"]"], "").$PARSE_JSON()[0].thread_id
        date: >
          $CONCAT(["[",data.action_output.$REPLACE("data: ", ",").$TRIM()[1:],"]"], "").$PARSE_JSON()[0].date
        text: 
          EVAL():
            expression: $CONCAT(x, " ")
            args: 
                x:
                  MAP():
                    items: >
                      $CONCAT(["[",data.action_output.$REPLACE("data: ", ",").$TRIM()[1:],"]"], "").$PARSE_JSON()[1:]
                    converter: item.chunk

How it works:

  • Replaces each data: prefix with a comma, then drops the leading comma via [1:].
  • Wraps the result in brackets to form a JSON array and parses it once.
  • Pulls metadata from the first element and concatenates chunk fields from the rest.

Troubleshooting SSE

  • Action stuck in Processing: Your endpoint likely never closes. Test against an endpoint that terminates; many demo SSE servers are intentionally infinite.
  • Invalid JSON errors: Parse line‑by‑line. The raw SSE payload is not a single JSON object.
  • Large responses: If you concatenate many chunks, you may hit the 200 KB cap—truncate or summarize upstream.

XML responses

  • Prefer JSON when possible: If the API supports it, send Accept: application/json and/or use a JSON variant of the endpoint.
  • When XML is unavoidable:
    • The HTTP Action stores the raw XML in your output_key (e.g., action_output).
    • There is no built‑in XML parser in the output mapper; use either simple string/regex extraction for a few fields or a Script Action for robust parsing.

Option A: Simple extraction with DSL (for a few tags)

For basic cases you can extract values using $MATCH, $REPLACE, etc. Example (extract <status>):

status: >
  data.action_output
    .$MATCH("<status>.*?</status>", true)[0]
    .$REPLACE("</?status>", "", true)
    .$TRIM()

This approach is brittle for complex XML—prefer Option B below.

Option B: Parse XML in a Script Action (BeautifulSoup)

Use a Python Script Action after the HTTP Action to convert XML to JSON‑like text you can parse back in the mapper.

  1. HTTP Action: set output_key: action_output.

  2. Script Action:

  • Input argument xml_body mapped from data.action_output.
  • Output key xml_parsed.
  • Code:
import json
from bs4 import BeautifulSoup

# xml_body is the input argument
soup = BeautifulSoup(xml_body, "xml")

result = {
    "status": (soup.find("status").get_text(strip=True) if soup.find("status") else None),
    "id": (soup.find("id").get_text(strip=True) if soup.find("id") else None),
    "message": (soup.find("message").get_text(strip=True) if soup.find("message") else None),
}

# Return a JSON string for downstream mapping
json.dumps(result)
  1. Return step: parse the script output and expose fields:
steps:
  - action:
      action_name: xml_api
      output_key: action_output
      headers:
        Accept: application/xml
  - script:
      language: python
      input_args:
        xml_body: data.action_output
      output_key: xml_parsed
      code: |
        import json
        from bs4 import BeautifulSoup
        soup = BeautifulSoup(xml_body, "xml")
        result = {
            "status": (soup.find("status").get_text(strip=True) if soup.find("status") else None),
            "id": (soup.find("id").get_text(strip=True) if soup.find("id") else None),
            "message": (soup.find("message").get_text(strip=True) if soup.find("message") else None),
        }
        json.dumps(result)
  - return:
      output_mapper:
        status: data.xml_parsed.$PARSE_JSON().status
        id: data.xml_parsed.$PARSE_JSON().id
        message: data.xml_parsed.$PARSE_JSON().message

Best practices

  • Use a safe output_key: Avoid result and data. Prefer action_output, xml_parsed, etc.
  • Keep payloads small: Summarize upstream or stop early to stay under the 200 KB limit.
  • Validate in Postman first: Confirm the endpoint closes the stream and returns the expected format before configuring in Agent Studio.
  • Log and test: Use the Test button and check logs if mapping fails.