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 . |
|
|
Used only for logging in and getting an auth token. Available by default in Data Lab. |
|
|
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.
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:
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 |
|---|---|---|
|
|
|
Start a run. Streaming → |
|
|
|
Run status, per-node outputs, full thinking log |
|
|
|
Re-attach to a live run's visible-text stream |
|
|
|
Live progress events, newline-delimited JSON |
|
|
|
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)
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(andX-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.
-
Errors mid-run arrive as text, not as an HTTP error, since the status line was already sent. A failed run appends
Workflow error: …to the stream. Check the run record afterwards (https://seeq.atlassian.net/wiki/spaces/SD/pages/edit-v2/5444730972#Recipe-C---fire-and-poll ) if you need a reliable success/failure signal.
Synchronous variant
If you have no async context, requests works the same way:
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.
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 |
|---|---|
|
|
A graph node started, finished, or failed. Carries |
|
|
The whole run reached a terminal state. |
|
|
A grouped unit of work (what the UI shows as a collapsible section). Carries |
|
|
An individual step inside a section - usually a tool call. Carries |
|
|
Non-fatal. e.g. |
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.
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 whenstate == "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
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:
{"detail": {"code": "UNREACHABLE_NODES", "message": "…", "nodeIds": ["node-3"]}}
Branch on code, not on the message text. Codes you will actually hit:
|
Code |
Cause |
|---|---|
|
|
Bad id, archived agent, or not shared with you |
|
|
The graph has nodes no path from Start reaches - usually an unpublished/broken draft |
|
|
HTTP |
|
|
The stored graph is not valid JSON - re-save the agent in the UI |
|
|
Missing/conflicting |
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.