Skip to content
Get started

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.

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.

A typical automation task flows through these phases:

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
}
}

The 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.

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)
}
break
case 'agent:status':
console.log('Status:', event.data.message)
break

agent:processing is high-frequency. Use it to drive a “Thinking…” indicator, but filter it out of logs you want to stay quiet.

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}`)
break
case 'agent:reasoned':
console.log('Reasoning:', event.data.reasoning)
break
case 'agent:action':
console.log('Action:', event.data.action, '--', event.data.value)
break
case 'browser:navigated':
console.log('Navigated:', event.data.url, '(title:', event.data.title, ')')
break
case 'browser:action_started':
console.log('Browser action started')
break
case 'browser:action_completed':
console.log('Browser action completed')
break

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:

  1. Read the requestId and fields array to determine what data the agent needs.
  2. Collect the values (from a user prompt, database, or another system).
  3. 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

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.

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
}

You may receive multiple agent:extracted events if the task involves extracting from different pages or elements.

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)
break
case 'task:completed':
console.log('Completed:', event.data.finalAnswer)
break
case '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

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.

Errors surface in two places:

  1. 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 the automate(...) call and the iteration loop in try/catch.
  2. In-stream error events 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 } }, where timestamp is an ISO-8601 string. The SDK exposes this as a typed variant of the AutomateEvent union, so a regular case 'error': (TypeScript) / case "error": (Python) narrows event.data for 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
}
}

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.

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)
  • 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 narrows event.data to the right typed variant for each case.
  • Track progress with agent:step. Use currentIteration / current_iteration to show “Step N of M”.
  • extractedData may be fenced. When the agent returns JSON, it sometimes wraps it in ```json fences — 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.message is typed.
  • complete carries the canonical final state. Inspect event.data.success and event.data.error?.code for agent-level aborts (TASK_ABORTED, MAX_ITERATIONS, etc.); the in-stream error event is reserved for task-runner crashes, not agent decisions.
  • The loop ends naturally. When the for await / for ... in iteration returns, the stream is finished; there’s no separate close to call.