Understanding Automate Event Flow
When you call the `/v1/automate` endpoint, the API streams real-time updates about task execution through Server-Sent Events (SSE). This guide explains how events flow during a typical automation task and how to use them effectively in your application.
How Event Streaming Works
Section titled “How Event Streaming Works”Under the hood, /v1/automate delivers events over Server-Sent Events (SSE), but the Tabstack SDKs wrap that framing for you. Calling client.agent.automate(...) returns a stream of typed AutomateEvent values; iterate it with for await ... of in TypeScript or for ... in in Python. Each event has a string event name and a data payload, and switching on event.event narrows event.data to the right variant.
Consuming the stream this way lets you:
- Display progress updates to users as the agent works.
- Track the current state of the task (planning, executing, extracting, done).
- Capture extracted data as it becomes available.
- Handle errors gracefully, both before and during execution.
For a scannable summary of event ordering, filtering, and conditional events, see the Automate Events reference. This guide walks through the same lifecycle end-to-end with runnable code.
The Event Lifecycle
Section titled “The Event Lifecycle”A typical automation task flows through these phases:
1. Initialization
Section titled “1. Initialization”When you submit a request, the SDK opens the stream and the first events establish the task context. You’ll see cdp:endpoint_connected (the browser session has attached), then a couple of agent:processing / agent:status events as the agent builds its plan, then task:started carrying the full plan payload:
import Tabstack from '@tabstack/sdk'
const client = new Tabstack()
const stream = await client.agent.automate({ task: 'Find the product price', url: 'https://example.com/product',})
for await (const event of stream) { switch (event.event) { case 'cdp:endpoint_connected': console.log('Browser session attached') break case 'task:started': console.log('Task started:', event.data.task) console.log('Plan:', event.data.plan) console.log('Success criteria:', event.data.successCriteria) console.log('Action items:', event.data.actionItems) break // ... phases 2+ continue this switch }}from tabstack import Tabstack
client = Tabstack()
stream = client.agent.automate( task="Find the product price", url="https://example.com/product",)
for event in stream: match event.event: case "cdp:endpoint_connected": print("Browser session attached") case "task:started": print("Task started:", event.data.task) print("Plan:", event.data.plan) print("Success criteria:", event.data.success_criteria) print("Action items:", event.data.action_items) # ... phases 2+ continue this matchThe task:started payload includes the task string, the URL, the page title, a human-readable plan, the successCriteria string the agent will evaluate against, and an actionItems list. Display any or all of these so users can see what the automation will do.
2. Planning and Processing
Section titled “2. Planning and Processing”The agent emits agent:processing events whenever it’s thinking, and agent:status events when it wants to surface a human-readable status message. The SDK exposes the fields directly — no manual JSON parsing — so you can react to them as typed data:
// (setup as in step 1)case 'agent:processing': if (event.data.hasScreenshot) { console.log('Analyzing page:', event.data.operation) } else { console.log('Thinking:', event.data.operation) } breakcase 'agent:status': console.log('Status:', event.data.message) break# (setup as in step 1)case "agent:processing": if event.data.has_screenshot: print("Analyzing page:", event.data.operation) else: print("Thinking:", event.data.operation)case "agent:status": print("Status:", event.data.message)agent:processing is high-frequency. Use it to drive a “Thinking…” indicator, but filter it out of logs you want to stay quiet.
3. Execution Steps
Section titled “3. Execution Steps”As the agent executes the plan, it emits events for each iteration. A typical loop is agent:step (iteration begins) → agent:reasoned (the agent’s reasoning output) → agent:action (the concrete browser action) with browser:navigated, browser:action_started, and browser:action_completed interleaved:
// (setup as in step 1)case 'agent:step': console.log(`Step ${event.data.currentIteration + 1}`) breakcase 'agent:reasoned': console.log('Reasoning:', event.data.reasoning) breakcase 'agent:action': console.log('Action:', event.data.action, '--', event.data.value) breakcase 'browser:navigated': console.log('Navigated:', event.data.url, '(title:', event.data.title, ')') breakcase 'browser:action_started': console.log('Browser action started') breakcase 'browser:action_completed': console.log('Browser action completed') break# (setup as in step 1)case "agent:step": print(f"Step {event.data.current_iteration + 1}")case "agent:reasoned": print("Reasoning:", event.data.reasoning)case "agent:action": print("Action:", event.data.action, "--", event.data.value)case "browser:navigated": print("Navigated:", event.data.url, "(title:", event.data.title, ")")case "browser:action_started": print("Browser action started")case "browser:action_completed": print("Browser action completed")agent:action.action is an operation name like extract, click, type, or done; agent:action.value is a short human-readable description of that operation. Use event.data.currentIteration to drive a “Step N of M” UI.
4. Interactive Form Input (when interactive: true)
Section titled “4. Interactive Form Input (when interactive: true)”If interactive mode is enabled and the agent encounters a form requiring user data, the stream pauses mid-task with an interactive:form_data:request event. Your client should:
- Read the
requestIdandfieldsarray to determine what data the agent needs. - Collect the values (from a user prompt, database, or another system).
- Submit them via
client.agent.submitFormData({ requestId, values })(TS) /client.agent.submit_form_data(request_id=..., values=...)(Python).
// (setup as in step 1, with `interactive: true` in the automate() call)case 'interactive:form_data:request': console.log('Form fields needed:', event.data.fields) console.log('Page:', event.data.pageTitle, '-', event.data.pageUrl) // Collect values, then: // await client.agent.submitFormData({ requestId: event.data.requestId, values: {...} }) break# (setup as in step 1, with interactive=True in the automate() call)case "interactive:form_data:request": print("Form fields needed:", event.data.fields) print("Page:", event.data.page_title, "-", event.data.page_url) # Collect values, then: # client.agent.submit_form_data(request_id=event.data.request_id, values={...})Once you submit, the agent fills the form and resumes. If validation fails, you’ll receive an interactive:form_data:error event describing which fields need correction. For a complete walkthrough, see the Interactive Mode guide.
5. Data Extraction
Section titled “5. Data Extraction”When the agent extracts data, it emits agent:extracted with the payload in event.data.extractedData (TS) / event.data.extracted_data (Python). The field is a string. When the task asks for structured data, the agent typically returns JSON, but it may wrap that JSON in a Markdown code fence — strip the fence before parsing. This fence wrapping isn’t documented in the SDK or spec — it’s model-output non-determinism. The code strips fences defensively so your handler doesn’t break when they appear:
// (setup as in step 1)case 'agent:extracted': { const raw = event.data.extractedData // The agent sometimes wraps JSON in ```json ... ``` fences; strip them. const unfenced = raw .replace(/^\s*```(?:json|markdown)?\s*\n?/g, '') .replace(/\n?```\s*$/g, '') .trim() let data: unknown = unfenced try { data = JSON.parse(unfenced) } catch { // Not valid JSON -- the agent returned a plain string answer. } console.log('Extracted:', data) break}import jsonimport re
# (setup as in step 1)case "agent:extracted": raw = event.data.extracted_data unfenced = re.sub(r"^\s*```(?:json|markdown)?\s*\n?", "", raw) unfenced = re.sub(r"\n?```\s*$", "", unfenced).strip() try: data = json.loads(unfenced) except json.JSONDecodeError: # Not valid JSON -- the agent returned a plain string answer. data = unfenced print("Extracted:", data)You may receive multiple agent:extracted events if the task involves extracting from different pages or elements.
6. Completion
Section titled “6. Completion”A successful run ends with four landmark events: task:validated (the completion check passed), task:completed (the agent decides the task is done), complete (final result with summary stats), and done (stream terminator with an empty payload):
// (setup as in step 1)case 'task:validated': console.log('Validation:', event.data.completionQuality) console.log('Observation:', event.data.observation) breakcase 'task:completed': console.log('Completed:', event.data.finalAnswer) breakcase 'complete': if (event.data.success) { console.log(`Finished in ${event.data.stats.durationMs}ms:`, event.data.finalAnswer) } else { console.error(`Failed (${event.data.error?.code}):`, event.data.error?.message) } break# (setup as in step 1)case "task:validated": print("Validation:", event.data.completion_quality) print("Observation:", event.data.observation)case "task:completed": print("Completed:", event.data.final_answer)case "complete": if event.data.success: print(f"Finished in {event.data.stats.duration_ms}ms:", event.data.final_answer) else: code = event.data.error.code if event.data.error else "UNKNOWN" msg = event.data.error.message if event.data.error else "" print(f"Failed ({code}):", msg)task:completed.data carries {finalAnswer, success}. complete.data carries {finalAnswer, stats, success} plus an optional structured error for success: false cases, where stats is {actions, durationMs, endTime, iterations, startTime} — useful for showing “Completed in N seconds across M iterations.” When the for await / for ... in loop exits, the stream is finished; there is no separate close call to make.
Handling Errors
Section titled “Handling Errors”Errors surface in two places:
- HTTP-level exceptions raised before the stream opens (e.g. a malformed body, missing task, auth failure, rate limit). These are the SDK’s typed error classes —
BadRequestError,AuthenticationError,RateLimitError, etc. Wrap theautomate(...)call and the iteration loop in try/catch. - In-stream
errorevents that arrive mid-task when the task runner itself fails (e.g. an unreachable URL). The payload shape is{ success: false, error: { code, message, timestamp } }, wheretimestampis an ISO-8601 string. The SDK exposes this as a typed variant of theAutomateEventunion, so a regularcase 'error':(TypeScript) /case "error":(Python) narrowsevent.datafor you.
import Tabstack, { BadRequestError, AuthenticationError, RateLimitError } from '@tabstack/sdk'
const client = new Tabstack()
try { const stream = await client.agent.automate({ task: 'Find the product price', url: 'https://example.com/product', })
for await (const event of stream) { if (event.event === 'error') { console.error('In-stream error:', event.data.error.message) break } // ... handle the rest of the events }} catch (err) { if (err instanceof BadRequestError) { console.error('Bad request:', err.message) } else if (err instanceof AuthenticationError) { console.error('Auth failed -- check your API key') } else if (err instanceof RateLimitError) { console.error('Rate limited -- back off and retry') } else { throw err }}from tabstack import Tabstack, BadRequestError, AuthenticationError, RateLimitError
client = Tabstack()
try: stream = client.agent.automate( task="Find the product price", url="https://example.com/product", ) for event in stream: if event.event == "error": print("In-stream error:", event.data.error.message) break # ... handle the rest of the eventsexcept BadRequestError as exc: print("Bad request:", exc)except AuthenticationError: print("Auth failed -- check your API key")except RateLimitError: print("Rate limited -- back off and retry")If a task is terminated early, you’ll also see a task:aborted event before the stream closes. Agent-level aborts (the success: false path) also surface inside the complete event with a structured error payload — see the Automate events reference for the distinction between the two.
Building a Client
Section titled “Building a Client”Here’s a pattern for consuming the full stream and keeping an incrementally-updated state object you can bind to a UI:
import Tabstack from '@tabstack/sdk'
const client = new Tabstack()
type ClientState = { status: 'starting' | 'running' | 'completed' | 'error' plan: string | null currentStep: number extractedData: unknown[] result: string | null error: string | null}
const state: ClientState = { status: 'starting', plan: null, currentStep: 0, extractedData: [], result: null, error: null,}
const stream = await client.agent.automate({ task: 'Find the product price', url: 'https://example.com/product',})
for await (const event of stream) { switch (event.event) { case 'task:started': state.status = 'running' state.plan = event.data.plan break case 'agent:step': state.currentStep = event.data.currentIteration + 1 break case 'agent:extracted': { const raw = event.data.extractedData const unfenced = raw .replace(/^\s*```(?:json|markdown)?\s*\n?/g, '') .replace(/\n?```\s*$/g, '') .trim() try { state.extractedData.push(JSON.parse(unfenced)) } catch { state.extractedData.push(unfenced) } break } case 'task:completed': state.status = 'completed' state.result = event.data.finalAnswer break case 'error': state.status = 'error' state.error = event.data.error.message break }}
console.log(state)import jsonimport refrom tabstack import Tabstack
client = Tabstack()
state = { "status": "starting", "plan": None, "current_step": 0, "extracted_data": [], "result": None, "error": None,}
stream = client.agent.automate( task="Find the product price", url="https://example.com/product",)
for event in stream: match event.event: case "task:started": state["status"] = "running" state["plan"] = event.data.plan case "agent:step": state["current_step"] = event.data.current_iteration + 1 case "agent:extracted": raw = event.data.extracted_data unfenced = re.sub(r"^\s*```(?:json|markdown)?\s*\n?", "", raw) unfenced = re.sub(r"\n?```\s*$", "", unfenced).strip() try: state["extracted_data"].append(json.loads(unfenced)) except json.JSONDecodeError: state["extracted_data"].append(unfenced) case "task:completed": state["status"] = "completed" state["result"] = event.data.final_answer case "error": state["status"] = "error" state["error"] = event.data.error.message
print(state)Key Takeaways
Section titled “Key Takeaways”- Events stream in real-time. Update your UI as each event arrives rather than waiting for the whole run to finish.
- Switch on
event.event. The SDK narrowsevent.datato the right typed variant for each case. - Track progress with
agent:step. UsecurrentIteration/current_iterationto show “Step N of M”. extractedDatamay be fenced. When the agent returns JSON, it sometimes wraps it in```jsonfences — strip before parsing, and fall back gracefully if the string isn’t JSON.- Always handle errors. Wrap the call in try/catch for HTTP-level failures, and add a
case 'error':arm for in-stream runtime failures —event.data.error.messageis typed. completecarries the canonical final state. Inspectevent.data.successandevent.data.error?.codefor agent-level aborts (TASK_ABORTED,MAX_ITERATIONS, etc.); the in-streamerrorevent is reserved for task-runner crashes, not agent decisions.- The loop ends naturally. When the
for await/for ... initeration returns, the stream is finished; there’s no separate close to call.
See also
Section titled “See also”- Automate Events reference — scannable summary of event ordering, filtering, and conditional events.
- API reference — automate — complete event schema with all payload fields.
- Interactive Mode guide — how to handle
interactive:form_data:*events end-to-end.