Skip to main content

Advanced: Building a Chat Client

Wire Managed Agents into a web or mobile product: SSE delta stitching, reasoning and turn-state handling, reconnect without duplicate submissions, and mounting or unmounting attachments mid-session.

This page covers the client-side engineering for wiring Managed Agents into an existing web or mobile product: showing the answer as it is produced, letting one conversation gain and drop files, and recovering from a dropped connection without re-running the task. Examples use REST/curl.

Scenario: how a conversation should render in your product

The product already has its own chat UI; Managed Agents now powers the answers. Three experience requirements shape the client protocol:
  • Answers should appear incrementally, not only when fully generated; thinking needs an indicator;
  • Attachments can be added and removed within the conversation without disturbing it;
  • After a refresh or a network drop, the conversation recovers without ever resubmitting input.

Put a business backend between the UI and Managed Agents

Web / mobile UI
  → authenticated business backend (conversation ownership, turn IDs)
    → Managed Agents Session + Event API
    ← SSE event frames
  ← product events (answer updates, activity progress, turn state)
The API key stays on the backend. The backend persists the mapping from its own conversations to session_id and reuses one session across turns; preparing agents and environments is application initialization (see getting started) — never recreate them per message. Assign a business turn_id when a message arrives. A single turn may produce multiple ordered assistant messages: the agent may speak, call a tool, then speak again — one turn is not necessarily one message. Store each one, in order.

Subscribe first, then submit

Sending events and subscribing to the stream are two separate endpoints. Establish the SSE stream first, confirm it is up, then POST the input over a different connection:
curl -N --no-buffer --get "$BASE/sessions/$SESSION_ID/events/stream" \
  -H "Authorization: Bearer $DASHSCOPE_API_KEY" \
  --data-urlencode 'event_deltas[]=message' \
  --data-urlencode 'event_deltas[]=reasoning'
The order cannot be reversed: SSE does not replay history. Sending before subscribing can miss quick replies or immediate errors; and do not let the subscription occupy the only connection you can submit over.
Engineering details:
  • The frontend may echo the input immediately; while waiting, the backend reports confirmed stages (connecting, submitted, running);
  • Flush downstream output streams promptly and disable proxy response buffering — buffering swallows the "streaming" effect;
  • Set connection, first-event, idle, and whole-turn timeouts separately; keep-alive comments do not mean the business made progress;
  • Parse SSE frame by frame: skip comment lines, accept both data: and data: (with a space); merge multi-line data first, decode the JSON, and dispatch on its type.

Delta stitching and deduplication

With event_deltas[] enabled, a declared event type first emits an event_start frame (event.id is the final complete message's ID), then delta frames in generation order, and finally the aggregated complete message. Dispatch by frame type:
Upstream frameBackend handlingUI effect
event_startRecord event.id and event.typeCreate a provisional message, or start the thinking indicator
event_deltaAppend text to (event_id, delta.index) in arrival order: message accumulates delta.content, reasoning accumulates delta.reasoningUpdate the existing answer or thinking block
Complete assistant messageReplace the provisional content with the complete contentMark the same message complete; do not add a copy
Complete reasoningEnd that thinking activityClear the thinking indicator — the turn may still be running
The minimal text buffer:
buffers = {}

def append_text_delta(event):
    delta = event["delta"]
    key = (event["event_id"], delta["index"])
    if delta["type"] == "reasoning_delta":
        buffers[key] = buffers.get(key, "") + delta.get("reasoning", "")
    else:
        content = delta.get("content", {})
        if content.get("type") == "text":
            buffers[key] = buffers.get(key, "") + content.get("text", "")
    return key
Three common pitfalls:
  1. delta.index identifies a content block, not a fragment sequence number. Append every fragment under the same key, even when the text repeats — "3" and "23" at index 0 assemble to "323"; when the final complete message "323" arrives, replace the buffer instead of appending into "323323".
  2. Dispatch by the registered event type so reasoning text never lands in the answer; filter the echo of user input — only complete messages with role: assistant belong to the assistant transcript.
  3. Route MultiAgent output by thread first before deciding whether it joins the main answer, see multi-agent.
The backend can expose a slim custom protocol to the UI, for example:
{"type":"answer.updated","turn_id":"turn_1","event_id":"msg_1","block_index":0,"text":"323"}
This is an application-defined event, not a Managed Agents API field. text is the current full snapshot of that content block, so the UI replaces the block on every update. When downstream events may replay or arrive out of order, use stable event/content-block keys plus a backend-assigned revision number; remove provisional blocks that are no longer valid when the final message replaces them. Escape model text before rendering — never inject unprocessed HTML.

Keep thinking and turn completion separate

Thinking uses reasoning events. With event_deltas[]=reasoning enabled, thinking text arrives chunk by chunk in delta.reasoning on frames with delta.type=reasoning_delta; accumulate it by event_id + delta.index. Show the thinking indicator while the activity is live, and validate against the complete Message's content when it ends. An event may contain no event_delta; in that case update state from the start and final frames only. A turn is genuinely over per the stop_reason in session_status; map it to product state:
Observed stateProduct behavior
runningKeep the turn active, showing answers or tool progress from events
idle + end_turnMark the turn complete
idle + requires_actionShow the action-needed state (approval flow: event stream)
idle + retries_exhaustedMark the turn failed, keep the conversation
terminatedStop loading, show the terminal result
errorRead top-level error.code / error.message, keep the diagnostic ID, and check session state
HTTP 200, one complete assistant message, one reasoning end, or an SSE close — none of them alone proves the turn succeeded: more tool calls or messages may follow the last assistant message. Track tool progress by call ID, and settle any UI activities still waiting when the turn ends or is interrupted.

Reconnect without resubmitting

After a page refresh, restore the backend's saved turns and messages first. If the backend's upstream connection is still alive, have the UI reattach to that business stream instead of starting a new Managed Agents turn. If the upstream connection is gone: keep the Session ID and recover complete messages through the paginated Event List. Open a fresh realtime subscription first and buffer new events while reconciling history, so results finishing mid-recovery are not lost; insert or update complete events by ID, attach them to the right turn, and replace incomplete text with complete messages. A recovered stream may contain only the tail of a message still being generated — do not treat it as the complete answer; reconcile history once more after the turn reaches a terminal state.
Do not deduplicate individual deltas by (event_id, index), and do not resend the original input merely because a connection closed. The upstream stream has no resumable delta cursor; if the submission request itself timed out, check history before deciding whether a safe resend is possible.
Test evidence (2026-10-15): disconnected after the first answer delta, opened a replacement subscription, then fetched paginated history; only one input was submitted, and the multiple assistant messages produced afterward merged by ID matched the final history exactly.

Letting a conversation gain attachments

The attachment control needs a lifecycle independent of the answer stream:
selected → uploading → checking → mounting → ready
                   └→ rejected    └→ timed out
ready → removing → removed
These are application UI states, not a list of platform status values. On the backend, tie every attachment to its conversation, the uploaded file_id, the returned resource ID, and the full mount path; validate ownership before acting. Follow the upload flow in getting started until the file is available, then add it to the existing session:
curl -s -X POST "$BASE/sessions/$SESSION_ID/resources" \
  -H "Authorization: Bearer $DASHSCOPE_API_KEY" -H "Content-Type: application/json" \
  -d '{"type": "file", "file_id": "<available_file_id>", "mount_path": "/uploads/late.txt"}'
Three returned fields to handle carefully:
  • The sesrsc_... resource ID must be stored separately from the uploaded file_id — removal uses the resource ID;
  • The returned mount_path is already the full sandbox path;
  • The returned file_id may point to a server-side copy of the file.
Mounting a resource submits no message and generates no session event. Update the attachment UI from backend operation results plus runtime checks — never mark an attachment readable just because it appears in the resource list.
How to do the runtime check: on the next turn, have the agent wait for the path within a deadline, then read and reconcile. The follow-up message used in testing:
Use bash to wait up to 60 seconds for /mnt/session/uploads/late.txt
(test -f, sleep 2). Read it and report the sum of the amount column.
Do not use web tools.
The agent read the file and returned 350. Use a deadline, not a fixed propagation delay: in most runs the first tool attempt found the path; one early run waited about 35 seconds. Mark the attachment ready only after runtime access is confirmed — otherwise show a timeout. When following up in-business, also state whether the new file appends to, replaces, or supplements the previous input — a bare mount does not ask the agent to revise its answer. If the current turn is still running, the client may finish uploading first and queue the follow-up for the next turn; do not promise that an in-flight tool will restart with the new input. The tested flow applied changes between two turns of the same session.
This test workspace rejects .csv uploads (HTTP 415); the same content uploaded as .txt succeeded. Confirm allowed formats before uploading attachments.

Unmounting attachments without resetting the conversation

Removal calls with the previously stored resource ID:
curl -s -X DELETE "$BASE/sessions/$SESSION_ID/resources/$RESOURCE_ID" \
  -H "Authorization: Bearer $DASHSCOPE_API_KEY"
Show "removing" in the UI while reconciling two layers of state:
  1. Paginated GET /sessions/{id}/resources — confirm the resource ID is gone;
  2. On a later turn, have the agent wait for the full path to disappear within a deadline, explicitly instructing it not to delete the file itself, which would mask an unmount failure.
Both checks passed in testing. On timeout, keep the IDs and offer state recovery — do not show removal as successful. What is unmounted is the session's mounted copy, not the uploaded source file; information already read into the conversation and derived artifacts remain — "removed" only means the attachment is no longer mounted, not that the agent has forgotten its content.
A memory store must be mounted at session creation and does not follow this dynamic-mount flow — see Advanced: Cross-Session Working Memory Handoff.

Integration acceptance checklist

  • Short text answers: stitched deltas match the final complete message, with no duplicate answer;
  • Reasoning deltas: thinking text updates chunk by chunk and matches the final complete message;
  • Multiple assistant messages around tool calls: each message keeps its correct order within the turn;
  • Approval pauses or failures: requires_action and errors are never shown as successful completion;
  • Page refresh and upstream disconnect: history recovery produces no duplicate messages and no re-execution of input;
  • Attachment added after a turn: the same session can read it once the runtime check confirms readiness;
  • Attachment removal: the resource entry and sandbox path disappear, and prior conversation messages remain.
The SDK version noted here (1.27.3) has no event_deltas parameter on events.stream() — use the REST SSE query parameters as documented for delta streaming; verify parameter support before adopting other SDK versions.

Next steps