A sandbox run streams NDJSON — one JSON object per line, no SSE framing on the wire. The chat backend (stream-processor.ts + envelope-emitter.ts) parses each line and translates it to SSE for the browser. The Agents block forwards NDJSON straight into the workflow run stream.
The line types are defined as the discriminated union AssistantSandboxNDJSONLine in lib/assistant-sandbox/envelope.ts.
Envelope schema
Every line has a type and an optional ts (Unix epoch ms). Unknown type values are rejected defensively by the chat-backend transformer.
log
Free-form diagnostic line. Not surfaced in the chat thread by default.
{ "type": "log", "level": "info", "message": "Loaded 3 skills", "ts": 1700000000000 }level is debug | info | warn | error. The chat backend filters logs out of the SSE stream; they appear in run traces only.
step
One iteration of the agent loop. Emitted at least twice per step:
- once with
status: 'running'when the call starts, - once with
status: 'succeeded' | 'failed'when it ends.
The frontend correlates by id so the same row updates rather than appending duplicates.
{
"type": "step",
"id": "step_8f3...",
"name": "recipes/add-node.sh",
"status": "running",
"args": { "nodeType": "inference" },
"ts": 1700000000123
}{
"type": "step",
"id": "step_8f3...",
"name": "recipes/add-node.sh",
"status": "succeeded",
"result": { "nodeId": "node_..." },
"durationMs": 412,
"ts": 1700000000535
}name is the tool / recipe / skill the step ran. args is the model-emitted call arguments; result is the tool's return value (truncated client-side for display). On failed, error carries the message.
result
The final assistant message body. Emitted exactly once per turn, after the last step.
{ "type": "result", "message": "Added an Inference node…", "ts": 1700000000999 }The chat backend persists this onto the conversation as the assistant message and emits a terminating SSE event. The Agents block surfaces it on the block's response output.
error
Terminal failure. Emitted in place of result when the agent loop cannot produce a final message. Closes the stream.
{ "type": "error", "code": "model_timeout", "message": "Provider timed out after 60s", "ts": 1700000001000 }Wire flow
sandbox container
│
│ newline-delimited JSON lines on response body
▼
POST {SANDBOX_URL}/stream
│
│ raw NDJSON ReadableStream
▼
caller (Assistant chat / Agents block)
│
├─ Assistant: NDJSON → SSE (envelope-emitter.ts) → browser EventSource
└─ Agents block: NDJSON forwarded into the workflow run streamCancellation
Both callers wire AbortSignal into the upstream fetch:
- Assistant: client
EventSourceclose → serverrequest.signalaborts →provisionAssistantSandboxcancels the upstream fetch → sandbox container terminates. - Agents block: workflow cancellation →
ctx.abortSignal→agents-handler.tspasses signal intofetch(...).
When the upstream fetch aborts mid-stream, the next NDJSON line read fails and the caller cleans up. Any in-progress sandbox work is lost — there is no checkpoint protocol.
Cancellation does not refund the upload OTP. If the run was already producing assets when cancelled, those assets are not written back; the OTP simply expires unused.
Replay limit
The Assistant sends the last 20 turns to the sandbox per dispatch (REPLAY_TURN_LIMIT in app/api/assistant/chat/stream/route.ts). Older history is summarized client-side or omitted; do not rely on the sandbox seeing the entire conversation.
Source
apps/actana/lib/assistant-sandbox/envelope.ts— line type definitionsapps/actana/lib/assistant-sandbox/stream-processor.ts— NDJSON → SSE transformapps/actana/lib/assistant-sandbox/envelope-emitter.ts— SSE shaping helpers