Seeq Knowledge Base

Triggering a Custom Agent from Data Lab

This page provides examples of how run a custom agent built in Agent Builder from a Python script in Data Lab and read the response back off the async stream as it is produced. This functionality will be superseded with additional methods in the future, so reach out via Seeq support if you have other ways you would like to use to access custom agents.

The GenAI service is available under /genai on your Seeq host (e.g., https://my-company.seeq.site/genai), and it accepts the same auth token that spy.login gives you. The approach below will work as is in Data Lab, but if you choose to execute Python outside Data Lab you will need to add appropriate authentication for the targeted instance.


Prerequisites

Requirement

Notes

A saved Custom Agent

Built and saved in Agent Builder. Publish a version if you want runs pinned to it.

The agent's id

A GUID, see https://seeq.atlassian.net/wiki/spaces/SD/pages/edit-v2/5444730972#Find-your-agent%27s-ID .

seeq SPy package

Used only for logging in and getting an auth token. Available by default in Data Lab.

httpx

For the async streaming examples. Available by default in Data Lab.

Permission to run the agent

The run executes as you; the agent must be shared with your user.

Log in and build the request headers

Every call below needs the same two things: the base URL of your Seeq server, and an auth header derived from your logged-in session.

Python
import os

from seeq import sdk  # spy is pre-imported in Data Lab

# if you are running outside Data Lab, you'll need to install and import the seeq.spy package and authenticate first

api_client = spy.client

APPSERVER_JSON = "application/vnd.seeq.v1+json"

def base_url(api_client: sdk.ApiClient) -> str:
    """spy's client host is '<server>/api'; the /genai routes hang off the server root."""
    if os.environ.get("SEEQ_SERVER_URL"):
        return os.environ["SEEQ_SERVER_URL"].rstrip("/")

    host = (getattr(api_client, "host", None) or "").rstrip("/")
    return host[: -len("/api")] if host.endswith("/api") else host

def auth_headers(api_client: sdk.ApiClient) -> dict[str, str]:
    headers = {
        "Accept": APPSERVER_JSON,
        "Content-Type": APPSERVER_JSON,
        "x-sq-origin": "datalab",
        "x-sq-origin-label": "custom-agent-trigger",
    }
    api_client.add_authorization_header(headers)
    csrf = getattr(api_client, "csrf_token", None)

    if isinstance(csrf, str) and csrf:
        headers["x-sq-csrf"] = csrf
    return headers

SEEQ = base_url(api_client)
HEADERS = auth_headers(api_client)


Token lifetime. add_authorization_header reads the token off the client at call time. For a long-running script, rebuild HEADERS before each run rather than caching it for hours.

Find your agent's ID

Either copy it out of the Agent Builder URL (e.g., .../agent-builder/<agentId>) or list them:

Python
agents_api = sdk.CustomAgentsApi(api_client)

# All agents you can see
print("All agents:")
for agent in agents_api.get_custom_agents():
    print(agent.id, "\t", agent.name)

# Only agents that have a published production version (what you usually want to trigger)
published = agents_api.get_custom_agents(versioned=True, production="set")
print("\nPublished agents:")
for agent in published:
    print(agent.id, "\t", agent.name)

get_custom_agents also accepts ownership with a value from ["all", "mine", "accessible"], and is paginated via the offset, and limit values.

To pin a run to a specific version instead of the agent's current graph, list versions with agents_api.get_custom_agent_versions(id=agent_id) and pass the version id as id in the run payload.

The endpoints

All paths are relative to your Seeq server root.

Method

Path

Purpose

POST

/genai/agent-workflows/runs

Start a run. Streaming → 200 + text/plain body; non-streaming → 202 + {"runId", "chatId"}

GET

/genai/agent-workflows/runs/{runId}

Run status, per-node outputs, full thinking log

GET

/genai/agent-workflows/runs/{runId}/stream

Re-attach to a live run's visible-text stream

GET

/genai/agent-workflows/runs/{runId}/stream/thinking

Live progress events, newline-delimited JSON

POST

/genai/agent-workflows/runs/{runId}/cancel

Cancel a run

Run-start payload

{
  "customAgentId": "…",        // required (mutually exclusive with templateId)
  "templateId": "…",           // use instead of customAgentId to run a built-in template
  "versionId": "…",            // optional: pin to a published version
  "prompt": "…",               // the user message handed to the agent
  "inputs": {"node-id": "…"},  // optional: override a node's stored instruction for this run
  "chatId": "…",               // optional: associate the run with a chat thread
  "options": {
    "stream": true,            // stream the response body (default true)
    "streamTools": true,       // emit tool/progress events (default true)
    "interleaveThinking": true // mix those events into the text body as JSON (default true)
  }
}

The payload rejects unknown fields, and exactly one of customAgentId / templateId must be set.

Set interleaveThinking: false in scripts. With the default true, the response body is a mix of assistant text and raw JSON objects like {"type": "thinking", "data": {…}} with no delimiters between them - convenient for the web UI, painful to parse. Turning it off gives you a clean text stream; if you want the progress events, read them off the dedicated thinking stream (https://seeq.atlassian.net/wiki/spaces/SD/pages/edit-v2/5444730972#Recipe-B---streaming-with-live-progress-events ) instead.

For custom agents that are triggered with an input form, the “prompt” is provided as a string with each field’s name and the appropriate entry. For example for the Simple Analytics and Reporting on Asset Template, “prompt”: “Asset Name: Area A Start Date: 05-01-2026 End Date: 05-05-2026”.

Recipe A - Stream the response (the common case)

Python
import asyncio
import httpx

async def run_agent(agent_id: str, prompt: str, *, version_id: str | None = None) -> str:
    body = {
        "customAgentId": agent_id,
        "prompt": prompt,
        "options": {
            "stream": True,
            "streamTools": False,
            "interleaveThinking": False,  # keep the body pure text
        },
    }
  
    if version_id:
        body["versionId"] = version_id

    # Runs can take minutes; only bound the connect phase.
    timeout = httpx.Timeout(600.0, connect=30.0)  # 10 minutes read, 30s connect
    pieces: list[str] = []
    url = f"{SEEQ}/genai/agent-workflows/runs"

    async with httpx.AsyncClient(timeout=timeout) as client:
        async with client.stream("POST", url, json=body, headers=HEADERS) as response:
            if response.status_code >= 400:
                await response.aread()
                raise RuntimeError(f"Run start failed ({response.status_code}): {response.text}")
            run_id = response.headers.get("X-Workflow-Run-Id")
            print(f"run {run_id} started")
            async for chunk in response.aiter_text():
                pieces.append(chunk)
                print(chunk, end="", flush=True)  # live output

    return "".join(pieces)

answer = await run_agent("<agent-id>", "Summarize last week's compressor downtime.")

Three things worth knowing about this call:

  • The run does not start until you read the body. The server registers the run and returns headers immediately, then executes the graph as you consume the stream. Opening the response and walking away means nothing happens.

  • X-Workflow-Run-Id (and X-Workflow-Chat-Id) arrive on the response headers, before the first body byte. Keep the run id - you need it to poll, cancel, or re-attach.

Synchronous variant

If you have no async context, requests works the same way:

Python
import requests

url = f"{SEEQ}/genai/agent-workflows/runs"

with requests.post(url, json=body, headers=HEADERS, stream=True, timeout=(30, 600)) as response:
    response.raise_for_status()
    run_id = response.headers.get("X-Workflow-Run-Id")
    for chunk in response.iter_content(chunk_size=None, decode_unicode=True):
        print(chunk, end="", flush=True)

Recipe B - Streaming with live progress events

Multi-node agents can be quiet for a while between visible outputs. The thinking stream tells you which node is running, which tools it called, and when each finishes. Read it on a second connection, concurrently with the text stream.

Python
import asyncio
import json
import httpx

async def _read_thinking(client: httpx.AsyncClient, run_id: str, printer: "ProgressPrinter") -> None:
    """Consume the JSONL progress stream for a run."""
    url = f"{SEEQ}/genai/agent-workflows/runs/{run_id}/stream/thinking"
    async with client.stream("GET", url, headers=HEADERS) as response:
        buffer = ""
        async for chunk in response.aiter_text():
            buffer += chunk
            while "\n" in buffer:
                line, buffer = buffer.split("\n", 1)
                line = line.strip()
                if not line:
                    continue
                try:
                    event = json.loads(line)
                except json.JSONDecodeError:
                    continue  # partial line; the next chunk completes it

                printer.handle(event)


class ProgressPrinter:
    """Renders the thinking stream, collapsing each section/item to a single line.
    Placeholder events (ids only, no label) are skipped; each section and item prints
    once, when its label arrives.
    """
    def __init__(self, show_reasoning: bool = False) -> None:
        self._seen: set[tuple] = set()
        self._show_reasoning = show_reasoning

    def handle(self, event: dict) -> None:
        kind = event.get("type", "")
        if kind == "workflow_node_start":
            print(f"\n▶ node {event.get('node_id')} started", flush=True)
        elif kind == "workflow_node_complete":
            print(f"\n✔ node {event.get('node_id')} finished", flush=True)
        elif kind == "workflow_node_error":
            print(f"\n✘ node {event.get('node_id')}: {event.get('error')}", flush=True)
        elif kind == "workflow_warning":
            print(f"\n⚠ {event.get('code')}: {event.get('message')}", flush=True)
        elif kind in ("workflow_complete", "workflow_error"):
            print(f"\n== {kind} ==", flush=True)
        elif kind == "thinking_section_start":
            self._once(("section", event.get("section_id")), event.get("title"), "  ┌ ")
        elif kind == "thinking_item_start":
            if event.get("item_type") == "reasoning_summary" and not self._show_reasoning:
                return  # the model's own reasoning; label is always blank
            key = ("item", event.get("section_id"), event.get("item_id"))
            label = event.get("name") or event.get("description")
            self._once(key, label, "  … ")
        elif kind == "thinking_item_error":
            print(f"\n  ✘ {event.get('description')}: {event.get('error')}", flush=True)

    def _once(self, key: tuple, label: str | None, prefix: str) -> None:
        label = (label or "").strip()
        if not label or label == "N/A":
            return  # placeholder - the labeled event follows
        if key in self._seen:
            return  # already printed this section/item
        self._seen.add(key)
        print(f"\n{prefix}{label}", flush=True)

async def run_agent_with_progress(agent_id: str, prompt: str) -> str:
    body = {
        "customAgentId": agent_id,
        "prompt": prompt,
        "options": {
            "stream": True,
            "streamTools": True,  # required for thinking events to be emitted
            "interleaveThinking": False,  # …but keep them out of the text body
        },
    }

    timeout = httpx.Timeout(600, connect=30.0)
    pieces: list[str] = []
    url = f"{SEEQ}/genai/agent-workflows/runs"

    async with httpx.AsyncClient(timeout=timeout) as client:
        async with client.stream("POST", url, json=body, headers=HEADERS) as response:
            if response.status_code >= 400:
                await response.aread()
                raise RuntimeError(f"Run start failed ({response.status_code}): {response.text}")
            run_id = response.headers["X-Workflow-Run-Id"]
            thinking = asyncio.create_task(_read_thinking(client, run_id, ProgressPrinter()))
            async for chunk in response.aiter_text():
                pieces.append(chunk)
                print(chunk, end="", flush=True)
            await thinking
    return "".join(pieces)

The task is started only after the POST headers land, so the run id is always available by then. The thinking stream ends on its own when the run finishes, so await thinking will not hang past the run - but wrap it in asyncio.wait_for(...) if you want a hard ceiling.

Event types

Each line of the thinking stream is one JSON object with a type:

Type

Meaning

workflow_node_start / _complete / _error

A graph node started, finished, or failed. Carries node_id.

workflow_complete / workflow_error

The whole run reached a terminal state.

thinking_section_start / _complete

A grouped unit of work (what the UI shows as a collapsible section). Carries section_id, title.

thinking_item_start / _update / _complete / _error

An individual step inside a section - usually a tool call. Carries item_id, name, content.

workflow_warning

Non-fatal. e.g. code: SKILLS_UNAVAILABLE when a node's attached skill could not be resolved for your user.

Treat this list as advisory rather than exhaustive - ignore types you do not recognize rather than raising on them, since new event types get added.

Recipe C - Fire and Poll

If you would rather not hold a connection open (scheduled jobs, long runs, anything crossing a proxy with an idle timeout), start the run non-streaming and poll the record.

Python
import time
import requests

body = {
    "customAgentId": agent_id,
    "prompt": prompt,
    "options": {"stream": False, "streamTools": True},
}

start_url = f"{SEEQ}/genai/agent-workflows/runs"
start = requests.post(start_url, json=body, headers=HEADERS, timeout=60)
start.raise_for_status()  # 202 Accepted
run_id = start.json()["runId"]
poll_url = f"{SEEQ}/genai/agent-workflows/runs/{run_id}"

TERMINAL = {"finished", "error", "canceled"}

while True:
    status = requests.get(poll_url, headers=HEADERS, timeout=30).json()
    if status["state"] in TERMINAL:
        break
    time.sleep(2)

print("state:", status["state"])

for node_id, output in status["nodeOutputs"].items():
    print(f"\n--- {node_id} ---\n{output.get('text', '')}")

if status["state"] == "error":
    print("error:", status.get("error"))

The status payload contains:

  • state - queued | running | finished | error | canceled

  • nodeOutputs - {node_id: {"text": …, "classification": …}}. Note that a node inside a loop runs more than once and only its **latest** output appears here.

  • thinking - the full progress-event log for the run, flattened

  • graphSummary - per-node status, handy for rendering progress

  • error - populated when state == "error"

  • startedAt / updatedAt / finishedAt

Polling every ~2s matches what the web UI does. There is no server-side pagination on this record, so avoid polling a very chatty run at sub-second intervals.

Cancelling a run

Python
requests.post(f"{SEEQ}/genai/agent-workflows/runs/{run_id}/cancel", headers=HEADERS, timeout=30)

Returns 202 with {"runId": …, "state": "canceled"}. For a streaming run, also close your response - cancelling alone does not tear down the HTTP connection.

Errors and gotchas

Run-start failures return structured detail. A 4xx/5xx on the POST carries:

JSON
{"detail": {"code": "UNREACHABLE_NODES", "message": "…", "nodeIds": ["node-3"]}}

Branch on code, not on the message text. Codes you will actually hit:

Code

Cause

CUSTOM_AGENT_NOT_FOUND

Bad id, archived agent, or not shared with you

UNREACHABLE_NODES

The graph has nodes no path from Start reaches - usually an unpublished/broken draft

RUN_ALREADY_ACTIVE

HTTP 409. See below.

INVALID_CUSTOM_AGENT_WORKFLOW

The stored graph is not valid JSON - re-save the agent in the UI

INVALID_RUN_START_PAYLOAD

Missing/conflicting customAgentId / templateId

One active run per chat. If you pass a chatId, a second run against that same chat is rejected with 409 RUN_ALREADY_ACTIVE until the first finishes or is cancelled. Omit chatId entirely for independent script runs and this never comes up.

Timeouts. A multi-node agent can run for many minutes. Set the read timeout to a generous ceiling (you can use None to wait forever, but that's discouraged) and only bound the connect phase, as the examples do - the default 5s–30s read timeout in most HTTP clients will kill a healthy run mid-stream.

A run with no output still emits a single non-breaking space (` `) so the stream is never completely empty. Strip it if you are checking for an empty answer.

Which graph runs. With no versionId, the run uses the agent's current saved graph - including an unpublished draft. Pass an explicit versionId when you need reproducibility across runs.

Draft agents can fail validation. An agent that is mid-edit in the UI may have disconnected nodes; that surfaces as UNREACHABLE_NODES at start. Publish a version before automating against it.