Custom Agents
This section 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.
Built in Agents (Agent Q, Actions Agent, etc)
Agent Q is exposed through the same GenAI service that powers the AI Assistant in Workbench. From a Data Lab notebook you can call it with the credentials you are already logged in with via SPy.
Endpoints
|
Endpoint |
Method |
Purpose |
|---|---|---|
|
|
POST |
Start (or continue) a chat |
|
|
GET |
Stream the assistant's answer text (async mode) |
|
|
GET |
Stream tool-call / progress updates (NDJSON) |
|
|
POST |
Upload a file attachment (image, CSV, PDF) before the chat |
These endpoints are served from the server root (not under /api), e.g.
<https://yourserver.seeq.site/genai/llm/chat.>
Authentication
Inside Data Lab, SPy is already authenticated. Reuse its token:
import requests
import os
# Server root, e.g. https://yourserver.seeq.site
base_url = os.environ["SEEQ_SERVER_URL"]
headers = { "Content-Type": "application/json" }
spy.client.add_authorization_header(headers)
Basic request
A minimal request to Agent Q needs a prompt and agentType: "aq". Supplying your own chat_id (any new UUID) lets you continue the conversation in later calls and correlate the streams. This is the basic version and will likely work for most scripting needs.
import uuid
chat_id = str(uuid.uuid4())
body = {
"prompt": "Find the Area A temperature signal and summarize the last week of data.",
"agentType": "aq",
"chat_id": chat_id,
"stream": True,
"streamTools": True, # emit tool-call updates on the thinking stream
"asyncStream": False, # synchronous: the POST response streams the answer text
}
with requests.post(f"{base_url}/genai/llm/chat", json=body, headers=headers, stream=True) as r:
r.raise_for_status()
answer = []
for chunk in r.iter_content(chunk_size=None, decode_unicode=True):
answer.append(chunk)
print("".join(answer))
Useful optional fields:
-
previousMessages: list of prior{"role": ..., "content": ...}messages to continue a conversation -
context: workbench context (workbook/worksheet IDs) so Agent Q can see what you are looking at -
reasoningEffort: reasoning effort override -
messageId: client-supplied ID for the user message
Calling Each Agent
The different agents have different required body and header fields. Below is a table of the agents and the headers and body values needed for each:
|
Agent |
Body |
Additional Headers |
|---|---|---|
|
Agent Q |
|
None Optional: |
|
Actions (Workbench Action) |
|
|
|
Vantage |
|
|
|
General |
|
None |
|
Data Lab |
|
None |
|
Formula |
|
None |
Using this table information to adapt the basic example above to call the Actions agent results in:
import uuid
chat_id = str(uuid.uuid4())
body = {
"prompt": "Make all temperature signals blue",
"agentType": "actions",
"chat_id": chat_id,
"stream": True,
"streamTools": True, # emit tool-call updates on the thinking stream
"asyncStream": False, # synchronous: the POST response streams the answer text
}
workbook_id = "0F1B12F8-882F-E8A0-9666-4A3F74ECBED3"
worksheet_id = "0F1B12F8-88C9-7590-B3F4-835469EA7C95"
headers.update(
{
"x-sq-origin": "workbench",
"x-sq-origin-url": f"/workbook/{workbook_id}/worksheet/{worksheet_id}"
}
)
with requests.post(f"{base_url}/genai/llm/chat", json=body, headers=headers, stream=True) as r:
r.raise_for_status()
answer = []
for chunk in r.iter_content(chunk_size=None, decode_unicode=True):
answer.append(chunk)
print("".join(answer))
Async mode: separate answer and tool-update streams
Async mode provides a way to get the tool calls and descriptions as well as the streamed response in two separate outputs. With "asyncStream": true, the POST returns immediately with a bot_message_id, and you read two streams in parallel:
body["asyncStream"] = True
resp = requests.post(f"{base_url}/genai/llm/chat", json=body, headers=headers)
resp.raise_for_status()
bot_message_id = resp.json()["bot_message_id"]
# 1) The answer text
answer = requests.get(
f"{base_url}/genai/llm/chat/stream/{chat_id}",
params={"bot_message_id": bot_message_id},
headers=headers,
stream=True,
)
# 2) The tool-call / progress updates (see next section)
thinking = requests.get(
f"{base_url}/genai/llm/chat/stream/thinking/{chat_id}",
params={"bot_message_id": bot_message_id},
headers=headers,
stream=True,
)
In a notebook, read them with two threads (or just read the thinking stream first if you only care about the final answer - the answer stream is buffered server-side until you connect):
import json
import threading
def tail_thinking():
"""Print tool-call / progress updates as they arrive."""
with requests.get(
f"{base_url}/genai/llm/chat/stream/thinking/{chat_id}",
params={"bot_message_id": bot_message_id},
headers=headers,
stream=True,
) as r:
r.raise_for_status()
for line in r.iter_lines(decode_unicode=True):
if not line:
continue
event = json.loads(line)
print(f"[{event.get('type')}] {event.get('description') or event.get('title') or ''}")
def tail_answer(out: list):
"""Accumulate (and echo) the answer text."""
with requests.get(
f"{base_url}/genai/llm/chat/stream/{chat_id}",
params={"bot_message_id": bot_message_id},
headers=headers,
stream=True,
) as r:
r.raise_for_status()
for chunk in r.iter_content(chunk_size=None, decode_unicode=True):
out.append(chunk)
print(chunk, end="")
answer_parts: list[str] = []
thinking_thread = threading.Thread(target=tail_thinking)
answer_thread = threading.Thread(target=tail_answer, args=(answer_parts,))
thinking_thread.start()
answer_thread.start()
thinking_thread.join()
answer_thread.join()
answer_text = "".join(answer_parts)
Both streams close on their own when the agent finishes, so join() returns without needing a timeout or sentinel. If you only want the final answer, skip the threads entirely: consume the thinking stream to completion first (or not at all), then read the answer stream - the answer is buffered server-side until you connect.
Getting tool-call updates
The thinking stream (/genai/llm/chat/stream/thinking/{chat_id}) returns newline-delimited JSON (NDJSON): one JSON object per line. It is only populated when the chat was started with "streamTools": true. This is the same feed the AI Assistant UI uses to render the collapsible "working" sections while Agent Q runs.
Key event types:
|
|
Meaning |
|---|---|
|
|
Agent Q started a logical section of work - |
|
|
A step started - |
|
|
Incremental content for a running item - |
|
|
The step finished - |
|
|
The step failed - includes an |
|
|
The section finished - optional |
|
|
Pipeline stage/monitoring update - |
Important: treat every field except type as optional. Section/item start events are sometimes emitted as id-only placeholders ({"type": "thinking_section_start", "section_id": "..."}) with the full payload (title, description, etc.) following as a separate line once the agent fills it in. Duplicate section_id/item_id values across lines are updates to the same section/item.
Example consumer that prints tool calls as they happen:
import json
with requests.get(
f"{base_url}/genai/llm/chat/stream/thinking/{chat_id}",
params={"bot_message_id": bot_message_id},
headers=headers,
stream=True,
) as r:
r.raise_for_status()
for line in r.iter_lines(decode_unicode=True):
if not line:
continue
event = json.loads(line)
match event.get("type"):
case "thinking_section_start":
# May be an id-only placeholder; the title arrives in a later update
print(f"\n== {event.get('title', event.get('section_id', ''))} ==")
case "thinking_item_start":
print(f" -> {event.get('name', '')}: {event.get('description', '')}")
case "thinking_item_complete":
print(f" ✓ {event.get('description', '')}")
case "thinking_item_error":
print(f" ✗ {event.get('description', '')}: {event.get('error', '')}")
case "thinking_section_complete":
status = "with errors" if event.get("error") else "ok"
print(f"== section done ({status}) ==")
The stream ends when the agent finishes; the connection closes after the final events are flushed. If no task is running for the chat_id, the endpoint returns an empty body.
Sending attachments
Agent Q accepts image, csv, and pdf attachments. Attachments are uploaded first through the markdown-links endpoint, then referenced by ID in the chat request.
Step 1 - upload the file:
with open("process_data.csv", "rb") as f:
upload = requests.post(
f"{base_url}/api/markdown/links",
headers={"sq-auth": spy.client.auth_token},
files={"file": ("process_data.csv", f, "text/csv")},
)
upload.raise_for_status()
# Response contains the stored link, e.g. ".../api/markdown/links/<uuid>.csv"
file_url = upload.json()["link"]
file_id = file_url.rsplit("/", 1)[-1] # "<uuid>.csv"
Step 2 - reference it in the chat request:
body = {
"prompt": "Analyze the attached CSV and describe any anomalies.",
"agentType": "aq",
"chat_id": chat_id,
"streamTools": True,
"attachments": [
{
"id": file_id, # file id with extension
"url": f"/api/markdown/links/{file_id}",
"type": "csv", # "image" | "csv" | "pdf"
"mimeType": "text/csv",
"name": "process_data.csv",
# Optional but recommended for CSVs: first ~20 rows so the agent
# can see the shape of the data without a tool round-trip
"csvPreview": csv_preview_text,
}
],
}
Notes:
-
Images (
type: "image", e.g.image/png,image/jpeg) are passed to the model as vision input. -
CSVs are made available to Agent Q's code tool as data it can load and analyze; include
csvPreviewwhen convenient. -
PDFs are extracted as document text.
-
The GenAI service fetches the bytes with your credentials, so the
idmust be exactly the file id
(with extension) returned by the upload - full URLs or paths are rejected.